diff --git a/.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.i18n.yaml index 50fa27df10..9f174f4f54 100644 --- a/.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.i18n.yaml @@ -18,5 +18,5 @@ en: fa503dcfaa6f438f zh: 26052212a124e133 /agent-note-compiler-independent-typert-type-model/consequences: - en: 4a472acf377f8361 - zh: cb83602b123a4aae + en: e1022cf9523fb28e + zh: 01dfb764fb98de0d diff --git a/.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.md b/.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.md index 5083c55176..6ca9186f7f 100644 --- a/.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.md +++ b/.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.md @@ -51,3 +51,5 @@ For each supported node kind and literal category, Zod emitter tests run both su New generation targets and static checks can reuse the same TypeGraph, and business categories can extend PackageModel without parsing the AST again. Preserving pre-evaluation types and independent faces makes the model more complex than a flattened schema; emitters must explicitly declare their supported scope and fail on missing capabilities. Explicit package opt-in keeps artifact publication and exports under package ownership. Repository orchestration may still run the whole-workspace Host contract pass for every opted-in package; that pass remains owned by the later Remote Gateway Agent Note. The static Cordis catalogs remain reproducible from the canonical model without coupling `tool-cordis` to runtime registry state. `ctx.typert` reflects only artifacts mounted in the current runtime, and unloading does not control Zod instances that consumers retain after materializing them. + +Ordinary Host and Remote artifacts reuse this model to emit one static method spec per endpoint. Each parameter has normal `codec` and `strictCodec` variants derived from the same checker-resolved type model; neither analysis nor the model carries a per-method input policy. A pure factory returns a lazy info instance whose direct method index and descriptor list share objects; the existing Registry retains those descriptors, while `TYPERT_REMOTE.methods` supplies exact static types. The [unified Typert/Remote metadata note](../../proposed/architecture/2026-09-20-unified-typert-remote-metadata.md) records this delivery mechanism and the incomplete downstream adaptation. Declaration and face-isolation rules remain unchanged; separate Programs and processes do not share object identity. diff --git a/.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.zh.md b/.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.zh.md index 70d6954fae..1a4a870fab 100644 --- a/.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.zh.md @@ -51,3 +51,5 @@ Zod emitter 对支持的节点和各类 literal 逐类执行成功与失败 pars 新增生成目标或静态检查可复用同一 TypeGraph,业务类目也可在 PackageModel 上扩展,而无需再次解析 AST。保留计算前类型和独立 face 的代价是模型比打平后的 schema 更复杂,emitter 必须显式声明支持范围并对缺失能力失败。 包级显式 opt-in 使产物发布与 exports 由各包自行管理。仓库编排仍可为每个已 opt-in 的包运行全仓 Host 约定 pass;该 pass 仍由后续 Remote Gateway Agent Note 负责说明。静态 Cordis catalog 可从标准模型复现,同时不把 `tool-cordis` 与运行时注册表状态耦合。`ctx.typert` 只反映当前运行时中已挂载的产物;对于消费方物化后仍持有的 Zod 实例,卸载流程无法控制。 + +普通 Host 与 Remote 产物复用此模型,为每个端点生成一份静态方法 spec。每个参数都有从同一 checker 解析后类型模型派生的普通 `codec` 和 `strictCodec` 变体;分析过程与模型均不携带逐方法输入策略。纯 factory 返回惰性 info 实例,其直接方法索引与描述符列表共享对象;既有 Registry 保留这些描述符,而 `TYPERT_REMOTE.methods` 提供精确静态类型。[统一 Typert/Remote 元数据 note](../../proposed/architecture/2026-09-20-unified-typert-remote-metadata.zh.md)记录该交付机制及尚未完成的下游适配。声明与编译面隔离规则保持不变;独立 Program 和进程不共享对象身份。 diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml index 5be7e083a3..82629e613f 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml @@ -12,14 +12,14 @@ en: 44d946b53496974c zh: 403447c25a5b2d7c /agent-note-typert-gateway-targeted-method-calls/components-and-cordis-services: - en: aa7f509189ade32e - zh: ef2841bc793f31b2 + en: 02c20706e48467e6 + zh: 2b0916ae0cee351b /agent-note-typert-gateway-targeted-method-calls/business-declarations: - en: c6b70d442b137d2e - zh: 6d0dbefe89937ef1 + en: b9394d5ef98cc82a + zh: 1fce07fe150722ee /agent-note-typert-gateway-targeted-method-calls/decorators-and-the-explicit-gateway-facet: - en: 155afbe4b8af600b - zh: 14705698a9cdbd84 + en: 161dccf2b4cf565d + zh: fa587fe0b46bf551 /agent-note-typert-gateway-targeted-method-calls/lookup-and-remote-scope-registration: en: 9f7448898cf5cadc zh: 419573c589ed6f7d @@ -33,8 +33,8 @@ en: a244f6a88fdabf3d zh: e4feedb7297f4865 /agent-note-typert-gateway-targeted-method-calls/three-artifact-kinds-and-two-typescript-programs: - en: 6ea4bddcf35a834d - zh: 4af474c6818b1cf0 + en: c5bcd6eb76bf3d42 + zh: 9df2fbb364643186 /agent-note-typert-gateway-targeted-method-calls/the-remote-package-entry: en: 75e977fe0fb71d59 zh: 00760c79da173cb7 @@ -48,8 +48,8 @@ en: 5d14e1b7afc53efb zh: e2f4b5814083738f /agent-note-typert-gateway-targeted-method-calls/src-and-lib-operating-modes: - en: 1e366dcfab74831b - zh: 14199f25f20a9352 + en: f1b8426d1cfaed35 + zh: a370376923c98d6e /agent-note-typert-gateway-targeted-method-calls/host-gateway-resolution: en: 88a95d7ab4621caf zh: 89b63fb6440fca79 @@ -66,8 +66,8 @@ en: b1c0c3dd77c56ce8 zh: 769ffd4aeefd949c /agent-note-typert-gateway-targeted-method-calls/alternatives-considered: - en: 0d68c499b7780f4f - zh: bdd1e57232b996e2 + en: eaa3ffe5ca14005c + zh: 1f0b455e30ade872 /agent-note-typert-gateway-targeted-method-calls/verification: en: 7433bdb522bb2083 zh: 545c17613b636f00 diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md index 58135ff56e..ba4e8eb4c9 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md @@ -28,7 +28,7 @@ The Remote consumer projection contains `.d.ts`, `.d.ts.map`, and `.js` files. T | Component | Cordis service | Responsibility | |---|---|---| -| `@deepseek-ai/dsh-typert-protocol` | Declares only the minimal `ctx.typert` protocol | `TypertRemoteService`, decorators, binding fallback, descriptors, lookup/Context, and the Remote map; no dependency on the compiler, Zod, Connection, or Browser | +| `@deepseek-ai/dsh-typert-protocol` | Declares only the minimal `ctx.typert` protocol | `TypertRemoteService`, decorators, binding fallback, descriptors, lookup/Context, the Remote map, and Zod-backed lazy info assembly; no dependency on the compiler, Connection, or Browser | | Typert registry | `ctx.typert` | Separately stores reflection for the current environment, imported Remote contributions, lookup providers, and Context providers | | Typert generator/loader | No new business service | Generates three kinds of `lib` artifacts from the Host/Client Programs and registers the current environment's artifacts with `ctx.typert` | | API Gateway's Host face | `ctx.typertGateway` | Associates Host definitions with live Services, decodes parameters, resolves receivers, invokes methods, and projects unary results into carrier-neutral fields and attachments | @@ -81,16 +81,18 @@ export class ScopedGoalService extends TypertRemoteService { An endpoint selects exactly one invocation mode. A flow that needs an explicit `Agent` parameter uses `@Remote`. A flow that first switches to an Agent Context and then resolves a scoped receiver uses `@RemoteScope('agent')`. Typert does not infer either mode from the method body or from a missing parameter. -Business packages depend only on the lightweight `@deepseek-ai/dsh-typert-protocol`. It provides `TypertRemoteService` and declaration protocols for decorators, the binding fallback, lookup, Remote Scope, and descriptors, without depending on the TypeScript compiler, Zod, HTTP, or the Client runtime. +Business packages depend only on `@deepseek-ai/dsh-typert-protocol`. It provides `TypertRemoteService` and declaration protocols for decorators, the binding fallback, lookup, Remote Scope, and descriptors, without depending on the TypeScript compiler, HTTP, or the Client runtime. Its pure `./remote-info` entry uses Zod to assemble lazy named-input schemas without importing Cordis; the root entry also re-exports it alongside Cordis-backed declarations. A method that cooperatively supports cancellation declares `signal: AbortSignal` as its final Host parameter. This reserved parameter is not a business value, lookup, or JSON field. The generated consumer method exposes it as a final optional parameter so ordinary calls remain unchanged while callers that own cancellation can pass a signal. ## Decorators and the explicit Gateway facet -A decorator only states that a method participates in the Remote contract. It performs no runtime type reflection and injects no hidden symbol into a Service constructor. The arguments to `@Remote('create')` and `@RemoteScope('agent', 'create')` are external method names; the decorated member may be the business method itself or an adapter such as `remoteExportCreate`. The member name becomes the external method name only when no alias is provided. Inheriting `TypertRemoteService` is the normal explicit declaration that a Service has joined the Gateway; its public readonly `typertGateway` field keeps the binding visible on the runtime instance. +A decorator states Remote participation, an optional export name, and stream delivery; `@Remote` options contain only `name` and `mode`. It performs no runtime type reflection and injects no hidden symbol into a Service constructor. The arguments to `@Remote('create')` and `@RemoteScope('agent', 'create')` are external method names; the decorated member may be the business method itself or an adapter such as `remoteExportCreate`. The member name becomes the external method name only when no alias is provided. Inheriting `TypertRemoteService` is the normal explicit declaration that a Service has joined the Gateway; its public readonly `typertRemote` field keeps the binding visible on the runtime instance. In SRC mode, the decorator records the method name and invocation mode in a versioned descriptor on the Service prototype. The descriptor uses a stable string property name, so `remoteMethods()` can read markers produced by another installed copy of `dsh-typert-protocol`; it writes nothing to the Service instance, constructor, or method function. +Ordinary artifacts construct a lazy Remote info instance from one static method spec per endpoint. Its direct `TypertRemoteMethod` objects retain descriptor fields and add named `input(options?)` and `invoke`. Consumers select matching strict parsing and projection with `input({ strict: true })`; `input()` preserves normal handling. Host parameter codecs and the single normal `result` codec are unchanged. Existing Registry queries retain those objects, while `TYPERT_REMOTE.methods` supplies exact static types. Strict source startup prebuilds and loads ordinary Typert artifacts; fallback cannot recover erased schemas. The [unified metadata note](../../proposed/architecture/2026-09-20-unified-typert-remote-metadata.md) records this delivery mechanism and the incomplete consumer adaptation. + In LIB mode, the Typert compiler performs strict method discovery, type resolution, and descriptor generation. It accepts a literal service key in `TypertRemoteService`'s direct `super()` call or the explicit binding fallback; generation neither rewrites business source nor injects hidden registration metadata. ## Lookup and Remote Scope registration @@ -215,7 +217,7 @@ Host lib build → Vite 构建 Web ``` -The existing top-level `build` still runs `build:lib` before `build:web`, but `build:lib` must complete the Host and Remote artifacts before starting Client TypeScript compilation. A clean build must not depend on stale `.d.ts` files from an earlier build. +The top-level `build` runs the step graph in `scripts/build.ts`, which completes the Host bundle and the Remote artifacts before starting Client TypeScript compilation. `build:lib` remains the complete serial lib build for `build:bench`, and its Host half `build:lib:host` serves the vendor release workflows. `typecheck` writes the Remote artifacts through `scripts/generate-typert.ts`, beside Host compilation, before Client TypeScript compilation. A clean build must not depend on stale `.d.ts` files from an earlier build. Compiler-backed repository gates that resolve the consumer surface have the same prerequisite even when their primary inputs are source files. The public `typecheck`, `lint`, and `doc-typecheck` commands run the Host contract pass first. The gate scheduler may use their `*:contracts-ready` variants only after an explicit Typert-contract or complete-build dependency, so parallel lanes neither read missing declarations nor run concurrent generators against the same outputs. @@ -349,9 +351,9 @@ The Web already depends on build artifacts such as `lib/client.js`, so it requir ## SRC and LIB operating modes -SRC supports local source startup. The versioned prototype descriptors created by `@Remote` and `@RemoteScope()` provide method names and invocation modes. At runtime, the system reads ordered parameter names from the JavaScript function signature and combines them with registered lookup/Context providers to produce a permissive descriptor. +SRC supports local source startup. For methods without registered strict definitions, the versioned prototype descriptors created by `@Remote` and `@RemoteScope()` provide method names and invocation modes. At runtime, the system reads ordered parameter names from the JavaScript function signature and combines them with registered lookup/Context providers to produce a permissive descriptor. -For example, `@Remote('create') remoteExportCreate(agent, request, signal)` resolves to the external method `create`, implementation member `remoteExportCreate`, two top-level business parameters, and one cancellation injection point. Lookup registration rewrites `agent` to the wire field `agentId`, `request` is passed as a same-named JSON parameter, and the final `signal` stays outside the payload. SRC does not start a `ts.Program`, use a preload or loader hook, generate or rewrite source, or inspect the internal structure of an ordinary JSON object. +For example, `@Remote('create') remoteExportCreate(agent, request, signal)` resolves to the external method `create`, implementation member `remoteExportCreate`, two top-level business parameters, and one cancellation injection point. Lookup registration rewrites `agent` to the wire field `agentId`, `request` is passed as a same-named JSON parameter, and the final `signal` stays outside the payload. The conservative fallback does not start a `ts.Program`, use a preload or loader hook, generate or rewrite source, or inspect the internal structure of an ordinary JSON object. A signature that SRC cannot resolve unambiguously fails on the first invocation that resolves its descriptor; Service mounting records only the decorator marker and does not inspect the JavaScript signature. SRC does not guess at object destructuring, ambiguity caused by default parameters, rest parameters, nested lookups, or complex types. @@ -474,7 +476,7 @@ The package topology is `api/remotes → api/gateway → client/connection → h **Perform strict reflection through decorators at runtime.** JavaScript decorators cannot recover erased TypeScript types, public symbol identity, or complete Zod codecs. Injecting a compiler-private symbol into a constructor would also hide the business class's real dependencies, so Typert generates strict information at compile time. -**Use a preload, loader hook, or complete `ts.Program` during SRC startup.** This could reuse LIB analysis but would add requirements to every source startup entry. SRC needs only a usable permissive descriptor, so it uses decorator markers, function parameter names, and explicit providers; strict checks remain in the LIB contract pass. +**Use a preload, loader hook, or complete `ts.Program` during SRC startup.** This could reuse LIB analysis but would add requirements to every source startup entry. Source startup can load ordinary prebuilt artifacts or use conservative markers without loading a TypeScript Program. **Hand-write the Client interface.** A hand-written interface cannot guarantee that it contains only Remote-marked methods and can drift from Host signatures, lookup IDs, and Zod schemas. Client types are therefore projected automatically from the Host Program. diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md index 21d51800ae..a6eb7dae73 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md @@ -28,7 +28,7 @@ Remote 消费端投影同时包含 `.d.ts`、`.d.ts.map` 和 `.js`。`.d.ts` 只 | 组件 | Cordis 服务 | 职责 | |---|---|---| -| `@deepseek-ai/dsh-typert-protocol` | 只声明 `ctx.typert` 的最小协议 | `TypertRemoteService`、decorator、binding 回退、descriptor、lookup/Context 和 Remote map;不依赖 compiler、Zod、Connection 或 Browser | +| `@deepseek-ai/dsh-typert-protocol` | 只声明 `ctx.typert` 的最小协议 | `TypertRemoteService`、decorator、binding 回退、descriptor、lookup/Context、Remote map 和基于 Zod 的惰性 info 组装;不依赖 compiler、Connection 或 Browser | | Typert registry | `ctx.typert` | 分开保存当前环境 reflection、导入的 Remote contribution、lookup provider 和 Context provider | | Typert generator/loader | 无新增业务服务 | 从 Host/Client Program 生成三类 `lib` 产物,并把当前环境产物注册到 `ctx.typert` | | API Gateway 的 Host face | `ctx.typertGateway` | 关联 Host definition 与活 Service,解码参数、解析 receiver、调用方法,并将一元结果投影为载体无关的字段与附件 | @@ -81,16 +81,18 @@ export class ScopedGoalService extends TypertRemoteService { 同一个 endpoint 只能选择一种调用模式。需要显式 `Agent` 参数的流程使用 `@Remote`;需要切换到 Agent Context 再解析 scoped receiver 的流程使用 `@RemoteScope('agent')`,两者不会由 Typert 根据方法体或参数缺失自动猜测。 -业务包只依赖轻量的 `@deepseek-ai/dsh-typert-protocol`。它提供 `TypertRemoteService`,以及 decorator、binding 回退、lookup、Remote Scope 和 descriptor 的声明协议,不依赖 TypeScript compiler、Zod、HTTP 或 Client runtime。 +业务包只依赖 `@deepseek-ai/dsh-typert-protocol`。它提供 `TypertRemoteService`,以及 decorator、binding 回退、lookup、Remote Scope 和 descriptor 的声明协议,不依赖 TypeScript compiler、HTTP 或 Client runtime。其纯 `./remote-info` 入口使用 Zod 组装惰性具名输入 schema,不导入 Cordis;根入口在提供依赖 Cordis 的声明之余,也重新导出该能力。 支持协作式取消的方法会把 `signal: AbortSignal` 声明为最后一个 Host 参数。这个保留参数不是业务值、lookup 或 JSON 字段。生成的消费方方法将其暴露为最后一个可选参数,因此普通调用保持不变,而拥有取消控制权的调用方可以传入 signal。 ## Decorator 与显式 Gateway facet -Decorator 只表达“该方法参与 Remote 约定”,不负责运行时类型反射,也不向 Service constructor 注入隐藏 symbol。`@Remote('create')` 和 `@RemoteScope('agent', 'create')` 的参数是外部方法名;被装饰成员既可以是业务方法本身,也可以是 `remoteExportCreate` 这样的适配器。未给别名时才使用成员名作为外部方法名。继承 `TypertRemoteService` 是 Service 加入 Gateway 的常规显式声明;其 public readonly `typertGateway` 字段使运行时实例上的绑定保持可见。 +Decorator 表达 Remote 参与、可选导出名称和流式投递;`@Remote` 选项仅包含 `name` 和 `mode`。它不负责运行时类型反射,也不向 Service constructor 注入隐藏 symbol。`@Remote('create')` 和 `@RemoteScope('agent', 'create')` 的参数是外部方法名;被装饰成员既可以是业务方法本身,也可以是 `remoteExportCreate` 这样的适配器。未给别名时才使用成员名作为外部方法名。继承 `TypertRemoteService` 是 Service 加入 Gateway 的常规显式声明;其 public readonly `typertRemote` 字段使运行时实例上的绑定保持可见。 SRC 模式下,decorator 把方法名和调用模式记录在 Service prototype 上的带版本描述符中。描述符使用稳定的字符串属性名,因此 `remoteMethods()` 可以读取 `dsh-typert-protocol` 另一个已安装副本生成的标记;它不会向 Service 实例、constructor 或方法函数写入任何内容。 +普通产物从每个端点唯一的静态方法 spec 构造惰性 Remote info 实例。其直接 `TypertRemoteMethod` 对象保留描述符字段,并增加具名 `input(options?)` 和 `invoke`。消费方通过 `input({ strict: true })` 选择配套的严格解析与投影;`input()` 保留普通处理。Host 参数 codec 与唯一的普通 `result` codec 保持不变。既有 Registry 查询保留这些对象,`TYPERT_REMOTE.methods` 则提供精确静态类型。严格源码启动预构建并加载普通 Typert 产物;fallback 无法恢复被擦除的 schema。[统一元数据 note](../../proposed/architecture/2026-09-20-unified-typert-remote-metadata.zh.md)记录该交付机制及尚未完成的消费方适配。 + LIB 的严格方法发现、类型解析和 descriptor 生成由 Typert compiler 完成。它接受 `TypertRemoteService` 直接 `super()` 调用中的字面量 service key,或显式 binding 回退;生成过程不改写业务源码,也不注入隐藏注册元数据。 ## Lookup 与 Remote Scope 注册 @@ -215,7 +217,7 @@ Host lib build → Vite 构建 Web ``` -现有顶层 `build` 仍表现为先 `build:lib`、再 `build:web`,但 `build:lib` 内部必须先完成 Host 与 Remote artifact,再启动 Client TypeScript 编译。一次干净构建不能依赖上次残留的 `.d.ts`。 +顶层 `build` 运行 `scripts/build.ts` 中的步骤图,先完成 Host bundle 与 Remote artifact,再启动 Client TypeScript 编译。`build:lib` 仍是 `build:bench` 使用的完整串行 lib 构建,其 Host 部分 `build:lib:host` 供 vendor 发布 workflow 使用。`typecheck` 与 Host 编译并行,经 `scripts/generate-typert.ts` 写出 Remote artifact,再启动 Client TypeScript 编译。一次干净构建不能依赖上次残留的 `.d.ts`。 即使主要输入是源文件,需要通过编译器解析消费方 surface 的仓库门禁也有相同的前置条件。公共 `typecheck`、`lint` 和 `doc-typecheck` 命令会先执行 Host 约定 pass。门禁调度器仅可在显式的 Typert 约定依赖或完整构建依赖完成后使用对应的 `*:contracts-ready` 变体,使并行 lane 既不会读取缺失的声明,也不会针对同一输出并发运行多个生成器。 @@ -349,9 +351,9 @@ Web 本身依赖 `lib/client.js` 等构建产物,因此启动 Web 前要求完 ## SRC 与 LIB 运行模式 -SRC 面向本地源码启动。`@Remote` 和 `@RemoteScope()` 创建的带版本 prototype 描述符给出方法名和调用模式,运行时从 JavaScript 函数签名读取顺序参数名,并结合已注册 lookup/Context provider 生成弱 descriptor。 +SRC 面向本地源码启动。对于没有已注册严格定义的方法,`@Remote` 和 `@RemoteScope()` 创建的带版本 prototype 描述符给出方法名和调用模式,运行时从 JavaScript 函数签名读取顺序参数名,并结合已注册 lookup/Context provider 生成弱 descriptor。 -例如 `@Remote('create') remoteExportCreate(agent, request, signal)` 解析为外部方法 `create`、实现成员 `remoteExportCreate`、两个顶层业务参数和一个取消注入点;lookup 注册把 `agent` 改写为 wire 字段 `agentId`,`request` 按同名 JSON 参数传递,最后一个 `signal` 则留在 payload 之外。SRC 不启动 `ts.Program`,不使用 preload、loader hook、源码生成或模块改写,也不检查普通 JSON 对象的内部结构。 +例如 `@Remote('create') remoteExportCreate(agent, request, signal)` 解析为外部方法 `create`、实现成员 `remoteExportCreate`、两个顶层业务参数和一个取消注入点;lookup 注册把 `agent` 改写为 wire 字段 `agentId`,`request` 按同名 JSON 参数传递,最后一个 `signal` 则留在 payload 之外。保守回退不启动 `ts.Program`,不使用 preload、loader hook、源码生成或模块改写,也不检查普通 JSON 对象的内部结构。 SRC 无法明确解析的签名会在首次调用解析其 descriptor 时失败;Service 挂载只记录 decorator 标记,不检查 JavaScript 签名。SRC 不会猜测对象解构、默认参数造成的歧义、rest 参数、嵌套 lookup 或复杂类型。 @@ -474,7 +476,7 @@ Connection 提供共享 channel interceptor 与当前 HTTP carrier 映射。WebS **让 decorator 在运行时完成严格反射。** JavaScript decorator 无法恢复擦除后的 TypeScript 类型、公共符号身份和完整 Zod codec;向 constructor 注入 compiler 私有 symbol 又会隐藏业务类的真实依赖,因此严格信息由 Typert compiler 生成。 -**SRC 启动时使用 preload、loader hook 或完整 `ts.Program`。** 这能复用 LIB 分析,但增加所有源码启动入口的要求。SRC 只需要可用的弱 descriptor,因此采用 decorator 标记、函数参数名和显式 provider;严格检查留给 LIB 约定 pass。 +**SRC 启动时使用 preload、loader hook 或完整 `ts.Program`。** 这能复用 LIB 分析,但增加所有源码启动入口的要求。源码启动可加载普通预构建产物或使用保守标记,而无需加载 TypeScript Program。 **手写 Client interface。** 手写接口不能保证只包含 Remote 标记的方法,也会与 Host 签名、lookup ID 和 Zod schema 漂移,因此 Client 类型从 Host Program 自动投影。 diff --git a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml index 9cd1ebd5f3..bdd096c69d 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml @@ -3,8 +3,8 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md /agent-note-one-ordering-for-configuration-sources-and-what-a-discovered-file-may-not-decide: - en: 1f6ee603f36c2e09 - zh: e08197958663219c + en: 35dea8a3a8bdc842 + zh: 7a1cdf184ffd889f /agent-note-one-ordering-for-configuration-sources-and-what-a-discovered-file-may-not-decide/problem: en: d94bb81479bfa4a1 zh: af44ceaecf84ee51 diff --git a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md index b8c146ed76..ee4ea2a5b9 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md +++ b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md @@ -4,7 +4,7 @@ Status: implemented English | [中文](2026-08-04-configuration-source-ownership.zh.md) -The non-secret settings tier below is superseded by [profile-owned live configuration](2026-09-19-profile-owned-live-configuration.md). Credential ordering and environment restrictions remain current. +The non-secret settings tier below is superseded by [profile-owned live configuration](2026-09-19-profile-owned-live-configuration.md), and the credential ordering by [stored credential precedence](2026-10-09-stored-credential-precedence.md). Environment restrictions remain current. ## Problem diff --git a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md index 22afc15aec..024c93d3d1 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md @@ -4,7 +4,7 @@ Status: implemented [English](2026-08-04-configuration-source-ownership.md) | 中文 -下文的非秘密 settings 层已由 [profile 所属实时配置](2026-09-19-profile-owned-live-configuration.zh.md) 替代。凭据优先级与环境限制仍然适用。 +下文的非秘密 settings 层已由 [profile 所属实时配置](2026-09-19-profile-owned-live-configuration.zh.md) 替代,凭据优先级已由[存储凭据优先级](2026-10-09-stored-credential-precedence.zh.md)替代。环境限制仍然适用。 ## Problem diff --git a/.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.i18n.yaml index f32414668b..ebe9f20065 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.i18n.yaml @@ -9,14 +9,14 @@ en: 1fe344c95a0de36d zh: 656c3e0f1117f64e /agent-note-forked-children-preserve-the-parent-request-prefix/decision: - en: b70aafc9ce30dd00 - zh: 74e3ff915bd968a9 + en: 3f5b3a4646c9466b + zh: 690172d6a4025b2a /agent-note-forked-children-preserve-the-parent-request-prefix/alternatives-considered: - en: 80504ffd8331e6c5 - zh: 193261bdde24b4bb + en: bc437309b64997ab + zh: 947010eed8d6fac4 /agent-note-forked-children-preserve-the-parent-request-prefix/consequences: - en: 0738d36f17c889b5 - zh: b71ee47f75d6d4e0 + en: 0f4d735328b3211d + zh: db4adb0f46b33bba /agent-note-forked-children-preserve-the-parent-request-prefix/consequences/accepted-risks: - en: f634caa9973eaedb - zh: 820291096c8e7352 + en: 72f4913b603e8596 + zh: 50924c62200070d1 diff --git a/.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md b/.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md index 4b52122e86..728d9eb15b 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md +++ b/.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md @@ -18,7 +18,7 @@ The model-facing `send_message` tool is registered globally for every Agent in a The base bundle and the `cordis`, `standard`, and `ptc` CLI presets bind fork to the continuable lifecycle. Base-backed profiles, including headless, inherit that default. `ForkInProcessProvider.prepareContinuable()` and `ctx.subagents.startActivation()` preserve the same request-prefix behavior across these compositions. -Byte-identical prefix reuse is qualified by explicit deployment choices. A fork delegation that applies a child persona or `toolFilter` may still change the request head. In particular, filtering out `send_message` removes both the schema and the return guidance from the child; the runtime does not bypass an explicit allow-list. +Byte-identical prefix reuse depends on the assembled inputs. Structured output or generated SDK differences can change the request head. Historical child descriptors can also restore persona or tool restrictions during cold resume; filtering out `send_message` removes both its schema and return guidance. New fork starts inherit the parent preset without these override fields. ## Alternatives considered @@ -28,11 +28,11 @@ Byte-identical prefix reuse is qualified by explicit deployment choices. A fork **Add the return instruction to the system prompt.** This would place child-only bytes ahead of inherited messages. Appending it to the initial user task preserves the inherited prefix and keeps the parent id next to the task that needs it. -**Ignore an explicit child `toolFilter`.** Structural return tools previously bypassed the child allow-list. Rejected because a declared tool restriction must determine both schema visibility and guidance; hidden authority would make the model-facing roster inaccurate. +**Ignore a historical child `toolFilter` on resume.** A stored restriction must continue to determine both schema visibility and guidance; silently discarding it changes the restored agent’s behavior. ## Consequences -- Parent and continuable-fork child expose byte-identical ordered tool schemas when the delegation does not request a persona or tool filter. +- Parent and continuable-fork child expose byte-identical ordered tool schemas when they use the same preset and have no child-only structured output or restored historical overrides. - The inherited Session seed precedes the child's initial task and return guidance. - The base bundle and full CLI presets use local continuable fork for both parent and caller delivery, without a child-only request-head addition. - The child can send messages to its direct parent through `send_message`. Parent delivery adds a separate runtime settlement notice; caller delivery returns the result through the activation receipt. @@ -40,4 +40,4 @@ Byte-identical prefix reuse is qualified by explicit deployment choices. A fork ### Accepted risks -Provider-side prefix reuse still depends on the selected provider and model and on the absence of explicit persona or tool-filter differences. The harness proves equality of its assembled request-head inputs, not a provider's cache behavior. +Provider-side prefix reuse still depends on the selected provider and model and matching assembled inputs. The harness tests equality of its assembled request-head inputs, not a provider’s cache behavior. diff --git a/.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.zh.md b/.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.zh.md index 2c426a1e7c..2ebaa9ddbc 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.zh.md @@ -18,7 +18,7 @@ fork 与 spawn 的差异在于:fork 会用 parent 已完成轮次的前缀作 base 组合包与 `cordis`、`standard` 和 `ptc` CLI preset 将 fork 绑定为可继续生命周期。基于 base 的 profile(包括 headless)继承这一默认值。`ForkInProcessProvider.prepareContinuable()` 与 `ctx.subagents.startActivation()` 在这些组合中保持相同的请求前缀行为。 -逐字节相同的前缀复用受显式部署选择约束。配置 child persona 或 `toolFilter` 的 fork 委派仍可能改变请求头。尤其是过滤掉 `send_message` 时,child 会同时失去该 schema 与返回指引;运行时不会绕过显式 allow-list。 +逐字节相同的前缀复用取决于组装输入。结构化输出或生成 SDK 差异可以改变请求头。历史子级 descriptor 也可在冷恢复时恢复 persona 或工具限制;过滤掉 `send_message` 会同时移除其 schema 和返回指引。新 fork 启动继承父级 preset,不含这些覆盖字段。 ## 考虑过的替代方案 @@ -28,11 +28,11 @@ base 组合包与 `cordis`、`standard` 和 `ptc` CLI preset 将 fork 绑定为 **把返回指令放进系统提示词。** 这会在继承消息之前加入 child-only 字节。将其追加到初始用户任务,可以保留继承前缀,并让 parent id 紧邻需要它的任务。 -**忽略显式 child `toolFilter`。** 结构性返回工具过去会绕过 child allow-list。否决该方案,因为声明的工具限制必须同时决定 schema 可见性与指引;隐藏权限会让面向模型的工具清单失真。 +**恢复时忽略历史子级 `toolFilter`。** 存储的限制必须继续决定 schema 可见性和指引;静默丢弃它会改变恢复 agent 的行为。 ## 后果 -- 未请求 persona 或工具过滤时,parent 与可继续 fork child 暴露逐字节相同且顺序一致的工具 schema。 +- 使用相同 preset 且没有子级专属结构化输出或恢复的历史覆盖时,父级与可继续的 fork 子级公开逐字节相同、顺序一致的工具 schema。 - 继承的 Session 种子位于 child 初始任务及其返回指引之前。 - base 组合包与完整 CLI preset 对 parent 和 caller 投递均使用本地可继续 fork,不增加 child-only 请求头字段。 - child 可以通过 `send_message` 向直接 parent 发消息。parent 投递会追加独立的运行时结算通知;caller 投递通过 activation 回执返回结果。 @@ -40,4 +40,4 @@ base 组合包与 `cordis`、`standard` 和 `ptc` CLI preset 将 fork 绑定为 ### 已接受风险 -提供方侧前缀复用仍取决于选定的提供方和模型,以及是否不存在显式 persona 或工具过滤差异。harness 证明的是自己组装出的请求头输入相等,而不是提供方的缓存行为。 +提供方侧前缀复用仍取决于所选提供方和模型,以及组装输入是否相同。harness 测试的是自身组装请求头输入相等,而非提供方的缓存行为。 diff --git a/.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.i18n.yaml index 6512532f4a..6401383a39 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.i18n.yaml @@ -18,5 +18,5 @@ en: ca7c9088d0793e1e zh: ea648bac9e726c64 /agent-note-what-stays-host-plane-once-presets-own-the-agent-plane/consequences: - en: de26cde94adda770 - zh: 7f39760b77958966 + en: 0a426923c2a1f774 + zh: 29626804b99b35ff diff --git a/.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.md b/.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.md index 546423f32e..620692ee9f 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.md +++ b/.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.md @@ -38,6 +38,6 @@ Projection key presence is not a per-session capability signal ([session project ## Consequences -The context meter becomes a per-session fact instead of a function of mount history. A preset can no longer opt out of token accounting; no shipped preset did, and `minimal` now says it drops auto-compaction rather than the accounting. +The context meter is a per-session fact independent of mount history. Presets resolve the host meter for token accounting while selecting their own compaction backend; `minimal` supports both automatic compaction and the human `/compact` command. Production entry points must compose Agents explicitly; installing the registry alone does not bind them. An unjoined Agent receives no tools, prompt sections, or skills and reports no error. diff --git a/.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.zh.md b/.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.zh.md index 59d318b410..325758c769 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.zh.md @@ -38,6 +38,6 @@ Status: implemented ## 后果 -context meter 成为逐会话的事实,而不再是挂载历史的函数。代价是 preset 不能再选择不做 token 记账;随附的 preset 没有一个这么做,`minimal` 现在也写明它放弃的是自动压缩而非记账。 +context meter 是独立于挂载历史的逐会话事实。Preset 使用宿主 meter 进行 token 记账,并选择自己的压缩后端;`minimal` 同时支持自动压缩和用户 `/compact` 命令。 生产入口必须显式组装 Agent;仅安装注册表不会自动绑定。未加入的 Agent 不会获得工具、提示词段落或 skill,也不会报告错误。 diff --git a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml index 981cb258f7..ad94eb0bd5 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml @@ -21,11 +21,11 @@ en: 29def175ab33b7d6 zh: 87e55ec28c20c7c1 /agent-note-package-and-update-the-electron-desktop-application/updates-and-recovery: - en: e9f140cd22670dfa - zh: bf85cfe9c12a0345 + en: 4cb7014acfe71762 + zh: 2178e0337cba0324 /agent-note-package-and-update-the-electron-desktop-application/security-and-release-policy: - en: 97ee608e96fc3602 - zh: 932b98e7d344cd87 + en: 5faaa53b58121311 + zh: e1c3fc8d21a27d1a /agent-note-package-and-update-the-electron-desktop-application/implementation: en: 041a6d35ed61b149 zh: 617c6b90a6f4fa4d diff --git a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md index 26a6b9ba99..0241afef65 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md +++ b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md @@ -75,13 +75,13 @@ The [immediate-window reference](../../../../apps/desktop/README.md) owns the lo `DSH_DESKTOP_AUTO_UPDATE_ENV` selects the test deployment by default or the production deployment for both the target-specific generic-provider URL and COS destination. Release automation supplies the test HTTPS origin through `DOWNLOAD_TEST_ORIGIN` and each deployment's bucket through `DOWNLOAD_TEST_COS_BUCKET` or `DOWNLOAD_PROD_COS_BUCKET`; keeping mutable test routing and COS storage identities out of source lets deployment infrastructure change without a code release, while the public production origin remains fixed. Packaging resolves only the public updater URL, disables electron-builder publishing, removes every COS credential field from its subprocess environment, and writes a completion record only after electron-builder and every signing or notarization hook succeeds. Target upload additionally requires the selected bucket, then requires the completion record, root dsh version, Desktop version, version-derived channel metadata, artifact names, sizes, and SHA-512 values to agree before it reads the selected credentials or sends data. It uploads immutable versioned updater payloads and any separate blockmaps before replacing the channel metadata emitted by electron-builder, and it never deletes historical objects. Stable versions use the `latest` metadata name; prereleases use the first semantic-version prerelease identifier. NSIS embeds its blockmap in the signed executable; the macOS ZIP carries a separate blockmap. Both let electron-updater download changed blocks when supported, while application replacement and the local pnpm package operation remain separate operations. -Test releases require `DOWNLOAD_TEST_RELEASE_ID` from the platform dotenv file. Their feeds and binaries share `dsh-desk//`; production retains its fixed directories. The ID is part of the packaged update URL, so upload rejects a different ID. Internal distribution can supply a new installer after rotation; installed clients retain their existing feed. Random directories reduce guessing but provide no authorization for link holders. The [release configuration](../../../../apps/desktop/README.md#upload-updates) defines generation and reuse. +Test releases require `DOWNLOAD_TEST_RELEASE_ID` from the platform dotenv file. Their feeds and binaries share `dsh-desk//`; production retains its fixed directories. The ID is part of the packaged update URL, so upload rejects a different ID. Internal distribution can supply a new installer after rotation; installed clients retain their existing feed. Random directories reduce guessing but provide no authorization for link holders. The [release configuration](../../../../apps/desktop/publishing/README.md#upload-updates) defines generation and reuse. ## Security and release policy Core dsh and the private Desktop Host come only from the signed application resource tree. Plugin installation forwards package specs to pnpm, including local and remote sources, but never accepts raw pnpm commands. pnpm owns dependency resolution and the profile’s `allowBuilds` policy; the Host loads activated bundles. -Electron release artifacts are signed; macOS artifacts are notarized. Release package and upload commands read release settings from the Git-ignored target `.env.windows` or `.env.macos`; subprocesses receive the fields selected by orchestration. The target file exclusively owns release fields so stale shell or system credentials cannot override local selection, and loading does not mutate the parent environment. Packaging validates the mode-specific application ID, update origin, signing identity, and local files before builds, downloads, or release-record cleanup; macOS also requires one complete notarization strategy. The separate `check:package` command runs the same validation without accessing the token or Apple; actual signing and notarization still verify authentication. Configuration loading rejects missing or malformed identifiers and incomplete notarization credentials, while macOS release packaging requires signing so certificate discovery cannot silently select another installed identity or emit an unsigned release. Runtime preparation verifies the exact Authority and Team ID plus the timestamp and hardened-runtime flags on every embedded Mach-O file. An after-sign hook performs Apple's deep strict application verification and requires the same leaf Authority and Team ID before artifact creation continues. The fixed-target installer command uses [isolated App copies for parallel notarization](../process/2026-09-09-parallel-macos-notarization.md): the ZIP contains a stapled App, while the signed DMG carries the ticket covering its unstapled inner App. The DMG artifact-completion hook requires the configured identity, a valid ticket, and Gatekeeper acceptance. Both artifact lanes must succeed before the command promotes their outputs and writes the release completion record; directory-only commands still notarize and staple the App. DMG blockmaps are disabled because macOS updates consume the signed ZIP, and stapling would otherwise invalidate an already-generated DMG blockmap. +Electron release artifacts are signed; macOS artifacts are notarized. Release package and upload commands read release settings from the Git-ignored target `.env.windows` or `.env.macos`; subprocesses receive the fields selected by orchestration. Windows packaging can instead select a machine-owned public `presets/.env`, plus `secrets/windows-signing.env` for signed builds; unsigned and preparation-only modes omit the signing file. The selected files exclusively own release fields so stale shell or system credentials cannot override local selection, and loading does not mutate the parent environment. Packaging validates the mode-specific application ID, update origin, signing identity, and local files before builds, downloads, or release-record cleanup; macOS also requires one complete notarization strategy. The separate `check:package` command runs the same validation without accessing the token or Apple; actual signing and notarization still verify authentication. Configuration loading rejects missing or malformed identifiers and incomplete notarization credentials, while macOS release packaging requires signing so certificate discovery cannot silently select another installed identity or emit an unsigned release. Runtime preparation verifies the exact Authority and Team ID plus the timestamp and hardened-runtime flags on every embedded Mach-O file. An after-sign hook performs Apple's deep strict application verification and requires the same leaf Authority and Team ID before artifact creation continues. The fixed-target installer command uses [isolated App copies for parallel notarization](../process/2026-09-09-parallel-macos-notarization.md): the ZIP contains a stapled App, while the signed DMG carries the ticket covering its unstapled inner App. The DMG artifact-completion hook requires the configured identity, a valid ticket, and Gatekeeper acceptance. Both artifact lanes must succeed before the command promotes their outputs and writes the release completion record; directory-only commands still notarize and staple the App. DMG blockmaps are disabled because macOS updates consume the signed ZIP, and stapling would otherwise invalidate an already-generated DMG blockmap. The [pinned osx-sign patch](../../../../patches/@electron__osx-sign@1.3.3.patch) uses `lstat` in both published module builds, so Framework file and directory aliases do not trigger duplicate signing. The patch remains necessary until the selected upstream release skips those aliases. PAK files are resources sealed by the enclosing bundle; individual signatures add serial timestamp requests without additional resource integrity. Desktop preserves all locale files and skips only their standalone signatures. Executable code retains Developer ID signatures, secure timestamps, and hardened runtime. The [signer traversal regression](../../../../apps/desktop/tests/macos-signing-walk.spec.ts) exercises the installed dependency with real Framework aliases; release qualification still requires strict application verification, notarization, and startup. @@ -91,7 +91,7 @@ Windows package invocations force `ELECTRON_BUILDER_7Z_FILTER=BCJ`. The bundled Local Windows and macOS installation testing uses an explicit `--unsigned` package invocation with the same build and runtime preparation. It strips certificate inputs, isolates artifacts in `unsigned-artifacts`, and omits updater configuration and the release completion record. macOS local builds ignore dotenv files and inherited release settings, use the standard application ID, and omit mandatory-update policy so local testing needs no deployment configuration. Their App receives a certificate-free ad-hoc signature; their DMG is unsigned and unnotarized. Windows local builds retain their platform configuration requirements. The regular package command explicitly selects signed mode even when its parent environment requests unsigned mode. This separation permits installation diagnosis without release credentials while preventing local test output from qualifying for release upload. -Windows application replacement follows the [directory-installation reference](../../../../apps/desktop/README.md): extract beside the destination with a command-line tool that returns failure status, then rename complete directories on the same volume. The installer retains the old directory through staging and restores it if promotion fails. Registration, shortcuts, and the signed uninstaller remain owned by electron-builder. +Windows application replacement follows the [directory-installation reference](../../../../apps/desktop/packaging/README.md#runtime-file-selection): extract beside the destination with a command-line tool that returns failure status, then rename complete directories on the same volume. The installer retains the old directory through staging and restores it if promotion fails. Registration, shortcuts, and the signed uninstaller remain owned by electron-builder. Packaged applications ignore development resource and project environment overrides. Only an unpackaged Electron process can replace the pnpm entry, dsh resources, or active project. diff --git a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md index 21ef91d0e0..f3075a175d 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md @@ -75,13 +75,13 @@ Electron 更新只使用一个 `electron-updater` 发布流和签名 `electron-b `DSH_DESKTOP_AUTO_UPDATE_ENV` 默认为测试部署,也可以选择生产部署,并同时决定目标专用的 generic-provider URL 与 COS 目标。发布自动化通过 `DOWNLOAD_TEST_ORIGIN` 提供测试 HTTPS origin,并通过 `DOWNLOAD_TEST_COS_BUCKET` 或 `DOWNLOAD_PROD_COS_BUCKET` 提供各部署的 bucket;可变的测试路由与 COS 存储身份不写入源码,部署基础设施变更时无需发布新代码,而公开的生产 origin 仍固定。打包只解析公开更新 URL、禁止 electron-builder 发布、从子进程环境中删除每个 COS 凭据字段,并且只有在 electron-builder 以及每个签名或公证 hook 成功后才写入完成记录。目标上传还必须提供所选 bucket,随后会先要求完成记录、根 dsh 版本、Desktop 版本、根据版本得出的频道元数据、产物名称、大小与 SHA-512 全部一致,再读取所选凭据或发送数据。它先上传不可变且带版本的更新载荷与所有独立 blockmap,最后替换 electron-builder 生成的频道元数据,并且不会删除历史对象。稳定版本使用 `latest` 元数据名称,预发布版本则使用语义化版本的第一个预发布标识符。NSIS 把 blockmap 嵌入已签名的可执行文件,macOS ZIP 则使用独立 blockmap;两者都让 electron-updater 在平台支持时只下载变化的数据块,而应用替换与本地 pnpm 包操作仍是两个独立操作。 -测试发布必须从平台 dotenv 文件提供 `DOWNLOAD_TEST_RELEASE_ID`。清单和安装包共用 `dsh-desk//`,生产环境保留固定目录。ID 是打包更新 URL 的一部分,因此上传会拒绝不同的 ID。轮换后可通过内部分发提供新安装包,已安装客户端保留原清单地址。随机目录降低被猜中的概率,但不对持有链接者进行鉴权。[发布配置](../../../../apps/desktop/README.zh.md#upload-updates)定义生成与复用方式。 +测试发布必须从平台 dotenv 文件提供 `DOWNLOAD_TEST_RELEASE_ID`。清单和安装包共用 `dsh-desk//`,生产环境保留固定目录。ID 是打包更新 URL 的一部分,因此上传会拒绝不同的 ID。轮换后可通过内部分发提供新安装包,已安装客户端保留原清单地址。随机目录降低被猜中的概率,但不对持有链接者进行鉴权。[发布配置](../../../../apps/desktop/publishing/README.zh.md#upload-updates)定义生成与复用方式。 ## 安全与发布策略 核心 dsh 和私有 Desktop Host 只来自签名应用的资源树。插件安装把包规格交给 pnpm,包括本地和远程来源,但不接受原始 pnpm 命令。pnpm 负责依赖解析和 profile 的 `allowBuilds` 策略;Host 加载已启用的 bundle。 -Electron 发布产物必须签名;macOS 产物必须公证。发布打包与上传命令从 Git 忽略的目标 `.env.windows` 或 `.env.macos` 读取发布配置,子进程通过编排器选择的环境字段接收配置。目标文件是发布字段的唯一来源,避免旧的 shell 或系统凭据覆盖本地选择;配置加载不修改父进程环境。打包在构建、下载或清理发布记录前校验该模式必需的应用 ID、更新地址、签名身份及本地文件,macOS 还要求一套完整公证凭据。单独的 `check:package` 执行同一校验而不访问 Token 或 Apple;凭据真实性仍由实际签名与公证验证。配置加载会拒绝缺失或格式错误的标识符和不完整的公证凭据,macOS 发布打包还会强制签名,避免证书发现过程静默选择其他已安装身份或生成未签名发布。运行时准备会验证每个内嵌 Mach-O 文件的精确 Authority 与 Team ID,以及时间戳和 hardened-runtime 标记。签名后钩子会执行 Apple 的深度严格应用验证,并要求同一叶证书 Authority 与 Team ID 完全匹配,验证通过后才继续生成产物。固定目标安装包命令使用[隔离的 App 副本并行公证](../process/2026-09-09-parallel-macos-notarization.zh.md):ZIP 包含已钉票的 App,签名 DMG 则携带覆盖其中未钉票 App 的票据。DMG 的 artifact-completion hook 要求其使用配置的身份、具备有效票据并通过 Gatekeeper。只有两条产物流都成功,命令才会移入其输出并写入发布完成记录;仅生成目录的命令仍会公证 App 并钉票。macOS 更新使用签名 ZIP,因此 DMG 不生成 blockmap;否则钉票会让已经生成的 DMG blockmap 失效。 +Electron 发布产物必须签名;macOS 产物必须公证。发布打包与上传命令从 Git 忽略的目标 `.env.windows` 或 `.env.macos` 读取发布配置,子进程通过编排器选择的环境字段接收配置。Windows 打包也可选择机器管理的公开 `presets/.env`,签名构建另读 `secrets/windows-signing.env`;未签名和仅准备模式不读取签名文件。所选文件是发布字段的唯一来源,避免旧的 shell 或系统凭据覆盖本地选择;配置加载不修改父进程环境。打包在构建、下载或清理发布记录前校验该模式必需的应用 ID、更新地址、签名身份及本地文件,macOS 还要求一套完整公证凭据。单独的 `check:package` 执行同一校验而不访问 Token 或 Apple;凭据真实性仍由实际签名与公证验证。配置加载会拒绝缺失或格式错误的标识符和不完整的公证凭据,macOS 发布打包还会强制签名,避免证书发现过程静默选择其他已安装身份或生成未签名发布。运行时准备会验证每个内嵌 Mach-O 文件的精确 Authority 与 Team ID,以及时间戳和 hardened-runtime 标记。签名后钩子会执行 Apple 的深度严格应用验证,并要求同一叶证书 Authority 与 Team ID 完全匹配,验证通过后才继续生成产物。固定目标安装包命令使用[隔离的 App 副本并行公证](../process/2026-09-09-parallel-macos-notarization.zh.md):ZIP 包含已钉票的 App,签名 DMG 则携带覆盖其中未钉票 App 的票据。DMG 的 artifact-completion hook 要求其使用配置的身份、具备有效票据并通过 Gatekeeper。只有两条产物流都成功,命令才会移入其输出并写入发布完成记录;仅生成目录的命令仍会公证 App 并钉票。macOS 更新使用签名 ZIP,因此 DMG 不生成 blockmap;否则钉票会让已经生成的 DMG blockmap 失效。 [固定版本的 osx-sign 补丁](../../../../patches/@electron__osx-sign@1.3.3.patch)在两种已发布模块构建中使用 `lstat`,因此 Framework 的文件和目录别名不会触发重复签名。选定的上游版本能够跳过这些别名前,仍需保留该补丁。PAK 文件由外层 bundle 签名记录完整性;逐个签名会增加串行时间戳请求,但不会增加资源完整性保护。Desktop 保留全部语言文件,只跳过其单独签名。可执行代码仍使用 Developer ID 签名、安全时间戳和 hardened runtime。[签名器遍历回归测试](../../../../apps/desktop/tests/macos-signing-walk.spec.ts)使用真实 Framework 别名执行已安装依赖;发布验收仍要求严格应用验证、公证和启动。 @@ -91,7 +91,7 @@ Windows 打包调用强制设置 `ELECTRON_BUILDER_7Z_FILTER=BCJ`。内置的 7- 本地 Windows 和 macOS 安装测试使用显式的 `--unsigned` 打包调用,并执行相同的构建和运行时准备。它清除证书输入,将产物隔离到 `unsigned-artifacts`,并省略更新器配置和发布完成记录。macOS 本地构建忽略 dotenv 文件和继承的发布配置,使用标准应用 ID,并省略强制更新策略,使本地测试无需部署配置。其 App 使用无需证书的 ad-hoc 签名,DMG 不签名也不公证。Windows 本地构建保留平台配置要求。即使父进程环境请求未签名模式,常规打包命令也会显式选择签名模式。这样既能在没有发布凭据时诊断安装问题,也能防止本地测试产物通过发布上传校验。 -Windows 应用替换遵循[目录安装参考](../../../../apps/desktop/README.zh.md):使用能返回失败状态的命令行工具解压到目标旁边,再在同卷内改名替换完整目录。安装器在暂存期间保留旧目录,正式替换失败时恢复旧目录。注册信息、快捷方式和签名卸载器仍由 electron-builder 持有。 +Windows 应用替换遵循[目录安装参考](../../../../apps/desktop/packaging/README.zh.md#runtime-file-selection):使用能返回失败状态的命令行工具解压到目标旁边,再在同卷内改名替换完整目录。安装器在暂存期间保留旧目录,正式替换失败时恢复旧目录。注册信息、快捷方式和签名卸载器仍由 electron-builder 持有。 打包应用会忽略开发资源和项目环境变量覆盖。只有未打包的 Electron 进程可以替换 pnpm 入口、dsh 资源 或活跃项目。 diff --git a/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.i18n.yaml index 207508cad2..260adf6610 100644 --- a/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.i18n.yaml @@ -12,8 +12,8 @@ en: 1188c699ce6e6ca2 zh: 4d5b974cd1216f06 /agent-note-adjacent-agents-share-one-steer-send-message-operation/decision/one-model-tool-and-one-return-instruction: - en: 8a4be155a4984ca8 - zh: 00c2a61da2443530 + en: 4b0a25389324fa91 + zh: c2cdd9f7e40ef43b /agent-note-adjacent-agents-share-one-steer-send-message-operation/decision/complete-removal-and-reintroduction-condition: en: 5a9ec6478cf7430c zh: f4c2f067cc72040e diff --git a/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.md b/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.md index 43048f9a8c..4efb9d334e 100644 --- a/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.md +++ b/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.md @@ -46,7 +46,7 @@ interface SendMessageInput { } ``` -Parents and children inherit the same definition in the same registry order. The standard definition carries a process-stable internal identity that a scoped same-name tool does not satisfy. A child `toolFilter` may explicitly remove the inherited tool, and a scoped replacement may provide different semantics; neither case receives the standard call instruction. When the standard tool remains visible, the continuation manager appends the JSON-encoded direct parent id and the instruction to send one self-contained result before finishing, plus earlier actionable findings, to the child's initial user task. For a fork child this task follows the inherited completed-turn prefix; no child-only system-prompt section or tool schema precedes that prefix. +Parents and children inherit the same definition in the same registry order. The standard definition carries a process-stable internal identity that a scoped same-name tool does not satisfy. An independently selected preset may omit the tool, a historical descriptor filter may remove it on resume, and a scoped replacement may provide different semantics; neither case receives the standard call instruction. When the standard tool remains visible, the continuation manager appends the JSON-encoded direct parent id and the instruction to send one self-contained result before finishing, plus earlier actionable findings, to the child's initial user task. For a fork child this task follows the inherited completed-turn prefix; no child-only system-prompt section or tool schema precedes that prefix. The instruction is guidance, not settlement enforcement. Sending does not end the child's turn, zero or several calls remain mechanically valid, and the runtime never rejects a child for staying silent. The manager-owned `subagent-settled` notice remains unconditional and separately attributed because it records how an Activation ended and preserves terminal output when the child cannot cooperate. diff --git a/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.zh.md b/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.zh.md index 6d1e2d4923..b7ed157f54 100644 --- a/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.zh.md @@ -46,7 +46,7 @@ interface SendMessageInput { } ``` -parent 与 child 以相同注册表顺序继承相同定义。标准定义携带进程稳定的内部身份,同名的作用域工具不满足该身份。child `toolFilter` 可以显式移除继承的工具,作用域替代工具也可以提供不同语义;两种情况都不会收到标准调用指令。当标准工具仍可见时,继续执行管理器会把经过 JSON 编码的直接 parent id、结束前发送一份自包含结果的指令,以及更早发送可操作发现的指令追加到 child 初始用户任务。对 fork child 而言,该任务位于继承的已完成轮次前缀之后;没有 child 专属系统提示词 section 或工具 schema 位于此前缀之前。 +parent 与 child 以相同注册表顺序继承相同定义。标准定义携带进程稳定的内部身份,同名的作用域工具不满足该身份。独立选择的 preset 可以省略该工具,历史 descriptor 过滤器可在恢复时移除该工具,作用域替代工具也可以提供不同语义;两种情况都不会收到标准调用指令。当标准工具仍可见时,继续执行管理器会把经过 JSON 编码的直接 parent id、结束前发送一份自包含结果的指令,以及更早发送可操作发现的指令追加到 child 初始用户任务。对 fork child 而言,该任务位于继承的已完成轮次前缀之后;没有 child 专属系统提示词 section 或工具 schema 位于此前缀之前。 该指令是指导,不是结算强制。发送不会结束 child 轮次,机制仍允许零次或多次调用,runtime 绝不会因 child 保持沉默而拒绝它。由管理器负责的 `subagent-settled` 通知仍无条件发送并采用独立来源,因为它记录 Activation 如何结束,并在 child 无法配合时保留终态输出。 diff --git a/.agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.i18n.yaml index 2a8fdcb1f7..b66d66b8e0 100644 --- a/.agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.i18n.yaml @@ -15,5 +15,5 @@ en: 09be2a1cf1d52fd5 zh: 0bf0e18c73542012 /agent-note-one-output-ring-one-projection-one-event-stream-for-background-jobs/consequences: - en: 464eb843a71eb04e - zh: 783062f5b288b1b4 + en: 3263801e173b6b86 + zh: f6f51bb3f77a817e diff --git a/.agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.md b/.agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.md index 0a75d42b30..04f106edf3 100644 --- a/.agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.md +++ b/.agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.md @@ -35,3 +35,5 @@ The job seam had grown by accretion. Producers declared their output twice — a The model-visible text is unchanged: status lines, tool descriptions and schemas, and notice wording are byte-identical, and the only new model-visible fact is the kill reason a `killed` status line now carries. A producer that needs to stream output picks pull sources or `append`; it never formats a consuming delta. The Web client renders every live job as expandable, so a job that never writes shows an empty running panel instead of a static row. `JobKindMap` merges are host-side, so a browser program types `JobView.kind` as the kinds it knows and treats the rest as opaque strings, which is all the roster does with it. Review follow-ups after the master merge tightened four of these edges. A job's first event is always `registered`: the announcement precedes the pump, whose first drain runs synchronously and may announce output. A settled ring keeps every byte the model cursor has not consumed until the first terminal model read, so a job that finishes before its first `job_output` loses nothing the live cap retained. `dsh-tool-jobs` holds one claim per wait call with a `live` flag that the caller's abort clears synchronously from the abort event, so a settlement announced before the registry's rejection reaches the tool still delivers its notice, and a timed-out wait drops only its own claim while a concurrent wait keeps the settlement covered. The browser records an observation failure that precedes the anchor as an errored view, so the panel shows the notice instead of staying blank. The spill file a pull source keeps is job metadata (`JobView.output.spillPaths`, reported by every pump read and withdrawn once a read stops naming it), not per-chunk metadata, and the model's read renders the shell tools' dropped-output notice for a producer-side gap as well as for ring eviction, naming those files even after the gap chunk itself was evicted — the `before this read` variant this change had introduced is gone. An observation generation that sees its job removed mid-stream (the owner's teardown) closes with the removed job's terminal projection instead of a failed read. A rejected background spawn produces no process output, so the shell providers now serve the `spawn failed: …` note as the whole observed stderr stream (the consuming read still folds it in once), and `ShellProcess.observed` with both readers is required: the model's first `job_output` after such a failure reads exactly what master's consuming read rendered. + +The [Session lifecycle decision](../bug-fix/2026-09-30-job-session-lifecycle.md) records registration and settlement for durable SDK consumers while preserving this live event stream and output ownership. diff --git a/.agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.zh.md b/.agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.zh.md index 5cfc88927c..798f9d2b7c 100644 --- a/.agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.zh.md @@ -35,3 +35,5 @@ job seam 是逐步堆出来的。生产者要把输出声明两次——模型 模型可见文本保持不变:状态行、工具描述与 schema、播报措辞逐字节相同,唯一新增的模型可见事实是 `killed` 状态行现在携带的 kill 原因。需要流式输出的生产者选择拉取源或 `append`,从不格式化消耗式增量。Web 客户端把每个进行中的 job 渲染为可展开,因此从不写输出的 job 显示为空的运行中面板而非静态行。`JobKindMap` 的合并发生在宿主侧,浏览器程序把 `JobView.kind` 视为它认识的 kind 集合,其余当作不透明字符串——名册对它也只做这些。 合并 master 之后的评审跟进收紧了其中四条边。job 的第一个事件永远是 `registered`:通告先于泵,而泵的首轮 drain 同步执行且可能通告输出。已结算的环在模型的首次终态读取之前保留模型游标尚未消费的全部字节,因此在首次 `job_output` 之前结束的 job 不会丢失运行期上限保留的任何内容。`dsh-tool-jobs` 为每次等待调用保存一条带 `live` 标记的认领,调用方 abort 时在 abort 事件里同步清掉该标记,因此注册表的拒绝尚未传回工具之前宣布的结算仍会投递通知;超时的等待只丢弃自己的认领,并发的另一个等待仍覆盖该结算。浏览器把先于锚点发生的观察失败记录为带错误的视图,面板显示提示而不是留白。拉取源保留的 spill 文件是 job 元数据(`JobView.output.spillPaths`,每次泵读取都会上报,读取不再点名时即撤回),不是逐块元数据;模型读取对生产者侧缺口与环淘汰都渲染 shell 工具的丢失输出提示并列出这些文件,即使缺口块本身已被淘汰——本次变更曾引入的 `before this read` 变体已删除。观察生成过程若在流中看到其 job 被移除(拥有者 teardown),以被移除 job 的终态投影收尾,而不是一次失败的读取。被拒绝的后台 spawn 不产生任何进程输出,因此 shell provider 现在把 `spawn failed: …` 提示作为 observed stderr 的整条流提供(消耗式读取仍只折入一次),且 `ShellProcess.observed` 及其两个读取器改为必填:这类失败之后模型的首次 `job_output` 读到的正是 master 的消耗式读取所渲染的内容。 + +[Session 生命周期决策](../bug-fix/2026-09-30-job-session-lifecycle.zh.md)为持久化 SDK 消费方记录注册与结算,同时保留这里的运行期事件流和输出归属。 diff --git a/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.i18n.yaml index f12b7ff463..40b064454f 100644 --- a/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.i18n.yaml @@ -18,8 +18,8 @@ en: 53d48f71fcc1c52e zh: 7d61923484dbec1c /agent-note-bundle-the-desktop-runtime-and-retain-external-plugins/alternatives-considered: - en: ead5bbd94044c3de - zh: 057cf069413d2a49 + en: 67b3ba2b37e92f88 + zh: 785083e5de098af8 /agent-note-bundle-the-desktop-runtime-and-retain-external-plugins/consequences: en: 6dfa581ec6879c5b zh: f6c735ad6a00b2b3 diff --git a/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md b/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md index ccb70a68ab..a1aeb68f2d 100644 --- a/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md +++ b/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md @@ -48,7 +48,7 @@ The [immediate-window reference](../../../../apps/desktop/README.md) owns direct ## Alternatives considered -Full runtime verification belongs to packaging. Startup reads the resource descriptor and checks shared package records and required Host entries. The [release-validation reference](../../../../apps/desktop/README.md) assigns release and target compatibility checks to packaging. Startup neither enumerates nor hashes installed runtime files. Reading every file before backend loading adds I/O proportional to distribution size; unusable modules instead fail when loaded. Build-time verification rejects changed, missing, extra, or linked files against the recorded inventory. +Full runtime verification belongs to packaging. Startup reads the resource descriptor and checks shared package records and required Host entries. The [release-validation reference](../../../../apps/desktop/packaging/README.md#desktop-runtime-preparation) assigns release and target compatibility checks to packaging. Startup neither enumerates nor hashes installed runtime files. Reading every file before backend loading adds I/O proportional to distribution size; unusable modules instead fail when loaded. Build-time verification rejects changed, missing, extra, or linked files against the recorded inventory. - **Install the bundled offline seed at startup.** This preserves an ordinary pnpm installation procedure but repeats core extraction and installation on every affected machine. Materialized resources remove that work at the cost of more application files and release-builder responsibility. - **Force host dependency versions into plugins.** This unnecessarily couples ordinary plugin dependencies to the host. The runtime resolution supplies missing packages while pnpm-owned entries retain independent versions. diff --git a/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md b/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md index c75bc1c411..73728d1feb 100644 --- a/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md @@ -48,7 +48,7 @@ profile manifest 分别记录 pnpm 安装的依赖及已启用 bundle 列表。 ## 考虑过的替代方案 -完整运行时验证属于打包流程。启动读取资源描述文件,并检查共享包记录及必要的 Host 入口。[发布验证参考](../../../../apps/desktop/README.zh.md)将发布与目标兼容性检查交给打包流程。启动既不枚举已安装运行时文件,也不计算其哈希。后端加载前读取每个文件会增加与分发体积成正比的 I/O;不可用模块改由加载时失败暴露。构建时验证按记录清单拒绝内容变化、缺失、多余或链接文件。 +完整运行时验证属于打包流程。启动读取资源描述文件,并检查共享包记录及必要的 Host 入口。[发布验证参考](../../../../apps/desktop/packaging/README.zh.md#desktop-runtime-preparation)将发布与目标兼容性检查交给打包流程。启动既不枚举已安装运行时文件,也不计算其哈希。后端加载前读取每个文件会增加与分发体积成正比的 I/O;不可用模块改由加载时失败暴露。构建时验证按记录清单拒绝内容变化、缺失、多余或链接文件。 - **启动时安装内置离线 seed。** 这保留普通 pnpm 安装流程,但会在每台受影响机器上重复核心解压与安装。物化资源消除了这部分工作,代价是更多应用文件和发布构建器责任。 - **强制插件使用 Host 依赖版本。** 这会让普通插件依赖不必要地耦合于 Host。runtime resolution 提供缺失的包,pnpm 拥有的条目保留独立版本。 diff --git a/.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.i18n.yaml index 5cadfbef6f..8d31b237e6 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.i18n.yaml @@ -33,8 +33,8 @@ en: 99308bd758859e02 zh: 35f1e7719fe80d2e /agent-note-add-immutable-profile-resolution-generations/decision/additive-package-changes: - en: 0e5e2006caec3c91 - zh: fb3b61dd5692d2e3 + en: 39d27cff0d419259 + zh: 6cbdc88a450614d0 /agent-note-add-immutable-profile-resolution-generations/decision/filesystem-and-runtime-carriers: en: b0607713102a093d zh: 2de9c33bfaa0e3e2 diff --git a/.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.md b/.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.md index d933a4507e..08aeffa559 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.md +++ b/.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.md @@ -107,7 +107,7 @@ New Workers inherit the latest published generation. Existing Workers keep the g A caller adding a package completes its pnpm transaction before constructing a successor generation. Replacement rejects any generation that changes the directory or version of a retained package. Plugin Manager publishes the successor before mounting the new Loader row. A mount failure may leave the package installed but inactive. -A retained package keeps its normalized directory, version, and scope; installation entries also keep their declaring anchor and cannot be removed. Violating these restrictions requires process restart because Node's ESM Module Map, CommonJS cache, existing object references, and running Workers can retain the old module identity. A profile entry may change its declaring anchor: deselecting the first bundle that reaches a shared dependency can leave another bundle selecting the same physical package. Profile-scoped mappings and profile-local package names may be removed; the caller stops their plugins before deleting package files. These restrictions do not prohibit removing a linked root from the interception scope. Generation replacement does not claim to unload modules. +A retained package keeps its normalized directory, version, and scope; installation entries also keep their declaring anchor and cannot be removed. Violating these restrictions requires process restart because Node's ESM Module Map, CommonJS cache, existing object references, and running Workers can retain the old module identity. A profile entry may change its declaring anchor: deselecting the first bundle that reaches a shared dependency can leave another bundle selecting the same physical package. Profile-scoped mappings and profile-local package names may be removed; the caller stops their plugins before deleting package files. A profile mapping moves to another directory only through removal and a new installation; a linked root may point to another target ([replacement Note](2026-10-01-replace-changed-package-modules-on-recomposition.md)). These restrictions do not prohibit removing a linked root from the interception scope. Generation replacement does not claim to unload modules. ### Filesystem and runtime carriers diff --git a/.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.zh.md b/.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.zh.md index 24da72defb..cf63626c6a 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.zh.md @@ -107,7 +107,7 @@ runtime resolution 列出它提供的包;Loader entries 组成活动插件列 添加包的调用方先完成 pnpm 事务,再构造下一代。替换操作会拒绝改变任何保留包的目录或版本。Plugin Manager 先发布后继 generation,再挂载新的 Loader 配置项。挂载失败可以留下已安装但未启用的包。 -保留包的规范化目录、版本和作用域必须不变;安装条目还必须保留声明锚点,且不能移除。违反这些限制需要重启,因为 Node 的 ESM Module Map、CommonJS cache、现存对象引用和运行中的 Worker 都可能保留旧模块 identity。profile 条目可以改变声明锚点:取消选入最先找到共享依赖的组合包后,另一个组合包仍可能选中同一物理包。profile 范围的映射和 profile 本地包名可以移除;调用方在删除包文件之前先停止相关插件。这些限制不禁止从拦截范围移除 linked root。generation 换代不声称卸载模块。 +保留包的规范化目录、版本和作用域必须不变;安装条目还必须保留声明锚点,且不能移除。违反这些限制需要重启,因为 Node 的 ESM Module Map、CommonJS cache、现存对象引用和运行中的 Worker 都可能保留旧模块 identity。profile 条目可以改变声明锚点:取消选入最先找到共享依赖的组合包后,另一个组合包仍可能选中同一物理包。profile 范围的映射和 profile 本地包名可以移除;调用方在删除包文件之前先停止相关插件。profile 映射只能通过卸载和重新安装移到其他目录;linked root 可以指向其他目标([替换 Note](2026-10-01-replace-changed-package-modules-on-recomposition.zh.md))。这些限制不禁止从拦截范围移除 linked root。generation 换代不声称卸载模块。 ### 文件系统与运行时载体 diff --git a/.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.i18n.yaml index 4bef95dc24..b348c71538 100644 --- a/.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.i18n.yaml @@ -15,8 +15,8 @@ en: 3c2dd2c948ffb51b zh: d019197df0ee3dcf /agent-note-deepseek-account-browser-login/consequences: - en: a6eff4a0350e9018 - zh: 2e3200ca089023ab + en: 3c5681b492cd34cf + zh: d7707fed9241f9bc /agent-note-deepseek-account-browser-login/verification: en: 881edcb454b941a6 zh: 7dc5a9b7a066f623 diff --git a/.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.md b/.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.md index 70ed73f265..2e08d36606 100644 --- a/.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.md +++ b/.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.md @@ -46,7 +46,7 @@ Profile and recharge-wallet queries use independent getProfile and getBalance op Deployment authentication uses explicit Host-only requestHeaders on the configured Platform origin. The provider rejects redirects and reserved-header overrides so deployment cookies cannot replace account authorization or follow a browser destination. Environment-specific authentication protocols remain outside the account provider. -The bundled account UI is Desktop-only: its preload bridge enables the account launcher, settings and sign-in onboarding. Plain Web retains API-key onboarding and the standard Settings launcher without account subscription or login. The Host protocol accepts login_source (desktop or web) in auth_init; the bundled UI sends desktop. The backend accepts localhost callbacks. DSH preserves the browser-supplied localhost hostname and port without DNS resolution or conversion to an IP literal. +The bundled account UI mounts the account launcher, settings and sign-in onboarding in both Desktop and plain Web. The Host sends login_source (desktop or web) in auth_init so Platform can choose the completion experience; the UI sends desktop when the Desktop preload bridge is present and web otherwise. Web failures close the authorization tab while the original tab receives Host state; no Web UI return URL is used. The backend accepts localhost callbacks. DSH preserves the browser-supplied localhost hostname and port without DNS resolution or conversion to an IP literal. The macOS development launcher registers an isolated, ad-hoc-signed application bundle for `dsh://open`. The bundle retains the workspace entry and development paths for Launch Services cold starts without copying credentials or changing the package-manager-owned Electron application. Protocol registration targets the latest launched development or packaged application. diff --git a/.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.zh.md b/.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.zh.md index 5b0df92064..235fcba920 100644 --- a/.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.zh.md @@ -46,7 +46,7 @@ DSH 授权通过 x-dsh-auth-token 请求头鉴权 Platform、推理和 Files 请 开发环境认证通过配置的平台来源上的显式 Host 请求头 requestHeaders 完成。提供者拒绝重定向和保留请求头覆盖,防止开发环境 Cookie 替换账号授权或跟随浏览器跳转地址。特定环境的认证协议不属于账号提供者。 -内置账号界面仅用于 Desktop:preload 桥接启用账号入口、设置和登录引导。普通 Web 保留 API Key 引导及标准设置入口,不订阅账号状态或显示登录。Host 协议在 auth_init 中接受 login_source(desktop 或 web);内置界面发送 desktop。后端接受 localhost 回调。DSH 保留浏览器提供的 localhost 主机名和端口,不做 DNS 解析或 IP 字面量转换。 +内置账号界面在 Desktop 和普通 Web 中都挂载账号入口、设置和登录引导。Host 在 auth_init 中发送 login_source(desktop 或 web),以便 Platform 选择完成页体验;存在 Desktop preload 桥接时界面发送 desktop,否则发送 web。Web 登录失败时关闭授权标签页,原标签页通过 Host 状态获知结果;不使用 Web UI 返回 URL。后端接受 localhost 回调。DSH 保留浏览器提供的 localhost 主机名和端口,不做 DNS 解析或 IP 字面量转换。 macOS 开发启动器为 `dsh://open` 注册独立、经临时签名的应用包。该应用包保留工作区入口和开发路径,使 Launch Services 能够冷启动;凭证不会被复制,也不修改包管理器安装的 Electron 应用。协议注册指向最近启动的开发版或打包版应用。 diff --git a/.agents/notes/implemented/architecture/2026-09-17-process-local-blank-sessions.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-17-process-local-blank-sessions.i18n.yaml index ae4cd23658..5441a65985 100644 --- a/.agents/notes/implemented/architecture/2026-09-17-process-local-blank-sessions.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-17-process-local-blank-sessions.i18n.yaml @@ -9,14 +9,14 @@ en: 837ebb0071a5611b zh: 23d0f0713889ead6 /agent-note-writer-acquisition-for-blank-session-reuse/decision: - en: 7f2676feb224e7ea - zh: babfda5b31dfd0fe + en: a54db0d4a97aa9b8 + zh: a52d257b657fe99b /agent-note-writer-acquisition-for-blank-session-reuse/alternatives-considered: en: 2a67a53f7f396ba8 zh: 6df6ccc071fb0de2 /agent-note-writer-acquisition-for-blank-session-reuse/consequences: - en: 2e1f1894337689a2 - zh: d0cab07cc48083ee + en: 8307612b7476e8f3 + zh: 87fa39e58d33d4f1 /agent-note-writer-acquisition-for-blank-session-reuse/verification: - en: 4680e845e2ab294e - zh: ea5890c5ffb09831 + en: 4367d55ae9dce5c1 + zh: c610626f4e15eb77 diff --git a/.agents/notes/implemented/architecture/2026-09-17-process-local-blank-sessions.md b/.agents/notes/implemented/architecture/2026-09-17-process-local-blank-sessions.md index c832604dfb..d6be46b3ed 100644 --- a/.agents/notes/implemented/architecture/2026-09-17-process-local-blank-sessions.md +++ b/.agents/notes/implemented/architecture/2026-09-17-process-local-blank-sessions.md @@ -10,7 +10,7 @@ Two Hosts sharing Session storage can select the same blank Session when opening ## Decision -The Session catalog includes persisted blanks. Startup restoration selects the saved blank; ordinary Workspace navigation selects the first eligible unarchived member in catalog order. Explicit-id `session.create` resumes that Session and acquires its writer before opening history. Only `session/writer-held` triggers creation of a new Session. Other errors propagate. Concurrent Workspace connects in one Client share the acquisition and fallback. Later navigation cancels a pending startup selection. +The Session catalog includes persisted blanks. Startup restoration selects the saved blank; ordinary Workspace navigation selects the first eligible unarchived member in catalog order. Explicit-id `session.create` names that Session; the Host resumes it and acquires its writer before opening history, and creates a new Session when another owner holds the writer ([reuse and preset rules](../bug-fix/2026-09-29-reused-blank-runs-requested-preset.md)). Other errors propagate. A Workspace connect in one Client joins the latest in-flight connect to that Workspace only when both request the same preset. Later navigation cancels a pending startup selection. The Agent retains the acquired handle throughout its lifetime. Checking ownership and releasing a probe lock would leave a race before resume. No PID or process identity enters persisted data or the Remote schema. @@ -28,8 +28,8 @@ This decision extends the blank-reuse policy in the [Client Session scope refere ## Consequences -A Host can reuse its own live blank or reclaim an unowned persisted blank with its slash state. Separate Hosts create distinct blanks while existing writers remain held. A held selected blank causes fresh creation even when another blank is free; navigation does not search for another candidate. No cleanup is added. Opening the same established conversation from two Hosts can still encounter writer contention. Unknown projection hints remain visible without cold body scans; automatic reuse requires known blank metadata. A non-contention acquisition failure aborts New Session and is currently reported only to the console; hidden blanks have no recovery action in this flow. +A Host can reuse its own live blank or reclaim an unowned persisted blank with its slash state. Separate Hosts create distinct blanks while existing writers remain held. A held selected blank causes fresh creation even when another blank is free; navigation does not search for another candidate. No cleanup is added. Opening the same established conversation from two Hosts can still encounter writer contention. Unknown projection hints remain visible without cold body scans; automatic reuse requires known blank metadata. A non-contention acquisition failure aborts the navigation; a hero Workspace pick or explicit draft preparation reports it as a notice and startup restoration only to the console, and a hidden blank that fails this way has no recovery action in this flow. ## Verification -Navigation tests cover saved selection, single-candidate contention fallback, non-contention failures, overlapping acquisition, and superseding navigation. A shipped Web-composition test holds a real persistence writer, verifies fresh creation despite another free blank, and reclaims the released selected blank with its plan state. The fresh-round-trip recorded Session scenario reloads its blank before submitting the recorded prompt. +Navigation tests cover saved selection, opening the Session the Host returns for a held candidate, non-contention failures, overlapping acquisition, and superseding navigation. A shipped Web-composition test holds a real persistence writer, verifies fresh creation despite another free blank, and reclaims the released selected blank with its plan state. The fresh-round-trip recorded Session scenario reloads its blank before submitting the recorded prompt. diff --git a/.agents/notes/implemented/architecture/2026-09-17-process-local-blank-sessions.zh.md b/.agents/notes/implemented/architecture/2026-09-17-process-local-blank-sessions.zh.md index f5d5d58bc6..d477e9770c 100644 --- a/.agents/notes/implemented/architecture/2026-09-17-process-local-blank-sessions.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-17-process-local-blank-sessions.zh.md @@ -10,7 +10,7 @@ Status: implemented ## Decision -Session 列表包含持久化空白会话。启动恢复选择已保存的空白会话;普通 Workspace 导航按列表顺序选择第一个符合条件且未归档的成员。显式 ID 的 `session.create` 会恢复该 Session,并在打开历史前取得写锁。只有 `session/writer-held` 会触发新建 Session。其他错误返回调用方。同一 Client 的并发 Workspace 连接共享获取与回退过程。后续导航会取消尚未完成的启动选择。 +Session 列表包含持久化空白会话。启动恢复选择已保存的空白会话;普通 Workspace 导航按列表顺序选择第一个符合条件且未归档的成员。显式 ID 的 `session.create` 指明该 Session;Host 恢复它并在打开历史前取得写锁,写锁被其他写者持有时改为新建 Session([复用与 preset 规则](../bug-fix/2026-09-29-reused-blank-runs-requested-preset.zh.md))。其他错误返回调用方。同一 Client 中的 Workspace 连接仅在与该 Workspace 最近一次进行中的连接请求相同 preset 时与之合并。后续导航会取消尚未完成的启动选择。 Agent 在整个生命周期内持有取得的句柄。检查占用后释放探测锁,会在恢复前留下竞态。持久化数据与 Remote schema 均不引入 PID 或进程身份。 @@ -28,8 +28,8 @@ Agent 在整个生命周期内持有取得的句柄。检查占用后释放探 ## Consequences -Host 可以复用自己的活动空白会话,也可以接管无人占用的持久化空白会话并保留其 slash 状态。现有写锁被持有时,不同 Host 创建不同的空白会话。所选空白会话被占用时,即使另有空闲空白会话也会新建;导航不再寻找其他候选。不增加清理逻辑。两个 Host 打开同一个已有对话时仍可能遇到写锁争用。未知投影提示仍可见且不扫描冷日志正文;自动复用要求已知的空白元数据。非锁争用的获取错误会中止新建会话,目前只报告到控制台;此流程没有针对隐藏空白会话的恢复操作。 +Host 可以复用自己的活动空白会话,也可以接管无人占用的持久化空白会话并保留其 slash 状态。现有写锁被持有时,不同 Host 创建不同的空白会话。所选空白会话被占用时,即使另有空闲空白会话也会新建;导航不再寻找其他候选。不增加清理逻辑。两个 Host 打开同一个已有对话时仍可能遇到写锁争用。未知投影提示仍可见且不扫描冷日志正文;自动复用要求已知的空白元数据。非锁争用的获取错误会中止该次导航;在 hero 中选择工作区或显式准备草稿时以提示展示该错误,启动恢复只报告到控制台,以此方式失败的隐藏空白会话在此流程中没有恢复操作。 ## Verification -导航测试覆盖已保存选择、单候选锁争用回退、其他错误、重叠获取以及后续导航替代。实际 Web 组合测试持有真实持久化写锁,验证另有空闲空白会话时仍新建,并接管已释放的所选空白会话及其 plan 状态。fresh-round-trip 的录制 Session 场景在提交已录制的提示词前刷新空白会话。 +导航测试覆盖已保存选择、为被占用候选打开 Host 返回的 Session、其他错误、重叠获取以及后续导航替代。实际 Web 组合测试持有真实持久化写锁,验证另有空闲空白会话时仍新建,并接管已释放的所选空白会话及其 plan 状态。fresh-round-trip 的录制 Session 场景在提交已录制的提示词前刷新空白会话。 diff --git a/.agents/notes/implemented/architecture/2026-09-18-declarative-agent-presets.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-18-declarative-agent-presets.i18n.yaml index 70d50d1b1a..455daa7fff 100644 --- a/.agents/notes/implemented/architecture/2026-09-18-declarative-agent-presets.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-18-declarative-agent-presets.i18n.yaml @@ -9,8 +9,8 @@ en: 725f773bb350218e zh: dd8fbc09ef48208d /agent-note-declarative-agent-presets-and-retained-revisions/decision: - en: 53fd72bf4bbe4eb3 - zh: 406eee6e4f818e7c + en: 88edb925b556300c + zh: c6f05b641513d768 /agent-note-declarative-agent-presets-and-retained-revisions/alternatives-considered: en: 09f40eab4659e9f6 zh: 7a70da0d0ae6845b diff --git a/.agents/notes/implemented/architecture/2026-09-18-declarative-agent-presets.md b/.agents/notes/implemented/architecture/2026-09-18-declarative-agent-presets.md index fe7df55cde..38fa045ef1 100644 --- a/.agents/notes/implemented/architecture/2026-09-18-declarative-agent-presets.md +++ b/.agents/notes/implemented/architecture/2026-09-18-declarative-agent-presets.md @@ -16,7 +16,7 @@ The registry owns each revision's scope and Loader tree. Agents link their own s The Web editor and `agent_preset` tool accept only child plugin YAML. Saving writes the active profile's user patch, preserving display metadata and unrelated configuration. Revision checks and profile locking prevent overwriting concurrent saves; validation rejects a higher-priority override that would defeat the edit. Persistence and activation outcomes are reported separately. Tool writes require approval or full access because configuration executes in the Host. -Session data continues to record the preset identity. Restart resolves that identity against current configuration and rejects a missing definition. Old executable revisions are process-local, not serialized. New sessions without an explicit preset resolve `selectedDefault`; Client choice filtering and default correction follow [the preset UI policy](../../../../packages/client/ui-agent-preset/README.md). +Session data continues to record the preset identity. Restart resolves that identity against current configuration and rejects a missing definition; reusing a blank Session for a new conversation does not resolve it ([decision](../bug-fix/2026-09-29-reused-blank-runs-requested-preset.md)). Old executable revisions are process-local, not serialized. New sessions without an explicit preset resolve `selectedDefault`; Client choice filtering and default correction follow [the preset UI policy](../../../../packages/client/ui-agent-preset/README.md). ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-09-18-declarative-agent-presets.zh.md b/.agents/notes/implemented/architecture/2026-09-18-declarative-agent-presets.zh.md index abe1ab27ae..4da53eb366 100644 --- a/.agents/notes/implemented/architecture/2026-09-18-declarative-agent-presets.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-18-declarative-agent-presets.zh.md @@ -16,7 +16,7 @@ Preset 目录重复承担 Cordis 配置的所有权。独立的发现、元数 Web 编辑器与 `agent_preset` 工具只接受子插件 YAML。保存写入当前 profile 的用户补丁,保留显示元数据和无关配置。版本检查和 profile 锁防止覆盖并发保存;验证拒绝会使编辑失效的更高优先级覆盖。持久化与激活结果分别报告。配置在 Host 中执行,因此工具写入需要批准或完全访问权限。 -会话数据继续记录 preset 标识。重启时根据当前配置解析该标识,定义缺失则拒绝恢复。旧的可执行代际仅保留在进程内,不会序列化。未显式指定 preset 的新会话解析 `selectedDefault`;客户端选项过滤和默认值修正遵循[预设 UI 策略](../../../../packages/client/ui-agent-preset/README.zh.md)。 +会话数据继续记录 preset 标识。重启时根据当前配置解析该标识,定义缺失则拒绝恢复;为新对话复用空白会话时不解析该标识([决策](../bug-fix/2026-09-29-reused-blank-runs-requested-preset.zh.md))。旧的可执行代际仅保留在进程内,不会序列化。未显式指定 preset 的新会话解析 `selectedDefault`;客户端选项过滤和默认值修正遵循[预设 UI 策略](../../../../packages/client/ui-agent-preset/README.zh.md)。 ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-09-19-profile-owned-live-configuration.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-19-profile-owned-live-configuration.i18n.yaml index 599afcc6f0..0fb431067d 100644 --- a/.agents/notes/implemented/architecture/2026-09-19-profile-owned-live-configuration.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-19-profile-owned-live-configuration.i18n.yaml @@ -15,5 +15,5 @@ en: 4089464ad1fb60bd zh: 3168e9e4a205145d /agent-note-keep-live-configuration-in-the-owning-profile/consequences: - en: 6033aaa12b2a521b - zh: 43934413c97670de + en: 41bb170694c785d6 + zh: 17419fdd6d937dce diff --git a/.agents/notes/implemented/architecture/2026-09-19-profile-owned-live-configuration.md b/.agents/notes/implemented/architecture/2026-09-19-profile-owned-live-configuration.md index 7acf109869..65cc8f60b1 100644 --- a/.agents/notes/implemented/architecture/2026-09-19-profile-owned-live-configuration.md +++ b/.agents/notes/implemented/architecture/2026-09-19-profile-owned-live-configuration.md @@ -24,6 +24,6 @@ The rejected alternative is retaining settings as a second store behind a config ## Consequences -This supersedes only the non-secret settings tier in [configuration source ownership](2026-08-04-configuration-source-ownership.md); its environment restrictions and credential ordering remain applicable. [Volatile references](2026-09-18-volatile-config-references.md) own reference lifetime and ordinary update behavior. +This supersedes only the non-secret settings tier in [configuration source ownership](2026-08-04-configuration-source-ownership.md); its environment restrictions remain applicable, and [stored credential precedence](2026-10-09-stored-credential-precedence.md) supersedes its credential ordering. [Volatile references](2026-09-18-volatile-config-references.md) own reference lifetime and ordinary update behavior. Real profile tests cover multiple instances, persisted restart, invalid edits, stale revisions, higher-layer rejection, secret redaction, and configuration expressions. Browser tests exercise form writes and reset through the assembled application. Runtime reference identity is checked across live edits. diff --git a/.agents/notes/implemented/architecture/2026-09-19-profile-owned-live-configuration.zh.md b/.agents/notes/implemented/architecture/2026-09-19-profile-owned-live-configuration.zh.md index c9b88462a5..9ce0952aab 100644 --- a/.agents/notes/implemented/architecture/2026-09-19-profile-owned-live-configuration.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-19-profile-owned-live-configuration.zh.md @@ -24,6 +24,6 @@ Cordis config patch 替换完整 entry config。编辑会保留普通字段、 ## 后果 -本决定只替代 [配置来源所有权](2026-08-04-configuration-source-ownership.zh.md) 中的非秘密 settings 层;其中的环境限制和凭据优先级仍适用。[Volatile 引用](2026-09-18-volatile-config-references.zh.md) 负责引用生命周期和普通更新行为。 +本决定只替代 [配置来源所有权](2026-08-04-configuration-source-ownership.zh.md) 中的非秘密 settings 层;其中的环境限制仍适用,其凭据优先级已由[存储凭据优先级](2026-10-09-stored-credential-precedence.zh.md)替代。[Volatile 引用](2026-09-18-volatile-config-references.zh.md) 负责引用生命周期和普通更新行为。 真实 profile 测试覆盖多实例、持久化重启、非法编辑、过期修订、高优先级层拒绝、秘密脱敏及配置表达式。浏览器测试通过完整应用执行表单写入和重置,并检查实时编辑前后的运行时引用身份。 diff --git a/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.i18n.yaml index 3bd20ef0bc..5de15975be 100644 --- a/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.i18n.yaml @@ -9,14 +9,14 @@ en: 70a969dce3283824 zh: 18c61bf840a6518a /agent-note-remote-duplex-stream-one-kind-of-stream-an-uplink-channel-and-an-invocation-context/problem/current-state: - en: 8fb06638898de59e - zh: 79ed0cfe79e40be1 -/agent-note-remote-duplex-stream-one-kind-of-stream-an-uplink-channel-and-an-invocation-context/problem/three-unrelated-uplink-mechanisms: - en: 386be7b691ab63e7 - zh: 935e0db22ae1cf5d -/agent-note-remote-duplex-stream-one-kind-of-stream-an-uplink-channel-and-an-invocation-context/problem/concrete-costs: - en: f374976b43645d15 - zh: 26b2c7aaafa41fba + en: 2de84c3fc0a3552e + zh: da9cd565588bf2dd +/agent-note-remote-duplex-stream-one-kind-of-stream-an-uplink-channel-and-an-invocation-context/problem/uplink-consumers: + en: 403103ef08958529 + zh: 2813d9d38c2c55b7 +/agent-note-remote-duplex-stream-one-kind-of-stream-an-uplink-channel-and-an-invocation-context/problem/consumer-responsibilities: + en: 28f8a3112d6ec5fe + zh: c70d68233e3f6b7f /agent-note-remote-duplex-stream-one-kind-of-stream-an-uplink-channel-and-an-invocation-context/problem/relation-to-background-jobs: en: 5e67475010d52ddb zh: ce23aad2d4aa0d67 @@ -30,8 +30,8 @@ en: 943208904785f7eb zh: bda1969132a16101 /agent-note-remote-duplex-stream-one-kind-of-stream-an-uplink-channel-and-an-invocation-context/decision/terms: - en: 401703adbd9cc1c2 - zh: 56d960c0b6fdff57 + en: a22cd84756773075 + zh: 49efa0c5a37d4b57 /agent-note-remote-duplex-stream-one-kind-of-stream-an-uplink-channel-and-an-invocation-context/decision/type-signatures: en: 5f9cd00890b6f4c3 zh: 600ecba7e60b63f6 @@ -42,8 +42,8 @@ en: a7180d3c194cf9d9 zh: 0dd7833e0e5d0a71 /agent-note-remote-duplex-stream-one-kind-of-stream-an-uplink-channel-and-an-invocation-context/decision/peerscope: - en: 202aa6e8e8b04bd6 - zh: b1fc897c09074a7c + en: a162af337571f56e + zh: 0ae0b9d62d2cb16e /agent-note-remote-duplex-stream-one-kind-of-stream-an-uplink-channel-and-an-invocation-context/decision/client-face: en: 9f10347a73402b8b zh: b939c7a254a8e579 @@ -75,8 +75,8 @@ en: 46a0c0b3ce1e5671 zh: 7743707de42b1d1d /agent-note-remote-duplex-stream-one-kind-of-stream-an-uplink-channel-and-an-invocation-context/decision/in-process-and-worker-carriers: - en: ec339e57dc2c0603 - zh: 3570be8c094ea24c + en: df4ae60d8403cdbb + zh: 70c6024a2a9a1e84 /agent-note-remote-duplex-stream-one-kind-of-stream-an-uplink-channel-and-an-invocation-context/decision/relation-to-events: en: a011793e5a4400ed zh: df8b20115f0348b8 @@ -102,8 +102,8 @@ en: 26dcd8bebdc8d974 zh: e74bcef7c549e70c /agent-note-remote-duplex-stream-one-kind-of-stream-an-uplink-channel-and-an-invocation-context/placement: - en: 41128746334320c7 - zh: 558a676340aae3a2 + en: ecfaff2b9b2ef021 + zh: 85b392d79364e0c3 /agent-note-remote-duplex-stream-one-kind-of-stream-an-uplink-channel-and-an-invocation-context/key-sequences: en: 130e4f9ec7296a10 zh: 2a5aa1302a8db61a @@ -126,17 +126,17 @@ en: b42ab0932f8b31b4 zh: 7fed8cb5b6e36788 /agent-note-remote-duplex-stream-one-kind-of-stream-an-uplink-channel-and-an-invocation-context/alternatives-considered: - en: 04e72789417d6e3a - zh: b57c5cc68dff45a5 + en: b0046c9f7c12a876 + zh: f3c37b466746b0fd /agent-note-remote-duplex-stream-one-kind-of-stream-an-uplink-channel-and-an-invocation-context/consequences: - en: 07524e0c2dc22ad5 - zh: 5bf4f27160d78b43 + en: dbc54ce699ef8e71 + zh: 83144f07ae8f8759 /agent-note-remote-duplex-stream-one-kind-of-stream-an-uplink-channel-and-an-invocation-context/testing: - en: 5c8188930b9d4fdb - zh: 1d94e6563cedde13 + en: 06fbeed7b82ded32 + zh: 66accb4bd2bf64a2 /agent-note-remote-duplex-stream-one-kind-of-stream-an-uplink-channel-and-an-invocation-context/deferred: - en: 3b99a327464edc3e - zh: dcffdc75369925dc + en: 8368c592f30af97f + zh: 2ff89acb7b3ed477 /agent-note-remote-duplex-stream-one-kind-of-stream-an-uplink-channel-and-an-invocation-context/related: - en: 85d77fb246564024 - zh: 8b400818a91a3e3a + en: 475d212ae1f9c1c9 + zh: 21fe6c6cdb900a6b diff --git a/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.md b/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.md index c1333ef62f..5555c46b04 100644 --- a/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.md +++ b/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.md @@ -8,41 +8,41 @@ English | [中文](2026-09-19-remote-duplex-stream.zh.md) ### Current state -`dsh-api-gateway` multiplexes every Typert Remote stream over one WebSocket at the fixed path `/api/remote.mux` (`packages/api/gateway/src/stream-protocol.ts`). A Host method decorated with `@Remote({ mode: 'stream' })` that returns an `Iterable` or `AsyncIterable` becomes one Host-to-Client logical stream; the optional final `signal: AbortSignal` is the only reserved parameter, never enters the wire arguments, and is appended by the Gateway after the decoded business parameters (`packages/api/gateway/src/index.ts`). The generated method the Client receives returns a bare `AsyncIterable`. +`dsh-api-gateway` multiplexes Typert Remote streams over one WebSocket at `/api/remote.mux`. A Host method declares both directions with `@Remote({ mode: 'stream' })` and `RemoteStream`; the generated Client method returns `RemoteStreamHandle`. Existing `Iterable` and `AsyncIterable` return types declare downlink-only methods. The optional final `signal: AbortSignal` is supplied by the Gateway and does not enter the wire arguments. -There are only five wire frames: +Both directions share one `streamId`: | Direction | Frame | Definition | | --- | --- | --- | | Client to Host | `{ type: 'open', streamId, endpoint, payload }` | `stream-protocol.ts` | +| Client to Host | `{ type: 'item', streamId, value? }` | `stream-protocol.ts` | +| Client to Host | `{ type: 'end', streamId }` | `stream-protocol.ts` | | Client to Host | `{ type: 'cancel', streamId }` | `stream-protocol.ts` | | Host to Client | `{ type: 'item', streamId, value? }` | `stream-protocol.ts` | | Host to Client | `{ type: 'error', streamId, error: { code, message, details } }` | `stream-protocol.ts` | | Host to Client | `{ type: 'end', streamId }` | `stream-protocol.ts` | -The Client can send only open and cancel. The parser `parseRemoteStreamClientMessage` (`stream-protocol.ts`) throws on any other frame, and a Host connection that receives one closes the whole socket with 1008 (`stream-server.ts`). In short, the Gateway today supports only "a backend iterable sent to the frontend". Every feature that needs the frontend to send data to the backend continuously takes its own detour. +The Client handle's `send()` supplies uplink items, `end()` half-closes the uplink, and `dispose()` cancels the logical stream. The Host reads decoded items through `this.ctx.invocation.uplink()`; the invocation also carries the admitted Peer and cancellation signal. -A Host method also has no notion of who initiated this call: `InvokeRemoteRequest = { namespace, method, args, signal }` (`packages/api/gateway/src/types.ts`) has no caller slot, `RemoteStreamOpener` has the signature `(endpoint, payload, signal)` (`stream-server.ts`), and the WebSocket checks the cookie once, at the handshake. +The transport owns validation, cancellation, and its bounded inbox. Consumer protocols own input authority, acknowledgements, and recovery after disconnection. -### Three unrelated uplink mechanisms +### Uplink consumers | Feature | Uplink path | Location | Cost | | --- | --- | --- | --- | -| Web terminal keystrokes | One unary RPC `terminal.write(agent, id, attachmentId, data)` per xterm `onData`; the Client serializes them through a promise chain and caps its own byte budget | `packages/api/terminal-controller/src/client/model.ts`; Host `src/index.ts` | One HTTP round trip and one authentication per keystroke; two carriers correlated by hand through `attachmentId`; `inputFull` when the budget is exhausted | +| Web terminal input and resize | `terminal.attach` carries `TerminalInput` uplink items and `TerminalFrame` downlink items; the Client pipelines commands and releases its raw-input byte budget on `ack` | `packages/api/terminal-controller/src/client/model.ts`; Host `src/terminal.ts` | The current attachment owns input; `inputFull` when the Client budget is exhausted; input is never replayed after reconnection | | File upload | A `Blob` or `ReadableStream` goes to the native `POST /api/session/uploadFileBinary` outside the Remote layer, with `duplex: 'half'` | `packages/client/file-upload/src/client/runtime.ts`; route `src/index.ts` | Bypasses Typert descriptors; no type projection | | Answers to approvals and questions | The Host sends waterfall frames down the `$events` stream, the Client answers through a separate unary RPC `$events/result`, correlated by `clientId` plus `eventId` | `packages/api/gateway/src/client/remote-events.ts`; Host `index.ts` | A second correlation key; `index.ts` records the race this creates | -These three mechanisms solve the same problem: the Client wants to write into a logical stream that is already open. +These consumers need Client-to-Host data while a related operation remains active. Web terminals use the duplex stream; file upload and event answers retain their own transports. -### Concrete costs +### Consumer responsibilities -- **Aligning two carriers.** The terminal uses `attachmentId` to correlate the `follow` stream with the `write` RPC and to decide who owns input. Approvals use `clientId` to correlate the `$events` stream with the `$events/result` RPC. -- **No ordering guarantee.** A unary RPC travels over HTTP and is unordered relative to the WebSocket downlink; the terminal can preserve order only by serializing writes on the Client. -- **Separate authentication and lookup per uplink.** Every `write(agent, id, attachmentId, data)` resolves `agent`, looks up the terminal, and validates the attachment. -- **No EOF.** The Client cannot express "I am done writing"; a half-close needs yet another RPC. -- **Cancellation does not cover both halves.** Cancelling the downlink stream does not cancel queued uplink writes, and vice versa. -- **Backpressure implemented three times.** The downlink pulls the iterator at the pace of the socket write callback (`stream-server.ts`); the terminal uses a bounded follower that makes a slow consumer fail explicitly (`terminal-controller/src/stream.ts`); the uplink relies on the Client capping `maxInputBytes` itself. -- **The documentation is already stale.** `docs/api-gateway.md:160` still says Remote handles one request and one result, a sentence that lags the stream mode the README records. +- **Input authority.** A terminal retains its controlling attachment as a Host-local follower. Snapshot and state frames report each attachment's `writable` state; older attachments keep receiving output after control transfers. +- **Ordering and acknowledgement.** Terminal writes and resizes share a consecutive command sequence and the terminal's operation queue. An `ack` reports provider acceptance or a control refusal; it never authorizes replay after disconnection. +- **Independent lifetimes.** Cancelling `attach` stops its input and output delivery, drops unstarted commands, and joins an in-flight input operation. The PTY lifetime is owned separately by retention and explicit close. +- **Coordinated byte limits.** The Gateway exposes its complete-frame inbox capacity and this stream's JSON envelope cost through `invocation.uplinkWindow`. The terminal publishes that window in its snapshot and uses provider acknowledgements to release in-flight capacity. Writes and resizes wait in a bounded Client queue when the window is full. `maxInputBytes` limits raw UTF-8 input; `maxBufferedBytes` separately limits the complete encoded input retained by the Client and output retained by each follower. +- **Remaining separate carriers.** Approval answers still correlate `$events` with the `$events/result` RPC through `clientId` and `eventId`. Moving this protocol onto the duplex stream remains deferred. ### Relation to background jobs @@ -66,7 +66,7 @@ Every Remote stream accepts a Client uplink. The method's return type `RemoteStr Client Host ───────────────────────────────── ─────────────────────────────────────────── const s = remote.job.attach(req, signal) - │ open { streamId, endpoint, payload } ───────▶ opener(endpoint, payload, uplink, peer, control) + │ open { streamId, endpoint, payload } ───────▶ opener(endpoint, payload, uplink, peer, control, window) │ │ prepareInvocation │ s.send(v1) → item { streamId, v1 } ───────▶ inbox.push(v1) │ invocation = { request, service, peer, signal, uplink() } │ s.send(v2) → item { streamId, v2 } ───────▶ inbox.push(v2) │ receiverContext.extend({ invocation }).get(service) @@ -88,7 +88,7 @@ Every Remote stream accepts a Client uplink. The method's return type `RemoteStr | Half-close | One direction has ended while the other remains open | | Item | One business value in one direction; every item is validated by the codec independently | | inbox | The bounded uplink queue the Host keeps per logical stream | -| Peer | The party the connection layer admitted, one `PeerScope`; this Host has exactly one Peer, the operator | +| Peer | The party one connection speaks for, one `PeerScope` carrying its principal; every browser connection shares `connection.browserPeer`, and in-process code creates its own through `connection.createPeer()` ([Peer principals](../../implemented/architecture/2026-10-12-peer-principal-and-remote-access.md)) | | Invocation context | `RemoteInvocation`: the request, receiving service, Peer, cancellation signal, and uplink entry of this call | ### Type signatures @@ -135,10 +135,11 @@ export interface RemoteInvocation { } /** Cordis service key of the receiving service. */ readonly service: string - /** The Peer that initiated the call. In-process carriers and unadmitted calls are the operator. */ + /** The Peer that initiated the call; every carrier names it explicitly. */ readonly peer: PeerScope /** Carrier cancellation: Client cancel, socket closure, uplink failure. */ readonly signal: AbortSignal + readonly uplinkWindow?: RemoteUplinkWindow | undefined /** * The uplink items of this call. Can be taken once; the second call throws. With an uplink codec * in the descriptor each item is decoded to In; without one, items are delivered as unknown with @@ -179,24 +180,28 @@ The mechanism relies on two existing Cordis behaviors: /** The opaque identity of one Peer. */ export type PeerId = Branded<'PeerId'> -/** One Peer's session on this Host: opened and released by the admitting side; ctx owns connection-lifetime registrations. */ +/** One Peer's session on this Host: opened and released by its creator; ctx owns connection-lifetime registrations. */ export interface PeerScope { readonly id: PeerId readonly ctx: Context + /** Caller identity fixed at creation. */ + readonly principal: Principal + /** Aborted once the Peer is disposed. */ + readonly signal: AbortSignal dispose(): Promise } ``` -This Host has exactly one Peer: the operator. `@deepseek-ai/dsh-client-connection` creates it with `createScope(connectionCtx, peer)` when the service applies, the same mechanism an Agent uses to create its own scope: the Peer object is the ScopeKey, `peer.ctx` carries connection-lifetime effects, and `dispose()` runs with the Connection's release and quiets the fiber. It is exposed as `connection.operator`. +`@deepseek-ai/dsh-client-connection` implements the one Peer class, `Peer(owner, { principal })`, with `createScope(owner, peer)`, the same mechanism an Agent uses to create its own scope: the Peer object is the ScopeKey, `peer.ctx` carries connection-lifetime effects, `peer.signal` aborts when the scope ends, and `dispose()` quiets the fiber. Connection creates `connection.browserPeer` with the owner principal when the service applies; [Peer principals and method-level Remote access control](../../implemented/architecture/2026-10-12-peer-principal-and-remote-access.md) owns principals, `createPeer()`, and access checks. | Member | Semantics | | --- | --- | -| `connection.operator: PeerScope` | The only Peer; lives and dies with the Connection | -| `connection.admit(request): PeerAdmission` | Runs the existing `requestRejection`: returns `{ rejection: 401 \| 403 }` when refused, otherwise `{ peer: operator }`. Future admitters plug in here | +| `connection.browserPeer: PeerScope` | The Peer every authenticated browser connection shares; owner principal; lives and dies with the Connection | +| `connection.admit(request): PeerAdmission` | Runs the existing `requestRejection`: returns `{ rejection: 401 \| 403 }` when refused, otherwise `{ peer: browserPeer }`. Future admitters plug in here | -The Gateway's `handleUpgrade(req, socket, head, peer)` binds an opener with a fixed Peer to that socket and registers socket closure in `peer.ctx.effect`; when the scope is already inactive, it closes the socket directly with 1001 instead. When `InvokeRemoteRequest.peer?` or the `peer` parameter of `RemoteStreamOpener` is absent, the answer is the operator, so existing in-process callers change nothing; in a composition without a Connection, the operator stand-in the Gateway builds for itself is likewise created through `createScope`, with the same `dispose()` contract. Looking up a Peer by id, associating a carrier with a second Peer, and Peer open and close events all wait until the first consumer that needs multiple Peers appears. +The Gateway's `handleUpgrade(req, socket, head, peer)` binds an opener with a fixed Peer to that socket and registers socket closure in `peer.ctx.effect`; when the scope is already inactive, it closes the socket directly with 1001 instead. `InvokeRemoteRequest.peer` and the `peer` parameter of `RemoteStreamOpener` and `wireStream.open` are required, so every in-process caller names its Peer, and the Gateway creates none. Looking up a Peer by id and Peer open and close events wait until the first consumer that needs them appears. -The Peer carries no access model: who the Peer is and what it may do are attached by business plugins on their own through `peer.ctx`. +The Peer carries its principal; what that principal may call is decided by the roles, bindings, and policies that [Peer principals and method-level Remote access control](../../implemented/architecture/2026-10-12-peer-principal-and-remote-access.md) defines. ### Client face @@ -311,7 +316,7 @@ Credit frames wait until a consumer that needs sustained high-volume uplink appe | Worker tunnel (`packages/experimental/webworker-runtime`) | The page adds `stream-uplink-item` / `stream-uplink-end` frames; `serveStream` assembles them into an `uplink` handed to `seams.openStream`. Same-origin trusted boundary: no inbox limit, and items after `end` are dropped | | `remote-mock` (`packages/test-support/remote-mock`) | `StreamHandle.uplink: AsyncIterable`; `rpc.open` passes it through | -An in-process carrier with no `peer` means the operator. +An in-process carrier passes its Peer explicitly; the in-page Host of the worker tunnel passes `connection.browserPeer`. ### Relation to `$events` @@ -325,7 +330,7 @@ An in-process carrier with no `peer` means the operator. // types.ts export type RemoteStream = AsyncIterable & { readonly [STREAM_UPLINK]?: In } export type PeerId = Branded<'PeerId'> -export interface PeerScope { readonly id: PeerId; readonly ctx: Context; dispose(): Promise } +export interface PeerScope { readonly id: PeerId; readonly ctx: Context; readonly principal: Principal; readonly signal: AbortSignal; dispose(): Promise } export interface RemoteInvocation { … } // see Host face declare module '@deepseek-ai/cordis' { interface Context { readonly invocation: RemoteInvocation | undefined } } @@ -362,13 +367,13 @@ export interface InvokeRemoteRequest { readonly args: Readonly> /** Uplink items; absent is equivalent to an iterator that ends immediately. */ readonly uplink?: AsyncIterable - /** The Peer that initiated the call; absent means the operator. */ - readonly peer?: PeerScope + /** The Peer that initiated the call; required on every path. */ + readonly peer: PeerScope readonly signal?: AbortSignal } export interface TypertGatewayWireStream { - open: (endpoint: string, payload: unknown, uplink: AsyncIterable, peer: PeerScope | undefined, signal: AbortSignal) => Promise> + open: (endpoint: string, payload: unknown, uplink: AsyncIterable, peer: PeerScope, signal: AbortSignal) => Promise> failure: (error: unknown) => RemoteStreamFailure } @@ -379,6 +384,7 @@ export type RemoteStreamOpener = ( uplink: AsyncIterable, peer: PeerScope, control: AbortController, + window: RemoteUplinkWindow, ) => Promise> // index.ts Config addition @@ -394,7 +400,7 @@ const signal = request.signal ?? NEVER_ABORTED_SIGNAL const invocation = new GatewayInvocation( { namespace: request.namespace, method: request.method, args: request.args }, descriptor.service, - request.peer ?? this.operatorPeer(), + request.peer, signal, new UplinkDecoder(request.uplink ?? EMPTY_ASYNC_ITERABLE, descriptor.uplink?.codec, endpoint, control), ) @@ -444,8 +450,8 @@ cancel : missing → ignore; otherwise control.abort(new Error('Remote stream ca | `dsh-typert-protocol` (`types.ts`, `index.ts`) | `RemoteStream`, `RemoteStreamHandle`, `PeerId`, `PeerScope`, `RemoteInvocation`, the `ctx.invocation` declaration merge, `InvocationDescriptor.uplink`; the decorator recognizes only `mode: 'stream'`; `TypertRemoteService` registers the `invocation` accessor when constructed | | `dsh-typert-registry` | Load-time validation that `uplink.codec` is a valid strict codec | | `dsh-typert-generator` (`model.ts`, `analyzer.ts`, `emitter.ts`) | `remoteResultType` recognizes `RemoteStream` and generates the `uplink` boundary; the descriptor emits `uplink: { codec }`; the generated Client signature returns `RemoteStreamHandle`; the parameter loop accepts only business parameters and a final `signal` | -| `dsh-client-connection` (`operator-peer.ts`, `rpc.ts`, `rpc-host.ts`, `index.ts`) | The operator `PeerScope`; `connection.operator` and `admit`; the `/api` and upgrade routes obtain the Peer through `admit`; the optional `uplink` of `rpc.open` | -| `dsh-api-gateway` Host (`stream-protocol.ts`, `stream-server.ts`, `types.ts`, `index.ts`) | `item` / `end` frames; `UplinkInbox`; `Config.streamInboxBytes`; `GatewayInvocation` and `extend({ invocation })`; `UplinkDecoder`; `handleUpgrade(req, socket, head, peer)`; `operatorPeer()` | +| `dsh-client-connection` (`peer.ts`, `rpc.ts`, `rpc-host.ts`, `index.ts`) | The `Peer` class; `connection.browserPeer` and `admit`; the `/api` and upgrade routes obtain the Peer through `admit`; the optional `uplink` of `rpc.open` | +| `dsh-api-gateway` Host (`stream-protocol.ts`, `stream-server.ts`, `types.ts`, `index.ts`) | `item` / `end` frames; `UplinkInbox`; `Config.streamInboxBytes`; `GatewayInvocation` and `extend({ invocation })`; `UplinkDecoder`; `handleUpgrade(req, socket, head, peer)` | | `dsh-api-gateway` Client (`client/index.ts`, `client/stream-client.ts`) | `invokeStream` returns the handle; the uplink pump is driven by the handle's queue | | `dsh-webworker-runtime`, `dsh-remote-mock` | The worker tunnel's two uplink frames and the `serveStream` assembly; `StreamHandle.uplink`; the direct proxy returns the real handle | | Documentation | The READMEs of gateway, the api group, connection, protocol, and generator; `docs/api-gateway`; the type-equiv blocks of `docs/subsystems/typert`; the config and Cordis catalogs | @@ -560,7 +566,7 @@ for await (const reply of stream) replies.push(reply) // ['> a', '> b'] **Putting the Client's `send` on the `AsyncIterable` the generated method returns.** Hanging methods on an `AsyncIterable` is unconventional; the handle type `RemoteStreamHandle` extends it explicitly. -**Copying the Access mechanism.** Access control is business semantics, served by `vouch` and the `remote/invoke` waterfall; this Note only needs "whose stream is this", and `PeerScope` suffices. +**Copying the Access mechanism.** Access control is business semantics, served by `vouch` and the `remote/invoke` waterfall; this Note only needs "whose stream is this", and `PeerScope` suffices. Method-level access control later moved to [Peer principals and method-level Remote access control](../../implemented/architecture/2026-10-12-peer-principal-and-remote-access.md), which supersedes this part. **Keeping unary RPC for the uplink.** One HTTP round trip, one authentication, and one lookup per uplink; two carriers with no ordering relation that only Client-side serialization can order; no EOF; cancellation that does not cover both halves; a hand-made correlation key per feature. @@ -574,10 +580,11 @@ for await (const reply of stream) replies.push(reply) // ['> a', '> b'] - **Bought**: one stream with two directions, so interactive scenarios such as terminal keystrokes, approval answers, and background-job stdin no longer each build their own "one stream plus one unary"; a Host method knows its caller; the uplink and downlink types are declared in one place, the return type; uplink items come from the browser and are validated strictly per item at the Host, while downlink items are Host-produced typed values that pass straight through; the generated output and wire frames of existing `AsyncIterable` methods and unary methods are completely unchanged. - **`this.ctx.invocation` is `undefined` outside a Remote call**, so a method that reads it must handle the optional; a service method invoked only directly in process correctly reads `undefined`. +- **Web terminals use `attach`** for screen output and input commands, with provider acknowledgements and per-attachment writable state; their Client retains the current generation's handle and never replays input. - **The inbox limit applies to every stream**: a method that does not read its uplink fails as a whole when it receives many uplink frames. This is a deliberate explicit failure, recorded in the README. -- **The operator is the only Peer**, and `admit` is the only admission point; the first consumer that needs a second Peer must add opening, association, and events in `dsh-client-connection`. +- **Every browser connection shares `browserPeer`**, and `admit` is the only network admission point; in-process code creates further Peers with `connection.createPeer()`. - **No cross-direction ordering guarantee between uplink and downlink**; a protocol that needs request-response pairing carries its own sequence numbers. -- **Upper-layer protocols do not yet expose `send`**: consumers of `$stream`, snapshot, and journal use only the downlink today; the object they receive is already the handle. +- **Upper-layer supervisors expose only the downlink**: `$stream`, snapshot and journal do not expose `send`; a domain that writes uplink retains its generated physical handle for that generation. - **The worker tunnel behaves differently from the WebSocket carrier**: no inbox limit, and items after `end` are dropped, because page to worker is a same-origin trusted boundary. ## Testing @@ -588,17 +595,15 @@ Retention cases in `gateway-stream.host.spec.ts` and `gateway.client.spec.ts` co - Gateway Host: exact-key validation in frame parsing; `UplinkInbox` buffering during opening, `item` after `end`, overflow, ignored unknown `streamId`, and cleanup on socket closure; `UplinkDecoder` delivery with and without a codec and abort on decode failure; `uplink()` taken only once; the `extend({ invocation })` view and the origin of `peer`; `cancellableStream` closing the uplink before `return()`. - Gateway Client: the handle's `send` (JSON validation, queued before open, throws after termination), idempotent `end`, `dispose` driving the `cancel` frame and ending later reads; the pump's three exits; the pump stopping as soon as a terminal frame arrives. - typert: the analyzer's handling of `RemoteStream`, `RemoteStream`, `never`, and the existing `AsyncIterable`; decorator options; registry load-time validation. -- connection: the three outcomes of `admit`, 401, 403, and the operator; the operator reaching handlers through the bridge and the shared handler; release with the Connection. +- connection: the three outcomes of `admit`, 401, 403, and `browserPeer`; `browserPeer` reaching handlers through the bridge and the shared handler; release with the Connection. - remote-mock and the worker tunnel: uplink pass-through; the direct proxy returning the real handle. - Existing stream and unary method tests pass without changed expectations. ## Deferred - `$stream` / snapshot / journal expose the handle's `send` to upper-layer protocols. -- Web terminal: one `attach` stream replaces `follow` plus `write` plus `resize`, and `attachmentId` retires. - Duplex `$events`: waterfall answers become uplink items on the same `streamId`, and `$events/result` and `clientId` retire. - Credit frames: wait for a consumer that needs sustained high-volume uplink; the inbox limit is the initial credit. -- The `streamInboxBytes` default of 262144 is far more than keyboard and stdin need; if the first consumer is terminal paste, it may be aligned with the `maxInputBytes` of `terminal-controller`. - A Host-initiated half-close frame (the Host stops reading the uplink but keeps writing the downlink): no consumer; not added. - `uplink()` on unary methods: it can read items that arrive while the method runs, semantically valid but of doubtful use; allowed, noted in the README. @@ -606,4 +611,4 @@ Retention cases in `gateway-stream.host.spec.ts` and `gateway.client.spec.ts` co - [Remote event delivery](../../../../packages/api/remotes/README.md): the unary `$events/result` answer path; the duplex `$events` item under Deferred partially supersedes it once it lands. - [Session history and event transport](2026-08-18-session-history-and-event-transport.md): the `$stream()` supervisor and the journal / snapshot protocols above it, which this note leaves unchanged. -- [Web sidebar terminal](../feature/2026-09-09-web-sidebar-terminal.md): the unary terminal `write` and `attachmentId`; the `attach` stream under Deferred replaces it once it lands. +- [Web sidebar terminal](../feature/2026-09-09-web-sidebar-terminal.md): the `attach` stream carries input commands, provider acknowledgements and per-attachment writable state; reconnect never replays input. diff --git a/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.zh.md b/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.zh.md index 791b5e3c81..4e5681b04d 100644 --- a/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.zh.md @@ -8,41 +8,41 @@ Status: implemented ### 现状 -`dsh-api-gateway` 在一条 WebSocket 上复用全部 Typert Remote 流,路径固定为 `/api/remote.mux`(`packages/api/gateway/src/stream-protocol.ts`)。一个 Host 方法用 `@Remote({ mode: 'stream' })` 修饰并返回 `Iterable` 或 `AsyncIterable`,就成为一条 Host 到 Client 的逻辑流;可选的末位 `signal: AbortSignal` 是唯一的保留参数,不进线路参数,由网关在解码后的业务参数之后追加(`packages/api/gateway/src/index.ts`)。客户端拿到的生成方法返回一个裸 `AsyncIterable`。 +`dsh-api-gateway` 在 `/api/remote.mux` 的一条 WebSocket 上复用 Typert Remote 流。Host 方法通过 `@Remote({ mode: 'stream' })` 和 `RemoteStream` 声明两个方向;生成的 Client 方法返回 `RemoteStreamHandle`。既有的 `Iterable` 和 `AsyncIterable` 返回类型声明仅下行的方法。可选的末位 `signal: AbortSignal` 由 Gateway 提供,不进入线路参数。 -线路帧只有五种: +两个方向共用一个 `streamId`: | 方向 | 帧 | 定义 | | --- | --- | --- | | Client 到 Host | `{ type: 'open', streamId, endpoint, payload }` | `stream-protocol.ts` | +| Client 到 Host | `{ type: 'item', streamId, value? }` | `stream-protocol.ts` | +| Client 到 Host | `{ type: 'end', streamId }` | `stream-protocol.ts` | | Client 到 Host | `{ type: 'cancel', streamId }` | `stream-protocol.ts` | | Host 到 Client | `{ type: 'item', streamId, value? }` | `stream-protocol.ts` | | Host 到 Client | `{ type: 'error', streamId, error: { code, message, details } }` | `stream-protocol.ts` | | Host 到 Client | `{ type: 'end', streamId }` | `stream-protocol.ts` | -客户端能发的只有打开与取消。解析器 `parseRemoteStreamClientMessage`(`stream-protocol.ts`)对其他任何帧抛错,Host 连接收到后以 1008 关闭整个 socket(`stream-server.ts`)。结论:网关今天只支持"后端 iterable 发到前端"。需要前端向后端持续发数据的功能,各自绕路。 +Client 句柄的 `send()` 提供上行项,`end()` 半关闭上行,`dispose()` 取消逻辑流。Host 通过 `this.ctx.invocation.uplink()` 读取解码后的项;调用上下文同时携带获准接入的 Peer 和取消信号。 -Host 方法也没有"这次调用是谁发起的"这个概念:`InvokeRemoteRequest = { namespace, method, args, signal }`(`packages/api/gateway/src/types.ts`)没有调用者槽位,`RemoteStreamOpener` 的签名是 `(endpoint, payload, signal)`(`stream-server.ts`),WebSocket 只在握手时校一次 cookie。 +传输层负责校验、取消和有界 inbox。消费者协议负责输入权限、确认以及断线后的恢复。 -### 三套各自为政的上行机制 +### 上行消费者 | 功能 | 上行方式 | 位置 | 代价 | | --- | --- | --- | --- | -| Web 终端按键 | 每次 xterm `onData` 一个 unary RPC `terminal.write(agent, id, attachmentId, data)`,客户端用 promise 链串行化并自限字节预算 | `packages/api/terminal-controller/src/client/model.ts`;Host `src/index.ts` | 一次按键一次 HTTP 往返与鉴权;两条载体靠 `attachmentId` 手工关联;满了报 `inputFull` | +| Web 终端输入与尺寸调整 | `terminal.attach` 承载上行 `TerminalInput` 和下行 `TerminalFrame`;Client 连续发送命令,并在收到 `ack` 后释放原始输入字节预算 | `packages/api/terminal-controller/src/client/model.ts`;Host `src/terminal.ts` | 当前附件拥有输入权;Client 预算耗尽时报 `inputFull`;重连后绝不重放输入 | | 文件上传 | `Blob` 或 `ReadableStream` 走 Remote 层之外的原生 `POST /api/session/uploadFileBinary`,`duplex: 'half'` | `packages/client/file-upload/src/client/runtime.ts`;路由 `src/index.ts` | 不经 Typert 描述符,没有类型投影 | | 审批与提问的回答 | Host 在 `$events` 流上下发 waterfall 帧,Client 用一个独立的 unary RPC `$events/result` 回答,靠 `clientId` 加 `eventId` 关联 | `packages/api/gateway/src/client/remote-events.ts`;Host `index.ts` | 第二套关联键;`index.ts` 记录了由此产生的竞态 | -这三套机制解决的是同一个问题:一条已经打开的逻辑流,客户端想往里写。 +这些消费者都需要在相关操作仍进行时从 Client 向 Host 传送数据。Web 终端使用双工流;文件上传和事件回答保留各自的传输方式。 -### 具体代价 +### 消费者职责 -- **两条载体对齐**。终端要用 `attachmentId` 把 `follow` 流与 `write` RPC 关联起来,并用它决定谁拥有输入权。审批要用 `clientId` 把 `$events` 流与 `$events/result` RPC 关联起来。 -- **没有顺序保证**。unary RPC 走 HTTP,与 WebSocket 下行互不排序;终端只能在客户端串行化写入来自保顺序。 -- **每次上行单独鉴权与查找**。`write(agent, id, attachmentId, data)` 每次解析 `agent`、查终端、校验 attachment。 -- **没有 EOF**。客户端无法表达"我这边写完了",半关闭要再加一个 RPC。 -- **取消不覆盖两半**。取消下行流不会取消排队中的上行写;反之亦然。 -- **背压各写一套**。下行按 socket 写回调节拍拉迭代器(`stream-server.ts`);终端用有界 follower 让慢消费者显式失败(`terminal-controller/src/stream.ts`);上行靠客户端自限 `maxInputBytes`。 -- **文档已经过时**。`docs/api-gateway.md:160` 仍写着 Remote 只处理一请求一结果,这一句已经落后于 README 记录的 stream 模式。 +- **输入权限**。终端通过 Host 本地的 follower 持有控制附件。快照和状态帧报告各附件的 `writable` 状态;控制权转移后,旧附件继续接收输出。 +- **顺序与确认**。终端输入和尺寸调整共用连续命令序列及终端操作队列。`ack` 报告提供者已接受操作或输入权限拒绝,绝不授权断线后重放。 +- **独立生命周期**。取消 `attach` 会停止其输入和输出传递、丢弃尚未开始的命令,并等待在途输入操作收尾。PTY 的生命周期由保留机制和显式关闭独立管理。 +- **协调字节限额**。Gateway 通过 `invocation.uplinkWindow` 提供完整帧 inbox 容量及本流的 JSON 封装开销。终端在快照中公布窗口,并通过提供者确认释放在途额度。窗口满时,输入和尺寸调整命令在有界 Client 队列中等待。`maxInputBytes` 限制原始 UTF-8 输入;`maxBufferedBytes` 分别限制 Client 保留的完整编码输入及每个 follower 保留的输出。 +- **仍独立的载体**。审批回答仍通过 `clientId` 和 `eventId` 将 `$events` 与 `$events/result` RPC 关联。将该协议迁入双工流仍属后续工作。 ### 与后台 job 的关系 @@ -66,7 +66,7 @@ Host 方法也没有"这次调用是谁发起的"这个概念:`InvokeRemoteReq Client Host ───────────────────────────────── ─────────────────────────────────────────── const s = remote.job.attach(req, signal) - │ open { streamId, endpoint, payload } ───────▶ opener(endpoint, payload, uplink, peer, control) + │ open { streamId, endpoint, payload } ───────▶ opener(endpoint, payload, uplink, peer, control, window) │ │ prepareInvocation │ s.send(v1) → item { streamId, v1 } ───────▶ inbox.push(v1) │ invocation = { request, service, peer, signal, uplink() } │ s.send(v2) → item { streamId, v2 } ───────▶ inbox.push(v2) │ receiverContext.extend({ invocation }).get(service) @@ -88,7 +88,7 @@ Host 方法也没有"这次调用是谁发起的"这个概念:`InvokeRemoteReq | 半关闭 | 一个方向已结束而另一个方向仍开放 | | 项 | 一个方向上的一个业务值;每项独立经 codec 校验 | | inbox | Host 侧每逻辑流一个的有界上行队列 | -| Peer | 连接层被接纳的一方,一个 `PeerScope`;本 Host 只有操作者一个 Peer | +| Peer | 一条连接代表的一方,一个携带 principal 的 `PeerScope`;所有浏览器连接共享 `connection.browserPeer`,进程内代码经 `connection.createPeer()` 自建([Peer 身份属性](../../implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md)) | | 调用上下文 | `RemoteInvocation`:本次调用的请求、接收服务、Peer、取消信号与上行入口 | ### 类型签名 @@ -135,10 +135,11 @@ export interface RemoteInvocation { } /** Cordis service key of the receiving service. */ readonly service: string - /** The Peer that initiated the call. In-process carriers and unadmitted calls are the operator. */ + /** The Peer that initiated the call; every carrier names it explicitly. */ readonly peer: PeerScope /** Carrier cancellation: Client cancel, socket closure, uplink failure. */ readonly signal: AbortSignal + readonly uplinkWindow?: RemoteUplinkWindow | undefined /** * The uplink items of this call. Can be taken once; the second call throws. With an uplink codec * in the descriptor each item is decoded to In; without one, items are delivered as unknown with @@ -179,24 +180,28 @@ declare module '@deepseek-ai/cordis' { /** The opaque identity of one Peer. */ export type PeerId = Branded<'PeerId'> -/** One Peer's session on this Host: opened and released by the admitting side; ctx owns connection-lifetime registrations. */ +/** One Peer's session on this Host: opened and released by its creator; ctx owns connection-lifetime registrations. */ export interface PeerScope { readonly id: PeerId readonly ctx: Context + /** Caller identity fixed at creation. */ + readonly principal: Principal + /** Aborted once the Peer is disposed. */ + readonly signal: AbortSignal dispose(): Promise } ``` -本 Host 只有一个 Peer:操作者。`@deepseek-ai/dsh-client-connection` 在服务 apply 时用 `createScope(connectionCtx, peer)` 建它,与 Agent 建自己的 scope 是同一机制:Peer 对象就是 ScopeKey,`peer.ctx` 承接连接期效果,`dispose()` 随 connection 释放让 fiber 静默。它以 `connection.operator` 暴露。 +`@deepseek-ai/dsh-client-connection` 实现唯一的 Peer 类 `Peer(owner, { principal })`,用 `createScope(owner, peer)` 建立,与 Agent 建自己的 scope 是同一机制:Peer 对象就是 ScopeKey,`peer.ctx` 承接连接期效果,scope 结束时 `peer.signal` 中止,`dispose()` 让 fiber 静默。Connection 在服务 apply 时以 owner principal 创建 `connection.browserPeer`;principal、`createPeer()` 与访问检查归 [Peer 身份属性与 Remote 方法级访问控制](../../implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md)。 | 成员 | 语义 | | --- | --- | -| `connection.operator: PeerScope` | 唯一的 Peer,随 connection 生灭 | -| `connection.admit(request): PeerAdmission` | 过既有的 `requestRejection`:被拒返回 `{ rejection: 401 \| 403 }`,否则返回 `{ peer: operator }`。将来的接纳方在这里接入 | +| `connection.browserPeer: PeerScope` | 所有通过认证的浏览器连接共享的 Peer;principal 为 owner;随 connection 生灭 | +| `connection.admit(request): PeerAdmission` | 过既有的 `requestRejection`:被拒返回 `{ rejection: 401 \| 403 }`,否则返回 `{ peer: browserPeer }`。将来的接纳方在这里接入 | -网关 `handleUpgrade(req, socket, head, peer)` 为该 socket 绑一个固定 Peer 的 opener,并在 `peer.ctx.effect` 里登记 socket 关闭;scope 已失效时改为直接以 1001 关闭 socket。`InvokeRemoteRequest.peer?` 与 `RemoteStreamOpener` 的 `peer` 形参缺席时按操作者作答,现有进程内调用方零改动;没有 connection 的组合里网关自建的操作者替身同样经 `createScope` 建立,`dispose()` 契约一致。按 id 查 Peer、把载体关联到第二个 Peer、Peer 的开关事件,都等第一个需要多 Peer 的消费者出现时再加。 +网关 `handleUpgrade(req, socket, head, peer)` 为该 socket 绑一个固定 Peer 的 opener,并在 `peer.ctx.effect` 里登记 socket 关闭;scope 已失效时改为直接以 1001 关闭 socket。`InvokeRemoteRequest.peer`、`RemoteStreamOpener` 与 `wireStream.open` 的 `peer` 形参均为 required,每个进程内调用方都显式给出 Peer,网关不自建 Peer。按 id 查 Peer、Peer 的开关事件,都等第一个需要它们的消费者出现时再加。 -Peer 不携带访问模型:Peer 是谁、能做什么,由业务插件通过 `peer.ctx` 自行附加。 +Peer 携带自己的 principal;该 principal 能调用什么,由 [Peer 身份属性与 Remote 方法级访问控制](../../implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md) 定义的 role、binding 与 policy 判定。 ### Client 面 @@ -311,7 +316,7 @@ credit 帧留待需要持续大流量上行的消费者出现时再加;inbox | worker 隧道(`packages/experimental/webworker-runtime`) | 页面新增 `stream-uplink-item` / `stream-uplink-end` 帧;`serveStream` 把它们组装成 `uplink` 交给 `seams.openStream`。同源可信边界:没有 inbox 上限,`end` 之后的项丢弃 | | `remote-mock`(`packages/test-support/remote-mock`) | `StreamHandle.uplink: AsyncIterable`;`rpc.open` 透传 | -进程内载体的 `peer` 缺席即操作者。 +进程内载体显式传入 Peer;worker 隧道的页内 Host 传 `connection.browserPeer`。 ### 与 `$events` 的关系 @@ -325,7 +330,7 @@ credit 帧留待需要持续大流量上行的消费者出现时再加;inbox // types.ts export type RemoteStream = AsyncIterable & { readonly [STREAM_UPLINK]?: In } export type PeerId = Branded<'PeerId'> -export interface PeerScope { readonly id: PeerId; readonly ctx: Context; dispose(): Promise } +export interface PeerScope { readonly id: PeerId; readonly ctx: Context; readonly principal: Principal; readonly signal: AbortSignal; dispose(): Promise } export interface RemoteInvocation { … } // see Host face declare module '@deepseek-ai/cordis' { interface Context { readonly invocation: RemoteInvocation | undefined } } @@ -362,13 +367,13 @@ export interface InvokeRemoteRequest { readonly args: Readonly> /** Uplink items; absent is equivalent to an iterator that ends immediately. */ readonly uplink?: AsyncIterable - /** The Peer that initiated the call; absent means the operator. */ - readonly peer?: PeerScope + /** The Peer that initiated the call; required on every path. */ + readonly peer: PeerScope readonly signal?: AbortSignal } export interface TypertGatewayWireStream { - open: (endpoint: string, payload: unknown, uplink: AsyncIterable, peer: PeerScope | undefined, signal: AbortSignal) => Promise> + open: (endpoint: string, payload: unknown, uplink: AsyncIterable, peer: PeerScope, signal: AbortSignal) => Promise> failure: (error: unknown) => RemoteStreamFailure } @@ -379,6 +384,7 @@ export type RemoteStreamOpener = ( uplink: AsyncIterable, peer: PeerScope, control: AbortController, + window: RemoteUplinkWindow, ) => Promise> // index.ts Config addition @@ -394,7 +400,7 @@ const signal = request.signal ?? NEVER_ABORTED_SIGNAL const invocation = new GatewayInvocation( { namespace: request.namespace, method: request.method, args: request.args }, descriptor.service, - request.peer ?? this.operatorPeer(), + request.peer, signal, new UplinkDecoder(request.uplink ?? EMPTY_ASYNC_ITERABLE, descriptor.uplink?.codec, endpoint, control), ) @@ -444,8 +450,8 @@ cancel : missing → ignore; otherwise control.abort(new Error('Remote stream ca | `dsh-typert-protocol`(`types.ts`、`index.ts`) | `RemoteStream`、`RemoteStreamHandle`、`PeerId`、`PeerScope`、`RemoteInvocation`、`ctx.invocation` 声明合并、`InvocationDescriptor.uplink`;修饰器只认 `mode: 'stream'`;`TypertRemoteService` 构造时注册 `invocation` accessor | | `dsh-typert-registry` | 加载校验 `uplink.codec` 为有效的严格 codec | | `dsh-typert-generator`(`model.ts`、`analyzer.ts`、`emitter.ts`) | `remoteResultType` 识别 `RemoteStream` 并生成 `uplink` boundary;描述符输出 `uplink: { codec }`;生成的 Client 签名返回 `RemoteStreamHandle`;参数循环只认业务参数与末位 `signal` | -| `dsh-client-connection`(`operator-peer.ts`、`rpc.ts`、`rpc-host.ts`、`index.ts`) | 操作者 `PeerScope`;`connection.operator` 与 `admit`;`/api` 与升级路由经 `admit` 取 Peer;`rpc.open` 的可选 `uplink` | -| `dsh-api-gateway` Host(`stream-protocol.ts`、`stream-server.ts`、`types.ts`、`index.ts`) | `item` / `end` 帧;`UplinkInbox`;`Config.streamInboxBytes`;`GatewayInvocation` 与 `extend({ invocation })`;`UplinkDecoder`;`handleUpgrade(req, socket, head, peer)`;`operatorPeer()` | +| `dsh-client-connection`(`peer.ts`、`rpc.ts`、`rpc-host.ts`、`index.ts`) | `Peer` 类;`connection.browserPeer` 与 `admit`;`/api` 与升级路由经 `admit` 取 Peer;`rpc.open` 的可选 `uplink` | +| `dsh-api-gateway` Host(`stream-protocol.ts`、`stream-server.ts`、`types.ts`、`index.ts`) | `item` / `end` 帧;`UplinkInbox`;`Config.streamInboxBytes`;`GatewayInvocation` 与 `extend({ invocation })`;`UplinkDecoder`;`handleUpgrade(req, socket, head, peer)` | | `dsh-api-gateway` Client(`client/index.ts`、`client/stream-client.ts`) | `invokeStream` 返回句柄;上行泵由句柄的队列驱动 | | `dsh-webworker-runtime`、`dsh-remote-mock` | worker 隧道的两种上行帧与 `serveStream` 组装;`StreamHandle.uplink`;直接代理返回真句柄 | | 文档 | gateway、api 组、connection、protocol、generator 的 README;`docs/api-gateway`;`docs/subsystems/typert` 的 type-equiv 块;config 与 Cordis 目录 | @@ -560,7 +566,7 @@ for await (const reply of stream) replies.push(reply) // ['> a', '> b'] **客户端 `send` 放在生成方法返回的 `AsyncIterable` 上。** `AsyncIterable` 上挂方法不常规;句柄类型 `RemoteStreamHandle` 显式扩展它。 -**照搬 Access 机制。** 访问控制是业务语义,`vouch` 与 `remote/invoke` waterfall 为它服务;本 Note 只需要"这条流是谁的",`PeerScope` 就够。 +**照搬 Access 机制。** 访问控制是业务语义,`vouch` 与 `remote/invoke` waterfall 为它服务;本 Note 只需要"这条流是谁的",`PeerScope` 就够。方法级访问控制后来归入 [Peer 身份属性与 Remote 方法级访问控制](../../implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md),该部分由其 supersede。 **继续用 unary RPC 做上行。** 每次上行一次 HTTP 往返、一次鉴权、一次查找;两条载体没有顺序关系,只能靠客户端串行化;没有 EOF;取消不覆盖两半;每个功能各造一套关联键。 @@ -574,10 +580,11 @@ for await (const reply of stream) replies.push(reply) // ['> a', '> b'] - **买到的**:一条流两个方向,终端按键、审批回答、后台任务 stdin 这类交互式场景不再各造一套"一条流加一个 unary";Host 方法知道调用者;上行类型与下行类型在返回类型里一处声明,上行项来自浏览器,在 Host 逐项严格校验,下行项是 Host 自产的类型值,直接透传;既有 `AsyncIterable` 方法与 unary 方法的生成物与线路帧完全不变。 - **`this.ctx.invocation` 在非 Remote 调用下是 `undefined`**,方法读它要处理可选;一个只被进程内直接调用的服务方法读到 `undefined` 是正确行为。 +- **Web 终端使用 `attach`** 传输屏幕输出和输入命令,并提供提供者确认和附件专属可写状态;Client 保留当前代次的句柄,绝不重放输入。 - **inbox 上限对所有流生效**:一个不读上行的方法收到大量上行帧会整流失败。这是刻意的显式失败,README 记明。 -- **Peer 只有操作者一个**,`admit` 是唯一的接纳点;第一个需要第二个 Peer 的消费者要在 `dsh-client-connection` 加上打开、关联与事件。 +- **所有浏览器连接共享 `browserPeer`**,`admit` 是唯一的网络接纳点;进程内代码经 `connection.createPeer()` 创建其他 Peer。 - **上行与下行之间没有跨方向顺序保证**;需要请求应答配对的协议自带序号。 -- **上层协议尚未透出 `send`**:`$stream`、snapshot、journal 的消费者今天只用下行;它们拿到的对象已经是句柄。 +- **上层监督器仅暴露下行**:`$stream`、snapshot 和 journal 不暴露 `send`;需要写入上行的领域保留该代次的生成物理句柄。 - **worker 隧道与 WebSocket 载体行为不同**:没有 inbox 上限,`end` 之后的项丢弃,因为页面到 worker 是同源可信边界。 ## 测试 @@ -588,17 +595,15 @@ for await (const reply of stream) replies.push(reply) // ['> a', '> b'] - 网关 Host:帧解析的精确键校验;`UplinkInbox` 的 opening 缓冲、`end` 后 `item`、超限、未知 `streamId` 忽略、socket 关闭时的收尾;`UplinkDecoder` 有无 codec 两种交付与解码失败中止;`uplink()` 只能取一次;`extend({ invocation })` 视图与 `peer` 的来源;`cancellableStream` 先关 uplink 再 `return()`。 - 网关 Client:句柄的 `send`(JSON 校验、open 前入队、终止后抛错)、`end` 幂等、`dispose` 驱动 `cancel` 帧并结束后续读取;泵的三种退出;终止帧到达即停泵。 - typert:analyzer 对 `RemoteStream`、`RemoteStream`、`never` 与既有 `AsyncIterable` 的处理;修饰器选项;registry 加载校验。 -- connection:`admit` 的 401、403 与操作者三种结果;操作者经桥与共享处理器到达;随 connection 释放。 +- connection:`admit` 的 401、403 与 `browserPeer` 三种结果;`browserPeer` 经桥与共享处理器到达;随 connection 释放。 - remote-mock、worker 隧道:上行透传;直接代理返回真句柄。 - 既有 stream 与 unary 方法的测试不改期望值即通过。 ## 后续 - `$stream` / snapshot / journal 把句柄的 `send` 透出给上层协议。 -- Web 终端:一条 `attach` 流取代 `follow` 加 `write` 加 `resize`,`attachmentId` 退场。 - 双工 `$events`:waterfall 的回答成为同一 `streamId` 上的上行项,`$events/result` 与 `clientId` 退场。 - credit 帧:等需要持续大流量上行的消费者出现;inbox 上限就是初始授信。 -- `streamInboxBytes` 默认值 262144 对键盘与 stdin 远超需要;若第一个消费者是终端粘贴,可能与 `terminal-controller` 的 `maxInputBytes` 对齐。 - Host 发起的半关闭帧(Host 不再读上行但继续写下行):没有消费者,不加。 - unary 方法的 `uplink()`:能读到方法运行期间到达的项,语义成立但用处存疑;允许,README 注明。 @@ -606,4 +611,4 @@ for await (const reply of stream) replies.push(reply) // ['> a', '> b'] - [Remote 事件投递](../../../../packages/api/remotes/README.zh.md):`$events/result` 的 unary 回答路径;「后续」里的双工 `$events` 落地后部分取代它。 - [会话历史与事件传输](2026-08-18-session-history-and-event-transport.zh.md):`$stream()` 监督器与其上的 journal / snapshot 协议,本 Note 不改它们。 -- [Web 侧边栏终端](../feature/2026-09-09-web-sidebar-terminal.zh.md):终端按键的 unary `write` 与 `attachmentId`;「后续」里的 `attach` 流落地后取代它。 +- [Web 侧边栏终端](../feature/2026-09-09-web-sidebar-terminal.zh.md):`attach` 流承载输入命令、提供者确认和附件专属可写状态;重连绝不重放输入。 diff --git a/.agents/notes/implemented/architecture/2026-09-19-timed-user-question-two-settlements.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-19-timed-user-question-two-settlements.i18n.yaml index a54db0a426..596b6ca28b 100644 --- a/.agents/notes/implemented/architecture/2026-09-19-timed-user-question-two-settlements.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-19-timed-user-question-two-settlements.i18n.yaml @@ -15,8 +15,8 @@ en: adccc5dc758ad8f6 zh: 50304d68db42e8b1 /agent-note-timed-user-questions-as-two-settlements/decision/ownership: - en: 0543678ad9d26fc5 - zh: aa6ba207e661c567 + en: a28e37fda6e9eaff + zh: 9ca622e09ab24b4d /agent-note-timed-user-questions-as-two-settlements/decision/foreground-wait-and-claim-lifetime: en: 447713bbdf1ab4d8 zh: 2757678ae47822aa @@ -30,14 +30,14 @@ en: 3a33f2583e30e32a zh: 0ad7e8d1e47dc510 /agent-note-timed-user-questions-as-two-settlements/decision/reply-node-and-grouping: - en: 10323b0e0340a40b - zh: a17fc3a4d4d820a7 + en: d87d7432c37405a7 + zh: 49f1bbf53856851a /agent-note-timed-user-questions-as-two-settlements/alternatives-considered: - en: bf61d0ec80c63bb4 - zh: d4ce4d4438ffb013 + en: 1a070542bfa40e68 + zh: 82e32964da912dd8 /agent-note-timed-user-questions-as-two-settlements/verification: - en: d912a73a5ff409eb - zh: 7b626794773e2301 + en: 1d19d196c363a273 + zh: 7cb0475f0cb554d1 /agent-note-timed-user-questions-as-two-settlements/consequences: - en: 2bc99c99be8ca9da - zh: 8f262ec8b9b0945e + en: 0580e85cc12439c5 + zh: a4b151d2c9e5b747 diff --git a/.agents/notes/implemented/architecture/2026-09-19-timed-user-question-two-settlements.md b/.agents/notes/implemented/architecture/2026-09-19-timed-user-question-two-settlements.md index 4bdf47cb2c..014912c9a8 100644 --- a/.agents/notes/implemented/architecture/2026-09-19-timed-user-question-two-settlements.md +++ b/.agents/notes/implemented/architecture/2026-09-19-timed-user-question-two-settlements.md @@ -29,7 +29,7 @@ Foreground answers use the existing Remote Event waterfall. A timeout ends that | `UserQuestionService` | Foreground waits and Client claims, unattended deadlines, late-answer validation and steering, and the durable question projection. | | Gateway and Remote runtime | Existing request delivery, settlement, cancellation, and Remote stream transport; no question-specific timer or event-name branch. | | `ui-user-questions` | One card per Session and call, answer-channel selection, local countdown and drafts, and the reply Definition and renderer. | -| `ui-chat` | Message-id-based presentation aggregation and ordinary process/Turn folding. | +| `ui-chat` | Ordinary process/Turn folding. | | Question-specific tool row | Pending/answered presentation and panel actions, composed in `QuestionToolRow` without adding `rowAction` to generic `ToolRow`. | The [question service](../../../../packages/interaction/user-questions/README.md), [tool](../../../../packages/interaction/tool-ask-user/README.md), and [questions UI](../../../../packages/client/ui-user-questions/README.md) own their package contracts. @@ -72,11 +72,11 @@ Autofocus applies only when the answer channel is ready, no countdown is active, The questions UI identifies a late reply from `source.kind`, then parses its recorded JSON for question-and-answer presentation. Pasting identical JSON in an ordinary user message does not identify it as a reply. Unreadable recorded payloads retain their raw-text presentation. -The ordinary message Definition and the question-reply Definition both match the appended message and retain separate Node Store projections with the same message id. Chat business grouping omits the `turn-trigger` presentation when a `question-reply` with that id is present. The duplicate trigger neither renders nor splits a group; the reply renders once. The same aggregation applies to unscoped historical input. +The question-reply Definition claims the appended reply message on the Chat target through an [exclusive match](2026-10-12-exclusive-conversation-matches.md), so the ordinary message Definition builds no `turn-trigger` or context projection for it and the reply renders once, including for unscoped historical input. The reply participates in ordinary process grouping and whole-Turn folding. It is not independently visible: reading it can require expanding both its Turn and process group. Turn disclosure stays before the group it controls, including a reply that opens the Turn. -This decision adds no process-role field, independent-layout flag, user-message structure change, or target-local fallback dispatch. The independent-kind rules stay in Chat's existing grouping logic. A node-data-owned declaration of grouping and overlap semantics is deferred infrastructure, not part of the question feature. +This decision adds no independent-layout flag or user-message structure change. The reply declares no group-tree position, so it follows the default placement of process content. ## Alternatives considered @@ -90,7 +90,7 @@ This decision adds no process-role field, independent-layout flag, user-message **Reclassify JSON inside generic `MessageItem`, borrow the relay form, or use the default context row.** Parsing text to identify the business message confuses its source with its content; the relay form describes another agent's message, and a generic context row exposes JSON. A dedicated source, Definition, and renderer own the question presentation. -**Independent reply layout through generic node metadata.** It broadens a question feature into a conversation-infrastructure change. Ordinary folding and Chat-owned aggregation are sufficient for this decision; independently declared node-data grouping semantics remain a separate design task. +**Independent reply layout through generic node metadata.** It broadens a question feature into a conversation-infrastructure change. Ordinary folding is sufficient for this decision; placing the reply as user input through its group-tree position is separate work. **A log-only close event or a manufactured dismissal message on panel close.** A log-only close hides the user's decision from the model and adds another durable mechanism. Manufacturing a reply treats hiding the panel as an answer. Panel close therefore leaves a timed question answerable. @@ -101,7 +101,7 @@ This decision adds no process-role field, independent-layout flag, user-message - [Service tests](../../../../packages/interaction/user-questions/tests/user-questions.spec.ts) and [wait tests](../../../../packages/interaction/user-questions/tests/timed-wait.spec.ts) cover claimed and unclaimed waits, last-claim disconnect, cancellation, timeout error mapping, continued-only answers, and exact answer-id validation. - [Projection tests](../../../../packages/interaction/user-questions/tests/projection.spec.ts) distinguish legacy and timed schemas and cover native and PTC pending calls, other failures, resume repair, queued reply cancellation, and admitted settlement. - [Client listener tests](../../../../packages/client/ui-user-questions/tests/browser-plugin.client.spec.ts) and [composer tests](../../../../packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx) cover claim retention through Host acceptance, focus before handshake, indefinite waits, draft retention, RPC resubmission, and panel actions. -- [Grouping tests](../../../../packages/client/ui-chat/tests/process-groups.client.spec.ts) and [browser replay](../../../../apps/web/tests/question-composer.e2e.ts) cover one rendered reply with both projections retained, ordinary collapsed placement, disclosure ordering, and the original tool row's read-only late-answer panel. +- [Grouping tests](../../../../packages/client/ui-chat/tests/process-groups.client.spec.ts) and [browser replay](../../../../apps/web/tests/question-composer.e2e.ts) cover ordinary collapsed placement, disclosure ordering, and the original tool row's read-only late-answer panel. - Legacy round-trip, cancellation, plan-review intent, and Session-remount regressions remain covered by their owning tests. Live multi-Client delivery races and real Host restart remain integration coverage gaps beyond isolated lifecycle and projection tests. ## Consequences @@ -111,7 +111,6 @@ The model can continue independent work while the question remains answerable. B - Take time affects only one Client. Another Client can answer or expire the shared waterfall; local focus does not establish a global hold. - A terminal claim-stream failure can reject the foreground question rather than produce pending. No unattended-wait fallback is provided for that failure. - Host and Client clocks need not align, but Client system-clock changes can move expiry and transport latency can delay the local countdown's start. -- The duplicate projections remain stored. Aggregation adds linear scans during structural grouping rebuilds; ordinary text-only streaming updates retain their incremental path. This is an algorithmic bound, not a measured latency claim. - Unanswered continued questions can remain after reopening a Session. Panel close records nothing, and visibility follows normal folding rather than an always-visible reply guarantee. - A queued late reply can be canceled before the agent admits it. The question stays answerable until an admitted `user/message` records the answer. - A PTC sub-call interrupted before its `tool/ptc-dispatch` result is recorded cannot be reconstructed from the parent `run_code` failure, so it cannot take a late answer after recovery. diff --git a/.agents/notes/implemented/architecture/2026-09-19-timed-user-question-two-settlements.zh.md b/.agents/notes/implemented/architecture/2026-09-19-timed-user-question-two-settlements.zh.md index 6bce5e9960..91ddd5c6b4 100644 --- a/.agents/notes/implemented/architecture/2026-09-19-timed-user-question-two-settlements.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-19-timed-user-question-two-settlements.zh.md @@ -29,7 +29,7 @@ Status: implemented | `UserQuestionService` | 前台等待与 Client 接手、无人接手时的 deadline、迟到回答校验与 steering(中途引导),以及持久问题投影。 | | Gateway 与 Remote 运行时 | 现有请求投递、结算、取消及 Remote stream 传输;不包含问题专属计时器或按事件名分支。 | | `ui-user-questions` | 每个 Session 和调用的一张卡片、回答通道选择、本地倒计时与草稿,以及回复 Definition 和渲染器。 | -| `ui-chat` | 基于消息 id 的呈现聚合,以及普通过程/轮次折叠。 | +| `ui-chat` | 普通过程/轮次折叠。 | | 问题专用工具行 | pending/已回答呈现与面板操作,在 `QuestionToolRow` 中组合,不向通用 `ToolRow` 添加 `rowAction`。 | [问题服务](../../../../packages/interaction/user-questions/README.zh.md)、[工具](../../../../packages/interaction/tool-ask-user/README.zh.md)和[问题 UI](../../../../packages/client/ui-user-questions/README.zh.md)分别拥有各包的约定。 @@ -72,11 +72,11 @@ Client 计算 `Date.now() + remainingMs`。手动聚焦未编辑的回答区会 问题 UI 通过 `source.kind` 识别迟到回复,再解析已记录的 JSON 来呈现问答。普通用户消息粘贴相同 JSON 不会被识别为回复。无法读取的已记录载荷保留原始文本呈现。 -普通消息 Definition 和问答回复 Definition 都匹配追加的消息,在 Node Store 中保留消息 id 相同的独立投影。存在同 id 的 `question-reply` 时,Chat 业务分组省略 `turn-trigger` 呈现。重复触发节点既不渲染,也不切开分组,回复只显示一次。相同聚合也适用于无轮次归属的历史输入。 +问答回复 Definition 通过[独占匹配](2026-10-12-exclusive-conversation-matches.zh.md)在 Chat target 上认领追加的回复消息,普通消息 Definition 因此不再为它建立 `turn-trigger` 或 context 投影,回复只显示一次;无轮次归属的历史输入同样如此。 回复参与普通过程分组和整轮折叠,不保证独立可见:阅读时可能需要展开所属轮次与过程分组。轮次折叠控件始终在所控制的分组之前,包括由回复开启轮次的情况。 -本决策不添加 process-role 字段、独立布局标记、用户消息结构变更或目标内 fallback 派发。独立 kind 规则保留在 Chat 现有分组逻辑中。由节点 data 声明分组与重叠语义的机制是留待拆分的基础设施,不属于问题功能。 +本决策不添加独立布局标记或用户消息结构变更。回复不声明分组树位置,因此按过程内容的默认位置放置。 ## Alternatives considered @@ -90,7 +90,7 @@ Client 计算 `Date.now() + remainingMs`。手动聚焦未编辑的回答区会 **在通用 `MessageItem` 内重新分类 JSON、借用 relay form,或使用默认 context 行。** 解析文本来识别业务消息混淆了来源与内容;relay form 表示其他 agent 的消息,通用 context 行则会暴露 JSON。专用来源、Definition 和渲染器拥有问题呈现。 -**通过通用节点元数据让回复独立布局。** 这会把问题功能扩大为对话基础设施变更。普通折叠与 Chat 内聚合足以满足本决策;由节点 data 独立声明分组语义仍是单独的设计任务。 +**通过通用节点元数据让回复独立布局。** 这会把问题功能扩大为对话基础设施变更。普通折叠足以满足本决策;通过分组树位置把回复放置为用户输入是单独的工作。 **面板关闭时写仅入日志的关闭事件,或伪造放弃消息。** 仅入日志会让模型看不到用户决定,还增加另一套持久机制。伪造回复则把隐藏面板当成回答。因此关闭面板仍让计时问题保持可回答。 @@ -101,7 +101,7 @@ Client 计算 `Date.now() + remainingMs`。手动聚焦未编辑的回答区会 - [服务测试](../../../../packages/interaction/user-questions/tests/user-questions.spec.ts)与[等待测试](../../../../packages/interaction/user-questions/tests/timed-wait.spec.ts)覆盖有人与无人接手、最后接手方断开、取消、超时错误映射、仅接受已继续问题的回答,以及答案 id 的精确校验。 - [投影测试](../../../../packages/interaction/user-questions/tests/projection.spec.ts)区分 legacy 与 timed schema,并覆盖原生及 PTC pending 调用、其他失败、恢复修复、排队回复取消和准入后结算。 - [Client 监听器测试](../../../../packages/client/ui-user-questions/tests/browser-plugin.client.spec.ts)与[composer 测试](../../../../packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx)覆盖接手保留到 Host 接受结果、握手前聚焦、无限期等待、草稿保留、RPC 重提及面板操作。 -- [分组测试](../../../../packages/client/ui-chat/tests/process-groups.client.spec.ts)与[浏览器回放](../../../../apps/web/tests/question-composer.e2e.ts)覆盖保留两个投影但只渲染一次回复、普通折叠位置、折叠控件顺序,以及原工具行的迟到答案只读面板。 +- [分组测试](../../../../packages/client/ui-chat/tests/process-groups.client.spec.ts)与[浏览器回放](../../../../apps/web/tests/question-composer.e2e.ts)覆盖普通折叠位置、折叠控件顺序,以及原工具行的迟到答案只读面板。 - legacy 问答往返、取消、计划评审意图及 Session 重新挂载的回归仍由各自测试覆盖。真实多 Client 投递竞争与真实 Host 重启仍是独立生命周期和投影测试之外的集成覆盖缺口。 ## Consequences @@ -111,7 +111,6 @@ Client 计算 `Date.now() + remainingMs`。手动聚焦未编辑的回答区会 - 「慢慢回答」只影响一个 Client。其他 Client 可以回答或让共享 waterfall 到期,本地聚焦不构成全局暂停。 - 接手 stream 的终止性失败可能让前台问题失败,而不是产生 pending。不为该失败提供无人接手等待的 fallback。 - Host 与 Client 时钟不必对齐,但 Client 系统时钟变化可能移动到期时间,传输延迟可能推迟本地倒计时的开始。 -- 重复投影仍保留在存储中。聚合在结构性分组重建时增加线性扫描;普通纯文本 stream 更新保留增量路径。这是算法复杂度界限,不是实测延迟结论。 - 无人回答的已继续问题可以在重新打开 Session 后继续存在。关闭面板不写记录,可见性遵循普通折叠,而不是保证回复始终可见。 - 排队的迟到回复可能在 agent 准入前被取消。只有已准入的 `user/message` 记录答案,问题在此之前保持可回答。 - PTC 子调用若在记录 `tool/ptc-dispatch` 结果前中断,无法从父 `run_code` 的失败中重建,因此恢复后不能接受迟到回答。 diff --git a/.agents/notes/implemented/architecture/2026-09-24-deepseek-account-remote-model-catalog.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-24-deepseek-account-remote-model-catalog.i18n.yaml new file mode 100644 index 0000000000..71c61113a6 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-24-deepseek-account-remote-model-catalog.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-09-24-deepseek-account-remote-model-catalog.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-24-deepseek-account-remote-model-catalog.md +/agent-note-deepseek-account-models-come-from-the-account-listing-and-gate-by-client-version: + en: e6d82714ac89a7ae + zh: 54c14278e749e7e1 +/agent-note-deepseek-account-models-come-from-the-account-listing-and-gate-by-client-version/problem: + en: 2eeb916eb65c66e7 + zh: 7bf5c0abb8bb61e4 +/agent-note-deepseek-account-models-come-from-the-account-listing-and-gate-by-client-version/decision: + en: 42e800c4cd3b6ebe + zh: 06fabe5a9681fa27 +/agent-note-deepseek-account-models-come-from-the-account-listing-and-gate-by-client-version/alternatives-considered: + en: 7c7880fdb0cc95f6 + zh: 2b8a539192626789 +/agent-note-deepseek-account-models-come-from-the-account-listing-and-gate-by-client-version/consequences: + en: 352ece6c2decaaf7 + zh: 1d0c3620fd26a35c diff --git a/.agents/notes/implemented/architecture/2026-09-24-deepseek-account-remote-model-catalog.md b/.agents/notes/implemented/architecture/2026-09-24-deepseek-account-remote-model-catalog.md new file mode 100644 index 0000000000..75265db1ce --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-24-deepseek-account-remote-model-catalog.md @@ -0,0 +1,36 @@ +# Agent Note: DeepSeek account models come from the account listing and gate by client version + +Status: implemented + +English | [中文](2026-09-24-deepseek-account-remote-model-catalog.zh.md) + +## Problem + +The account route's catalog was deployment configuration: a build advertised the models it shipped with, a new model reached users only through a release, and nothing kept an older build from requesting a model it cannot drive. The account service publishes per-account model metadata at `GET /models`, and that listing can name models an older build does not understand. + +## Decision + +- [`llm-deepseek-account`](../../../../packages/llm/llm-deepseek-account/README.md) loads the listing lazily for each credential, waits for it once up to `modelsTimeoutMs`, refreshes it in the background after `modelsMaxAgeMs`, keeps the last good listing when a load fails or returns no model, and serves the bundled catalog before any listing loads. The cache lives in memory and is keyed by endpoint and token fingerprint, so per-account rollouts stay separate. +- A listed model is available when its `client_requirements["deepseek-harness"].min_version` is at most the current DeepSeek Harness version in semantic-version order. Without that declaration, only ids the built-in catalog knows are available; the others stay hidden, because a client cannot tell which future version supports them. A request for a refused listed model fails before network I/O with `CLIENT_UPGRADE_REQUIRED` or `MODEL_UNSUPPORTED`. +- Listed capabilities replace bundled ones field by field; the bundled entry keeps client-side knowledge such as tool-update mode and image budgets; `modelOverrides` applies last and reuses the `models` entry fields and validation. `max_output_tokens` bounds the request default instead of becoming it. +- The shared transport gains only two provider hooks, `connect` and `admit`; listing, gating, and merging stay in the account package. + +## Alternatives considered + +**Server-side filtering by client version only.** Codex sends `client_version` and lets its backend drop models. An older build then cannot show that a newer version supports a model, and a listing that omits a declaration would expose an unknown model. + +**Default parameters for unknown models.** Reasonix and Pi drive unknown ids with generic defaults, and MiniMax treats that as a defect. A model that needs client changes would run with the wrong behavior. + +**Whole-listing validation.** Codex, ZCode, and Kimi reject the whole listing on one bad field. One malformed row would then send every account back to the bundled catalog. + +**Persisting the last good listing.** A durable cache adds a persistence format and its migration duty; the bundled catalog already covers offline starts. + +**Keeping `models` with merge-by-id semantics.** The API-key route's `models` replaces its catalog, so the same field name with merge semantics would invert its meaning between sibling routes. + +**Generalizing the shared transport.** Per-model effort levels, an output-capability field, and catalog-change announcements would widen the Messages package for one consumer. The listed effort levels match the bundled defaults, and a model that needs different ones needs a release, which the gate enforces. + +## Consequences + +- An older build never offers a model it does not know unless the service declares a minimum version that build satisfies, and the service must declare `min_version` for a future build to show that an upgrade enables a model. +- A modified client can still request any id; the service enforces version floors only if it rejects such requests itself. +- The first account access after sign-in may wait up to `modelsTimeoutMs`; an offline start uses bundled capabilities until a listing loads; a listing change during a running process reaches the Web picker at its next catalog refresh. diff --git a/.agents/notes/implemented/architecture/2026-09-24-deepseek-account-remote-model-catalog.zh.md b/.agents/notes/implemented/architecture/2026-09-24-deepseek-account-remote-model-catalog.zh.md new file mode 100644 index 0000000000..7199091c73 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-24-deepseek-account-remote-model-catalog.zh.md @@ -0,0 +1,36 @@ +# Agent Note: DeepSeek 账号模型来自账号列表,并按客户端版本门控 + +Status: implemented + +[English](2026-09-24-deepseek-account-remote-model-catalog.md) | 中文 + +## 问题 + +账号路由的目录原本是部署配置:每个构建只提供它发布时自带的模型,新模型只能随发版到达用户,也没有办法阻止旧构建请求它驱动不了的模型。账号服务在 `GET /models` 上按账号发布模型元数据,这份列表可能包含旧构建不认识的模型。 + +## 决策 + +- [`llm-deepseek-account`](../../../../packages/llm/llm-deepseek-account/README.zh.md) 按凭据懒加载列表,首次访问最多等待 `modelsTimeoutMs`,超过 `modelsMaxAgeMs` 后在后台刷新;获取失败或返回的列表中没有模型时保留上一份,还没有加载到任何列表时使用内置目录。缓存只在内存中,以端点和 token 指纹为键,因此按账号灰度互不干扰。 +- 列出的模型在其 `client_requirements["deepseek-harness"].min_version` 按 semver 顺序不高于当前 DeepSeek Harness 版本时可用。没有这个声明时,只有内置目录认识的 id 可用,其余保持隐藏,因为客户端无法判断将来哪个版本会支持它们。请求被拒绝的已列出模型时,在网络请求之前以 `CLIENT_UPGRADE_REQUIRED` 或 `MODEL_UNSUPPORTED` 失败。 +- 列表中的能力逐字段覆盖内置能力;内置条目保留工具更新模式、图片预算等客户端知识;`modelOverrides` 最后叠加,复用 `models` 条目的字段与校验。`max_output_tokens` 只限制请求默认值,不直接成为默认值。 +- 共享传输层只新增 `connect` 与 `admit` 两个提供方钩子;列表、门控与合并都留在账号包中。 + +## 考虑过的替代方案 + +**只在服务端按客户端版本过滤。** Codex 发送 `client_version`,由后端去掉模型。这样旧构建无法提示新版本支持某个模型,而且列表漏掉声明时会暴露未知模型。 + +**未知模型套用默认参数。** Reasonix 和 Pi 用通用默认值驱动未知 id,MiniMax 把这种做法当作缺陷。需要客户端改动的模型会以错误的方式运行。 + +**整份列表严格校验。** Codex、ZCode 和 Kimi 会因为一个坏字段拒绝整份列表。一条格式错误的记录就会让所有账号退回内置目录。 + +**持久化上一次成功的列表。** 持久缓存要新增持久化格式并承担迁移责任;内置目录已经覆盖离线启动。 + +**保留 `models` 并改为按 id 合并。** API key 路由的 `models` 会替换整个目录,同名字段在兄弟路由上改成合并语义会让含义相反。 + +**扩展共享传输层。** 按模型的推理档位、输出能力字段和目录变更通知,会为一个使用方扩大 Messages 包的范围。列表中的推理档位与内置默认值一致;需要不同档位的模型需要发版适配,门控正是用来保证这一点。 + +## 影响 + +- 旧构建永远不会提供它不认识的模型,除非服务端声明了该构建满足的最低版本;服务端必须声明 `min_version`,将来的构建才能提示升级后可用。 +- 改过的客户端仍然可以请求任意 id;只有服务端自己拒绝这类请求时,版本下限才会被强制执行。 +- 登录后的第一次账号访问最多等待 `modelsTimeoutMs`;离线启动在加载到列表之前使用内置能力;进程运行中途的列表变化要等 Web 选择器下一次刷新目录时才会显示。 diff --git a/.agents/notes/implemented/architecture/2026-09-24-preset-scoped-schedule-tools.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-24-preset-scoped-schedule-tools.i18n.yaml index 7c2ed58b01..01a5ad7732 100644 --- a/.agents/notes/implemented/architecture/2026-09-24-preset-scoped-schedule-tools.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-24-preset-scoped-schedule-tools.i18n.yaml @@ -15,8 +15,8 @@ en: 5cfe619038339caa zh: c2d2651d304d867c /agent-note-preset-scoped-schedule-tools/consequences: - en: 546d83d28487d1c1 - zh: 438922cd4f3ff9ec + en: d7c7e6b8e6268559 + zh: 801e5be70e00cc90 /agent-note-preset-scoped-schedule-tools/testing: - en: 1c5f34cf30a24fd7 - zh: 6f7a372de52d0ecd + en: d42cb866907a96b5 + zh: 6e66f8babe744bcb diff --git a/.agents/notes/implemented/architecture/2026-09-24-preset-scoped-schedule-tools.md b/.agents/notes/implemented/architecture/2026-09-24-preset-scoped-schedule-tools.md index c0b1b34412..169f3bea52 100644 --- a/.agents/notes/implemented/architecture/2026-09-24-preset-scoped-schedule-tools.md +++ b/.agents/notes/implemented/architecture/2026-09-24-preset-scoped-schedule-tools.md @@ -26,9 +26,9 @@ English | [中文](2026-09-24-preset-scoped-schedule-tools.zh.md) - Each preset that mounts the row pays the four schemas' fixed token cost, and tool availability is read from the composition instead of inferred from the Host service's presence. - A deployment can mount `dsh-schedule` for storage and delivery without granting its agents model-driven reminder management. - `dsh-schedule` injects no `ctx.tools` and registers no model-facing tool. -- A delegated child cannot use the four tools through either layer: the `tool-subagent` and `tool-subagent-fork` rows in `standard`, `cordis`, and `ptc` deny all four names through `toolFilter`, and the provider applies that filter in the child scope through `ctx.tools.restrict()`, so the tools leave the child's prompt; `subagentCallerRefusal` in `dsh-tool-schedule` independently returns `{ code: 'subagent_session', message: 'A delegated subagent cannot use reminders.' }` from every one of the four tools when the caller's delegation depth is above zero, so a call that reaches a tool anyway is refused. +- A delegated child inherits the preset's four reminder schemas. `subagentCallerRefusal` in `dsh-tool-schedule` returns `{ code: 'subagent_session', message: 'A delegated subagent cannot use reminders.' }` from each tool when the caller's delegation depth is above zero. The [preset composition decision](../simplification/2026-10-09-preset-owned-subagent-composition.md) removes the redundant child filter; visibility does not authorize execution. - A Session a delegated child owns can never receive a delivered reminder, so `ScheduleService.create` and `ScheduleService.update` refuse it with `subagent_session`. The refusal reads the delegation depth — the accounting the delegation cap itself enforces, carried by the persisted session header across a cold resume. The rule sits in the service, so other consumers reach it too — the Automation surface included. ## Testing -`packages/schedule/tool-schedule/tests/tool-schedule.spec.ts` pins the four definitions and their error mapping, including `refuses every tool for a delegated child caller`. `apps/cli/tests/web-agent-presets.e2e.ts` asserts the `minimal` preset's tool list, and `snapshots/web/minimal-preset/tool-schemas.expected.json` records its bash-only tool table. `packages/schedule/schedule/tests/subagent-ownership.spec.ts` pins the delegation-depth refusal, and `scripts/optional-bundles.spec.ts` asserts that both delegation rows in the three presets deny all four tool names. +`packages/schedule/tool-schedule/tests/tool-schedule.spec.ts` pins the four definitions and their error mapping, including `refuses every tool for a delegated child caller`. `apps/cli/tests/web-agent-presets.e2e.ts` asserts the `minimal` preset's tool list, and `snapshots/web/minimal-preset/tool-schemas.expected.json` records its tool table without reminder schemas. `packages/schedule/schedule/tests/subagent-ownership.spec.ts` pins the delegation-depth refusal, and `scripts/optional-bundles.spec.ts` asserts that both delegation rows in the three presets omit `toolFilter`. The Web preset e2e also checks equal parent/child tool schemas and refusal from all four reminder executors for fresh and seeded children. `snapshots/web/subagent-fork-schedule` records all four refusals in a seeded fork through the shipped Web composition and shares the standard preset's prompt and schema pins. diff --git a/.agents/notes/implemented/architecture/2026-09-24-preset-scoped-schedule-tools.zh.md b/.agents/notes/implemented/architecture/2026-09-24-preset-scoped-schedule-tools.zh.md index bb9a8c0894..75b41d8b9e 100644 --- a/.agents/notes/implemented/architecture/2026-09-24-preset-scoped-schedule-tools.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-24-preset-scoped-schedule-tools.zh.md @@ -26,9 +26,9 @@ Status: implemented - 挂载该行的每个 preset 承担四个 schema 的固定 token 成本,工具可用性取自组合,而不是由宿主服务是否存在推断。 - 部署可以只为存储与投递挂载 `dsh-schedule`,而不授予其 Agent 由模型驱动的提醒管理能力。 - `dsh-schedule` 不注入 `ctx.tools`,也不注册任何面向模型的工具。 -- 被委派的子 agent 无法通过两道中的任何一道使用这四个工具:`standard`、`cordis` 与 `ptc` 中的 `tool-subagent` 与 `tool-subagent-fork` 两行通过 `toolFilter` 拒绝全部四个名称,provider 会在子作用域内通过 `ctx.tools.restrict()` 应用该过滤,因此这些工具会从子 agent 的 prompt 中消失;`dsh-tool-schedule` 中的 `subagentCallerRefusal` 会在调用方委派深度大于零时,从四个工具中的每一个独立返回 `{ code: 'subagent_session', message: 'A delegated subagent cannot use reminders.' }`,因此即使某个调用仍能到达工具,也会被拒绝。 +- 被委派的子 agent 继承 preset 的四个提醒 schema。调用方委派深度大于零时,`dsh-tool-schedule` 中的 `subagentCallerRefusal` 从每个工具返回 `{ code: 'subagent_session', message: 'A delegated subagent cannot use reminders.' }`。[preset 组装决策](../simplification/2026-10-09-preset-owned-subagent-composition.zh.md)移除了重复的子级过滤;可见性不授予执行权限。 - 被委派子 agent 拥有的 Session 永远收不到投递的提醒,因此 `ScheduleService.create` 与 `ScheduleService.update` 会以 `subagent_session` 拒绝它。该拒绝读取委派深度——委派封顶本身执行的记账,由持久化 session header 承载并跨冷恢复保留。这条规则在服务层,因此其它消费方(包括自动化任务页面)也会命中。 ## Testing -`packages/schedule/tool-schedule/tests/tool-schedule.spec.ts` 固定这四个定义及其错误映射,包括 `refuses every tool for a delegated child caller`。`apps/cli/tests/web-agent-presets.e2e.ts` 断言 `minimal` preset 的工具列表,`snapshots/web/minimal-preset/tool-schemas.expected.json` 记录其只含 `bash` 的工具表。`packages/schedule/schedule/tests/subagent-ownership.spec.ts` 固定委派深度拒绝,`scripts/optional-bundles.spec.ts` 断言三个 preset 的两行委派行都拒绝全部四个工具名。 +`packages/schedule/tool-schedule/tests/tool-schedule.spec.ts` 固定这四个定义及其错误映射,包括 `refuses every tool for a delegated child caller`。`apps/cli/tests/web-agent-presets.e2e.ts` 断言 `minimal` preset 的工具列表,`snapshots/web/minimal-preset/tool-schemas.expected.json` 记录其不含提醒工具 schema 的工具表。`packages/schedule/schedule/tests/subagent-ownership.spec.ts` 固定委派深度拒绝,`scripts/optional-bundles.spec.ts` 断言三个 preset 的两行委派行都不含 `toolFilter`。Web preset e2e 还检查父子工具 schema 相等,且四个提醒执行器对新建和带种子的子级均拒绝执行。`snapshots/web/subagent-fork-schedule` 通过出货 Web 组合记录带种子 fork 中的四次拒绝,并共享 standard preset 的提示和 schema 预期。 diff --git a/.agents/notes/implemented/architecture/2026-09-27-progressive-office-skills.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-27-progressive-office-skills.i18n.yaml new file mode 100644 index 0000000000..01cda717dd --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-27-progressive-office-skills.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-09-27-progressive-office-skills.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-27-progressive-office-skills.md +/agent-note-progressive-office-skills-with-ordinary-shell-execution: + en: 42e2f062c37e9ef1 + zh: 5cd3d421adec1cac +/agent-note-progressive-office-skills-with-ordinary-shell-execution/problem: + en: a9984a594cdac98d + zh: 38768e7699bc8d39 +/agent-note-progressive-office-skills-with-ordinary-shell-execution/decision: + en: d057c558d6b9803c + zh: acf3fdbc4cd7cee3 +/agent-note-progressive-office-skills-with-ordinary-shell-execution/alternatives-considered: + en: 8bca955255e6d982 + zh: bc313a4bcab3f6f9 +/agent-note-progressive-office-skills-with-ordinary-shell-execution/consequences: + en: cd4ff2423b2f5f9e + zh: 63135aeae9c46415 diff --git a/.agents/notes/implemented/architecture/2026-09-27-progressive-office-skills.md b/.agents/notes/implemented/architecture/2026-09-27-progressive-office-skills.md new file mode 100644 index 0000000000..a89b896fe6 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-27-progressive-office-skills.md @@ -0,0 +1,32 @@ +# Agent Note: Progressive Office skills with ordinary shell execution + +Status: implemented + +English | [中文](2026-09-27-progressive-office-skills.zh.md) + +## Problem + +Combined authoring and rendering instructions spend context on operations unrelated to ordinary editing. Rendering tool activation adds a second discovery path and durable state even though shell tools already own execution permissions and process management. A missing renderer can prevent loading otherwise usable editing guidance. + +## Decision + + +The [Office provider](../../../../packages/skill/skill-office/README.md) lists a compact router and six format-specific authoring or operation skills. The model loads full instructions by name and selects rendering for appearance-focused changes unless the user explicitly skips it. Rendering uses a private, randomly named temporary directory inside the task workspace and removes it after inspection. Skills invoke the shared Python checker and configured LibreOffice Kit CLI through ordinary Bash or PowerShell. SDK and Desktop own their Office composition separately; ordinary Web and the shared base do not mount these plugins. + +Carriers own interpreter and resource locations. Operation skills append the configured Node and CLI paths only when their bodies are loaded; authoring skills use the selected Python and shared checker resource. Loading instructions does not prepare engines or authoring payloads. Ordinary shell execution retains its existing permissions and process management. + +Office dependency ranges allow patch updates; the root lock records the resolved adapter and engine versions. + +## Alternatives considered + +**Keep a dedicated renderer tool.** A small typed schema constrains rendering arguments, but requires its own execution configuration, scoped registration, projection, and activation lifecycle. The CLI operation skills accept shell quoting and model-selected arguments in exchange for reusing the existing approval, sandbox, cancellation, and output mechanisms. + +**Hide subskills from the catalog.** This requires a generic visibility policy independent of invocation permission. Listing seven short summaries preserves the existing skill API while full instruction bodies remain demand-loaded. + +**Automatically inject guides for file extensions.** Extensions do not distinguish text edits, recalculation, conversion, and requested visual checks. Model-selected routing keeps unrelated operations out of context and permits explicit direct invocation. + +## Consequences + +Instruction loading appends ordinary logged skill results; completion does not erase them. Historical tool records remain readable and existing declaration reconciliation handles resumed sessions without a Session format change or agent-loop modification. A dedicated renderer would need a new independently justified user or security requirement before reintroduction. + +Verification covers catalog entries, denied and explicit invocation, authoring without executable paths, native/PTC requests, resume and fork, ordinary and minimal profile composition, resource packaging, context budgets, and real-model operation selection. Rendering failures preserve usable sources and stop visual checks; successful commands alone cannot establish usable images. diff --git a/.agents/notes/implemented/architecture/2026-09-27-progressive-office-skills.zh.md b/.agents/notes/implemented/architecture/2026-09-27-progressive-office-skills.zh.md new file mode 100644 index 0000000000..0aeb95eb1d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-27-progressive-office-skills.zh.md @@ -0,0 +1,32 @@ +# Agent Note: Office 技能渐进加载与普通 shell 执行 + +Status: implemented + +[English](2026-09-27-progressive-office-skills.md) | 中文 + +## 问题 + +合并的创作和渲染说明会为普通编辑无关的操作消耗上下文。渲染工具激活增加第二条发现路径和持久状态,而 shell 工具已经负责执行权限及进程管理。缺少渲染器也可能阻止加载原本可用的编辑指南。 + +## 决策 + + +[Office 提供者](../../../../packages/skill/skill-office/README.zh.md) 列出精简路由和六个各格式创作或操作技能。模型按名称加载完整说明;以外观为目标的修改会选择渲染,除非用户明确要求跳过。渲染使用任务工作区内随机命名的私有临时目录,并在检查后删除。技能通过普通 Bash 或 PowerShell 调用共享 Python 检查器和配置的 LibreOffice Kit CLI。SDK 和 Desktop 分别拥有 Office 组合;普通 Web 和共享 base 不挂载这些插件。 + +载体负责解释器和资源位置。操作技能仅在加载正文时附加配置的 Node 与 CLI 路径;创作技能使用选定的 Python 和共享检查器资源。加载说明不准备引擎或创作 payload。普通 shell 执行保留现有权限和进程管理。 + +Office 依赖范围允许补丁版本更新;根锁文件记录实际解析的适配器与引擎版本。 + +## 考虑过的替代方案 + +**保留专属渲染工具。** 小型类型化 schema 能约束渲染参数,但需要专属执行配置、作用域注册、投影和激活生命周期。CLI 操作技能接受 shell 引用和模型选择参数的成本,以复用现有审批、沙箱、取消及输出机制。 + +**从目录隐藏子技能。** 这需要独立于调用权限的通用可见性策略。列出七条简短摘要可保留现有技能 API,完整说明仍按需加载。 + +**按文件扩展名自动注入指南。** 扩展名无法区分文本编辑、重计算、转换和用户要求的视觉检查。模型选路使无关操作不进入上下文,并允许显式直接调用。 + +## 影响 + +说明加载追加普通的已记录技能结果;操作完成不会擦除这些结果。历史工具记录仍可读取,恢复会话使用现有声明更新机制,无需修改 Session 格式或 agent loop。重新引入专属渲染器需要新的、独立成立的用户或安全要求。 + +验证覆盖目录条目、拒绝及显式调用、无可执行文件路径时的创作、native/PTC 请求、恢复和 fork、常规及 minimal 组合、资源打包、上下文预算和真实模型操作选择。渲染失败时保留可用源文件并停止视觉检查;命令成功本身不能证明图片可用。 diff --git a/.agents/notes/implemented/architecture/2026-09-30-profile-package-refresh-and-manifest-invalidation.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-30-profile-package-refresh-and-manifest-invalidation.i18n.yaml index 0187f43093..aa017c7b03 100644 --- a/.agents/notes/implemented/architecture/2026-09-30-profile-package-refresh-and-manifest-invalidation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-30-profile-package-refresh-and-manifest-invalidation.i18n.yaml @@ -12,23 +12,23 @@ en: a15c7c13f167e5ac zh: a15c7c13f167e5ac /agent-note-refresh-runtime-resolution-after-package-operations-and-expire-hmr-package-configuration/decision/responsibilities: - en: 3ffd0c78fa204b04 - zh: f468bab00a7ff357 + en: d53a850a2deabc08 + zh: 01c2f735f2a9562a /agent-note-refresh-runtime-resolution-after-package-operations-and-expire-hmr-package-configuration/decision/runtime-resolution: - en: ca45b973e1968533 - zh: 002f23bb70e4fed7 + en: 42a3ed903fbb5483 + zh: 7e1f6df1137794c3 /agent-note-refresh-runtime-resolution-after-package-operations-and-expire-hmr-package-configuration/decision/plugin-manager: - en: 9b04926c175f22e8 - zh: fbf05dd4065b0738 + en: 7ec1ad2a691bbd12 + zh: 01057bbe0240056a /agent-note-refresh-runtime-resolution-after-package-operations-and-expire-hmr-package-configuration/decision/hmr-package-configuration-expiry: en: c7de8dafe47a5587 zh: 6010a70d8451edd1 /agent-note-refresh-runtime-resolution-after-package-operations-and-expire-hmr-package-configuration/decision/future-work: - en: 31818f8f7f80470b - zh: ecb7ac85bb49cf6d + en: 8b5faf72418a43dd + zh: 3d676c9b54697a2a /agent-note-refresh-runtime-resolution-after-package-operations-and-expire-hmr-package-configuration/alternatives-considered: - en: 7c1c6d21570cda0d - zh: e6f8334c85f1b404 + en: f04a27b3525180b0 + zh: 9f3a01d0400c5a88 /agent-note-refresh-runtime-resolution-after-package-operations-and-expire-hmr-package-configuration/verification: en: 939cedceed200cb2 zh: c7bb02d0da400c8d diff --git a/.agents/notes/implemented/architecture/2026-09-30-profile-package-refresh-and-manifest-invalidation.md b/.agents/notes/implemented/architecture/2026-09-30-profile-package-refresh-and-manifest-invalidation.md index b1b86be199..83612801d2 100644 --- a/.agents/notes/implemented/architecture/2026-09-30-profile-package-refresh-and-manifest-invalidation.md +++ b/.agents/notes/implemented/architecture/2026-09-30-profile-package-refresh-and-manifest-invalidation.md @@ -17,9 +17,9 @@ Two pieces of Host state did not follow changes while the process ran: | Owner | Does | Does not | |---|---|---| -| resolver (`app-boot/src/profile-resolution/resolver.ts`) | `replace()` allows removing `scope: profile` entries and profile-local names | Touch any Node cache | -| plugin-manager | Calls `pluginPackages.refresh()` at fixed points of package operations | Construct resolutions or manage module and package-configuration caches | -| HMR (`packages/boot/hmr`) | Expires package configuration and preserves ordinary reloads for manifests loaded as JSON modules | Reload plugins for configuration-only manifests; handle `node_modules` package replacement | +| resolver (`app-boot/src/profile-resolution/resolver.ts`) | `replace()` allows removing `scope: profile` entries and profile-local names, and changing a retained profile entry's version in place | Touch any Node cache | +| plugin-manager | Without HMR, calls `pluginPackages.refresh()` at fixed points of package operations; with HMR, leaves publication to its recomposition | Construct resolutions or manage module and package-configuration caches | +| HMR (`packages/boot/hmr`) | Expires package configuration and preserves ordinary reloads for manifests loaded as JSON modules; replaces plugins whose loaded package files changed during recomposition ([replacement Note](2026-10-01-replace-changed-package-modules-on-recomposition.md)) | Reload plugins for configuration-only manifests | ### Runtime resolution @@ -29,20 +29,18 @@ Validation of a new generation in `replace()`: - `scope: profile` entries may be removed. - Profile-local package names may be removed. -- Retained profile entries keep their normalized directory, version, and scope, but may change declaring anchor when another selected bundle supplies the same package. Installation mappings and declaring anchors remain unchanged. The profile scope cannot change; a new local name cannot override an existing entry; a published link name cannot select another real directory. +- Retained profile entries keep their normalized directory and scope, but may change declaring anchor when another selected bundle supplies the same package, and may change version when the package is upgraded in place ([replacement Note](2026-10-01-replace-changed-package-modules-on-recomposition.md)). Installation mappings and declaring anchors remain unchanged. The profile scope cannot change; a new local name cannot override an existing entry. A published link name may select another real directory; Node's cached real paths of the previous one are relocated. ### Plugin Manager | Operation | Publication point | |---|---| -| Install a new package | After pnpm succeeds and the bundle selection is saved, before reconciliation, whether or not the bundle is enabled | -| Overwrite an installed package | No publication; the result is `restart-required` | -| Enable a bundle | After the selection is saved, before reconciliation | -| Disable a bundle | With HMR, after reconciliation stops its plugins; without HMR, no publication, and running plugins keep the current table | -| Remove | After pnpm remove succeeds, inside the HMR transaction | -| Failed or cancelled install, failed removal | No publication | +| Install, enable, disable or remove with HMR | Inside HMR's recomposition, after the rows the generation drops stopped and before the generation applies ([replacement Note](2026-10-01-replace-changed-package-modules-on-recomposition.md)) | +| Install a new package or enable a bundle without HMR | After the profile files are saved, before reconciliation | +| Remove without HMR | After pnpm remove succeeds | +| Disable without HMR, an install over an installed package, a failed or cancelled install, a failed removal | No publication; an install over a package the spec names is refused before pnpm runs | -`createRuntimeResolution()` returns a `ProfileRuntimeResolution`, which privately keeps the installation anchor, Harness home, and profile directory it was computed from. Its `computeLatestResolution()` rereads that profile with the same inputs and returns a new object for the latest generation, leaving the original unchanged; Workers receive only the table fields. `PluginPackages.refresh()` calls the current resolution's `computeLatestResolution()` and then `replace()`; a resolution constructed as plain data cannot be refreshed and makes the call throw. +`createRuntimeResolution()` returns a `ProfileRuntimeResolution`, which privately keeps the installation anchor, Harness home, and profile directory it was computed from. Its `computeLatestResolution()` rereads that profile with the same inputs and returns a new object for the latest generation, leaving the original unchanged; Workers receive only the table fields. `PluginPackages.refresh()` calls the current resolution's `computeLatestResolution()` and then `replace()`; a resolution constructed as plain data cannot be refreshed and makes the call throw. app-boot's `recomposeProfile()` first stops the rows the generation no longer lists, so their plugins dispose while their packages are still mapped, then publishes the latest generation once; a table that is already published is not published again. Without HMR, deselecting a startup bundle does not stop its plugins. While such a bundle remains deselected, later package operations keep the existing runtime table instead of publishing its removal. Other no-HMR installations and removals of non-running bundles still publish normally. @@ -79,7 +77,7 @@ The binding's `getNearestParentPackageJSON` is called only by `package_json_read ### Future Work -- Online replacement inside `node_modules`, same-path reinstall, changed link targets, and cross-package reload propagation remain unsupported. Package updates need process restart; this does not guarantee that every management result already reports that requirement correctly. +- A retained profile mapping that moves to another directory waits for a restart; Plugin Manager moves a package by removing and installing it. In-place replacement inside `node_modules` is handled by the [replacement Note](2026-10-01-replace-changed-package-modules-on-recomposition.md). - TSX versions using an asynchronous loader thread keep that thread's package configuration outside these hooks. Synchronizing it is deferred; this change does not provide general Worker cache synchronization. - CommonJS private-request-cache refresh with registered synchronous resolve hooks is deferred. Those requests retain their original loader behavior. - Disposing HMR restores the native readers, whose previous cached configuration can become visible again. Preserving invalidation state across HMR replacement is deferred. @@ -90,7 +88,7 @@ The binding's `getNearestParentPackageJSON` is called only by `package_json_read **Reread every observed manifest when runtime resolution publishes (#5496).** This places package-configuration expiry in table publication, decoupled from module caches: overwriting an installed package reports restart, yet the next unrelated publication makes new imports load the new version while the old plugin still runs. Package configuration is content and belongs with module caches, in HMR. -**Let HMR reload packages inside `node_modules`.** It requires finding affected plugins over a module graph that includes `node_modules`, preventing duplicate evaluation of shared libraries and Cordis, and handling missing dynamic-import edges, module side effects, and Workers. It is not done here; those scenarios keep requiring restart. +**Let HMR reload packages inside `node_modules`.** It requires finding affected plugins over a module graph that includes `node_modules`, preventing duplicate evaluation of shared libraries and Cordis, and handling missing dynamic-import edges, module side effects, and Workers. It is not done here; the [replacement Note](2026-10-01-replace-changed-package-modules-on-recomposition.md) adds it at profile recomposition. **Reload every plugin for a package.json change.** Configuration-only manifests do not require module evaluation. Manifests actually imported as JSON modules retain ordinary dependency-driven reloads. diff --git a/.agents/notes/implemented/architecture/2026-09-30-profile-package-refresh-and-manifest-invalidation.zh.md b/.agents/notes/implemented/architecture/2026-09-30-profile-package-refresh-and-manifest-invalidation.zh.md index 9ee201f27a..6f19b5fa4a 100644 --- a/.agents/notes/implemented/architecture/2026-09-30-profile-package-refresh-and-manifest-invalidation.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-30-profile-package-refresh-and-manifest-invalidation.zh.md @@ -17,9 +17,9 @@ Host 运行期间有两处状态没有随变化更新: | 负责方 | 做什么 | 不做什么 | |---|---|---| -| resolver(`app-boot/src/profile-resolution/resolver.ts`) | `replace()` 允许移除 `scope: profile` 条目和 profile 本地包名 | 不碰 Node 的任何缓存 | -| plugin-manager | 在包操作的确定时点调用 `pluginPackages.refresh()` | 不构造 resolution;不管模块缓存与包配置缓存 | -| HMR(`packages/boot/hmr`) | 使包配置失效,并保留作为 JSON 模块加载的 manifest 的普通重载 | 不因仅作为配置的 manifest 重载插件;不处理 `node_modules` 包替换 | +| resolver(`app-boot/src/profile-resolution/resolver.ts`) | `replace()` 允许移除 `scope: profile` 条目和 profile 本地包名,并允许原地更改保留的 profile 条目的版本 | 不碰 Node 的任何缓存 | +| plugin-manager | 没有 HMR 时,在包操作的确定时点调用 `pluginPackages.refresh()`;有 HMR 时,把发布交给其重新组合 | 不构造 resolution;不管模块缓存与包配置缓存 | +| HMR(`packages/boot/hmr`) | 使包配置失效,并保留作为 JSON 模块加载的 manifest 的普通重载;重新组合时替换已加载包文件发生变化的插件([替换说明](2026-10-01-replace-changed-package-modules-on-recomposition.zh.md)) | 不因仅作为配置的 manifest 重载插件 | ### runtime resolution @@ -29,20 +29,18 @@ Host 运行期间有两处状态没有随变化更新: - `scope: profile` 的条目可以移除。 - profile 本地包名可以移除。 -- 保留的 profile 条目保持规范化目录、版本与作用域不变;另一个已选 bundle 提供同一包时,可以更换声明锚点。安装范围的映射与声明锚点保持不变。profile 范围不能变;新本地包名不能覆盖已有条目;已发布的 link 名称不能换真实目录。 +- 保留的 profile 条目保持规范化目录与作用域不变;另一个已选 bundle 提供同一包时,可以更换声明锚点;包被原地升级时,可以更改版本([替换说明](2026-10-01-replace-changed-package-modules-on-recomposition.zh.md))。安装范围的映射与声明锚点保持不变。profile 范围不能变;新本地包名不能覆盖已有条目。已发布的 link 名称可以选中其他真实目录;Node 缓存的原目录真实路径会被重新定位。 ### plugin-manager | 操作 | 发布时点 | |---|---| -| 安装新包 | pnpm 成功、保存 bundle 选择之后,reconciliation 之前;无论是否立即启用都发布 | -| 覆盖已安装的包 | 不发布,结果为 `restart-required` | -| 启用 bundle | 保存选择后发布,再做 reconciliation | -| 停用 bundle | 有 HMR 时,reconciliation 让插件退出后再发布;没有 HMR 时不发布,运行中的插件继续使用原表 | -| 卸载 | pnpm remove 成功后,在 HMR 事务内发布 | -| 安装失败、取消、卸载失败 | 不发布 | +| 有 HMR 时的安装、启用、停用或卸载 | 在 HMR 的重新组合内,新一代丢弃的行停止之后、新一代应用之前([替换说明](2026-10-01-replace-changed-package-modules-on-recomposition.zh.md)) | +| 没有 HMR 时安装新包或启用 bundle | 保存 profile 文件之后,reconciliation 之前 | +| 没有 HMR 时卸载 | pnpm remove 成功后 | +| 没有 HMR 时停用、覆盖已安装的包、安装失败或取消、卸载失败 | 不发布;spec 指明已安装的包时,覆盖安装在 pnpm 运行之前被拒绝 | -`createRuntimeResolution()` 返回 `ProfileRuntimeResolution`,它私有保存构造时的 installAnchor、home 和 profile 目录;`computeLatestResolution()` 用同一组输入重新读取 profile,返回表示最新代的新对象,原实例不变;Worker 只收到表数据。`PluginPackages.refresh()` 调用当前 resolution 的 `computeLatestResolution()` 再 `replace()`;以纯数据构造的 resolution 无法刷新,调用时抛错。 +`createRuntimeResolution()` 返回 `ProfileRuntimeResolution`,它私有保存构造时的 installAnchor、home 和 profile 目录;`computeLatestResolution()` 用同一组输入重新读取 profile,返回表示最新代的新对象,原实例不变;Worker 只收到表数据。`PluginPackages.refresh()` 调用当前 resolution 的 `computeLatestResolution()`,再调用 `replace()`;以纯数据构造的 resolution 无法刷新,调用时抛错。app-boot 的 `recomposeProfile()` 先停止新一代不再列出的行,使其插件在包仍有映射时完成释放,再发布一次最新一代;已发布的表不会再次发布。 没有 HMR 时,取消启动 bundle 的选择不会停止它的插件。只要该 bundle 仍未选中,后续包操作就保留当前 runtime 表,不发布其移除。其他无 HMR 的安装和未运行 bundle 卸载仍正常发布。 @@ -79,7 +77,7 @@ binding 的 `getNearestParentPackageJSON` 只被 `package_json_reader` 自己调 ### 后续工作 -- 不支持 `node_modules` 内在线替换、同路径重装、link 换目标及跨包重载传播。包更新需要重启进程;这不保证每个管理操作的结果都已正确报告这一要求。 +- 移动到其他目录的保留 profile 映射需要等待重启;Plugin Manager 通过卸载再安装来移动包。`node_modules` 内的原地替换由[替换说明](2026-10-01-replace-changed-package-modules-on-recomposition.zh.md)处理。 - 使用异步 loader 线程的 TSX 版本,其线程内包配置不受这些 hook 管理。线程同步留待后续;本次不提供通用 Worker 缓存同步。 - 注册了同步 resolve hooks 时,CommonJS 私有请求缓存刷新留待后续;这些请求保留原有 loader 行为。 - 销毁 HMR 会恢复原生 reader,其此前缓存的配置可能重新可见。跨 HMR 替换保留失效状态留待后续。 @@ -90,7 +88,7 @@ binding 的 `getNearestParentPackageJSON` 只被 `package_json_reader` 自己调 **在 runtime resolution 发布时全量重读已见过的 manifest(#5496)。** 它把包配置失效放进了绑定表发布,和模块缓存脱了钩:覆盖已安装的包时报需要重启,但下一次无关的发布会让新导入拿到新版本,旧插件仍在运行。包配置缓存属于内容,应该和模块缓存由同一方负责,也就是 HMR。 -**让 HMR 支持 `node_modules` 内的包重载。** 这需要沿包含 `node_modules` 的模块图确定受影响插件,防止共享库和 Cordis 被重复求值,还要处理动态 import 漏边、模块副作用和 Worker。本次不做,这些场景继续要求重启。 +**让 HMR 支持 `node_modules` 内的包重载。** 这需要沿包含 `node_modules` 的模块图确定受影响插件,防止共享库和 Cordis 被重复求值,还要处理动态 import 漏边、模块副作用和 Worker。本次不做;[替换说明](2026-10-01-replace-changed-package-modules-on-recomposition.zh.md)在 profile 重新组合时加入了这一能力。 **package.json 变化时重载所有插件。** 仅作为配置的 manifest 不要求模块求值。实际作为 JSON 模块导入的 manifest 保留普通的依赖驱动重载。 diff --git a/.agents/notes/implemented/architecture/2026-10-01-replace-changed-package-modules-on-recomposition.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-01-replace-changed-package-modules-on-recomposition.i18n.yaml new file mode 100644 index 0000000000..576319b255 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-01-replace-changed-package-modules-on-recomposition.i18n.yaml @@ -0,0 +1,34 @@ +# Bilingual-pair consistency record for 2026-10-01-replace-changed-package-modules-on-recomposition.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-01-replace-changed-package-modules-on-recomposition.md +/agent-note-replace-plugins-whose-loaded-package-files-changed-during-profile-recomposition: + en: 72fe06d4e304418d + zh: bc3c96e957889bc1 +/agent-note-replace-plugins-whose-loaded-package-files-changed-during-profile-recomposition/problem: + en: c43fb0d2df54b28f + zh: 6636548449663fc5 +/agent-note-replace-plugins-whose-loaded-package-files-changed-during-profile-recomposition/decision: + en: a15c7c13f167e5ac + zh: a15c7c13f167e5ac +/agent-note-replace-plugins-whose-loaded-package-files-changed-during-profile-recomposition/decision/one-recomposition-path: + en: f2874c3fc946cf7a + zh: 4c32b9f94e1f9709 +/agent-note-replace-plugins-whose-loaded-package-files-changed-during-profile-recomposition/decision/detecting-changed-modules: + en: 1fedf2dde87f41bf + zh: c2249267d1c2bc78 +/agent-note-replace-plugins-whose-loaded-package-files-changed-during-profile-recomposition/decision/replacing-them: + en: 6fe2cbe5aa3a9786 + zh: fe30fbf672ea8c21 +/agent-note-replace-plugins-whose-loaded-package-files-changed-during-profile-recomposition/decision/plugin-manager-and-runtime-resolution: + en: 794ed33204d91014 + zh: b08daa2a43cbe3b3 +/agent-note-replace-plugins-whose-loaded-package-files-changed-during-profile-recomposition/alternatives-considered: + en: a67d0c6bc4957060 + zh: 32e02ab45b8e71cc +/agent-note-replace-plugins-whose-loaded-package-files-changed-during-profile-recomposition/consequences: + en: 2cb3f39c6a910685 + zh: e14938c868390f39 +/agent-note-replace-plugins-whose-loaded-package-files-changed-during-profile-recomposition/verification: + en: d4750abb810faac2 + zh: 78c9c4d86d719b9b diff --git a/.agents/notes/implemented/architecture/2026-10-01-replace-changed-package-modules-on-recomposition.md b/.agents/notes/implemented/architecture/2026-10-01-replace-changed-package-modules-on-recomposition.md new file mode 100644 index 0000000000..f599d86407 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-01-replace-changed-package-modules-on-recomposition.md @@ -0,0 +1,86 @@ +# Agent Note: Replace plugins whose loaded package files changed during profile recomposition + +Status: implemented + +English | [中文](2026-10-01-replace-changed-package-modules-on-recomposition.zh.md) + +## Problem + +Node caches an evaluated module by file URL. A profile installs packages with pnpm's hoisted layout, so a package lives at `node_modules/` whatever its version. Overwriting, upgrading or reinstalling it keeps every file URL, and a later import or Loader entry restart returns the cached module of the previous version. The [package-refresh Note](2026-09-30-profile-package-refresh-and-manifest-invalidation.md) refreshes runtime resolution and package configuration but leaves this module cache; Plugin Manager therefore reported overwrites as `restart-required`, and a reinstall at the same path after removal reported `applied` while the previous version kept running. A `link:` development package hits the same cache whenever its build output changes, unless its directory is listed in HMR's source watch roots. + +## Decision + +### One recomposition path + +`Hmr.reconcileProfile()` is the profile recomposition used by HMR's profile watches and by Plugin Manager; the Config Editor still recomposes through app-boot directly. It runs inside `runExclusive()`. Plugin Manager only edits profile files and runs pnpm, and refuses to install over an installed package it can name before pnpm runs; another version is a removal and an installation, each with its own recomposition. The steps of a recomposition are: + +1. Read the profile patches and compare loaded modules below the profile directory and its linked package targets with disk. +2. Plan the replacement as below; a patch read or comparison failure throws or is reported before any module changes. +3. Evict the replaceable changed modules and their importers. A changed module whose importers reach the process entry point's static graph or a plugin that no Loader entry owns stays loaded with its importers; the other changes proceed. +4. Stop the Loader entries running the replaced plugins by deleting their runtimes, so the Loader does not record them as disabled. +5. Call app-boot's `recomposeProfile()` once with the entries that ran before the stops. It records the activation state, stops the rows the generation no longer lists by removing them from their groups, publishes the runtime resolution, runs the compatibility preflight against the published packages, and applies the generation. The Loader imports the current files when it starts an entry: rows the generation keeps start once with the new code and their new configuration; rows it disables, refuses or removes stay stopped and never run the new code; an entry it inserts links against the current files. Stopped entries the update did not try to start, below an unchanged Include or outside the profile composition, start afterwards; an entry whose import failed during the update is imported once more. Activation is judged: a stopped entry that failed afterwards fails the operation, while an entry waiting for a service is a warning, since it starts once a provider appears. If no generation applied, the published resolution and the stopped rows are restored, and the stopped entries start again. +6. Evict the loaded modules below the directories of the mappings the recomposition dropped, so a removed or deselected package, including its dynamically imported dependencies, is imported afresh when it returns. A bundle switched off while its package stays mapped keeps its unchanged modules. + +### Detecting changed modules + +[`ModuleFiles`](../../../../packages/boot/hmr/src/module-files.ts) records inode, size and modification time for the loaded ES and CommonJS modules below the profile directory and the real directories of its linked packages. Modules of the dsh installation are outside this scope. Change time is excluded: pnpm imports packages by hard link from its store, and adding a link elsewhere changes a loaded file's change time without changing its content. Records are taken at application readiness, after each recomposition, and for modules a source reload evaluated again. A recomposition records the identities it read before replacing anything, so a write during the recomposition stays detectable; modules first loaded during it are recorded after settlement to avoid an unnecessary restart during the next installation. An edit between first evaluation and that record can be missed; the timestamp fallback still covers modules first loaded outside recomposition. If the readiness observation fails, the fallback starts at HMR construction time, not the Unix epoch. A module is stale when its file disappeared or its identity differs. A module first loaded after the last recording has no record; it is stale when its file was modified at or after that recording, less a 2-second filesystem timestamp resolution, so a reinstall between loading and the next comparison is not missed. Version numbers are not consulted: lockfiles record no content identity for `file:` and `link:` dependencies, and file identity covers registry, tarball, directory and link installs alike. + +### Replacing them + +A stale module whose importer closure reaches the process entry point's static graph, including installed packages, stays loaded and is reported. The remaining stale modules and their static importers, across ES module links and CommonJS `require` children, form the affected set, and every manifest from a stale file up to its profile root or linked target expires through `PackageManifests.invalidate()`. The whole affected set is evicted without a backup before recomposition, so every entry that imports during recomposition reaches the current files. A plugin registered outside a Loader entry has no owner that can start its replacement, so the changes its modules import stay pending; plugins are read from the ES module namespace and from cached CommonJS exports, so a CommonJS plugin registered through `require` keeps its cache too. HMR imports no replacement code itself, so admission is the Loader's ordinary activation: a disabled, removed or refused row never evaluates the new module, and dynamic `disabled` expressions and nested Includes are evaluated as at startup. Every recomposition imports again each enabled entry whose last import failed, after expiring the package configuration below the profile roots and evicting the modules whose links or evaluation failed, which Node keeps cached, so a corrected file or a later-installed dependency takes effect without a restart. A plugin counts as owned only when an affected plugin's own Loader entry fiber starts it; a changed plugin another plugin registers through a dynamic import stays pending. Unchanged dependencies of a replaced plugin are not re-evaluated, so shared libraries keep one instance. Source reloads through `partialReload()` keep their own in-place replacement with rollback. + +`reconcileProfile()` returns `pending`, each stale module that loads only after a restart with its reason (`host-module`, `unmanaged-plugin`, or `module-comparison` when planning failed). Pending modules stay stale, including modules first loaded after the last recording, so the next recomposition retries them. A module first loaded during a recomposition whose file was modified after the comparison also stays stale: activation may have evaluated it before that write. A failure while listing or comparing modules keeps the previous records and sets the profile-wide `restartReason: module-comparison`, even without known module URLs. Neither prevents recomposition. An activation failure, including a replacement that fails to import, is thrown with the Loader's import error; an unchanged failure is returned as a warning by later recompositions. Plugin Manager reports a thrown failure as `failed` with that diagnostic, and turns `pending` into `restart-required` with its `restartReason`, unless the operation reports `overridden`. The Web page localizes the reason, and the agent tool derives a `nextStep` sentence from it, so no surface parses a diagnostic string. + +The [packed worker loader](../../../../packages/experimental/webworker-runtime/src/module-system/module-loader.ts) stores evaluated modules in a private map and its `require` exposes no cache. HMR cannot replace them through Node APIs and reports a restart requirement. Native Node still exposes its caches when using asynchronous module hooks. + +### Plugin Manager and runtime resolution + +Plugin Manager refuses to install over an installed package it can name before pnpm runs, so a new version or location is a removal and an installation: the removal stops the plugins, evicts their modules and drops the mappings, and the installation maps the package afresh. Runtime resolution accepts a retained profile mapping whose version changes while its normalized directory and scope stay, as a shared dependency upgraded by another installation does, and rejects one that moved to another directory. A linked root may point to another directory; a file Node's cached link real path selects below the previous target, for an importer outside it, is relocated below the current one. Rejected publications, including changes to installation mappings or local overrides, leave the resolution and the composition unchanged and report `restart-required` with reason `package-moved`. + +## Alternatives considered + +**A separate package-replacement call from Plugin Manager.** Plugin Manager knows which package it installed, but not which Loader entries import it, and row additions, removals and configuration changes in the new bundle patch must apply in the same step. A second operation would split one profile change into two transactions with an undefined order. Recomposition already reads the new profile; comparing loaded files there covers Plugin Manager, CLI installs followed by a recomposition, and link builds. + +**Compare package versions from the profile manifest or lockfile.** The profile manifest stores specs, not resolved versions, and `pnpm update` can change a version without changing it. Lockfiles carry no content identity for `file:` and `link:` dependencies, and a same-version reinstall of a tarball changes content without changing the version. + +**Let app-boot call HMR from inside its recomposition.** Hooks for the steps before applying and before judging would split the order of one operation between two owners. HMR instead stops everything it replaces first and passes the stopped entries to one app-boot call. + +**Expose the recomposition's stages to HMR.** Separate calls to record activation, prepare after publication, apply, restore and drop mappings let HMR insert its stops between them, but they leave callers ordering obligations that the types cannot express. Stopping before the call costs a restart of the stopped plugins when preparation fails. + +**Publish twice, keeping the dropped mappings while the generation applies.** A temporary table with both the new and the dropped mappings lets one Loader update stop and start entries, but it is a third generation that must stay recomputable when the update fails. Stopping the dropped rows first lets one publication follow them. + +**Overwrite an installed bundle in one operation.** Stopping the bundle before pnpm and starting it afterwards, or detecting moved packages from the loaded module files, keeps one call for an upgrade, but needs a restart path for every pnpm failure, a selection restored in its order, and a release rule for moved directories. Refusing the overwrite leaves a removal and an installation, which the existing operations already run in this order. + +**Preload the replacement and keep the previous plugin when it fails to load.** HMR would import new code before stopping the previous runtime and restore the previous caches on failure. Preloading must not evaluate a row the generation disables, removes or refuses, so HMR would decide admission before the Loader does; dynamic `disabled` expressions and nested Includes cannot be decided without activation, so those changes would need a restart. Rollback must also remove new importers of restored modules, and the previous code would still receive the new configuration. A restart would load the same broken files, so keeping the previous plugin only delays a failure that needs a fix anyway. + +**Restart replaced plugins in place before recomposition.** New code would start with the previous configuration, which an upgraded version may reject, and a row the new composition disables or refuses, for example an incompatible version left on disk by a failed install, would briefly run. + +**Replace running plugins after recomposition.** Recomposition would restart the previous code with the new configuration, which the previous version may reject, so a successful upgrade would be reported as a failure; an entry inserted during recomposition would also link against the previous copy of a changed dependency still cached for a running plugin. + +**Stop affected plugins before pnpm runs.** This prevents previous code from lazily loading files pnpm has already replaced, but adds downtime for the whole install and holds the HMR queue during pnpm. + +**Re-evaluate every dependency of a replaced plugin.** Source reloads reload a plugin's non-`node_modules` dependency tree. Doing the same below `node_modules` would create second instances of unchanged shared libraries and Cordis packages used by other plugins. + +**Record identities in a module load hook.** `module.registerHooks()` could record each file at load time and close the window for modules loaded between recordings, but it adds a hook to every module load and interacts with TSX and the vendored Loader's internals. The timestamp rule only causes an extra reload in that window. + +## Consequences + +- Upgrades through a removal and an installation, and packages whose files changed in place, take effect without a restart when the replacement loads and starts; a replacement that fails to load or start stops the previous plugin and reports `failed` with the error, and it starts once its files or dependencies are corrected. +- Each recomposition stats every loaded file of the profile; the module graph is walked when a file changed, and the host graph is collected on first use. +- Dynamic `import()` edges are not followed: an importer keeps running while its next dynamic import evaluates the new file. Existing Workers and the TSX loader thread keep their caches. +- Replaced Loader entries stop before recomposition and start once afterwards; the previous version does not return. +- Loader entries outside the profile composition restart with replaced code without a new compatibility check ([limitation](../../../../packages/boot/hmr/README.md#known-limitations-and-deferred-work)). +- `ModuleFiles` belongs to one HMR instance. A recomposition that restarts the `hmr` entry itself starts a new instance whose first observation records the current files, so changes pending at that point are no longer reported or retried. +- `pending` describes the whole profile, so a host module brought into scope by a linked workspace package makes later unrelated operations report `restart-required` until the process restarts. +- A file replaced by a hard link to an older store file keeps an older modification time; when its module was first loaded after the last recording, the change is not detected. +- A change confined to `package.json` is not detected. HMR's profile watches do not recompose for dependency-only manifest changes, so installs performed outside Plugin Manager wait for the next recomposition or restart. + +## Verification + +| Coverage | Location | +|---|---| +| In-place upgrades with bundle patch changes, configurations the previous version rejects, inserted entries and disabled rows, plugins without a Loader entry, shared-dependency importers, linked packages, hard links, CommonJS `require` children and their failures, failed replacement and retry, nested groups and Includes, dynamic disabling, same-path reinstall, late-loaded modules, host-graph modules, comparison and recomposition failures, eviction of packages a recomposition stopped using or whose mappings it dropped, and the restart of stopped entries after a rejected publication or failed preparation | `packages/boot/hmr/tests/package-reload.spec.ts` | +| Refused overwrites, removal then installation with HMR, failed installation, relinked and link-to-registry reinstalls, unmanaged plugins, comparison failures | `packages/boot/plugin-manager/tests/package-reload.spec.ts`, `packages/boot/plugin-manager/tests/manager.spec.ts` | +| In-place profile mapping version change, retained directory and scope rejection including a relinked path inside the profiles tree, relinked roots and native lookups relocated below the current target | `packages/boot/app-boot/tests/profile-resolution.spec.ts` | + +Tests need no API key and make no model calls. diff --git a/.agents/notes/implemented/architecture/2026-10-01-replace-changed-package-modules-on-recomposition.zh.md b/.agents/notes/implemented/architecture/2026-10-01-replace-changed-package-modules-on-recomposition.zh.md new file mode 100644 index 0000000000..35ba5c98e9 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-01-replace-changed-package-modules-on-recomposition.zh.md @@ -0,0 +1,86 @@ +# Agent Note: profile 重新组合时替换已加载包文件发生变化的插件 + +Status: implemented + +[English](2026-10-01-replace-changed-package-modules-on-recomposition.md) | 中文 + +## Problem + +Node 按文件 URL 缓存已求值的模块。profile 使用 pnpm 的 hoisted 布局安装包,因此无论版本如何,包都位于 `node_modules/`。覆盖、升级或重装包时所有文件 URL 保持不变,之后的导入或 Loader entry 重启返回的是旧版本的缓存模块。[包刷新说明](2026-09-30-profile-package-refresh-and-manifest-invalidation.zh.md)刷新 runtime resolution 和包配置,但不处理这一模块缓存;因此 Plugin Manager 把覆盖报告为 `restart-required`,而删除后在同一路径重装时报告 `applied`,旧版本却仍在运行。`link:` 开发包的构建输出变化时也会命中同一缓存,除非其目录列在 HMR 的源码监听根目录中。 + +## Decision + +### 唯一的重新组合路径 + +`Hmr.reconcileProfile()` 是 HMR 的 profile 监听和 Plugin Manager 共用的 profile 重新组合入口;Config Editor 仍直接通过 app-boot 重新组合。它在 `runExclusive()` 内运行。Plugin Manager 只编辑 profile 文件并运行 pnpm;对于在运行 pnpm 之前能确定包名的已安装包,它拒绝覆盖安装;安装另一个版本需要先卸载再安装,每一步各有一次重新组合。一次重新组合的步骤如下: + +1. 读取 profile patch,将 profile 目录及其已链接包目标之下的已加载模块与磁盘比较。 +2. 按下文规划替换;patch 读取或比较失败时,在任何模块变化之前抛出错误或报告失败。 +3. 逐出可替换的已变化模块及其导入方。若已变化模块的导入方可到达进程入口的静态依赖图,或可到达没有 Loader entry 拥有的插件,该模块连同其导入方保持加载;其余变化照常处理。 +4. 通过删除运行时停止运行被替换插件的 Loader entry,使 Loader 不把它们记录为已停用。 +5. 以停止之前正在运行的条目调用一次 app-boot 的 `recomposeProfile()`。它记录激活状态,把新一代组合不再列出的行从其所在组中移除,以停止这些行,发布 runtime resolution,基于已发布的包运行兼容性预检,然后应用新一代组合。Loader 在启动条目时导入当前文件:新一代组合保留的行以新代码和新配置启动一次;停用、拒绝或删除的行保持停止,不会运行新代码;新插入的条目链接当前文件。更新未尝试启动的已停止条目(位于未变化 Include 之下或 profile 组合之外)随后启动;更新期间导入失败的条目会再导入一次。随后判定激活结果:之后失败的已停止条目使操作失败;等待服务的条目只是警告,因为出现提供方后它即启动。若没有新一代组合被应用,已发布的 resolution 和已停止的行被恢复,已停止的条目重新启动。 +6. 逐出重新组合删除的映射所在目录之下的已加载模块,使已删除或取消选入的包连同其动态导入的依赖在重新加入时重新导入。关闭组合包而其包仍有映射时,它未变化的模块保持加载。 + +### 检测已变化的模块 + +[`ModuleFiles`](../../../../packages/boot/hmr/src/module-files.ts) 为 profile 目录及其已链接包的真实目录之下已加载的 ES 和 CommonJS 模块记录 inode、大小和修改时间。dsh 安装目录中的模块不在此范围内。不记录状态变更时间:pnpm 通过硬链接从其 store 导入包,在其他位置添加链接会改变已加载文件的状态变更时间,而内容不变。记录发生在应用就绪时和每次重新组合之后;源码重载重新求值模块时,也为这些模块记录。重新组合记录的是它在替换任何模块之前读取的文件身份,因此重新组合期间发生的写入仍可被检测到;在重新组合期间首次加载的模块在结算后记录,避免下一次安装时发生不必要的重启。首次求值与记录之间的修改可能漏检;时间戳兜底仍覆盖重新组合之外首次加载的模块。就绪时的观察失败时,兜底从 HMR 构造时间开始,而非 Unix 纪元。文件已消失或身份不同的模块视为陈旧。在上次记录之后才首次加载的模块没有记录;若其文件的修改时间不早于该次记录时间减去 2 秒文件系统时间戳精度,则视为陈旧,因此不会漏掉在加载与下一次比较之间发生的重装。比较不参考版本号:lockfile 不为 `file:` 和 `link:` 依赖记录内容标识,而文件身份同样覆盖 registry、tarball、目录和 link 安装。 + +### 替换 + +导入方闭包可到达进程入口静态依赖图(包括已安装包)的陈旧模块保持加载,并被报告。其余陈旧模块及其静态导入方(沿 ES 模块链接和 CommonJS `require` 子模块)构成受影响集合,从每个陈旧文件到其 profile 根目录或链接目标的各级 manifest 都通过 `PackageManifests.invalidate()` 失效。整个受影响集合在重新组合之前被逐出且不保留备份,因此重新组合期间执行导入的每个条目都读取当前文件。在 Loader entry 之外注册的插件没有能启动其替换版本的所有者,因此其模块导入的变化保持待处理;插件从 ES 模块命名空间和已缓存的 CommonJS exports 中读取,因此通过 `require` 注册的 CommonJS 插件同样保留其缓存。HMR 本身不导入替换代码,因此是否允许加载由 Loader 的普通激活决定:停用、删除或被拒绝的行永远不会对新模块求值,动态 `disabled` 表达式和嵌套 Include 按启动时的方式求值。每次重新组合都会先让 profile 根目录下的包配置失效,并逐出链接或求值失败的模块(Node 会在缓存中保留这些模块),再次导入上次导入失败的每个已启用条目,因此修正后的文件或之后安装的依赖无需重启即生效。只有当受影响插件自身的 Loader entry fiber 启动某个插件时,该插件才算有所有者;由另一个插件通过动态导入注册的已变化插件保持待处理。被替换插件中未变化的依赖不会重新求值,因此共享库保持单一实例。通过 `partialReload()` 进行的源码重载保留其自身带回滚的原地替换。 + +`reconcileProfile()` 返回 `pending`,即每个只有重启后才能加载的陈旧模块及其原因(`host-module`、`unmanaged-plugin`,规划失败时为 `module-comparison`)。待处理模块保持陈旧,包括在上次记录之后才首次加载的模块,因此下一次重新组合会重试它们。在重新组合期间首次加载、且其文件在比较之后被修改的模块同样保持陈旧:激活可能在该写入之前已对其求值。列出或比较模块时发生的失败会保留原有记录,并设置 profile 级 `restartReason: module-comparison`,即使无法确定模块 URL。两种情况都不会阻止重新组合。激活失败(包括替换版本导入失败)会连同 Loader 的导入错误一起抛出;未变化的失败在后续重新组合中作为警告返回。Plugin Manager 把抛出的失败报告为带该诊断的 `failed`,把 `pending` 转为带其 `restartReason` 的 `restart-required`,除非操作报告 `overridden`。Web 页面本地化该原因,agent 工具据此生成一句 `nextStep`,因此没有任何界面解析诊断字符串。 + +[打包 worker loader](../../../../packages/experimental/webworker-runtime/src/module-system/module-loader.ts) 在私有映射中保存已求值模块,其 `require` 不暴露缓存。HMR 无法通过 Node API 替换它们,因此报告需要重启。原生 Node 使用异步模块 hook 时仍暴露其缓存。 + +### Plugin Manager 与 runtime resolution + +对于在运行 pnpm 之前能确定包名的已安装包,Plugin Manager 拒绝覆盖安装,因此新版本或新位置需要先卸载再安装:卸载停止插件、逐出其模块并删除映射,安装再重新映射该包。runtime resolution 接受保留的 profile 映射在规范化目录和作用域不变时更改版本,即共享依赖被另一次安装升级的情形;移动到其他目录的映射被拒绝。linked root 可以指向其他目录;对于原目标之外的导入方,Node 缓存的链接真实路径若选中原目标之下的文件,该文件会被重新定位到当前目标之下。被拒绝的发布,包括对安装范围映射或本地覆盖的更改,不改变 resolution 和组合,并报告原因是 `package-moved` 的 `restart-required`。 + +## Alternatives considered + +**由 Plugin Manager 单独调用包替换。** Plugin Manager 知道自己安装了哪个包,但不知道哪些 Loader entry 导入了它;新组合包 patch 中的行增加、删除和配置变更也必须在同一步应用。第二个操作会把一次 profile 变更拆成两个顺序未定义的事务。重新组合本来就会读取新的 profile;在此处比较已加载文件,同时覆盖 Plugin Manager、之后执行重新组合的 CLI 安装以及 link 构建。 + +**比较 profile manifest 或 lockfile 中的包版本。** profile manifest 存储的是 spec,不是解析后的版本,`pnpm update` 可以在不修改 spec 的情况下改变版本。lockfile 不为 `file:` 和 `link:` 依赖记录内容标识,而同版本重装 tarball 会改变内容却不改变版本。 + +**让 app-boot 在其重新组合内部调用 HMR。** 为应用之前和判定之前的步骤提供 hook,会把一次操作的顺序拆分给两个所有者。HMR 改为先停止它替换的所有内容,再把已停止的条目传给一次 app-boot 调用。 + +**向 HMR 暴露重新组合的各个阶段。** 分别调用记录激活状态、发布后准备、应用、恢复和丢弃映射,可让 HMR 在其间插入停止操作,但会给调用方留下类型无法表达的顺序义务。在调用之前停止插件,代价是准备失败时需要重新启动已停止的插件。 + +**发布两次,在应用新一代组合期间保留被丢弃的映射。** 同时包含新映射和被丢弃映射的临时表,可以让一次 Loader 更新既停止又启动条目,但它是第三个 generation,更新失败时必须仍能重新计算。先停止被丢弃的行,之后只需发布一次。 + +**在一次操作中覆盖已安装的组合包。** 在 pnpm 运行之前停止该组合包、之后再启动,或根据已加载模块文件检测已移动的包,可以让升级只需一次调用;但这需要为每种 pnpm 失败提供重启路径、按原顺序恢复选择列表,并为已移动的目录制定释放规则。拒绝覆盖安装后只剩卸载和安装,现有操作已按此顺序执行。 + +**预加载替换版本,并在其加载失败时保留原插件。** HMR 将在停止旧运行时之前导入新代码,并在失败时恢复旧缓存。预加载不得对新一代组合停用、删除或拒绝的行求值,因此 HMR 要先于 Loader 决定是否允许加载;动态 `disabled` 表达式和嵌套 Include 不经激活就无法判定,因此这些变化需要重启。回滚还必须删除已恢复模块的新导入方,而旧代码仍会收到新配置。重启会加载相同的损坏文件,因此保留原插件只是推迟一个无论如何都需要修复的失败。 + +**在重新组合之前原地重启被替换的插件。** 新代码会先以旧配置启动,而升级后的版本可能拒绝旧配置;新组合停用或拒绝的行(例如安装失败后留在磁盘上的不兼容版本)也会短暂运行。 + +**在重新组合之后替换运行中的插件。** 重新组合会以新配置重启旧代码,而旧版本可能拒绝新配置,导致一次成功的升级被报告为失败;重新组合期间插入的条目也会链接到仍为运行中插件缓存的、已变化依赖的旧副本。 + +**在 pnpm 运行前停止受影响插件。** 这能防止旧代码惰性加载 pnpm 已经替换的文件,但会让整个安装期间插件不可用,并在 pnpm 运行期间占用 HMR 队列。 + +**重新求值被替换插件的全部依赖。** 源码重载会重载插件在 `node_modules` 之外的依赖树。若对 `node_modules` 之下做同样处理,会为其他插件使用的、未变化的共享库和 Cordis 包创建第二个实例。 + +**在模块加载 hook 中记录文件身份。** `module.registerHooks()` 可以在加载时记录每个文件,消除两次记录之间加载的模块的检测空窗,但这会给每次模块加载增加一个 hook,并与 TSX 和 vendored Loader 的内部机制相互影响。时间戳规则在该空窗内只会导致一次多余的重载。 + +## Consequences + +- 通过卸载再安装进行的升级,以及文件被原地更改的包,在替换版本成功加载并启动时无需重启即生效;替换版本加载或启动失败时,原插件停止并报告 `failed` 及该错误,其文件或依赖被修正后它即启动。 +- 每次重新组合对 profile 的所有已加载文件执行一次 stat;有文件变化时才遍历模块图,宿主依赖图在首次使用时收集。 +- 不跟踪动态 `import()` 边:导入方继续运行,而其下一次动态导入会求值新文件。已存在的 Worker 和 TSX loader 线程保留各自的缓存。 +- 被替换的 Loader entry 在重新组合之前停止,之后启动一次;旧版本不会恢复。 +- profile 组合之外的 Loader entry 以替换后的代码重启,不重新进行兼容性检查([限制](../../../../packages/boot/hmr/README.zh.md#known-limitations-and-deferred-work))。 +- `ModuleFiles` 属于单个 HMR 实例。重启 `hmr` 条目本身的重新组合会启动一个新实例,其首次观察记录当前文件,因此此时待处理的变化不再被报告或重试。 +- `pending` 描述整个 profile,因此由已链接工作区包纳入范围的宿主模块,会使后续无关操作也报告 `restart-required`,直到进程重启。 +- 若文件被替换为指向较旧 store 文件的硬链接,它保留较旧的修改时间;当其模块在上次记录之后才首次加载时,该变化不会被检测到。 +- 仅限于 `package.json` 的变化不会被检测到。HMR 的 profile 监听不会因仅涉及依赖的 manifest 变化而重新组合,因此在 Plugin Manager 之外执行的安装要等到下一次重新组合或重启。 + +## Verification + +| 覆盖面 | 位置 | +|---|---| +| 伴随组合包 patch 变更的原地升级、旧版本拒绝的配置、新插入的条目和停用行、没有 Loader entry 的插件、共享依赖的导入方、已链接包、硬链接、CommonJS `require` 子模块及其失败、替换失败与重试、嵌套 group 和 Include、动态停用、同路径重装、延后加载的模块、宿主依赖图中的模块、比较失败与重新组合失败、逐出重新组合不再使用或已删除其映射的包,以及发布被拒绝或准备失败后已停止条目的重启 | `packages/boot/hmr/tests/package-reload.spec.ts` | +| 被拒绝的覆盖安装、有 HMR 时先卸载再安装、安装失败、重新链接和从链接改为 registry 的重装、非托管插件、比较失败 | `packages/boot/plugin-manager/tests/package-reload.spec.ts`、`packages/boot/plugin-manager/tests/manager.spec.ts` | +| profile 映射原地更改版本、保留目录和作用域的拒绝(包括 profiles 树内重新链接的路径)、重新链接的 root 以及被重新定位到当前目标之下的原生查询 | `packages/boot/app-boot/tests/profile-resolution.spec.ts` | + +测试不需要 API key,不调用模型。 diff --git a/.agents/notes/implemented/architecture/2026-10-09-stored-credential-precedence.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-09-stored-credential-precedence.i18n.yaml new file mode 100644 index 0000000000..544d0c2c04 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-09-stored-credential-precedence.i18n.yaml @@ -0,0 +1,22 @@ +# Bilingual-pair consistency record for 2026-10-09-stored-credential-precedence.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-09-stored-credential-precedence.md +/agent-note-stored-credentials-take-precedence-over-the-launch-environment: + en: 5253af6605ba0fac + zh: 160ac751c664a502 +/agent-note-stored-credentials-take-precedence-over-the-launch-environment/problem: + en: 878c68dcfe5b3771 + zh: 8a24d16118155245 +/agent-note-stored-credentials-take-precedence-over-the-launch-environment/decision: + en: 7cd89bda9f793431 + zh: 93d26a948eea63f3 +/agent-note-stored-credentials-take-precedence-over-the-launch-environment/per-run-overrides: + en: 7479ffc82280a1fb + zh: ce3d42489191ab2b +/agent-note-stored-credentials-take-precedence-over-the-launch-environment/alternatives-considered: + en: 268add7fd8415280 + zh: 0765cbfd3fc7b57c +/agent-note-stored-credentials-take-precedence-over-the-launch-environment/consequences: + en: 2d71fd527b3060c4 + zh: fafed884315b9782 diff --git a/.agents/notes/implemented/architecture/2026-10-09-stored-credential-precedence.md b/.agents/notes/implemented/architecture/2026-10-09-stored-credential-precedence.md new file mode 100644 index 0000000000..75997d9db0 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-09-stored-credential-precedence.md @@ -0,0 +1,45 @@ +# Agent Note: Stored credentials take precedence over the launch environment + +Status: implemented + +English | [中文](2026-10-09-stored-credential-precedence.zh.md) + +The [headless environment authentication](2026-10-12-headless-environment-auth.md) decision partially supersedes this note's per-run override policy: the shipped headless profile omits the store. The ordering below remains the store's contract when mounted. + +## Problem + +[Configuration source ownership](2026-08-04-configuration-source-ownership.md) ranked the inherited process environment above `$DSH_HOME/.credentials.yaml`, and `credentials-local` refused `set` and `unset` for a name the launching shell exported. Two kinds of stored credential then had opposite precedence. pi-ai's resolvers read the credential record stored under `llm-pi-ai/` before any environment variable, so a stored OAuth grant or `api-key` record won over an exported key. A key that the Models page stored under a reference lost to the same exported name. Which credential a request used therefore depended on the provider family, and the Models page disabled its key field whenever the name was exported (#5527). + +## Decision + +**A stored credential wins for every provider.** `LocalCredentialProvider` resolves a reference in this order: + +```text +$DSH_HOME/.credentials.yaml (provider-managed, written by set/unset) +> inherited process environment (fallback; the provider never writes it) +> /.env (fallback; the provider never writes it) +> $DSH_HOME/.env (fallback; the provider never writes it) +``` + +A save always writes the winning layer, so `describe` reports `writable: true` for every reference, and `set` and `unset` never refuse a name because it is exported. `resolve` and `describe` still report the supplying layer as `source` (`file`, `env`, `project-env`, or `user-env`). `authContextFrom` in `llm-pi-ai` answers pi-ai's `env()` from the credential seam before the launch environment, so a pi-ai resolver reads its stored record first, then a stored reference, then the fallbacks in the order above. + +The non-secret ordering and the environment restrictions in configuration source ownership are unchanged. This note supersedes only its credential ordering. + +## Per-run overrides + +The previous order existed so that CI, containers, and a one-shot `DEEPSEEK_API_KEY=… dsh` could override a credential without editing machine state. A fresh CI or container harness home has no `.credentials.yaml`, so its exported secret is still the effective value. A harness home holds a stored key only because someone saved it there, through the Models page or by editing the file. To use an exported value for that name, the user deletes the `refs` entry, or launches with a separate `DSH_HOME`. + +## Alternatives considered + +**Keep the launch environment on top.** It keeps per-run overrides without touching the store, but it keeps the opposite precedence of stored records and stored references, and the Models page cannot save or replace a key while the launching shell exports the name. + +**Refuse a write while only the launch environment supplies the name.** Under the new order such a write takes effect, because the stored value outranks the environment. Refusing it would block the first save of a key for every exported name, and it would keep the read-only field state that the new order removes. + +**Warn when a stored value hides an exported variable.** The provider would compare layers on every resolution and report the result for users who keep both values on purpose, such as a shell profile that exports a key for other tools. The Models page help text states the order, and `describe` reports the supplying layer. + +## Consequences + +- A key left in `.credentials.yaml` overrides an exported variable with the same name, including a CI secret on a machine whose harness home stores a value for that name. The [upgrade guide](../../../../docs/upgrade-guide/v0.2.1-alpha.2/stored-credentials-precedence/guide.md) tells affected users how to keep the exported account. +- The Models page can always save, replace, or remove a key, and it does not disable the key field for an exported name. +- There is no per-run flag above the store. A one-run override takes a separate `DSH_HOME`. +- `packages/credentials/credentials-local/tests/local.spec.ts` covers a store over a launch environment value and its return after `unset`; `packages/llm/llm-pi-ai/tests/auth.spec.ts` covers a stored credential that wins over every launch layer. diff --git a/.agents/notes/implemented/architecture/2026-10-09-stored-credential-precedence.zh.md b/.agents/notes/implemented/architecture/2026-10-09-stored-credential-precedence.zh.md new file mode 100644 index 0000000000..3846944c8d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-09-stored-credential-precedence.zh.md @@ -0,0 +1,45 @@ +# Agent Note: 存储的凭据优先于启动环境 + +Status: implemented + +[English](2026-10-09-stored-credential-precedence.md) | 中文 + +[Headless 环境认证](2026-10-12-headless-environment-auth.zh.md)决策部分取代本文的单次覆盖策略:随附 headless profile 不挂载存储。存储挂载时仍遵循下述顺序。 + +## 问题 + +[配置来源所有权](2026-08-04-configuration-source-ownership.zh.md) 把继承的进程环境排在 `$DSH_HOME/.credentials.yaml` 之前,并且 `credentials-local` 会拒绝对启动 shell 已导出的名字执行 `set` 和 `unset`。于是两类存储的凭据具有相反的优先级。pi-ai 的解析器先读取存储在 `llm-pi-ai/` 下的凭据记录,再读取任何环境变量,因此存储的 OAuth 授权或 `api-key` 记录会压过导出的密钥;而 Models 页面以引用形式存储的密钥会输给同名的导出变量。一次请求使用哪个凭据因此取决于提供方类别,而且只要该名字被导出,Models 页面就会禁用其密钥输入框(#5527)。 + +## 决策 + +**存储的凭据对每个提供方都优先。** `LocalCredentialProvider` 按以下顺序解析引用: + +```text +$DSH_HOME/.credentials.yaml (provider-managed, written by set/unset) +> inherited process environment (fallback; the provider never writes it) +> /.env (fallback; the provider never writes it) +> $DSH_HOME/.env (fallback; the provider never writes it) +``` + +保存总是写入胜出的那一层,因此 `describe` 对每个引用都报告 `writable: true`,`set` 与 `unset` 也不会因为名字已被导出而拒绝。`resolve` 与 `describe` 仍以 `source`(`file`、`env`、`project-env` 或 `user-env`)报告提供值的层。`llm-pi-ai` 中的 `authContextFrom` 先从凭据 seam、再从启动环境回答 pi-ai 的 `env()`,因此 pi-ai 解析器依次读取其存储的记录、存储的引用,再按上面的顺序读取后备来源。 + +配置来源所有权中的非秘密顺序与环境限制保持不变。本 Note 只替代其中的凭据优先级。 + +## 按次覆盖 + +之前的顺序让 CI、容器和一次性的 `DEEPSEEK_API_KEY=… dsh` 无需修改机器状态就能覆盖凭据。全新的 CI 或容器 harness home 中没有 `.credentials.yaml`,因此其导出的机密仍是生效值。harness home 中存有密钥,只可能是有人通过 Models 页面或直接编辑该文件存入的。要对该名字使用导出的值,用户应删除对应的 `refs` 条目,或使用单独的 `DSH_HOME` 启动。 + +## 考虑过的替代方案 + +**保持启动环境在最上层。** 它无需改动存储就能保留按次覆盖,但会保留存储记录与存储引用之间相反的优先级,而且只要启动 shell 导出了该名字,Models 页面就无法保存或替换密钥。 + +**只有启动环境提供该名字时拒绝写入。** 在新顺序下这样的写入会生效,因为存储值优先于环境。拒绝它会阻止为每个已导出的名字首次保存密钥,也会保留新顺序已经去掉的只读输入框状态。 + +**存储值遮蔽导出变量时发出警告。** 提供方需要在每次解析时比较各层,并向有意同时保留两个值的用户报告结果,例如在 shell 配置文件中为其他工具导出密钥的用户。Models 页面的帮助文本说明了该顺序,`describe` 也会报告提供值的层。 + +## 后果 + +- 留在 `.credentials.yaml` 中的密钥会覆盖同名的导出变量,包括在 harness home 存有该名字的机器上运行的 CI 机密。[升级指南](../../../../docs/upgrade-guide/v0.2.1-alpha.2/stored-credentials-precedence/guide.zh.md)说明受影响的用户如何继续使用导出的账户。 +- Models 页面总能保存、替换或删除密钥,也不会因名字被导出而禁用密钥输入框。 +- 存储之上没有按次生效的标志。一次运行的覆盖需要使用单独的 `DSH_HOME`。 +- `packages/credentials/credentials-local/tests/local.spec.ts` 覆盖在启动环境值之上存储、以及 `unset` 后恢复该值;`packages/llm/llm-pi-ai/tests/auth.spec.ts` 覆盖存储的凭据压过每个启动层。 diff --git a/.agents/notes/implemented/architecture/2026-10-11-chat-group-tree-exchanges.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-11-chat-group-tree-exchanges.i18n.yaml new file mode 100644 index 0000000000..b34903faef --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-11-chat-group-tree-exchanges.i18n.yaml @@ -0,0 +1,25 @@ +# Bilingual-pair consistency record for 2026-10-11-chat-group-tree-exchanges.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-11-chat-group-tree-exchanges.md +/agent-note-chat-group-tree-positions-and-exchanges: + en: b26212eda152d6c6 + zh: a801a75ee0c07911 +/agent-note-chat-group-tree-positions-and-exchanges/problem: + en: 84858bc0e1e268ab + zh: 39ebb2979968d6cc +/agent-note-chat-group-tree-positions-and-exchanges/decision: + en: ca3153ae96994baa + zh: de6c7d925559efbb +/agent-note-chat-group-tree-positions-and-exchanges/why-mark-the-opening-rather-than-the-notice: + en: db8d77cd773ad646 + zh: ab2f03b8fc58e952 +/agent-note-chat-group-tree-positions-and-exchanges/alternatives-considered: + en: fd9152c3f5edb035 + zh: 259337cdcc5969a5 +/agent-note-chat-group-tree-positions-and-exchanges/consequences: + en: 2a98bb21bb1315d1 + zh: be4a320e9b427a79 +/agent-note-chat-group-tree-positions-and-exchanges/testing: + en: e2e3982718e16ef4 + zh: 4f3802946e51c55c diff --git a/.agents/notes/implemented/architecture/2026-10-11-chat-group-tree-exchanges.md b/.agents/notes/implemented/architecture/2026-10-11-chat-group-tree-exchanges.md new file mode 100644 index 0000000000..0fffb84e71 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-11-chat-group-tree-exchanges.md @@ -0,0 +1,56 @@ +# Agent Note: Chat group-tree positions and exchanges + +Status: implemented + +English | [中文](2026-10-11-chat-group-tree-exchanges.zh.md) + +## Problem + +On next message folding kept only the last Turn of the loaded timeline expanded. A Turn woken by a notice (a settled subagent, goal round, schedule, or background job) is not submitted by this Client, yet its arrival made the preceding Turn historical and folded it. No own-input driver computed the exact remaining room for that fold, so the scroll layer reserved generic bottom space and a reader following the tail saw blank space. In the recorded Session the notice woke the next Turn 13–15 ms after the previous Turn ended; the minimal reproduction left 596 px blank that had not recovered after five seconds. + +## Decision + +A notice is neither body text nor a user message: folding and bottom advancement ignore it. The bottom changes from the last Turn to the latest exchange. + +- **Group-tree positions.** `ChatConversationViewNode.groupTree` declares a Node's position in the group tree, typed `ChatGroupTree = 'exchange' | 'input' | 'turn' | 'process'` and ordered outermost first; a Node at a position also has the effect of every inner position. `exchange` opens an exchange. `input` makes the Node an input of its Turn: before the Turn's process it is the opening input, after it an interleaved input that keeps the whole Turn open. `turn` keeps the Node outside the Turn's process disclosure, `process` keeps it inside the disclosure but outside step runs, and a Node without a position joins the surrounding step run. `isAtOrOutside(node, position)` is the single reader of that order: process grouping reads `process`, whole-Turn folding reads `turn`, Turn-input detection reads `input`, and exchanges read `exchange`. +- **Exchanges.** A Turn holding an `exchange` Node opens an exchange, which lasts until the next opening Turn. The business message Definition marks only human input claimed in Step 1 (`user` or `steering`) as `exchange`; later human input and trigger notices stand at `input` and never open an exchange. +- **Built-in positions.** `turn-error`, `turn-max-tokens`, `turn-tail`, and the `turn-process` control stand at `turn`; `model-retry` stands at `process`. Two placement rules stay with ui-chat's own `assistant-step` Node: its reasoning row joins the step run while its reply row stands outside it, and the Turn's process specification keeps the finalized answer's reply outside the disclosure because which step answers changes as later steps arrive. +- **Mechanism.** `GroupTreeSegments(position)` splits the loaded Turns at the Turns holding a Node at that position. The latest segment starts at the last opening Turn, or at the first loaded Turn when the window holds none. It counts per Turn incrementally without scanning loaded Nodes, and a start move returns only the Turns that entered or left the latest segment. The Chat Builder's `bottomSource` is "the Node is visible and belongs to the latest exchange". + +As a result, a notice-woken Turn no longer folds earlier Turns, so no room is reserved; the human's next ordinary message folds the whole exchange through the own-input path. + +## Why mark the opening rather than the notice + +The host claims the waking input right after `turn/start`, assembles the system prompt, and only then logs `step/start` and `user/message`; the recorded Session spent 187 ms in that gap. Marking only the notice leaves the new Turn untagged during the gap, so the bottom still advances and the previous Turn folds and then reopens. Marking the human opening and letting unmarked Turns continue the exchange keeps the gap inert. + +## Alternatives considered + +**Tag the notice as a continuation.** The 187 ms gap above makes it fold and reopen, and a claim that mixes a notice with human input needs extra classification. + +**Name opening kinds in a list (`user`, `steering`).** A kind cannot separate Step 1 steering from mid-Turn steering, so a human steering into a notice-woken Turn would wrongly fold the previous exchange. + +**Publish Turn-level Location data.** Only one Context may publish a key for a Turn, while opening input comes from several message Contexts; aggregating per Turn would repeat the inbox claim classification in a new Definition and read every Turn on each update. + +**Declare a business role (`input`, `notice`) on Nodes.** That places business semantics in the grouping mechanism; group-tree positions describe structure only, and each business Definition chooses its position. + +**Correct the scroll room for folds not caused by own input.** Notices should not fold at all, and correcting the room would not remove that unnecessary fold. Folds caused by human input without an echo on this page are handled by [one fold motion for collapsing on the next message](2026-10-13-chat-single-fold-motion.md). + +**Read Turn inputs at `turn`.** Terminal errors, max-token notices, and the completed-turn footer also stand outside the process disclosure, so reading `turn` would treat them as opening input; Turn inputs need their own position. + +**Derive the exchange from an `input` in Step 1.** A trigger notice is its Turn's opening input but must not open an exchange. Deriving the exchange would drop trigger notices from `input` and change how notice-woken Turns order their process rows. + +## Consequences + +- Notice-woken Turns no longer cause a fold, reserved room, or blank space. +- External Definitions place their Chat rows through `groupTree`. Apart from the two `assistant-step` rules, ui-chat names no kind when it groups, folds, or detects Turn inputs, and built-in kinds keep the placement their former kind lists gave them. +- Under On next message, a long run of notice-woken Turns (up to 31 goal-mode Turns in sampled Sessions) stays expanded until the human types again. +- When the loaded window holds no opening Turn, every loaded Turn belongs to the latest exchange. +- Human input from another tab folds when its `user/message` arrives, about 200 ms later than the former `turn/start` fold; its landing follows [one fold motion for collapsing on the next message](2026-10-13-chat-single-fold-motion.md). + +## Testing + +- `packages/client/ui-chat/tests/group-tree-segments.client.spec.ts`: position order, segmentation, count fallback, and the empty window. +- `packages/client/ui-chat/tests/chat-node-bottom.client.spec.ts`: latest-exchange membership and notification scope. +- `packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts`: a real assembler across an opening Turn, mid-Turn steering, the pre-Step gap, a notice wake, and an idle steer, with non-opening inputs at `input`. +- `packages/client/ui-chat/tests/process-groups.client.spec.ts`: a Node at any position splits the step run regardless of its kind, and a position change regroups. +- `apps/web/tests/notice-wake-tail.e2e.ts`: after a long Turn a notice wakes the next Turn; the long Turn stays expanded through the pre-Step gap and after the notice reply, and a following reader sees the notice and the reply. diff --git a/.agents/notes/implemented/architecture/2026-10-11-chat-group-tree-exchanges.zh.md b/.agents/notes/implemented/architecture/2026-10-11-chat-group-tree-exchanges.zh.md new file mode 100644 index 0000000000..395faed58b --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-11-chat-group-tree-exchanges.zh.md @@ -0,0 +1,56 @@ +# Agent Note: Chat 分组树位置与交互段 + +Status: implemented + +[English](2026-10-11-chat-group-tree-exchanges.md) | 中文 + +## Problem + +「下次发送时收起」原本只让已加载时间线的最后一个 Turn 保持展开。由通知唤醒的 Turn(子 agent 完成、Goal Round、定时任务、后台作业)不是本客户端提交的,它一出现,上一轮就被当作历史折叠。这次折叠没有自己输入的驱动来计算精确的剩余补白,滚动层只能按通用规则预留底部空间,正在跟尾的读者因此看到一片空白。录制的真实 Session 中,通知在上一轮结束后 13–15 ms 唤醒新轮次;最小复现里空白高 596 px,五秒后仍未恢复。 + +## Decision + +通知不算正文,也不算用户消息:折叠和推进底部时都忽略它。底部从"最后一个 Turn"改为"最新交互段"。 + +- **分组树位置。** `ChatConversationViewNode.groupTree` 声明节点在分组树中的位置,类型为 `ChatGroupTree = 'exchange' | 'input' | 'turn' | 'process'`,由外到内有序;站在某个位置的节点同时具有所有内层位置的效果。`exchange` 开启一个交互段。`input` 表示节点是所在 Turn 的输入:出现在该轮过程之前即为开场输入,之后出现即为中途输入,使整个 Turn 保持展开。`turn` 让节点留在 Turn 的整轮折叠之外,`process` 让节点留在整轮折叠之内、步骤组之外,没有位置的节点并入相邻的步骤组。`isAtOrOutside(node, position)` 是读取这一顺序的唯一入口:过程分组读 `process`,整轮折叠读 `turn`,轮次输入判断读 `input`,交互段读 `exchange`。 +- **交互段。** 含 `exchange` 节点的 Turn 开启一个交互段,交互段持续到下一个开启 Turn。业务层的 message Definition 只给 Step 1 中被认领的真人输入(`user` 或 `steering`)标 `exchange`;之后的真人输入和触发通知位于 `input`,不开启交互段。 +- **内置位置。** `turn-error`、`turn-max-tokens`、`turn-tail` 与 `turn-process` 控件位于 `turn`;`model-retry` 位于 `process`。ui-chat 自己的 `assistant-step` 节点保留两条放置规则:推理行并入步骤组、回复行在步骤组之外;该轮的过程规格让最终答案的回复留在整轮折叠之外,因为哪一步是最终答案会随后续步骤到来而变化。 +- **机制。** `GroupTreeSegments(position)` 按含该位置节点的 Turn 切分已加载的 Turn,最新一段从最后一个开启 Turn 开始,窗口中没有开启 Turn 时从第一个已加载 Turn 开始。它按 Turn 增量计数,不扫描已加载节点;起点移动时只返回进入或离开最新一段的 Turn。Chat Builder 的 `bottomSource` 为"节点可见且属于最新交互段"。 + +结果是:通知唤醒的 Turn 不再折叠前面的 Turn,没有折叠就没有补白;真人下一次普通输入时,整个交互段经由自己输入的路径一起折叠。 + +## 为什么标开头而不是标通知 + +Host 在 `turn/start` 之后立即认领唤醒输入,然后组装系统提示,再写 `step/start` 和 `user/message`。录制的 Session 中这段间隔为 187 ms。只给通知打标的话,这段时间新 Turn 还没有标签,底部照样推进,上一轮会先折叠再展开。把标签放在真人开头上、未标的 Turn 默认续接,空白间隔里什么都不会发生。 + +## Alternatives considered + +**给通知打"续接"标签。** 上面那 187 ms 的间隔会让它先折后展;同一次认领里通知和真人输入并存时还要额外判断。 + +**按 kind 写一张开启名单(`user`、`steering`)。** kind 区分不了 Step 1 的 steering 与 Turn 中途的 steering,真人往通知唤醒的 Turn 里插话时会错误地折叠前一个交互段。 + +**发布到 Turn 级 Location data。** 同一 Turn 的同一个 key 只允许一个 Context 发布,而开启输入来自多个消息 Context;改成按 Turn 汇总就得在新的 Definition 里重复 inbox 认领分类,并且每次都要读取所有 Turn。 + +**节点声明业务角色(`input`、`notice`)。** 这会把业务语义放进分组机制;分组树位置只描述结构,业务 Definition 自行决定位置。 + +**修正非自身输入折叠时的滚动补白。** 通知本来就不应触发折叠,修补白不能消除这次不必要的折叠。本页没有回显的真人输入引起的折叠,由[「下条消息时收起」只用一条折叠动画](2026-10-13-chat-single-fold-motion.zh.md)处理。 + +**在 `turn` 位置上判断轮次输入。** 终止错误、最大 token 提示和已完成轮次页脚同样在整轮折叠之外,读 `turn` 会把它们误当作开场输入;轮次输入需要自己的位置。 + +**由 Step 1 中的 `input` 推导交互段。** 触发通知是所在 Turn 的开场输入,却不能开启交互段。推导交互段就得把触发通知移出 `input`,从而改变通知唤醒的 Turn 中过程行的排列。 + +## Consequences + +- 通知唤醒的 Turn 不再产生折叠、补白和空白。 +- 外部 Definition 通过 `groupTree` 放置自己的 Chat 行。除 `assistant-step` 的两条规则外,ui-chat 在分组、折叠和判断轮次输入时不点名任何 kind,内置 kind 保持原 kind 名单给出的放置。 +- 「下次发送时收起」下,一长串通知唤醒的 Turn(抽样会话中 Goal 模式最长 31 轮)会一直展开,直到真人再次输入。 +- 已加载窗口里没有开启 Turn 时,所有已加载 Turn 都属于最新交互段。 +- 另一个标签页里的真人输入在它的 `user/message` 到达时才折叠,比原来在 `turn/start` 时晚约 200 ms;其落点按[「下条消息时收起」只用一条折叠动画](2026-10-13-chat-single-fold-motion.zh.md)决定。 + +## Testing + +- `packages/client/ui-chat/tests/group-tree-segments.client.spec.ts`:位置顺序、切分、计数回退与空窗口。 +- `packages/client/ui-chat/tests/chat-node-bottom.client.spec.ts`:最新交互段的成员与通知范围。 +- `packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts`:真实 assembler 下的开轮、中途 steering、Step 1 前的间隔、通知唤醒与空闲 steer,非开启输入位于 `input`。 +- `packages/client/ui-chat/tests/process-groups.client.spec.ts`:任意位置上的节点不论 kind 都会切开步骤组,位置变化会触发重新分组。 +- `apps/web/tests/notice-wake-tail.e2e.ts`:长 Turn 之后由通知唤醒,在 Step 1 前的间隔和通知回复之后,上一轮都保持展开,跟尾读者看到通知与回复。 diff --git a/.agents/notes/implemented/architecture/2026-10-12-exclusive-conversation-matches.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-12-exclusive-conversation-matches.i18n.yaml new file mode 100644 index 0000000000..e1f4c7c1e9 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-12-exclusive-conversation-matches.i18n.yaml @@ -0,0 +1,22 @@ +# Bilingual-pair consistency record for 2026-10-12-exclusive-conversation-matches.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-12-exclusive-conversation-matches.md +/agent-note-exclusive-conversation-matches: + en: 6f78605cc85cf7f2 + zh: f73e26bb3f9d1896 +/agent-note-exclusive-conversation-matches/problem: + en: 38ed7541d4cb7019 + zh: 98ed08f8c0c7c5ba +/agent-note-exclusive-conversation-matches/decision: + en: f2bfa3d0cc6b429a + zh: c1dd501c79086d71 +/agent-note-exclusive-conversation-matches/alternatives-considered: + en: efd209a2c0e97381 + zh: 8b6f10ff4de49449 +/agent-note-exclusive-conversation-matches/consequences: + en: 414ae7349bd4cc03 + zh: 9861b3e72bdfc548 +/agent-note-exclusive-conversation-matches/testing: + en: a88ed18065da4d28 + zh: 5bf4448a8b1f4763 diff --git a/.agents/notes/implemented/architecture/2026-10-12-exclusive-conversation-matches.md b/.agents/notes/implemented/architecture/2026-10-12-exclusive-conversation-matches.md new file mode 100644 index 0000000000..5dc353f598 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-12-exclusive-conversation-matches.md @@ -0,0 +1,52 @@ +# Agent Note: Exclusive Conversation matches + +Status: implemented + +English | [中文](2026-10-12-exclusive-conversation-matches.zh.md) + +## Problem + +Every event Definition matches each event independently, and a target may hold a generic Definition that presents a whole event family. When a business Definition presents one kind of that family itself, both Definitions match the same event and each builds a Context and a Node. Nothing let the business Definition say that the event belongs to it on that target. + +The late question reply is the shipped case. Chat's generic `input-message` Definition matches every appended `user/message`, and the questions UI's `question-reply` Definition matches the reply message it presents. Chat process grouping therefore dropped the duplicate `turn-trigger` presentation by message id, in both the Turn-owned and unscoped paths, and anchored the Turn control to the first group member so it stayed above the reply. The duplicate Node remained in the Node Store, and the generic Definition kept treating the reply as context or a trigger notice. + +## Decision + +A match result may claim its event on the Definition's target: + +```ts +interface ConversationMatchResult { + readonly id: string + readonly role: 'start' | 'update' + readonly exclusive?: true +} +``` + +- **Dispatch.** `ConversationNodeAssembler.dispatchInput()` evaluates every match of one event into two reused arrays before accepting any, then settles claims. Other Definitions of a claimed target build no Context for the event, and that target's unmatched-event fallback does not run. Definitions of other targets and Definitions without a target still receive it. The outcome is independent of registration order. Live append and window collection share this dispatch. +- **Failures.** Two claims on one target, or a claim from a Definition without a target, throw during dispatch and name the event, its `seq`, and the claiming Definition kinds. +- **Scope.** A claim belongs to one event and comes from `match()`, so a Definition claims only the events it recognizes. The generic Definition declares nothing. +- **Current use.** `question-reply` returns `exclusive: true` for a recorded `user-question-reply` message. Chat no longer aggregates by message id, and the reply's placement is unchanged. + +## Alternatives considered + +**Definition precedence (`specific` / `default`).** A generic Definition would declare itself overridable, and dispatch would become a priority order rather than a statement of which Definition presents an event. A claim needs no change to the generic Definition, and two claims fail instead of silently ranking. + +**Declare human sources in a registry and render the reply through user-text rules.** The reply would stay a generic message Node with customized text. Chat's message and Inbox Definitions would need a service-backed predicate, registry changes would have to reassemble every bound Conversation, and the text rule would need the message source as a fact. A claim keeps the reply in its owning Definition. + +**Mark human-authored sources in durable data.** A generic field on the message source would let the generic Definition classify the reply, but it changes a persisted type, and replies recorded by released builds do not carry it. + +**Skip the reply kind in `input-message`.** The generic Chat Definition would name a plugin's source kind. + +**Deduplicate in the grouping output.** This is the replaced mechanism: the duplicate Node stays stored and visible to every other Chat reader, and each grouping path repeats the aggregation. + +## Consequences + +- A claimed event has exactly one Context per claimed target, so no hidden duplicate remains in the Node Store. +- A conflicting claim fails at dispatch rather than at registration, because claims are decided per event from event data. +- Dispatch makes one extra pass over an event's matches, usually one to three, and allocates nothing for events without a claim. +- A plugin that presents part of a generic Definition's event family on one target uses a claim; it does not need the generic Definition's cooperation. + +## Testing + +- `packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts` ("exclusive matches"): a claim withholds the event from other Definitions of its target in either registration order and on live append; other targets, target-less Definitions, and no fallback; two claims and a target-less claim throw. +- `packages/client/ui-user-questions/tests/question-reply.client.spec.ts`: the reply match claims its event, and an assembler holding both message Definitions produces one Chat Node for the reply. diff --git a/.agents/notes/implemented/architecture/2026-10-12-exclusive-conversation-matches.zh.md b/.agents/notes/implemented/architecture/2026-10-12-exclusive-conversation-matches.zh.md new file mode 100644 index 0000000000..021b1cc918 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-12-exclusive-conversation-matches.zh.md @@ -0,0 +1,52 @@ +# Agent Note: Conversation 独占匹配 + +Status: implemented + +[English](2026-10-12-exclusive-conversation-matches.md) | 中文 + +## Problem + +每个事件 Definition 各自独立地匹配每个事件,同一个 target 上可能有一个呈现整类事件的通用 Definition。业务 Definition 自己呈现这类事件中的一种时,两个 Definition 会命中同一个事件,各自建立 Context 和 Node。业务 Definition 没有办法声明这个事件在该 target 上归它呈现。 + +迟到的问题回答是已上线的实例。Chat 的通用 `input-message` Definition 匹配每条追加的 `user/message`,问题 UI 的 `question-reply` Definition 也匹配它要呈现的回答消息。Chat 过程分组因此在有轮次归属和无轮次归属两条路径上都按消息 id 丢掉重复的 `turn-trigger` 呈现,并把整轮控件锚到组首成员,让它留在回答之上。重复的 Node 仍留在 Node Store 中,通用 Definition 也仍把回答当作 context 或触发通知。 + +## Decision + +match 结果可以在 Definition 的 target 上认领它的事件: + +```ts +interface ConversationMatchResult { + readonly id: string + readonly role: 'start' | 'update' + readonly exclusive?: true +} +``` + +- **派发。** `ConversationNodeAssembler.dispatchInput()` 先把一个事件的全部 match 求入两个复用数组,再结算认领,之后才逐个接收。被认领 target 上的其他 Definition 不为该事件建立 Context,该 target 的未匹配事件 fallback 也不运行;其他 target 的 Definition 与没有 target 的 Definition 照常接收。结果与注册顺序无关。实时追加和窗口收集共用这一派发。 +- **失败。** 同一 target 被认领两次,或没有 target 的 Definition 发起认领,都会在派发时抛错,错误信息包含事件、其 `seq` 与认领方 Definition 的 kind。 +- **范围。** 认领只对一个事件生效,由 `match()` 返回,因此 Definition 只认领它识别出的事件;通用 Definition 不需要声明任何东西。 +- **当前用法。** `question-reply` 对已记录的 `user-question-reply` 消息返回 `exclusive: true`。Chat 不再按消息 id 聚合,回答的放置不变。 + +## Alternatives considered + +**Definition 优先级(`specific` / `default`)。** 通用 Definition 得声明自己可被覆盖,派发也变成优先级排序,而不是声明由哪个 Definition 呈现事件。认领不要求改动通用 Definition,两个认领冲突时直接失败,而不是静默排序。 + +**用注册表声明真人来源,并通过用户文本规则渲染回答。** 回答会保持为通用消息 Node,只定制文本。Chat 的 message 与 Inbox Definition 需要一个依赖 service 的判断函数,注册表变化必须重新组装所有已绑定的 Conversation,文本规则还要把消息来源当作 fact 读取。认领让回答留在它自己的 Definition 中。 + +**在持久数据中标记真人撰写的来源。** 消息来源上的通用字段可以让通用 Definition 自行分类回答,但这会改动持久化类型,已发布版本记录的回答也不带这个字段。 + +**在 `input-message` 中跳过回答的 kind。** 通用的 Chat Definition 会点名插件的来源 kind。 + +**在分组输出层去重。** 这是被替换的机制:重复 Node 仍留在存储里,对其他所有 Chat 读取方可见,每条分组路径都要重复这一聚合。 + +## Consequences + +- 被认领的事件在每个被认领 target 上恰好只有一个 Context,Node Store 中不再残留隐藏的重复节点。 +- 认领冲突在派发时失败,而不是在注册时失败,因为认领由事件数据逐个决定。 +- 派发对每个事件的 match 多遍历一次,通常一到三个;没有认领的事件不产生额外分配。 +- 插件在一个 target 上呈现通用 Definition 事件族中的一部分时,使用认领即可,不需要通用 Definition 配合。 + +## Testing + +- `packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts`("exclusive matches"):两种注册顺序与实时追加下,认领都会对同 target 的其他 Definition 屏蔽该事件;其他 target 与无 target 的 Definition 照常接收,fallback 不运行;两次认领与无 target 认领会抛错。 +- `packages/client/ui-user-questions/tests/question-reply.client.spec.ts`:回答的 match 认领其事件;同时持有两个消息 Definition 的 assembler 只为回答产生一个 Chat Node。 diff --git a/.agents/notes/implemented/architecture/2026-10-12-headless-environment-auth.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-12-headless-environment-auth.i18n.yaml new file mode 100644 index 0000000000..df5212392d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-12-headless-environment-auth.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-10-12-headless-environment-auth.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-12-headless-environment-auth.md +/agent-note-headless-jobs-omit-the-interactive-credential-store: + en: c768db5a7e821bf0 + zh: 423b3e423d071ddc +/agent-note-headless-jobs-omit-the-interactive-credential-store/problem: + en: 829df36d87975148 + zh: 171d29ef2f2e5079 +/agent-note-headless-jobs-omit-the-interactive-credential-store/decision: + en: af275ffea713f76b + zh: 12a3bacfcb147df7 +/agent-note-headless-jobs-omit-the-interactive-credential-store/alternatives-considered: + en: f6f8dfde4789adc8 + zh: ced62f3d1ec402e6 +/agent-note-headless-jobs-omit-the-interactive-credential-store/consequences: + en: 1c1a699dc9f5681f + zh: 4a72b09d24f9a005 diff --git a/.agents/notes/implemented/architecture/2026-10-12-headless-environment-auth.md b/.agents/notes/implemented/architecture/2026-10-12-headless-environment-auth.md new file mode 100644 index 0000000000..0a497c44e7 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-12-headless-environment-auth.md @@ -0,0 +1,30 @@ +# Agent Note: Headless jobs omit the interactive credential store + +Status: implemented + +English | [中文](2026-10-12-headless-environment-auth.zh.md) + +## Problem + +The [stored credential precedence](2026-10-09-stored-credential-precedence.md) decision makes GUI saves effective even when the shell exports a key for another application. A headless job sharing that Harness home can then use the GUI account instead of the key explicitly supplied to the job. Changing reference precedence alone would leave pi-ai provider records and saved grants ahead of the environment. + +## Decision + +The shipped headless bundle disables the base `credentials`, `authorization` and `deepseek-account` rows. API-key adapters already support compositions without the credentials service and resolve through the launch snapshot: process environment, invocation-directory `.env`, then Harness-home `.env`. The profile does not read, migrate, write or delete `.credentials.yaml`. Dependent authorization and account rows are disabled explicitly so ordinary jobs do not emit pending-service warnings. + +This partially supersedes the per-run override policy in stored credential precedence for the shipped headless profile. Other profiles and an explicitly restored headless store retain stored-first resolution. The headless [README](../../../../packages/bundle/headless/README.md#credentials) owns the restoration patch. + +## Alternatives considered + +**Put the environment first in every profile.** It prevents a GUI save from replacing a key left in the launching environment and restores inconsistent reference and provider-record precedence. + +**Disable only the credentials row.** It achieves environment selection but leaves authorization and the account provider pending, producing warnings on every launch. + +**Add a second precedence mode to the store.** It introduces configuration and record-handling policy into the shared provider when existing adapter fallbacks and profile overrides already supply the required behavior. + +## Consequences + +- Headless ignores saved API keys, provider records and grants. A provider requiring stored sign-in or the DeepSeek account route fails until the user restores stored authentication; restoring it also restores saved-first precedence. +- Environment and external provider credential chains remain available. Sessions, settings and tools stay composed; omission of the store does not deny filesystem reads by tools. +- Web, SDK and ACP retain the credential store. Their authentication, login and refresh implementations are unchanged. +- Profile-level process tests cover all three environment layers, named and native pi-ai authentication, missing keys, ignored malformed storage, restoration and preservation of the document. Existing headless transcripts and real-model workspace tests cover the remaining composition. diff --git a/.agents/notes/implemented/architecture/2026-10-12-headless-environment-auth.zh.md b/.agents/notes/implemented/architecture/2026-10-12-headless-environment-auth.zh.md new file mode 100644 index 0000000000..e6e0b8b870 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-12-headless-environment-auth.zh.md @@ -0,0 +1,30 @@ +# Agent Note: Headless 任务不挂载交互式凭据存储 + +Status: implemented + +[English](2026-10-12-headless-environment-auth.md) | 中文 + +## 问题 + +[已保存凭据优先级](2026-10-09-stored-credential-precedence.zh.md)的决策保证 GUI 保存的密钥生效,即使 shell 为其他应用导出了密钥。共享同一个 Harness home 的 headless 任务可能因此使用 GUI 的账户,而不是显式传给任务的密钥。只调整引用优先级仍会让 pi-ai 提供方记录和已保存 grant 排在环境变量之前。 + +## 决策 + +随附 headless 组合包禁用 base 中的 `credentials`、`authorization` 和 `deepseek-account` 行。API 密钥适配器已经支持不挂载凭据服务的组合,按启动快照解析:进程环境、运行命令的目录 `.env`、Harness home `.env`。该 profile 不读取、迁移、写入或删除 `.credentials.yaml`。显式禁用依赖它的 authorization 和 account 行,避免普通任务输出等待服务的警告。 + +这部分取代了已保存凭据优先级中针对随附 headless profile 的单次覆盖策略。其他 profile,以及显式恢复存储的 headless,仍按已保存凭据优先解析。headless [README](../../../../packages/bundle/headless/README.zh.md#credentials)负责恢复 patch 的用法。 + +## 考虑过的替代方案 + +**在所有 profile 中让环境变量优先。** 这会使 GUI 保存的密钥无法替换启动环境中遗留的密钥,并恢复引用与提供方记录之间不一致的优先级。 + +**只禁用 credentials 行。** 这能选用环境变量,但 authorization 与 account 提供方会保持 pending,每次启动都产生警告。 + +**给存储增加第二种优先级模式。** 现有适配器后备与 profile 覆盖已经满足需求;这种做法却会把额外配置和记录处理策略引入共享提供方。 + +## 后果 + +- Headless 忽略已保存 API 密钥、提供方记录和 grant。依赖已保存登录凭据的提供方或 DeepSeek account 路由会失败,直到用户恢复存储认证;恢复后也恢复已保存凭据优先。 +- 环境变量与提供方外部凭据链仍然可用。会话、settings 与工具继续组合;不挂载存储不会禁止工具读取文件系统。 +- Web、SDK 与 ACP 保留凭据存储;其认证、登录和刷新实现不变。 +- Profile 进程测试覆盖三个环境层、具名与原生 pi-ai 认证、缺失密钥、忽略畸形存储、恢复存储,以及文件保持原样。现有 headless 录制与真实模型工作区测试覆盖组合中的其余行为。 diff --git a/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.i18n.yaml new file mode 100644 index 0000000000..363cc2fb3c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.i18n.yaml @@ -0,0 +1,91 @@ +# Bilingual-pair consistency record for 2026-10-12-peer-principal-and-remote-access.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md +/agent-note-peer-principals-and-method-level-remote-access-control: + en: 0c0fd0f567bd5c89 + zh: 464e34d05737a7bc +/agent-note-peer-principals-and-method-level-remote-access-control/problem: + en: f5b70ee2c0a3e32a + zh: 68776a916cb41f11 +/agent-note-peer-principals-and-method-level-remote-access-control/decision: + en: a15c7c13f167e5ac + zh: 63bcc2ef60c2f344 +/agent-note-peer-principals-and-method-level-remote-access-control/decision/scope: + en: 86061e5578d06871 + zh: 08eb605fcd38329d +/agent-note-peer-principals-and-method-level-remote-access-control/decision/two-layers-principal-and-peer: + en: 6663ea33561eee82 + zh: 722fd3b8fae0ef90 +/agent-note-peer-principals-and-method-level-remote-access-control/decision/peer-model: + en: db5b0cf33679e5ed + zh: 1b04328268a95abc +/agent-note-peer-principals-and-method-level-remote-access-control/decision/identity-types: + en: 31430c5b95295ead + zh: 8f4ba35b4406558d +/agent-note-peer-principals-and-method-level-remote-access-control/decision/peer-lifetime-and-revocation: + en: a01ac57fc71a84f0 + zh: 3e488ef3da5e4b8c +/agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control: + en: a55516339b0d49dd + zh: 3281df9ea7878fc0 +/agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/correspondence-with-rbac: + en: a78a2410eb52f777 + zh: a868bf1b0e690c9f +/agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/resources-and-resource-readers-deepseek-ai-dsh-typert-protocol-deepseek-ai-dsh-typert-registry: + en: 625b20552962e268 + zh: 1136c0fd7cdc6901 +/agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/endpoint-resources: + en: ec1677d8e338e115 + zh: 6bedd163f208eaa9 +/agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/roles-bindings-and-policies-deepseek-ai-dsh-api-remote-access: + en: e57f396239ea9f56 + zh: ddb26278b1521ceb +/agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/example-share-viewer: + en: 3c911f57f0f6ca4b + zh: 1509781eeda4c922 +/agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/host-access-check-order: + en: 6c58dc8777e107ef + zh: bc14605c96e4d8f3 +/agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/forwarded-events: + en: 5fe34ee5ec31942a + zh: ef39b020b0539b8f +/agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/list-and-aggregate-methods: + en: 7865e159528e2d30 + zh: 1399d54632f4d926 +/agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/access-snapshot-stream: + en: 41d7ac1faf2448d7 + zh: 6d523895a9abae68 +/agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/client-face: + en: 7be28cd7e19e5377 + zh: 30b5aee957271d6c +/agent-note-peer-principals-and-method-level-remote-access-control/decision/security-invariants: + en: 2094587c320b6ccc + zh: 3a1d2a6b343500f4 +/agent-note-peer-principals-and-method-level-remote-access-control/decision/recording-the-message-source: + en: 2ee3abed24d418f4 + zh: 25653dd24b95c220 +/agent-note-peer-principals-and-method-level-remote-access-control/decision/code-review-rule-and-documentation: + en: e9a2e0d1534b8fe7 + zh: 085813c0b2b3c0d8 +/agent-note-peer-principals-and-method-level-remote-access-control/decision/out-of-scope-and-future-work: + en: 4bb0314c20e4f25c + zh: 6ede1dd2193a01d7 +/agent-note-peer-principals-and-method-level-remote-access-control/decision/relation-to-existing-notes: + en: 305ec45a7cd85ed7 + zh: d2cd4209a56b1d0c +/agent-note-peer-principals-and-method-level-remote-access-control/alternatives-considered: + en: 130df2981ba550df + zh: e9a0a89af6fb50a3 +/agent-note-peer-principals-and-method-level-remote-access-control/consequences: + en: 89452c3c3f042e75 + zh: 9bcad00a60c65303 +/agent-note-peer-principals-and-method-level-remote-access-control/consequences/verification: + en: 048067eb02e6bf8a + zh: 12508b59f6b206b8 +/agent-note-peer-principals-and-method-level-remote-access-control/consequences/costs-and-limits: + en: 1e254cec680b1a37 + zh: 06a31e057ba965aa +/agent-note-peer-principals-and-method-level-remote-access-control/consequences/migration-list-for-pr-5945: + en: f904c62b68d46bb5 + zh: 34eb45b97302badb diff --git a/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md new file mode 100644 index 0000000000..a62f0a5e74 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md @@ -0,0 +1,672 @@ +# Agent Note: Peer principals and method-level Remote access control + +Status: implemented + +English | [中文](2026-10-12-peer-principal-and-remote-access.zh.md) + +## Problem + +A Remote method on the Host could not tell whom one call spoke for, and could not restrict callable methods by caller. The [`PeerScope`](../../../../packages/typert/protocol/src/types.ts) contract stated "Who the Peer is and what it may do are not recorded here"; [Connection](../../../../packages/client/connection/src/rpc-host.ts) had a single operator Peer, `admit()` returned it for every authenticated request, and the `/api` route and the interceptor's Fetch handler used it unconditionally instead of the admission result; [Gateway](../../../../packages/api/gateway/src/index.ts) silently fell back to `operatorPeer()` when a caller named no Peer. + +The first non-owner caller is a liveshare viewer. Viewer requests reach the owner's liveshare Host plugin through the Relay, and the plugin dispatched them in the owner's process as the operator, so the owner's Session treated a viewer's prompt as the owner's own input. PR #3854 pushed liveshare semantics into session-controller for this: the request body carries `origin`, every prompt writes a required `prompt/origin` event correlated by requestId, the controller folds origins, `serializeAdmission` extends to every prompt, and a new `api-session/external-admission` hook appears; liveshare maintains its own `READ_ENDPOINTS` allowlist and trims list/follow results; the frontend `clientWritePolicy` lives in ui-conversation, with three ways of reading it across five packages and seven places that hide UI by whether a callback was injected. + +One general mechanism is needed: + +- In-process Host code assigns the caller identity; no wire field can set or override it. +- Every call path names its Peer explicitly; there is no implicit fallback. +- Method-level permission is computed and enforced in one place on the Host; the frontend reads the same result to show or hide entries and follows rule changes automatically. +- A prompt records its caller with the user message, so a later check can tell who initiated each turn. + +## Decision + +### Scope + +| Part | Content | +|---|---| +| Peer identity | `PeerScope.principal` and `PeerScope.signal`; one Peer implementation `Peer` (renamed from `OperatorPeer`) with an options constructor; removal of the operator concept and every implicit Peer fallback; `connection.browserPeer`, `connection.createPeer()`, and `fetch(request, peer)`; a disposed Peer is always refused with `gateway/peer-disposed`; the `user-rpc` source records `principal?` | +| Method-level access control | `resources` readers on `@Remote`, `TypertResourceMap` resource kinds, and the `typert.resources` registry (typert-protocol, typert-registry); Gateway resolution of calls and forwarded events to resources; new package `@deepseek-ai/dsh-api-remote-access` (Host and Client faces): RBAC decisions over roles, bindings, and policies through `decide(request)`, list filtering through `allows()`, the first snapshot in the index and the access snapshot stream, and access hooks | +| Documentation | One code-review rule and terminology | + +### Two layers: principal and peer + +| Layer | Concept | Example | Cardinality | +|---|---|---|---| +| Authorization | principal: the caller identity that permission decisions read | `share-viewer{shareId, actor}` proven by a share token; the local user `{ kind: 'owner' }` | one principal has N peers | +| Connection | peer: the lifetime scope of one connection | a browser connection, one connection of a liveshare viewer | one peer per connection | + +| Concern | Layer | +|---|---| +| Method-level decisions, access hooks, access snapshots | principal | +| Recording `source.principal` | principal | +| Subscriptions, streams, connection-lifetime registrations (`peer.ctx`) | peer | +| Revocation | both: after revocation the policy gives that principal no binding, so its calls are all refused; the creator disposes every peer under it | + +The creator owns the principal→peers table; liveshare, for example, keeps one object per share grant that records every peer opened under it. + +### Peer model + +There is one Peer concept and one implementation; the operator concept is removed. + +```text +// @deepseek-ai/dsh-typert-protocol +export interface PeerScope { + readonly id: PeerId + /** Connection-lifetime registrations; disposed with the Peer. */ + readonly ctx: Context + /** Caller identity fixed at creation by in-process Host code; no wire field can set it. */ + readonly principal: Principal + /** Aborted once the Peer is disposed; Remote streams that speak for the Peer end with it. */ + readonly signal: AbortSignal + dispose(): Promise +} + +// @deepseek-ai/dsh-client-connection +export class Peer implements PeerScope { + constructor(owner: Context, options: { readonly principal: Principal }) +} + +interface HostConnectionHandle { + /** The Peer every authenticated browser connection shares; principal { kind: 'owner' }. */ + readonly browserPeer: PeerScope + /** Network admission: the admitted Peer or the refusal status. */ + admit(request: ConnectionTrustRequest): PeerAdmission + /** In-process only; disposed with the Context that read `ctx.connection`, or earlier by dispose(). */ + createPeer(options: { readonly principal: Principal }): PeerScope +} + +interface ConnectionFetchHandler { + fetch(request: Request, peer: PeerScope): Promise +} +bridge(req, res, apiHandler, peer, maxRequestBodyBytes?) + +// @deepseek-ai/dsh-api-gateway +interface InvokeRemoteRequest { …; readonly peer: PeerScope } // was `peer?` +wireStream.open(endpoint, payload, uplink, peer: PeerScope, signal) // was `PeerScope | undefined` +``` + +| Rule | Detail | +|---|---| +| Every path names its Peer | Network paths take it from `admit()`; in-process paths receive it from the caller. Gateway deletes `operatorPeer()` and `request.peer ?? …` and never creates a Peer | +| Browser connections share `browserPeer` | Every browser connection shares one Peer, and every use site names `connection.browserPeer` explicitly; there is no fallback variable. The in-page Host (webworker-runtime) also passes `browserPeer` explicitly | +| In-process Host automation | Code that calls Remote methods creates its own Peer with `createPeer({ principal })`. No production caller exists yet, so no fixed Host Peer is pre-provisioned | +| Multiple Peers | Each `createPeer()` returns an independent Peer; liveshare is to create one per viewer connection (wired on the #3854 side) | +| Exact Fetch routes | Exact routes such as file-upload, present-open, media-references, session-log-export, and inspector bypass the Gateway; the shared Fetch handler answers 403 to every non-owner Peer | + +### Identity types + +```text +// @deepseek-ai/dsh-typert-protocol + +/** + * Merge-extensible caller identities. Each driver declares its own entry. + * Every entry is a JSON value with a unique literal `kind`, carries no + * credential, and is safe to persist in a Session log. + */ +export interface PrincipalMap { + owner: { readonly kind: 'owner' } +} +export type Principal = PrincipalMap[keyof PrincipalMap] + +/** + * @param invocation - `ctx.invocation`; `undefined` outside a Remote call. + * @returns the caller's principal when it is not the owner; `undefined` for the + * owner and for trusted in-process code outside a Remote call. + */ +export function principalOf(invocation: RemoteInvocation | undefined): Principal | undefined +``` + +The single read point is `this.ctx.invocation.peer.principal`; an owner test is written inline as `principal.kind === 'owner'`, and an unknown kind is never the owner. External drivers extend `PrincipalMap` by declaration merging in their own package; liveshare's `share-viewer{ shareId, actor }` is declared on the #3854 side, and liveshare itself is unchanged here. + +### Peer lifetime and revocation + +| Event | Behavior | +|---|---| +| Gateway dispatch | The first step checks `peer.signal.aborted` and refuses a disposed Peer with `gateway/peer-disposed`, on HTTP, WebSocket, and in-process paths alike; wire paths check before they read the payload, so a malformed payload from a disposed Peer gets the same code | +| Streams when a Peer is disposed | The signal a Remote stream method observes joins `peer.signal`, and so does the `$events` stream lifetime; a WebSocket still closes with 1001 | +| Unary calls when a Peer is disposed | A unary call already running is not cancelled; later calls are refused | +| Revocation | After a business package revokes a grant, its policy gives that principal no binding, and the business package calls `invalidate(principal)`; the creator disposes every peer in its principal→peers table | + +### Method-level access control + +Resource types and the `resources` readers of `@Remote` live in `@deepseek-ai/dsh-typert-protocol`; resource kinds live in typert beside lookups and Contexts: `TypertResourceMap` declares the kinds, `typert.resources` holds each kind's default wire field, and lookups and Host Context adapters declare the resource kind their wire identity names. The Gateway resolves one call or forwarded event to the resources it touches. Decisions, list filtering, and the access snapshot live in the new package `@deepseek-ai/dsh-api-remote-access` (`packages/api/remote-access`), which reads no argument field. The Gateway and domain packages depend on remote-access only for types and inject `remoteAccess`; their only value dependency is `dsh-typert-protocol`, so `package-dependency-policy` gains no remote-access entry. Business packages define and register roles, bindings, and policies; liveshare is the first user. Apart from the built-in `global`, no resource-kind name appears in remote-access or the Gateway, which follows the [`packages/api` carries generic orchestration only](../../../../docs/defensive-patterns/remote-and-api.md) review rule. + +#### Correspondence with RBAC + +| This design | RBAC / IAM term | Meaning | +|---|---|---| +| principal | Subject / Principal | The caller identity, one entry of `PrincipalMap` | +| Peer | Session (RBAC session) | One connection of a principal; one principal has N Peers | +| owner | Superuser | Skips every check | +| action | Action / Permission | A Remote endpoint `ns/method`, or a forwarded event `event:`; the Gateway still calls it an endpoint internally | +| resource | Resource / Object | `{ kind: 'global' }`, or `{ kind, id }` of a declared resource kind | +| role | Role | An action set over one resource kind; a plain value of the business package | +| binding | RoleBinding (role assignment) | The principal holds a role on a resource, optionally with a condition | +| condition | Condition (IAM Conditions) | Attached to a binding; allows by the request's action, resource, and arguments, or refuses with a reason | +| policy | Allow policy | The bindings and optional `explain` a business package gives for one non-owner principal kind | +| explain | Denial explanation | Names a refusal reason when no binding allows; never changes the decision | +| Gateway | PEP (Policy Enforcement Point) | Resolves resources and enforces the decision | +| remote-access | PDP (Policy Decision Point) | Computes decisions, list filtering, and access snapshots | + +| Rule | Content | +|---|---| +| Default deny | A non-owner is allowed only when the role of some binding explicitly lists the action; an action no role lists, a resource without a binding, and a principal kind without a policy are all refused. Every Remote method is under access control; there is no separate accessibility marker | +| No inheritance between resources | `global` is not a parent of other resources: a grant on `global` implies no grant on any Session or Workspace, and a grant on a Workspace implies no grant on its Sessions | +| A `global` grant is an explicit global grant | A call that resolves to no resource touches `global`. Putting an action in a `global` role lets that principal call it without resource restriction, which is a high-risk grant; once the method is allowed, it can still decide each item inside with `allows(resource)` | + +#### Resources and resource readers (`@deepseek-ai/dsh-typert-protocol`, `@deepseek-ai/dsh-typert-registry`) + +```text +/** Merge-extensible resource kinds: the key is the kind, the value declares its identity type. */ +export interface TypertResourceMap {} +// @deepseek-ai/dsh-session/types: session: TypertResource +// @deepseek-ai/dsh-workspace/types: workspace: TypertResource + +// Runtime registries in typert +ctx.typert.resources.register(kind, { field }): TypertDisposer +ctx.typert.lookups.register(key, { parameter, wire, resource?, … }) // resource: the kind its wire id names +ctx.typert.contexts.registerHost(key, { wire, resource?, … }) // a TypertHostContextAdapter + +export interface TypertHostContextAdapter { + readonly wire: string + readonly wireTypeSymbol: string + /** Resource kind the wire identity names; access checks read the identity from args before resolve(). */ + readonly resource?: TypertResourceKind + resolve(id: Wire): Context | undefined | Promise +} + +export type RemoteAccessResourceKind = 'global' | TypertResourceKind +export type RemoteAccessResource = + | { readonly kind: 'global' } + | { readonly kind: K; readonly id: TypertResourceId } // for each declared kind K +export type RemoteAccessResourceOf = Extract + +/** Per-kind readers, called with the method's positional wire arguments. */ +export type TypertResourceReaders = { + readonly [K in TypertResourceKind]?: (...args: WireArgs) => Id | readonly Id[] | undefined // Id = TypertResourceId +} + +export interface RemoteMethodOptions { + readonly name?: string + readonly mode?: 'stream' + readonly resources?: TypertResourceReaders +} + +export function Remote( + option: string | RemoteMethodOptions>>, +): (method: (this: This, ...args: Args) => Result, + context: ClassMethodDecoratorContext Result>) => void + +export interface RemoteScopeOptions { + readonly name?: string + readonly resources?: TypertResourceReaders +} + +export function RemoteScope( + key: Extract, + option?: string | RemoteScopeOptions>>, +): (method: (this: This, ...args: Args) => Result, + context: ClassMethodDecoratorContext Result>) => void +``` + +| Rule | Content | +|---|---| +| Syntax | `@Remote({ name: 'page', resources: { session: (request) => request.address.kind === 'session' ? request.address.sessionId : request.address.childSessionId } })`; a Context method writes `@RemoteScope('agent')` and gets its Session resource from the Agent Context's `resource: 'session'`, and writes `@RemoteScope('agent', { resources: { workspace: (request) => request.workspaceId } })` when it needs another resource. Most methods write no `resources` and get their resources from automatic resolution | +| Resource kinds | The lower-level packages that declare the identity types merge the resource kinds: `session: TypertResource` in `@deepseek-ai/dsh-session/types` and `workspace: TypertResource` in `@deepseek-ai/dsh-workspace/types`. session-controller references the commands package, so a merge in session-controller would make commands reference session-controller and form a project-reference cycle. remote-access builds in `global`, which has no id and stays out of `TypertResourceMap`; typert reserves the name, and `typert.resources.register('global', …)` throws | +| Default fields and declarations | The domain that owns a kind registers its default wire field when its controller is constructed: the `SessionController` constructor calls `typert.resources.register('session', { field: 'sessionId' })` and the `WorkspaceController` constructor registers `workspace` → `workspaceId`; registering a kind twice throws. The Agent, Session, and `workspaceFileScope` lookups declare `resource: 'session'`, and the agent package's Agent Context declares `resource: 'session'` | +| Resolution precedence | Each resource kind takes, in order: ① that kind's reader in the method's `resources`; ② every lookup parameter that declares that kind as its `resource` contributes its wire identity, and an omitted optional lookup parameter contributes none; when the Context of a Context method declares that kind as its `resource`, the Context's wire identity contributes too; ③ that kind's default field: a top-level named argument of that name when one exists, otherwise that field of the first object argument. A default field names one identity, so an array there is an invalid value | +| Argument values | Resource resolution and the `args` of `decide()` read the named arguments validated against their Typert-compiled types: each JSON argument is decoded once by its codec, which drops object fields its type does not declare, and dispatch reuses that value, so a caller cannot change the resources with an undeclared field; lookup parameters and the Context identity keep their wire identities, and no lookup or Agent is resolved at that point | +| Resolution result | The resources of every kind together are the resources the call touches; with none, the call touches `[global]`; when resources resolve, only those are used and `global` is not added | +| Readers | Called with the method's positional arguments: the named arguments mapped by the descriptor back to the method's parameter order (the validated value in a JSON position, the wire identity in a lookup position, and the call's signal in the cancellation position); return one id, an id array (every one must be allowed), or `undefined` (no resource of that kind) | +| Invalid values | A reader that throws or returns an empty array, or a read value (including a Context's wire identity) that is not a non-empty string, makes the resources invalid: the Gateway refuses the call with `remote-access/denied` (reason `resource-invalid`) without asking `decide()` | +| Completeness | `resources` and automatic resolution must cover every resource the method touches. A call that resolves no resource falls to `global`, which only a `global` role can allow, so it is refused by default; when only some resources resolve, the rest go unchecked and a principal granted on the resolved resources can reach them, which the method author must prevent | +| Type inference | The parameter types of `@Remote` and `@RemoteScope` readers are inferred from the decorated method: the factory parameter uses `NoInfer`, so `Args` is inferred only from the returned decorator; lookup parameters map to wire types through `TypertLookupMap` (for a method whose first parameter is `agent: Agent`, the reader's first parameter is `SessionId`). A missing property, more parameters than the method has, and a `global` key fail compilation | +| Storage | `resources` readers live on the class prototype with the other `@Remote` / `@RemoteScope` metadata, are recorded on instance construction, and are safe across duplicate installations; they do not enter Typert artifacts | +| Context methods | `@RemoteScope(key, option?)` matches `@Remote`: a string is the export name and an object is `{ name?, resources? }`. A Context's wire identity takes part in resolution like a lookup parameter that declares a `resource`, with the same precedence, and a `resources` reader for that kind overrides it; the Gateway reads that identity directly from args before `resolveReceiverContext`, so no Agent is activated. A Context that declares no `resource` contributes no resource from its identity, and the method resolves by the ordinary rules | + +#### Endpoint resources + +The table lists the endpoints #3854 viewers use and where their resources come from. Only `session/page` and `session/follow` need explicit `resources`; the rest rely on a lookup's `resource` or the default field: + +| Endpoint | Resource | Resolution source | Filtering inside the method | +|---|---|---|---| +| `workspace/follow` | `global` | no resource | yes: Workspaces and Sessions | +| `session/control` | `global` | no resource | yes: Sessions | +| `session/list` | `global` (`_request` has no `sessionId`) | no resource | yes: Session rows | +| `session/modelCatalog` | `global` | no resource | no | +| `session/canOpenWorkspacePath` | `global` | no resource | no | +| `llm/listConfigurableProviders` | `global` | no resource | no | +| `llm/listProviders` | `global` | no resource | no | +| `commands/list` | session: `args.agentId` (the Agent lookup's wire field) | Agent lookup `resource: 'session'` | no | +| `goals/get` | session: `args.agentId` | Agent lookup `resource: 'session'` | no | +| `session/attachment` | session: `args.request.sessionId` | default field | no | +| `skills/list` | session: `args.request.sessionId` | default field | no | +| `session/fork` | session: `args.request.sessionId` | default field | no | +| `session/prompt` | session: `args.request.sessionId` | default field | no | +| `session/cancel` | session: `args.request.sessionId` | default field | no | +| `session/projections` | session: `args.request.sessionId` | default field | no | +| `session/page` | session: `args.request.address.sessionId` for a Session address; `args.request.address.childSessionId` for a subagent address | `resources` | no | +| `session/follow` | same as above | `resources` | no | +| `remoteAccess/follow` | `global` | no resource; remote-access allows it for every principal | no | + +A subagent address reads the child transcript, so its resource is the child Session. A Client rereads the projections of known Sessions after a reconnect, including the parent of a subagent Session; a reader without a grant on that parent has its projection read refused. Whether a grant on a parent Session covers its subagent children is the policy's decision, that is, whether it gives a binding for the child Session; the generic layer derives no parent resource. + +Methods that need no non-owner access declare nothing, and a method no role lists is refused. The request fields of subagent `prompt` are `parentSessionId` / `childSessionId`, so automatic resolution finds no Session and resolves it to `global`; it must therefore stay out of every `global` role. `session/search` filters its hits per Session inside the method. `subagents/list` is not a Remote endpoint (`SubagentRuntime.list()` carries no `@Remote`). + +#### Roles, bindings, and policies (`@deepseek-ai/dsh-api-remote-access`) + +```text +/** Remote endpoint `ns/method`, or `event:` for one forwarded event; endpoint segments never contain `:`. */ +export type RemoteAccessAction = RemoteAccessEndpoint | `event:${string}` + +/** A named action set over one resource kind; a plain value owned by the business package. */ +export interface RemoteAccessRole { + readonly name: string + readonly resource: K + readonly actions: readonly RemoteAccessAction[] +} + +/** Answer of decide() and of a condition. */ +export type RemoteAccessDecision = + | { readonly kind: 'allow' } + | { readonly kind: 'deny'; readonly reason: string } + +/** Refines one binding for one action on its resource; `args` only for a concrete call. */ +export type RemoteAccessCondition = (request: { + readonly action: RemoteAccessAction + readonly resource: RemoteAccessResourceOf + readonly args?: Readonly> +}) => RemoteAccessDecision + +/** One role on one resource of the role's kind; the resource type follows from the role. */ +export type RemoteAccessBinding = { + [K in RemoteAccessResourceKind]: { + readonly role: RemoteAccessRole + readonly resource: RemoteAccessResourceOf + readonly condition?: RemoteAccessCondition + } +}[RemoteAccessResourceKind] + +/** One access question; the Gateway passes only valid resources, [global] when none. */ +export interface RemoteAccessRequest

{ + readonly principal: P + readonly action: RemoteAccessAction + /** Allow only when the action is allowed on every listed resource. */ + readonly resources: readonly RemoteAccessResource[] + /** Named arguments of a concrete call, validated against their Typert-compiled types with lookup parameters as wire identities; absent for events, snapshots, and list filtering. */ + readonly args?: Readonly> +} + +/** Business rules for one non-owner principal kind; synchronous over in-memory state. */ +export interface RemoteAccessPolicy

{ + bindings(principal: P): readonly RemoteAccessBinding[] + /** Reason for a request no binding allows; never changes the decision. */ + explain?(request: RemoteAccessRequest

): string | undefined +} + +/** details of `remote-access/denied` */ +export interface RemoteAccessDeniedDetails { + readonly action: string + readonly reason: string +} + +class RemoteAccessService { // ctx.remoteAccess; Remote namespace remoteAccess + registerPolicy>(kind: K, policy: RemoteAccessPolicy): () => void + invalidate(principal?: Principal): void + decide(request: RemoteAccessRequest): RemoteAccessDecision // Gateway-only + allows(resource: RemoteAccessResource): boolean // reads the accessing ctx's invocation + snapshotFor(principal: Principal): RemoteAccessBaseline // a page's opening baseline + follow(signal): AsyncIterable // remoteAccess/follow +} +``` + +| Rule | Content | +|---|---| +| Owner | The Gateway does not resolve resources or call `decide()` for the owner, and reads no policy | +| Registration | `registerPolicy(kind, policy)` returns a disposer, and a business package calls it inside `ctx.effect()`. Each non-owner principal kind has at most one policy, and a second registration for the same kind throws; `bindings()` and `explain()` receive principals typed by the kind | +| Built-in allow | `remoteAccess/follow` is allowed for every principal, so every Peer can read its own snapshot | +| Decision | A principal kind without a policy is refused with `no-allow`. Otherwise `bindings(principal)` is read, and a linear scan finds for each requested resource one binding whose resource is equal (the same `kind` and, for a non-`global` resource, the same `id`), whose role's `actions` contains the action, and that has no condition or whose condition allows; the request is allowed only when every resource has one (AND). A condition runs only for a binding whose resource is equal and whose role contains the action | +| Refusal reason | The first resource not allowed decides the reason, in this precedence: when matching bindings exist on that resource but all their conditions refuse, the first condition's reason; otherwise the return value of `explain(request)`; without `explain`, or when it returns `undefined`, `no-allow`. `explain` runs only after the decision is already a refusal and never changes it | +| Failure isolation | When `bindings()`, a condition, or `explain()` throws, the decision is refused with `policy-failed`; in a snapshot computation, a throwing `bindings()` leaves that principal no grant and a throwing condition excludes that action. A faulty rule therefore never breaks the shared `$events` source, a snapshot stream, or page rendering; after each `invalidate()` or policy change, the first failure of each policy is logged once | +| Synchronous | `bindings()`, conditions, and `explain()` return synchronously from state the business package preloads in memory, so list filtering can decide per item and snapshots can be computed | +| Evaluation timing | Each `decide()`, each `allows()`, and each snapshot computation calls `bindings()` once, with no caching; time-dependent state (such as expiry) applies immediately, and a long stream (such as `workspace/follow` or `session/control`) reflects added or removed bindings at its next per-item decision; snapshots are recomputed only after `invalidate()`, so a business package also calls `invalidate()` when a time event such as expiry happens | +| Notification | When a business state change (grant, revocation, mode switch) alters a binding or a condition's answer, the business package must call `invalidate(principal?)`; snapshots are recomputed only after `invalidate()` and policy registration or removal | +| `remote-access/denied` | Only a call refusal becomes an error, with details `{ action, reason }`, where reason is the reason a condition or `explain` gave, `no-allow`, `policy-failed`, or the `resource-invalid` the Gateway gives for invalid resources; a refused event is simply not pushed | +| Required service | The Gateway declares `static inject = ['typert', 'remoteAccess']`; a composition that mounts the Gateway mounts remote-access, which [`bundle/base`](../../../../packages/bundle/base/cordis.patch.yml) assembles right after `typert-gateway`; the plugin manager protects it like the Gateway, so it cannot be disabled from the Plugins page | + +#### Example: share viewer + +The `share-viewer` principal that liveshare declares shows that the derivation from business state to bindings stays in the business package; `shares` in the example stands for the share state the business package holds in memory: + +```text +const globalReader = { name: 'share:global-reader', resource: 'global', actions: ['session/list', 'session/control', 'workspace/follow', 'session/modelCatalog', 'llm/listProviders', 'commands/list'] } +const sessionReader = { name: 'share:session-reader', resource: 'session', actions: ['session/page', 'session/follow', 'session/list', 'session/control', 'workspace/follow', 'commands/list', 'event:api-session/status', 'event:api-session/added'] } +const workspaceReader = { name: 'share:workspace-reader', resource: 'workspace', actions: ['workspace/follow'] } +const forker = { name: 'share:forker', resource: 'session', actions: ['session/fork'] } +const writer = { name: 'share:writer', resource: 'session', actions: ['session/prompt', 'session/cancel'] } + +ctx.effect(() => ctx.remoteAccess.registerPolicy('share-viewer', { + bindings(viewer) { + const share = shares.serving(viewer) // undefined once stopped, expired, or no longer admitted + if (share === undefined) return [] + const source = { kind: 'session', id: share.sessionId } + return [ + { role: globalReader, resource: { kind: 'global' } }, + { role: sessionReader, resource: source }, + ...(share.allowsFork ? [{ role: forker, resource: source }] : []), + ...(share.mode === 'write' ? [{ role: writer, resource: source, condition: sourceWrite(share) }] : []), + ...shares.forksOf(viewer).flatMap(child => [ + { role: sessionReader, resource: { kind: 'session', id: child.id } }, + ...(child.revoked ? [] : [{ role: writer, resource: { kind: 'session', id: child.id }, condition: childPrompt(child) }]), + ]), + ...shares.workspacesShowing(viewer).map(id => ({ role: workspaceReader, resource: { kind: 'workspace', id } })), + ] + }, + explain({ principal, action }) { + return shares.refusal(principal) // 'share-expired', 'share-stopped', … + ?? (action === 'session/prompt' ? 'share-not-writable' : undefined) + }, +})) + +// Without args (snapshots, list filtering) a condition answers for the action alone. +const sourceWrite = share => () => share.confined ? allow : deny('source-write-unavailable') +const childPrompt = child => ({ action, args }) => + action === 'session/prompt' && args !== undefined && !child.allowsPrompt() ? deny('fork-prompt-refused') : allow +``` + +| Owned by | Content | +|---|---| +| Business package | The action lists of roles; the derivation from share state to bindings (stop, expiry, admission, mode, the viewer's own forks, revocation, Workspaces containing visible Sessions); per-request conditions; refusal reasons for the viewer page | +| Generic layer | Resource resolution, AND across resources, role and action matching, condition calls, reason precedence, list filtering, snapshot computation, and failure isolation | + +That viewer's snapshot contains grants on `global`, the source Session, each of its own forks, and each visible Workspace; `session/list` appears in both a `global` role and a session role, see [List and aggregate methods](#list-and-aggregate-methods). + +#### Host access-check order + +Every call path (HTTP `/api`, WebSocket streams, in-process `fetch(request, peer)` / `invoke` / `stream` / `wireStream.open`) passes through one Gateway sequence: + +| Step | Action | Failure | +|---|---|---| +| 1 | Admission yields the Peer: network paths from Connection `admit()` (`browserPeer`), in-process paths from the caller. The Gateway checks `peer.signal` before it reads the wire payload | Failed network admission answers HTTP 401 / 403 without entering the Gateway; a disposed Peer: `gateway/peer-disposed` | +| 2 | Resolve the endpoint and validate the argument field set (`resolveDescriptor`, `assertExactArguments`), then validate the JSON arguments against their Typert-compiled types, decoding each once | `gateway/invocation-unavailable`, `gateway/ambiguous-endpoint`, `gateway/definition-unavailable`; extra or missing fields: `gateway/arguments-invalid`; a JSON argument failing validation: `gateway/input-invalid` (for owners and non-owners alike, before the access check) | +| 3 | Owner pass-through: for `principal.kind === 'owner'` go straight to step 5 | — | +| 4 | Non-owner: the Gateway resolves resources from the arguments validated in step 2 (`resources` readers, then lookup parameters and the `resource` a Context declares, then default fields; lookup and Context identities are wire identities, and neither a lookup nor the Context is resolved), then asks `remoteAccess.decide({ principal, action: endpoint, resources, args })` with the validated arguments as `args`; invalid resources are refused with `resource-invalid` without asking `decide()`; a `deny` decision becomes the thrown error | `remote-access/denied` with details `{ action, reason }` | +| 5 | Dispatch: `resolveReceiverContext` (which can activate an Agent), lookup resolution (JSON arguments reuse the step 2 values), and the method call | Existing codes unchanged, such as `gateway/lookup-not-found`, `gateway/context-not-found`, `gateway/input-invalid`, `gateway/service-unavailable`, `gateway/method-unavailable`, and business errors | + +The access check (steps 3–4) precedes lookup and Context resolution, so a refused call activates no Session and causes no other side effect. The only new Gateway error code is `gateway/peer-disposed`; remote-access declares the refusal code `remote-access/denied`. + +Gateway-owned endpoints: a non-owner may subscribe to `$events` but never receives waterfall frames it could answer; `$events/result` from a non-owner is refused with the existing `gateway/invocation-unavailable`; both run the step-1 check. + +#### Forwarded events + +An `emit` entry of `API_REMOTE_FORWARDED_EVENTS` may declare `resources`: readers keyed by resource kind and typed by that event's own parameters through `TypertForwardableEventEntry`, for example `{ event: 'api-session/added', mode: 'emit', resources: { session: (summary) => summary.sessionId } }`. API Remotes attaches the readers to each forwarded frame; for every non-owner `$events` stream the Gateway resolves the resources once per frame and pushes the frame only when `remoteAccess.decide({ principal, action: 'event:', resources })` allows it. + +| Rule | Content | +|---|---| +| Declared events | `api-session/activity`, `api-session/added`, `api-session/error`, `api-session/removed`, and `api-session/status` declare their Session | +| Undeclared resources | An event without `resources`, or with an empty `resources: {}`, touches `global` and reaches a non-owner only when a `global` role lists `event:` | +| Missing resource | An event carries every resource it declares, so a reader returning `undefined` makes the event's resources invalid, and the Gateway delivers it to no non-owner without asking `decide()`; a call missing a resource touches `global` instead | +| Whole or nothing | An allowed event arrives with its payload unchanged; no payload is trimmed or projected per Peer | +| One resource per event | A notification carries at most one resource; collection changes travel on follow streams (`session/follow`, `session/control`, `workspace/follow`), whose methods decide each item inline through `allows()` | +| Waterfalls | Non-owners never receive waterfall frames, and no policy decides them | +| Non-owner stream facts | A non-owner `$events` stream opens with the same `host` as an owner stream, including `home`; `hasLiveClient()` counts only owner streams, the ones that can answer waterfalls | + + +#### List and aggregate methods + +Calls of list, paging, and aggregate methods resolve to `global` and are allowed by a `global` role; inside, the method calls `this.ctx.remoteAccess.allows(resource)` for every item to decide whether the current call's action is granted on that resource (without args). A list action therefore appears both in a `global` role (allowing the call) and in resource roles (allowing each item): `session/list` must be granted on `global` to be called, and each Session row also requires `session/list` granted on that Session. `allows()` reads the invocation on the Context that accessed the service; it is always true for the owner and for in-process code outside a Remote call, which are not filtered. + +| Method | Filtered items | +|---|---| +| `session/list` | Session rows (`session` resource) | +| `session/search` | search hits (`session` resource) | +| `session/control` | baseline projections and projection increments (`session` resource) | +| `workspace/follow` | Workspaces in the baseline, `upsert` frames, and `order` ids (`workspace` resource), the Sessions inside Workspaces, and the archived / pinned Session sets (`session` resource). The stream tracks the Workspaces it delivered; when one is no longer allowed at its next change or in a new order, it sends a `replace` increment with the complete filtered state instead of a `remove`, because the Client never resurrects a removed id, and the Workspace reappears through an ordinary `upsert` once access returns. A `remove` frame reports a deletion and reaches only a caller that received that Workspace | + +#### Access snapshot stream + +```text +// Remote: remoteAccess/follow (stream; every Peer may read its own snapshot) +type RemoteAccessFrame = + | { readonly type: 'baseline'; readonly owner: true } + | { readonly type: 'baseline'; readonly owner: false; readonly grants: readonly RemoteAccessGrant[] } + | { readonly type: 'upsert'; readonly grant: RemoteAccessGrant } + | { readonly type: 'remove'; readonly resource: RemoteAccessResource } + +interface RemoteAccessGrant { + readonly resource: RemoteAccessResource // global, or one declared-kind resource + readonly actions: readonly string[] // endpoint actions allowed on this resource without args, sorted +} + +/** Window global carrying a page's opening baseline. */ +export const REMOTE_ACCESS_SNAPSHOT_KEY = '__DSH_REMOTE_ACCESS__' +``` + +| Behavior | Detail | +|---|---| +| First snapshot | `snapshotFor(principal)` computes a page's opening baseline. remote-access adds the global `REMOTE_ACCESS_SNAPSHOT_KEY`, set to the owner baseline `{ type: 'baseline', owner: true }`, to every index the webServer renders, through `webserver/index-inject`. A business package that renders a non-owner page (such as liveshare on the owner Host) places an assignment of that global to `snapshotFor(principal)` after every index injection row; the later assignment wins, and the Client reads the global when it mounts | +| Subscription | Each Peer subscribes to one access snapshot stream, whose baseline replaces the injected one | +| Computation | All bindings of the policy for the principal's kind are merged by equal resource; each resource's `actions` are the endpoint actions of those bindings' roles that the same decision as a call allows (one resource, without args, conditions called without args); only a resource with at least one allowed endpoint action gets a grant. Decisions and snapshots share one source: for the same principal, action, and resource (without arguments), the snapshot answer equals `decide()` | +| Endpoint actions only | `event:` actions do not enter the snapshot; the frontend decides entry visibility from endpoint actions only | +| Increments | `invalidate()` or a policy registration or removal recomputes and pushes `upsert` / `remove`, and a resource whose allowed set becomes empty pushes `remove`; unchanged resources push nothing | +| Lifetime | A generation ends when its signal aborts or when the remote-access service unloads, so a reloaded service answers every reader from a fresh baseline; the Client treats the end as a carrier loss and reconnects | +| Owner | Receives only `{ owner: true }`, so every frontend answer is true | +| Wire type | `actions` is `readonly string[]`: Typert cannot generate a codec for template-literal types | + +Access hooks judge only by action and resource and never look at call arguments; at call time a condition may also read args for a finer decision, and the Host result prevails. + +#### Client face + +The Client uses framework standard-hook injection and no React context. Components receive hooks only through props and import no remote-access value. + +```text +// @deepseek-ai/dsh-api-remote-access/client +interface ClientRemoteAccess { // Client service ctx.remoteAccess + source(kind: TypertResourceKind, id: string, action: string): HostObservable +} +GlobalStandardProps.useRemoteAccess: (action: RemoteEndpoint) => boolean // remote-access; the global resource + +// @deepseek-ai/dsh-client-ui-session (injects remoteAccess) +GlobalStandardProps.useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean +SessionStandardProps.useAccess: (action: RemoteEndpoint) => boolean +SessionMaybeStandardProps.useAccess: (action: RemoteEndpoint) => boolean | undefined + +// @deepseek-ai/dsh-client-ui-workspace (injects remoteAccess) +GlobalStandardProps.useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean + +// RemoteEndpoint = Extract +``` + +| Concern | Decision | +|---|---| +| Snapshot and mount | The remote-access Client plugin starts from the baseline the index injected (read synchronously at startup and validated), follows the snapshot stream, applies each baseline in one pass, provides the Client service `ctx.remoteAccess.source(kind, id, action)` (an identity-stable boolean source), and injects `useRemoteAccess` on the `global` resource. The entry module imports the generated `/remote` contribution and hands it with the injected baseline to `mountAccess(ctx, contribution, initial)`, which mounts it through `ctx.remote.$mount` (unit specs pass a fake contribution), following experimental client-ui-cot-translation, voice-input, and claude-code-mods; a central mount in api/remotes would form the project-reference cycle ui-session → api/remotes → remote-access → ui-session | +| Session resource | ui-session gives the bound Session's answer through `uiSession.provide({ keyedHooks: ['access'], resolve })`; the framework binds the Session as it does for `useProjection`. `useSessionAccess` is injected through `provideRoot` | +| Workspace resource | ui-workspace injects `useWorkspaceAccess` through `provideRoot` | +| Distinct names | The `global` answer is `useRemoteAccess` and the Session answer is `useAccess`, so neither shadows the other; in a session-maybe scope without a Session, `useAccess` is `undefined`, which callers treat as not allowed, and a `global` decision reads `useRemoteAccess` | +| Two-argument calls | Framework keyed hooks take a standard second argument: `KeyedStandardSource` is `(key, member?) => source`; a string second argument makes the hook read the resolver's `(key, member)` source without a selector, and the resolver returns the same source for the same arguments. Existing `(key, selector?, equal?)` and `(selector, equal?)` calls are unchanged. Curried forms are forbidden | +| Return value | Every answer on a resource is a boolean; action types come from the `ctx.remote` namespaces and methods | +| No fallback | Asking a Session resource about a non-session method (such as `useAccess('session/create')`) reads the decision on that Session resource and never falls back to the `global` answer | +| No snapshot | True for the owner, also after its stream fails terminally; a non-owner page without an injected baseline, or whose stream fails terminally, answers false, and the terminal failure is reported with its error and the advice to reload; a reconnect keeps the last snapshot; a malformed injected baseline fails the mount; the Host still checks every call. Hooks never issue requests | +| Example consumers | The ui-workspace Session row menu's fork entry reads `useSessionAccess(sessionId, 'session/fork')` and is not rendered when it is false; the ui-chat turn tail's branch button reads `useAccess('session/fork')` on the Session resource and is not rendered when it is false. Replacing #3854's `clientWritePolicy` stays with #3854 | +| Tests | `@deepseek-ai/dsh-client-test-runtime` exports `allowAllAccess` props and `allowAllAccessPlugin`; TestClient drops the remote-access row as it drops api-remotes and installs the allow-all service, and SlotTestRuntime installs it before ui-session | + +### Security invariants + +| Invariant | Basis and enforcement point | +|---|---| +| Only in-process Host code supplies a principal, when it creates a Peer | `PeerScope.principal` is read-only; `createPeer()` has no route | +| No wire field can set or override a principal | Principals never come from args, the envelope, headers, the query, or cookies. Extra top-level args fields fail `assertExactArguments`; the payload must be exactly `{ args }`; [`clientRequestSchema`](../../../../packages/client/connection/src/rpc-schema.ts) drops unknown envelope fields; a same-named field inside a request body is business data | +| Browser network admission yields the owner | Connections that pass [browser token authentication](../../implemented/architecture/2026-08-24-browser-token-authentication.md) receive `browserPeer` | +| No implicit Peer | Neither the Gateway nor Connection supplies a default Peer; required parameters expose a missing Peer at compile time | +| A disposed Peer cannot dispatch | `gateway/peer-disposed` | +| A non-owner cannot call without authorization | A call is allowed only when a binding's role explicitly lists the action and its resource matches; a kind without a policy is refused; the Gateway depends on `remoteAccess` and does not start without it | +| A `global` grant implies no resource grant | Resources do not inherit; `allows()` inside a method decides each item independently | +| An unknown principal kind is not the owner | Only `kind === 'owner'` is the owner | +| Non-owners answer no waterfall | `$events` gives non-owners no waterfall frame, and `$events/result` refuses them | +| A non-owner receives only authorized notifications | Each forwarded frame is decided by `event:` and its resources, and reaches a non-owner only when allowed | + +### Recording the message source + +The `MessageSourceMap` entry in the [session-controller types](../../../../packages/api/session-controller/src/types.ts) gains an optional field: + +```text +'user-rpc': { + kind: 'user' + rpcId: SessionRequestId + clientTimeZone?: string + /** Non-owner caller that submitted this prompt; absent means the owner. */ + principal?: Principal +} +``` + +Two producers build a `kind: 'user'` + `rpcId` user-message source inside a Remote call. Both call `principalOf(this.ctx.invocation)` in the Remote method and write `principal` only when it is defined: + +| Producer | Location | +|---|---| +| `SessionController.prompt()` builds the source and `SessionCommandController.prompt(request, source)` only delivers it | [`session-controller/src/index.ts`](../../../../packages/api/session-controller/src/index.ts), [`commands.ts`](../../../../packages/api/session-controller/src/commands.ts) | +| subagent `prompt()` (`BrowserPromptSource`); no role lists it, so a non-owner reaches it only through in-process Host code, not through the Gateway | [`subagent/src/index.ts`](../../../../packages/subagent/subagent/src/index.ts) | + +Business code does not pass a principal down as a function parameter: the identity is read once from the invocation in the Remote method, and the built source travels down. The Agent inbox has no interception point that could add the field on enqueue (`followup` / `steer` accept built messages, and in-process producers such as the SDK and scheduling run outside Remote calls), so each producer records it, and the code-review rule covers new producers. + +| Fact | Detail | +|---|---| +| `kind` is unchanged | Still `'user'`; existing `kind === 'user'` checks are unaffected | +| Owner messages are unchanged | An owner call writes no field, so its source is byte-identical to the source before principals existed; a message without the field (including every older Session) is the owner's | +| Not part of model requests | LLM adapters read only the model source ([`llm-deepseek/src/replay.ts`](../../../../packages/llm/llm-deepseek/src/replay.ts)) | +| Session format | `SESSION_FORMAT_VERSION` is not bumped; per the [persistence type-change cookbook](../../../../docs/cookbook/reviewing-persistence-type-changes.md) the change records an acknowledgement | + +Example consumer use, outside this Note's scope: tool guards and pre-step rechecks read `source.principal` of the user messages in a turn and judge by the non-owner caller when any message carries one; a turn mixing owner and viewer messages takes the strictest. + +### Code-review rule and documentation + +The review rule's home is the "`@Remote` marks capability methods" section of [Remote and API rules](../../../../docs/defensive-patterns/remote-and-api.md): `@Remote({ resources })` and automatic resolution must cover every resource a method touches; aggregate results filter every item through `allows()`; the caller identity is read only from `invocation.peer.principal`, never from arguments; every call path names its Peer explicitly; business code does not pass a principal as a function parameter. + +| Document | Content | +|---|---| +| [`docs/subsystems/typert.md`](../../../../docs/subsystems/typert.md), [`docs/api-gateway.md`](../../../../docs/api-gateway.md) | `PeerScope`, `RemoteInvocation`, `InvokeRemoteRequest`, explicit Peers, `resources` on `@Remote`, and the access-check order | +| READMEs of connection, gateway, typert-protocol, typert-registry, api-remotes, remote-access, session-controller, workspace-controller, ui-session, ui-workspace, ui-slots, and client-test-runtime | Peer / `browserPeer` in place of the operator; the remote-access role, binding, and policy contract; access hooks | + +### Out of scope and future work + +- The `/connect` handshake, API cookies, one Peer per API connection, and WebSocket lifetimes belong to pr-5945. +- Multiple browser Peers (such as one per tab): every browser connection shares `browserPeer`. +- Interaction participation (interactions) and approval isolation by principal come with later work; non-owners currently receive only authorized `$events` broadcast frames. +- `agent-preset/selected` and the other forwarded events declare no resources and are decided on `global`; when its viewers need a Session event, #3854 declares that event's resources and lists `event:` in a role. +- Liveshare integration (declaring `share-viewer`, one Peer per viewer connection, defining roles and registering a policy, removing `origin` / `prompt/origin` / `READ_ENDPOINTS`) happens on the #3854 side. +- `session/cancel` ownership checks, the fork `childSessionId`, and `ToolExecutionInput.turn` do not change. +- Precise resource recognition (for example, the Typert generator locating request fields by branded id type and writing them into descriptors): this change uses the `resource` that lookups and Contexts declare, default fields, and `resources` readers, and resource resolution can later be replaced without changing the policy interface. +- Answering 403 to every non-owner on exact Fetch routes is temporary; each route is to declare its own access rule later. +- The resource declaration of subagent `prompt` is open: its request fields are `parentSessionId` / `childSessionId`, so it currently resolves to `global`. The producer records `source.principal` for in-process callers; no role lists it, so non-owners cannot reach it through the Gateway. +- Predefined and aggregate roles: when a second non-owner principal kind appears, domain packages declare the generic roles a method belongs to (such as `session:viewer`) with `@Remote({ roles })`, and each policy binds those roles; roles are currently defined by the business package as explicit action lists. + +### Relation to existing Notes + +- [Remote duplex stream](../../implemented/architecture/2026-09-19-remote-duplex-stream.md): partially superseded. The replaced parts are "the Peer records no identity or permission", "this Host has exactly one Peer, the operator", "an in-process carrier without a Peer means the operator", and its alternative stating that "the Access mechanism is business semantics"; the rest (the duplex stream, uplink, and `ctx.invocation` binding) stands. Both Notes stay active and cross-linked, and that Note's current facts are updated. +- [Browser token authentication](../../implemented/architecture/2026-08-24-browser-token-authentication.md): unaffected. Its rejection of per-method privilege concerns owner authentication; an authenticated browser is still the owner and is not checked per method. +- [Typert Remote method calls](../../implemented/architecture/2026-08-02-typert-remote-method-calls.md): unaffected; `@Remote` and `@RemoteScope` gain optional `resources` readers (the second argument of `@RemoteScope` may be `{ name?, resources? }`), read only by the Gateway's access check, and change neither endpoint naming, call modes, nor generated artifacts. +- [Producer-owned message sources](../../implemented/architecture/2026-09-09-producer-owned-message-sources.md): unaffected; the producer that builds a source writes `principal`. + +## Alternatives considered + +**A separate `prompt/origin` event correlated by requestId (the #3854 approach).** Every prompt writes a required event, so even ordinary Sessions become unreadable by older versions; correlation serializes every prompt and maintains an origin fold, and messages outside the fold window lose their initiator. + +**Principal and peer as parallel invocation fields.** One caller's identity would have two sources; every admission point would have to produce and pass both, and a path that passes only one would mismatch them. + +**Keep "the Peer records no identity".** When business plugins attach identity through `peer.ctx`, every reader must know who attached it and how to read it, and the wire admission path cannot guarantee the attachment happened. + +**Keep a default-Peer fallback (`request.peer ?? …`).** An implicit default silently grants owner identity to a call that forgot its Peer; a required parameter turns the omission into a compile error. + +**A new source kind.** Every existing `kind === 'user'` check would need review, and one miss makes viewer messages disappear from titles, outlines, queues, or chat rendering. + +**Identity in a request-body field.** Any caller able to build a request body could forge or override it. + +**React context for frontend resources.** Business components read no React context; the framework binds standard hooks per resource, and components receive them only through props. + +**Driver-maintained endpoint allowlists that trim results (the #3854 approach).** The allowlist and the result trimming both live in the driver, the Gateway does not enforce them, and the snapshot and the frontend each derive their own; the generic layer enforces role action lists, and `allows()` inside the method performs list filtering. + +**Business packages implement `decide()` / `abstain` providers whose answers are merged.** Each business package hand-writes action × resource branches plus a separate resource list for snapshots, and the two stay consistent only by hand; a policy gives only bindings, decisions and snapshots are computed from the same bindings, and each principal kind has one policy, so no merge rule is needed. + +**A separate `@RemoteAccess` marker.** Role action lists already guarantee default deny, so a marker is only a second consent; a method that resolves no resource falls to `global`, and only an explicit global grant allows it. Resource readers describe the method, so they live on `@Remote`. + +**Calling resource-less calls `page` or `host`.** `page` describes a frontend location, not a resource; `host` reads easily as the parent of every resource on this Host. `global` means a grant tied to no resource. + +**Resource inheritance (GCP style: a grant on `global` or on a Workspace covers lower resources).** List methods allowed by a `global` grant must decide each item, and inheritance would make any `global` grant imply every Session; without inheritance, a binding grants each resource explicitly. + +**A role registry and predefined aggregate roles.** The only non-owner principal today is managed by one business package, so roles can be plain values of that package; aggregate roles would let methods a new package declares enter a share viewer's reach automatically, so they wait for a second non-owner principal kind. + +**Conditions attached to a role's actions.** A role stays reusable plain data; a refinement by business state belongs to the binding that grants the role to a principal. + +**Refusal reasons from conditions only.** Without a binding there is no condition to give a reason, so a viewer page could not tell "share expired" from "share not writable"; `explain` gives a reason only after the decision is already a refusal and never changes it. + +**A global resource-resolver registry (domain packages register recognizers by field name).** Field recognition separates from the method, nothing flags a method signature change, and same-named fields in other packages are misread; resource resolution uses a default convention on the method plus per-method `resources`, and a domain package registers only its own kind's default field. + +**Dot-path resource declarations (`'request.address.sessionId'`).** Types cannot constrain a path; the parameter types of `resources` readers are inferred from the decorated method, so a typo fails compilation. + +**Generator recognition of resource fields by branded id type.** Requires changing Typert artifacts and the descriptor format, and SRC descriptors carry no types; kept as future work. + +**A central mount of the remote-access generated contribution in api/remotes.** The remote-access Client depends on ui-session and ui-session depends on api/remotes, so a central mount forms a project-reference cycle; remote-access mounts its own contribution instead. + +**Decisions inside the Gateway (a `check()` with rules written in the Gateway).** The Gateway would carry access-control semantics and extra error codes; it resolves resources as it resolves lookups and calls `remoteAccess.decide()` before dispatch, and the rules and refusal code belong to remote-access. + +**Asynchronous policy decisions.** List filtering and snapshot computation need synchronous answers; business packages preload asynchronous state into memory. + +**On-demand permission queries from the frontend.** Request volume grows with the number of entries and cannot follow rule changes; the Host pushes a snapshot stream and the frontend reads it synchronously. + +**An access check after lookup resolution.** A lookup can activate an Agent, so an unauthorized call would cause side effects first. + +**Resource fields registered in remote-access (`registerScope`).** The access package would recognize argument fields although lookups already know which wire field names a Session; typert holds resource kinds beside lookups and Contexts, so the Gateway resolves calls and events in one place and remote-access reads no argument field. + +**Trimming forwarded event payloads per Peer.** A per-event projection would repeat list filtering inside every notification; an event carries at most one resource and is delivered whole or not at all, while collection changes travel on follow streams that filter inline. + +**Answering true until the first snapshot arrives.** A non-owner page would briefly show entries it cannot use; the index carries the first snapshot, so a page without one has no access. + +## Consequences + +### Verification + +| Case | Behavior | Test location | +|---|---|---| +| The wire tries to set a principal (top-level args, a payload sibling, the envelope, inside the request body) | The first two fail argument or payload validation; the rest are dropped or treated as business data; `invocation.peer` remains the admitted Peer | Gateway host spec | +| Every call path names its Peer | `InvokeRemoteRequest.peer`, the `wireStream.open` peer, `fetch(request, peer)`, and the `bridge` peer are required; the Gateway has no fallback; the `/api` route passes `admission.peer` | typecheck, Connection host spec | +| Browser connections and `createPeer()` | `browserPeer` has the owner principal; each `createPeer()` returns an independent Peer released with the Context that read Connection; exact routes answer 403 to non-owners | Connection host spec | +| Disposed Peers | Later unary, stream, and `$events` dispatches fail with `gateway/peer-disposed`, also when the wire payload is malformed; its streams end; a running unary call completes; other Peers are unaffected | Gateway host spec | +| Check order | The owner calls any method normally without reading a policy; a method no role lists refuses non-owners with `remote-access/denied` (`no-allow`); the access check precedes lookups, so a refused call activates no Session | Gateway host spec | +| Policy registration | One policy per non-owner principal kind, and a second registration throws; once the disposer withdraws it, that kind is refused with `no-allow`; it is withdrawn with its fiber | remote-access host spec | +| Decisions | Every resource needs a matching binding (AND); a role without the action, an unequal resource, or a refusing condition does not allow; the refusal reason follows condition > `explain` > `no-allow`; `explain` never changes the decision; conditions receive args for calls but not for snapshots and list filtering; a throwing `bindings()`, condition, or `explain()` refuses with `policy-failed`, each policy is logged once between `invalidate()` calls, and on the event path the owner stream and the shared event source continue; `remoteAccess/follow` is allowed for every principal | remote-access host spec, Gateway host spec | +| Resource resolution | A default field (top-level or first object argument) yields its resource; a lookup parameter that declares that kind as its `resource` yields its wire identity; a Context method gets its Session resource from the Agent Context identity in args without activating the Agent, and may declare other resources through `resources`; a `resources` reader overrides its kind, an array yields several resources, and `undefined` none; with nothing the call is `[global]`; a throw, an empty array, or a value that is not a non-empty string (including a Context identity) is invalid, refused by the Gateway with `resource-invalid` without asking `decide()`; a Context that declares no `resource` resolves by the ordinary rules; resources and `args` read the validated arguments, so an undeclared nested field changes no resource and no condition sees it, and a JSON argument failing validation returns `gateway/input-invalid` before the access check | Gateway host spec (gateway-access) | +| Resource registry | `typert.resources` registers one default field per kind, rejects a second registration and `global`, and withdraws the registration with its fiber; a configured lookup resolver keeps the provider's `resource`; a Host Context adapter keeps its `resource` | typert-registry spec | +| Forwarded events | A non-owner `$events` stream receives an event, whole, only when `event:` is granted on its resources; an event that declares no resources is decided on `global`; an event whose reader returns `undefined` does not reach non-owners, and `decide()` is not asked; the owner receives all | Gateway host spec (gateway-access) | +| First snapshot | `snapshotFor()` returns the owner baseline or the principal's grants; remote-access injects the owner baseline into every rendered index; the Client starts from the injected baseline and rejects a malformed one | remote-access host and client specs | +| Type inference | The parameter types of `resources` readers are inferred from the method; lookup positions have wire types; a missing property, more parameters than the method has, and a `global` key fail compilation; a binding whose resource kind differs from its role's fails compilation | typert-protocol spec, remote-access spec (type tests) | +| List filtering | `session/list`, `session/search`, `session/control`, and `workspace/follow` return non-owners only items for which `allows()` is true; `workspace/follow` sends a `replace` increment when a delivered Workspace loses visibility, and the Workspace reappears once access returns; owner results are unchanged | session-controller / workspace-controller host spec | +| Access snapshots | The allowed set in a snapshot equals the Host decision for the same principal, action, and resource without arguments; it contains endpoint actions only; a resource with no allowed action gets no grant, and one whose allowed set becomes empty is removed; `invalidate()` and policy registration or removal push increments; a wait that a change ends leaves no abort listener behind; unloading the service ends every open snapshot stream; the owner receives only `{ owner: true }` | remote-access host spec | +| Access hooks | `source()`, `useRemoteAccess`, `useAccess`, `useSessionAccess`, and `useWorkspaceAccess` follow the snapshot; `useAccess` on a Session resource reads that Session's answer; the owner answers true; a non-owner without a snapshot or after its snapshot stream fails answers false; hooks issue no requests | remote-access client spec, ui-session / ui-workspace client spec | +| Two-argument keyed hooks | A string second argument reads the `(key, member)` source; existing call forms are unchanged | ui-renderer client spec | +| A non-owner dispatches `session/prompt` | The recorded user message's `source.principal` equals that principal, and a cold restore reads the same value | session-controller host spec | +| An in-process non-owner invocation of subagent `prompt` | The child Session's recorded user message has `source.principal` equal to that principal | subagent host spec | +| Owner messages | The source is byte-identical to the source before this change; existing keyless recorded-session snapshots are unchanged | `pnpm run test:snapshot` needs no re-recording | +| Persistence record | `verify-persistence-changes` passes | doc-sync | + +The SDK projections are unaffected: the TypeScript SDK server builds its own `{ kind: 'user' }` messages ([`sdk/server/src/server.ts`](../../../../packages/sdk/server/src/server.ts)) outside the Gateway and does not project the source; the Python SDK does not read the user source. + +### Costs and limits + +- The change spans protocol, Connection, Gateway, two domain controllers, subagent, a new package, and the frontend, with breaking API renames; it necessarily conflicts with pr-5945 in Connection and the Gateway, handled by the migration list. +- The Gateway depends on `remoteAccess`: a custom composition that mounts the Gateway without remote-access leaves the Gateway waiting. +- Session logs store viewer identities and travel with exported or shared Sessions, so no principal kind may carry a credential. +- Every browser connection shares one Peer, so disposal cannot distinguish tabs until multiple browser Peers exist. +- The default field convention reads by field name: a method that gains a same-named field touches that resource, which review must watch; precise recognition is future work. +- A method that resolves only some resources lets a principal exceed its grant: unresolved resources go unchecked, and the method author ensures `resources` is complete. +- Role actions are strings and are not checked against loaded endpoints: a misspelled or renamed action never matches and shows up as a refusal. +- `api-session/added` carries the whole `SessionSummary`, so a non-owner allowed to receive it also learns the Session's `parentSessionId`. +- A granted Session's cwd and tool paths already contain the owner's absolute paths, so the Gateway does not hide `home` from non-owner `$events` streams separately. +- `workspace/follow` notices a Workspace whose visibility a policy revoked only at that Workspace's next change or the next order change, and then sends the non-owner a `replace` increment; an access change alone sends no frame. + +### Migration list for pr-5945 + +| File | Migration | +|---|---| +| `packages/client/connection/src/peer.ts` | Replaces `operator-peer.ts`; construct `OperatorPeer` as `new Peer(owner, { principal })` | +| `packages/client/connection/src/rpc-host.ts` | `this.operator` becomes `browserPeer` with principal `{ kind: 'owner' }` | +| `packages/client/connection/src/rpc.ts` | `HostConnectionHandle` merges `admitConnection` with `browserPeer` / `createPeer` | +| `packages/api/gateway/src/index.ts` | Replace `peer ?? this.operatorPeer()` where `$events` opens with an explicit Peer; reuse `gateway/peer-disposed` | +| The operator-connect Note, upgrade guide, and subsystem changes | Rewrite operator terms as Peer / `browserPeer`; an API connection's Peer has principal `{ kind: 'owner' }` | diff --git a/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md new file mode 100644 index 0000000000..30d47c9ccb --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md @@ -0,0 +1,672 @@ +# Agent Note: Peer 身份属性与 Remote 方法级访问控制 + +Status: implemented + +[English](2026-10-12-peer-principal-and-remote-access.md) | 中文 + +## 问题 + +Host 上的 Remote 方法此前无法得知本次调用代表谁,也无法按调用方限制可调用的方法。[`PeerScope`](../../../../packages/typert/protocol/src/types.ts) 的契约曾写明「Who the Peer is and what it may do are not recorded here」;[Connection](../../../../packages/client/connection/src/rpc-host.ts) 只有一个 operator Peer,`admit()` 对每个通过认证的请求都返回它,`/api` 路由与 interceptor 的 Fetch handler 固定使用它而不使用准入结果;[Gateway](../../../../packages/api/gateway/src/index.ts) 在调用方未给出 Peer 时隐式回退到 `operatorPeer()`。 + +首个非 owner 调用方是 liveshare 读者。读者请求经 Relay 到达 owner 的 liveshare Host 插件,插件此前在 owner 进程内以 operator 身份分派,owner 的 Session 因此把读者的 prompt 当作 owner 本人的输入。PR #3854 为此把 liveshare 语义塞进 session-controller:请求体携带 `origin`,每条 prompt 都写 required 的 `prompt/origin` 事件并按 requestId 关联,controller 内做来源 fold,`serializeAdmission` 扩到全部 prompt,新增 `api-session/external-admission` 钩子;liveshare 自己维护接口白名单 `READ_ENDPOINTS` 并裁剪 list/follow 结果;前端的 `clientWritePolicy` 定义在 ui-conversation,5 个包三种读法,7 处靠「是否注入某回调」隐藏界面。 + +需要一套通用机制: + +- 调用方身份由 Host 进程内代码赋予,wire 上任何字段都无法设置或覆盖。 +- 每条调用路径都显式给出 Peer,不存在隐式兜底。 +- 方法级权限在后端一处计算、一处把关;前端读同一份结果决定入口显隐,规则变化时自动同步。 +- prompt 投递时身份随用户消息落盘,供之后按 turn 判定发起者。 + +## 决策 + +### 范围 + +| 部分 | 内容 | +|---|---| +| Peer 身份 | `PeerScope.principal` 与 `PeerScope.signal`;唯一 Peer 实现 `Peer`(`OperatorPeer` 改名)与 options 构造;删除 operator 概念与所有隐式 Peer 兜底;`connection.browserPeer`、`connection.createPeer()` 与 `fetch(request, peer)`;已 dispose 的 Peer 一律以 `gateway/peer-disposed` 拒绝;`user-rpc` source 落盘 `principal?` | +| 方法级访问控制 | `@Remote` 的 `resources` 读取函数、`TypertResourceMap` 资源种类与 `typert.resources` 注册表(typert-protocol、typert-registry);Gateway 把调用与转发事件解析为资源;新包 `@deepseek-ai/dsh-api-remote-access`(Host 面 + Client 面):按 role、binding 与 policy 的 RBAC 判定 `decide(request)`、列表过滤 `allows()`、index 中的首份快照与访问快照流、access hooks | +| 文档 | 一条代码评审标准与术语 | + +### 两层概念:principal 与 peer + +| 层 | 概念 | 例子 | 数量关系 | +|---|---|---|---| +| 授权层 | principal:调用方身份,权限判定的依据 | 一份分享授权 token 证明的 `share-viewer{shareId, actor}`;本机用户 `{ kind: 'owner' }` | 一个 principal 对应 N 个 peer | +| 连接层 | peer:一条连接的生命周期作用域 | 浏览器连接、liveshare 读者的一条连接 | 每条连接一个 peer | + +| 事项 | 归属层 | +|---|---| +| 方法级权限判定、access hooks、访问快照 | principal | +| `source.principal` 落盘 | principal | +| 订阅、stream、连接期注册(`peer.ctx`) | peer | +| 撤销授权 | 两层:撤权后 policy 不为该 principal 给出 binding,其调用一律被拒;创建方 dispose 其名下全部 peer | + +principal→peers 的对应表由创建方持有,例如 liveshare 每份授权一个对象,记录该授权下建立的全部 peer。 + +### Peer 模型 + +只有 Peer 一个概念、一个实现,删除 operator 概念。 + +```text +// @deepseek-ai/dsh-typert-protocol +export interface PeerScope { + readonly id: PeerId + /** Connection-lifetime registrations; disposed with the Peer. */ + readonly ctx: Context + /** Caller identity fixed at creation by in-process Host code; no wire field can set it. */ + readonly principal: Principal + /** Aborted once the Peer is disposed; Remote streams that speak for the Peer end with it. */ + readonly signal: AbortSignal + dispose(): Promise +} + +// @deepseek-ai/dsh-client-connection +export class Peer implements PeerScope { + constructor(owner: Context, options: { readonly principal: Principal }) +} + +interface HostConnectionHandle { + /** The Peer every authenticated browser connection shares; principal { kind: 'owner' }. */ + readonly browserPeer: PeerScope + /** Network admission: the admitted Peer or the refusal status. */ + admit(request: ConnectionTrustRequest): PeerAdmission + /** In-process only; disposed with the Context that read `ctx.connection`, or earlier by dispose(). */ + createPeer(options: { readonly principal: Principal }): PeerScope +} + +interface ConnectionFetchHandler { + fetch(request: Request, peer: PeerScope): Promise +} +bridge(req, res, apiHandler, peer, maxRequestBodyBytes?) + +// @deepseek-ai/dsh-api-gateway +interface InvokeRemoteRequest { …; readonly peer: PeerScope } // was `peer?` +wireStream.open(endpoint, payload, uplink, peer: PeerScope, signal) // was `PeerScope | undefined` +``` + +| 规则 | 说明 | +|---|---| +| 每条路径显式给出 Peer | 网络路径由 `admit()` 的结果给出;进程内路径由调用方传入。Gateway 删除 `operatorPeer()` 与 `request.peer ?? …`,不再自建 Peer | +| 浏览器连接共享 `browserPeer` | 所有浏览器连接共享一个 Peer,每个使用处都显式引用 `connection.browserPeer`,不存在兜底变量;页内 Host(webworker-runtime)同样显式传 `browserPeer` | +| Host 进程内自动化 | 需要调用 Remote 方法时用 `createPeer({ principal })` 创建自己的 Peer。目前没有这类生产调用方,因此不预置固定的 Host Peer | +| 多 Peer | `createPeer()` 每次返回独立 Peer;liveshare 将用它为读者的每条连接建一个 Peer(在 #3854 侧接入) | +| exact Fetch route | file-upload、present-open、media-references、session-log-export、inspector 等 exact route 不经 Gateway;共享 Fetch handler 对非 owner Peer 一律返回 403 | + +### 身份类型 + +```text +// @deepseek-ai/dsh-typert-protocol + +/** + * Merge-extensible caller identities. Each driver declares its own entry. + * Every entry is a JSON value with a unique literal `kind`, carries no + * credential, and is safe to persist in a Session log. + */ +export interface PrincipalMap { + owner: { readonly kind: 'owner' } +} +export type Principal = PrincipalMap[keyof PrincipalMap] + +/** + * @param invocation - `ctx.invocation`; `undefined` outside a Remote call. + * @returns the caller's principal when it is not the owner; `undefined` for the + * owner and for trusted in-process code outside a Remote call. + */ +export function principalOf(invocation: RemoteInvocation | undefined): Principal | undefined +``` + +读取入口统一为 `this.ctx.invocation.peer.principal`;owner 判断就地写 `principal.kind === 'owner'`,未知 kind 永远不是 owner。外部 driver 在自己的包内用声明合并扩展 `PrincipalMap`;liveshare 的 `share-viewer{ shareId, actor }` 在 #3854 侧声明,此处不改 liveshare。 + +### Peer 生命周期与撤权 + +| 事件 | 行为 | +|---|---| +| Gateway 分派 | 第一步检查 `peer.signal.aborted`,已 dispose 即以 `gateway/peer-disposed` 拒绝。对 HTTP、WebSocket 与进程内路径都生效;wire 路径在读取 payload 之前检查,已 dispose 的 Peer 携带畸形 payload 也得到同一错误码 | +| Peer dispose 时的 stream | Remote stream 方法看到的 signal 并入 `peer.signal`,`$events` 流的生命周期同样并入;WebSocket 照旧以 1001 关闭 | +| Peer dispose 时的 unary | 已在执行的 unary 调用不被取消;之后的调用被拒绝 | +| 撤销授权 | 业务包撤权后 policy 不为该 principal 给出 binding,业务包调用 `invalidate(principal)`;创建方按 principal→peers 表 dispose 全部 peer | + +### 方法级访问控制 + +资源类型与 `@Remote` 的 `resources` 读取函数在 `@deepseek-ai/dsh-typert-protocol`;资源种类在 typert 中与 lookups、Contexts 并列:`TypertResourceMap` 声明种类,`typert.resources` 保存每个种类的默认 wire 字段,lookup 与 Host Context adapter 声明其 wire 身份所属的资源种类。Gateway 把一次调用或一个转发事件解析为它访问的资源。判定、列表过滤与访问快照在新包 `@deepseek-ai/dsh-api-remote-access`(`packages/api/remote-access`),该包不读取任何参数字段。Gateway 与领域包对 remote-access 只做 type-only 依赖并 inject `remoteAccess`,值依赖只有 `dsh-typert-protocol`,不为 remote-access 新增 `package-dependency-policy` 条目。role、binding 与 policy 由业务包定义和注册,liveshare 为首个使用方。除内置的 `global` 外,remote-access 与 Gateway 中不出现任何资源种类名,符合 [`packages/api` 只承载通用编排](../../../../docs/defensive-patterns/remote-and-api.zh.md) 的评审规则。 + +#### 与 RBAC 的对应 + +| 本设计 | RBAC / IAM 术语 | 含义 | +|---|---|---| +| principal | Subject / Principal | 调用方身份,`PrincipalMap` 的一个条目 | +| Peer | Session(RBAC 会话) | principal 的一条连接;一个 principal 对应 N 个 Peer | +| owner | 超级用户 | 跳过全部检查 | +| action | Action / Permission | Remote 端点 `ns/method`,或转发事件 `event:`;Gateway 内部仍称端点 | +| resource | Resource / Object | `{ kind: 'global' }`,或某个已声明资源种类的 `{ kind, id }` | +| role | Role | 限定一种资源种类的 action 集合,是业务包的普通值 | +| binding | RoleBinding(角色指派) | principal 在某个资源上拥有某个 role,可附 condition | +| condition | Condition(IAM Conditions) | 挂在 binding 上,按请求的 action、资源与参数放行或带原因拒绝 | +| policy | Allow policy | 业务包为一种非 owner principal kind 给出 binding 与可选 `explain` | +| explain | 拒绝说明 | 没有 binding 放行时给出拒绝原因,不改变结论 | +| Gateway | PEP(Policy Enforcement Point) | 解析资源、执行结论 | +| remote-access | PDP(Policy Decision Point) | 计算结论、列表过滤与访问快照 | + +| 规则 | 内容 | +|---|---| +| 默认拒绝 | 非 owner 只有在某个 binding 的 role 显式列出该 action 时才被放行;role 未列出的 action、没有 binding 的资源、没有 policy 的 principal kind 一律被拒。所有 Remote 方法都受访问控制,没有单独的可访问标记 | +| 资源之间无继承 | `global` 不是其他资源的上级:`global` 上的授权不蕴含任何会话或工作区上的授权,工作区上的授权也不蕴含其中会话的授权 | +| `global` 授权即显式全局授权 | 调用解析不出任何资源即访问 `global`。把某个 action 放进 `global` 级 role,等于允许该 principal 不受资源限制地调用它,属高危授权;方法被放行后仍可在内部用 `allows(resource)` 逐项细判 | + +#### 资源与资源读取(`@deepseek-ai/dsh-typert-protocol`、`@deepseek-ai/dsh-typert-registry`) + +```text +/** Merge-extensible resource kinds: the key is the kind, the value declares its identity type. */ +export interface TypertResourceMap {} +// @deepseek-ai/dsh-session/types: session: TypertResource +// @deepseek-ai/dsh-workspace/types: workspace: TypertResource + +// Runtime registries in typert +ctx.typert.resources.register(kind, { field }): TypertDisposer +ctx.typert.lookups.register(key, { parameter, wire, resource?, … }) // resource: the kind its wire id names +ctx.typert.contexts.registerHost(key, { wire, resource?, … }) // a TypertHostContextAdapter + +export interface TypertHostContextAdapter { + readonly wire: string + readonly wireTypeSymbol: string + /** Resource kind the wire identity names; access checks read the identity from args before resolve(). */ + readonly resource?: TypertResourceKind + resolve(id: Wire): Context | undefined | Promise +} + +export type RemoteAccessResourceKind = 'global' | TypertResourceKind +export type RemoteAccessResource = + | { readonly kind: 'global' } + | { readonly kind: K; readonly id: TypertResourceId } // for each declared kind K +export type RemoteAccessResourceOf = Extract + +/** Per-kind readers, called with the method's positional wire arguments. */ +export type TypertResourceReaders = { + readonly [K in TypertResourceKind]?: (...args: WireArgs) => Id | readonly Id[] | undefined // Id = TypertResourceId +} + +export interface RemoteMethodOptions { + readonly name?: string + readonly mode?: 'stream' + readonly resources?: TypertResourceReaders +} + +export function Remote( + option: string | RemoteMethodOptions>>, +): (method: (this: This, ...args: Args) => Result, + context: ClassMethodDecoratorContext Result>) => void + +export interface RemoteScopeOptions { + readonly name?: string + readonly resources?: TypertResourceReaders +} + +export function RemoteScope( + key: Extract, + option?: string | RemoteScopeOptions>>, +): (method: (this: This, ...args: Args) => Result, + context: ClassMethodDecoratorContext Result>) => void +``` + +| 规则 | 内容 | +|---|---| +| 写法 | `@Remote({ name: 'page', resources: { session: (request) => request.address.kind === 'session' ? request.address.sessionId : request.address.childSessionId } })`;Context 方法写 `@RemoteScope('agent')`,由 Agent Context 的 `resource: 'session'` 得到会话资源,需要其他资源时写 `@RemoteScope('agent', { resources: { workspace: (request) => request.workspaceId } })`。多数方法不写 `resources`,由自动解析得到资源 | +| 资源种类 | 资源种类由声明 id 类型的底层包合并:`session: TypertResource` 在 `@deepseek-ai/dsh-session/types`,`workspace: TypertResource` 在 `@deepseek-ai/dsh-workspace/types`。session-controller 引用 commands 包,若在 session-controller 合并,commands 要引用 session-controller,形成 project reference 环。`global` 由 remote-access 内置,没有 id,不进 `TypertResourceMap`;typert 保留该名,`typert.resources.register('global', …)` 抛错 | +| 默认字段与声明 | 拥有该种类的领域在控制器构造时登记默认 wire 字段:`SessionController` 构造函数调用 `typert.resources.register('session', { field: 'sessionId' })`,`WorkspaceController` 构造函数登记 `workspace` → `workspaceId`;同一种类重复登记即抛错。Agent、Session 与 `workspaceFileScope` lookup 声明 `resource: 'session'`,agent 包的 Agent Context 声明 `resource: 'session'` | +| 解析优先级 | 对每个资源种类依次取:① 该方法 `resources` 中该种类的读取函数;② 声明了该种类 `resource` 的每个 lookup 参数贡献其 wire 身份,省略的可选 lookup 参数不贡献;Context 方法的 Context 声明了该种类 `resource` 时,其 wire 身份同样贡献;③ 该种类的默认字段:命名参数中有同名顶层参数就读它,否则读第一个对象参数的同名字段。默认字段只表示一个身份,读到数组即为非法值 | +| 参数来源 | 资源解析与 `decide()` 的 `args` 读取经 Typert 编译类型校验后的命名参数:每个 JSON 参数由其 codec 解码一次,类型未声明的对象字段被移除,分派复用该值,因此调用方无法用未声明字段改变资源;lookup 参数与 Context 身份取 wire 身份,此时不解析 lookup、不激活 Agent | +| 解析结果 | 各种类的资源合在一起即本次调用访问的资源;一个都没有即 `[global]`;解析出资源时只用这些资源,不附加 `global` | +| 读取函数 | 以方法的位置参数调用:按描述符把命名参数映射回方法参数顺序(JSON 位为校验后的值,lookup 位为 wire 身份,取消参数位为调用的 signal);返回一个 id、id 数组(每个都须被允许)或 `undefined`(该种类无资源) | +| 非法值 | 读取函数抛错或返回空数组,或读到的值(含 Context 的 wire 身份)不是非空字符串即资源非法:Gateway 不询问 `decide()`,直接以 `remote-access/denied`(reason `resource-invalid`)拒绝调用 | +| 完整性 | `resources` 与自动解析必须覆盖方法访问的全部资源。一个资源都没解析出的调用落到 `global`,只有 `global` 级 role 能放行,因而默认被拒;只解析出部分资源时,其余资源不受检查,持有已解析资源授权的 principal 可越权访问它们,由方法作者负责避免 | +| 类型推断 | `@Remote` 与 `@RemoteScope` 读取函数的参数类型从被装饰方法自动推断:工厂参数用 `NoInfer`,`Args` 只从返回的装饰器推断;lookup 参数按 `TypertLookupMap` 映射为 wire 类型(首个参数为 `agent: Agent` 的方法,其读取函数的第一个参数为 `SessionId`)。访问不存在的属性、参数多于方法参数与 `global` 键均为编译错误 | +| 存放 | `resources` 读取函数与 `@Remote` / `@RemoteScope` 的其他元数据一同存在类原型上,随实例构造登记,跨重复安装安全;不进入 Typert 生成物 | +| Context 方法 | `@RemoteScope(key, option?)` 与 `@Remote` 一致:字符串为导出名,对象为 `{ name?, resources? }`。Context 的 wire 身份按声明了 `resource` 的 lookup 参数参与解析,优先级与 lookup 相同,该种类有 `resources` 读取函数时被覆盖;Gateway 在 `resolveReceiverContext` 之前直接从 args 读取该身份,不激活 Agent。Context 未声明 `resource` 时其身份不贡献资源,方法按普通规则解析 | + +#### 端点的资源 + +下表列出 #3854 读者用到的端点及其资源来源。只有 `session/page` 与 `session/follow` 需要显式 `resources`,其余靠 lookup 的 `resource` 或默认字段: + +| 端点 | 资源 | 解析来源 | 方法内过滤 | +|---|---|---|---| +| `workspace/follow` | `global` | 无资源 | 是:工作区与会话 | +| `session/control` | `global` | 无资源 | 是:会话 | +| `session/list` | `global`(`_request` 无 `sessionId`) | 无资源 | 是:会话行 | +| `session/modelCatalog` | `global` | 无资源 | 否 | +| `session/canOpenWorkspacePath` | `global` | 无资源 | 否 | +| `llm/listConfigurableProviders` | `global` | 无资源 | 否 | +| `llm/listProviders` | `global` | 无资源 | 否 | +| `commands/list` | session:`args.agentId`(Agent lookup 的 wire 字段) | Agent lookup `resource: 'session'` | 否 | +| `goals/get` | session:`args.agentId` | Agent lookup `resource: 'session'` | 否 | +| `session/attachment` | session:`args.request.sessionId` | 默认字段 | 否 | +| `skills/list` | session:`args.request.sessionId` | 默认字段 | 否 | +| `session/fork` | session:`args.request.sessionId` | 默认字段 | 否 | +| `session/prompt` | session:`args.request.sessionId` | 默认字段 | 否 | +| `session/cancel` | session:`args.request.sessionId` | 默认字段 | 否 | +| `session/projections` | session:`args.request.sessionId` | 默认字段 | 否 | +| `session/page` | session:Session 地址读 `args.request.address.sessionId`;subagent 地址读 `args.request.address.childSessionId` | `resources` | 否 | +| `session/follow` | 同上 | `resources` | 否 | +| `remoteAccess/follow` | `global` | 无资源;remote-access 对任何 principal 放行 | 否 | + +subagent 地址读取的是子会话转录,因此其资源是子会话。Client 在重连后会重新读取已知会话的 projections,其中包括 subagent 会话的父会话;没有该父会话授权的读者,其 projection 读取被拒。父会话的授权是否覆盖其 subagent 子会话由 policy 决定,即是否为子会话给出 binding;通用层不推导父资源。 + +不需要非 owner 访问的方法不做任何声明,没有 role 列出它即被拒。subagent `prompt` 的请求字段为 `parentSessionId` / `childSessionId`,自动解析不到会话,解析为 `global`,因此不得放进任何 `global` 级 role;`session/search` 在方法内按会话逐项过滤命中。`subagents/list` 不是 Remote 端点(`SubagentRuntime.list()` 未标 `@Remote`)。 + +#### role、binding 与 policy(`@deepseek-ai/dsh-api-remote-access`) + +```text +/** Remote endpoint `ns/method`, or `event:` for one forwarded event; endpoint segments never contain `:`. */ +export type RemoteAccessAction = RemoteAccessEndpoint | `event:${string}` + +/** A named action set over one resource kind; a plain value owned by the business package. */ +export interface RemoteAccessRole { + readonly name: string + readonly resource: K + readonly actions: readonly RemoteAccessAction[] +} + +/** Answer of decide() and of a condition. */ +export type RemoteAccessDecision = + | { readonly kind: 'allow' } + | { readonly kind: 'deny'; readonly reason: string } + +/** Refines one binding for one action on its resource; `args` only for a concrete call. */ +export type RemoteAccessCondition = (request: { + readonly action: RemoteAccessAction + readonly resource: RemoteAccessResourceOf + readonly args?: Readonly> +}) => RemoteAccessDecision + +/** One role on one resource of the role's kind; the resource type follows from the role. */ +export type RemoteAccessBinding = { + [K in RemoteAccessResourceKind]: { + readonly role: RemoteAccessRole + readonly resource: RemoteAccessResourceOf + readonly condition?: RemoteAccessCondition + } +}[RemoteAccessResourceKind] + +/** One access question; the Gateway passes only valid resources, [global] when none. */ +export interface RemoteAccessRequest

{ + readonly principal: P + readonly action: RemoteAccessAction + /** Allow only when the action is allowed on every listed resource. */ + readonly resources: readonly RemoteAccessResource[] + /** Named arguments of a concrete call, validated against their Typert-compiled types with lookup parameters as wire identities; absent for events, snapshots, and list filtering. */ + readonly args?: Readonly> +} + +/** Business rules for one non-owner principal kind; synchronous over in-memory state. */ +export interface RemoteAccessPolicy

{ + bindings(principal: P): readonly RemoteAccessBinding[] + /** Reason for a request no binding allows; never changes the decision. */ + explain?(request: RemoteAccessRequest

): string | undefined +} + +/** details of `remote-access/denied` */ +export interface RemoteAccessDeniedDetails { + readonly action: string + readonly reason: string +} + +class RemoteAccessService { // ctx.remoteAccess; Remote namespace remoteAccess + registerPolicy>(kind: K, policy: RemoteAccessPolicy): () => void + invalidate(principal?: Principal): void + decide(request: RemoteAccessRequest): RemoteAccessDecision // Gateway-only + allows(resource: RemoteAccessResource): boolean // reads the accessing ctx's invocation + snapshotFor(principal: Principal): RemoteAccessBaseline // a page's opening baseline + follow(signal): AsyncIterable // remoteAccess/follow +} +``` + +| 规则 | 内容 | +|---|---| +| owner | Gateway 对 owner 不解析资源、不调用 `decide()`,也不读取任何 policy | +| 注册 | `registerPolicy(kind, policy)` 返回 disposer,业务包在 `ctx.effect()` 中调用。每个非 owner principal kind 至多一个 policy,同一 kind 重复注册抛错;`bindings()` 与 `explain()` 收到的 principal 按 kind 定型 | +| 内置放行 | `remoteAccess/follow` 对任何 principal 放行,每个 Peer 都能读自己的快照 | +| 判定 | 没有该 principal kind 的 policy 以 `no-allow` 拒绝。否则取 `bindings(principal)`,对请求的每个资源线性扫描找一个 binding:资源相等(`kind` 相同,且非 `global` 时 `id` 相同)、role 的 `actions` 含该 action、没有 condition 或 condition 放行;每个资源都找到才放行(AND)。condition 只对资源相等且 role 含该 action 的 binding 调用 | +| 拒绝原因 | 第一个未被放行的资源决定原因,优先级为:该资源上存在匹配 binding 但其 condition 全部拒绝时,取第一个 condition 的原因;否则取 `explain(request)` 的返回值;没有 `explain` 或其返回 `undefined` 时为 `no-allow`。`explain` 只在结论已为拒绝后调用,不改变结论 | +| 失败隔离 | `bindings()`、condition 或 `explain()` 抛错时,本次判定以 `policy-failed` 拒绝;计算快照时 `bindings()` 抛错即该 principal 没有 grant,condition 抛错即该 action 不放行。有问题的规则因此不会破坏共享的 `$events` 事件源、快照流或页面渲染;每次 `invalidate()` 或 policy 变更之后,每个 policy 的第一次失败只记录一次日志 | +| 同步 | `bindings()`、condition 与 `explain()` 同步返回,状态由业务包预先载入内存,以便列表逐项过滤与快照计算 | +| 求值时机 | 每次 `decide()`、`allows()` 与快照计算都调用一次 `bindings()`,不缓存;依赖时间的状态(如过期)即时生效,长流(如 `workspace/follow`、`session/control`)在 binding 增删后的下一次逐项判定即反映;快照只在 `invalidate()` 后重算,业务包在过期等时间事件发生时也须调用 `invalidate()` | +| 通知 | 业务状态变化(授权、撤权、模式切换)改变 binding 或 condition 的结论时,业务包必须调用 `invalidate(principal?)`;快照只在 `invalidate()` 与 policy 注册、撤回后重算 | +| `remote-access/denied` | 只有调用的拒绝变成错误,details `{ action, reason }`,reason 为 condition 或 `explain` 给出的原因、`no-allow`、`policy-failed`,或 Gateway 对非法资源给出的 `resource-invalid`;被拒的事件只是不推送 | +| 必需服务 | Gateway `static inject = ['typert', 'remoteAccess']`;挂载 Gateway 的组合必须挂载 remote-access,[`bundle/base`](../../../../packages/bundle/base/cordis.patch.yml) 紧随 `typert-gateway` 装配;plugin manager 像保护 Gateway 一样保护它,插件页面无法禁用 | + +#### 示例:分享读者 + +以 liveshare 声明的 `share-viewer` principal 为例,说明业务状态到 binding 的推导留在业务包;示例中的 `shares` 代表业务包在内存中持有的分享状态: + +```text +const globalReader = { name: 'share:global-reader', resource: 'global', actions: ['session/list', 'session/control', 'workspace/follow', 'session/modelCatalog', 'llm/listProviders', 'commands/list'] } +const sessionReader = { name: 'share:session-reader', resource: 'session', actions: ['session/page', 'session/follow', 'session/list', 'session/control', 'workspace/follow', 'commands/list', 'event:api-session/status', 'event:api-session/added'] } +const workspaceReader = { name: 'share:workspace-reader', resource: 'workspace', actions: ['workspace/follow'] } +const forker = { name: 'share:forker', resource: 'session', actions: ['session/fork'] } +const writer = { name: 'share:writer', resource: 'session', actions: ['session/prompt', 'session/cancel'] } + +ctx.effect(() => ctx.remoteAccess.registerPolicy('share-viewer', { + bindings(viewer) { + const share = shares.serving(viewer) // undefined once stopped, expired, or no longer admitted + if (share === undefined) return [] + const source = { kind: 'session', id: share.sessionId } + return [ + { role: globalReader, resource: { kind: 'global' } }, + { role: sessionReader, resource: source }, + ...(share.allowsFork ? [{ role: forker, resource: source }] : []), + ...(share.mode === 'write' ? [{ role: writer, resource: source, condition: sourceWrite(share) }] : []), + ...shares.forksOf(viewer).flatMap(child => [ + { role: sessionReader, resource: { kind: 'session', id: child.id } }, + ...(child.revoked ? [] : [{ role: writer, resource: { kind: 'session', id: child.id }, condition: childPrompt(child) }]), + ]), + ...shares.workspacesShowing(viewer).map(id => ({ role: workspaceReader, resource: { kind: 'workspace', id } })), + ] + }, + explain({ principal, action }) { + return shares.refusal(principal) // 'share-expired', 'share-stopped', … + ?? (action === 'session/prompt' ? 'share-not-writable' : undefined) + }, +})) + +// Without args (snapshots, list filtering) a condition answers for the action alone. +const sourceWrite = share => () => share.confined ? allow : deny('source-write-unavailable') +const childPrompt = child => ({ action, args }) => + action === 'session/prompt' && args !== undefined && !child.allowsPrompt() ? deny('fork-prompt-refused') : allow +``` + +| 归属 | 内容 | +|---|---| +| 业务包 | role 的 action 清单;分享状态到 binding 的推导(停止、过期、准入、模式、读者自己的分支、撤销、包含可见会话的工作区);按请求细化的 condition;面向读者页面的拒绝原因 | +| 通用层 | 资源解析、按资源 AND、role 与 action 匹配、condition 调用、原因优先级、列表过滤、快照计算与失败隔离 | + +该读者的快照包含 `global`、源会话、自己的每个分支与每个可见工作区上的 grant;`session/list` 同时出现在 `global` 级与 session 级 role 中,见[列表与聚合方法](#list-and-aggregate-methods)。 + +#### Host 侧鉴权调用顺序 + +所有调用路径(HTTP `/api`、WebSocket stream、进程内 `fetch(request, peer)` / `invoke` / `stream` / `wireStream.open`)都经过同一段 Gateway 逻辑: + +| 步 | 动作 | 失败结果 | +|---|---|---| +| 1 | 准入得到 Peer:网络路径由 Connection `admit()` 给出(`browserPeer`);进程内路径由调用方传入。Gateway 在读取 wire payload 之前检查 `peer.signal` | 网络准入失败返回 HTTP 401 / 403(不进入 Gateway);Peer 已 dispose:`gateway/peer-disposed` | +| 2 | 解析端点并做参数字段集校验(`resolveDescriptor`、`assertExactArguments`),再按 Typert 编译类型校验 JSON 参数,每个参数解码一次 | `gateway/invocation-unavailable`、`gateway/ambiguous-endpoint`、`gateway/definition-unavailable`;字段多余或缺失:`gateway/arguments-invalid`;JSON 参数校验失败:`gateway/input-invalid`(owner 与非 owner 相同,先于访问检查) | +| 3 | owner 直通:`principal.kind === 'owner'` 时直接进入第 5 步 | — | +| 4 | 非 owner:Gateway 从第 2 步校验后的参数解析资源(`resources` 读取函数、其次 lookup 参数与 Context 声明的 `resource`、再次默认字段;lookup 与 Context 身份取 wire 身份,不解析 lookup 与 Context),再以校验后的参数为 `args` 询问 `remoteAccess.decide({ principal, action: endpoint, resources, args })`;资源非法时不询问 `decide()`,直接以 `resource-invalid` 拒绝;`deny` 结论变为抛出的错误 | `remote-access/denied`,details `{ action, reason }` | +| 5 | 分派:`resolveReceiverContext`(可能激活 Agent)、lookup 解析(JSON 参数复用第 2 步的值)、调用方法 | 既有错误码不变,如 `gateway/lookup-not-found`、`gateway/context-not-found`、`gateway/input-invalid`、`gateway/service-unavailable`、`gateway/method-unavailable` 与业务错误 | + +鉴权(第 3–4 步)位于 lookup 与 Context 解析之前,未授权调用不会激活会话或产生其他副作用。Gateway 新增的错误码只有 `gateway/peer-disposed`;拒绝码 `remote-access/denied` 由 remote-access 声明。 + +Gateway 自有端点:非 owner 可订阅 `$events`,但从不收到需要回答的 waterfall 帧;`$events/result` 对非 owner 以既有的 `gateway/invocation-unavailable` 拒绝;两者都做第 1 步的检查。 + +#### 转发事件 + +`API_REMOTE_FORWARDED_EVENTS` 的 `emit` 条目可以声明 `resources`:以资源种类为键、经 `TypertForwardableEventEntry` 按该事件自身参数定型的读取函数,例如 `{ event: 'api-session/added', mode: 'emit', resources: { session: (summary) => summary.sessionId } }`。API Remotes 把读取函数附在每个转发帧上;对每条非 owner 的 `$events` 流,Gateway 每帧解析一次资源,只有 `remoteAccess.decide({ principal, action: 'event:', resources })` 允许时才推送。 + +| 规则 | 内容 | +|---|---| +| 已声明事件 | `api-session/activity`、`api-session/added`、`api-session/error`、`api-session/removed` 与 `api-session/status` 声明其 Session | +| 未声明资源 | 未声明 `resources` 或声明为空 `resources: {}` 的事件访问 `global`,只有 `global` 级 role 列出 `event:` 时才送达非 owner | +| 缺失资源 | 事件携带其声明的每个资源,因此读取函数返回 `undefined` 时该事件的资源非法,Gateway 不询问 `decide()`,不送达任何非 owner;调用缺少资源时则为 `global` | +| 整条放行或拒绝 | 允许的事件 payload 原样送达;不按 Peer 裁剪或投影 payload | +| 每个事件一个资源 | 通知至多携带一个资源;集合类变化走 follow 类流(`session/follow`、`session/control`、`workspace/follow`),由这些方法内联经 `allows()` 逐项判定 | +| waterfall | 非 owner 从不收到 waterfall 帧,不经 policy 判定 | +| 非 owner 流的 Host 信息 | 非 owner 的 `$events` 流与 owner 流一样以完整 `host`(含 `home`)打开;`hasLiveClient()` 只统计能回答 waterfall 的 owner 流 | + + +#### 列表与聚合方法 + +列表、分页与聚合类方法的调用解析为 `global`,由 `global` 级 role 放行;方法内部对每一项调用 `this.ctx.remoteAccess.allows(resource)`,判定当前调用的 action 在该资源上是否被授权(不带 args)。因此列表类 action 同时出现在 `global` 级 role(放行调用)与资源级 role(放行逐项)中:`session/list` 在 `global` 上被授权才能调用,每个会话行还要求 `session/list` 在该会话上被授权。`allows()` 读访问该服务的 Context 上的 invocation,owner 与 Remote 调用之外的进程内代码恒为 true,不过滤。 + +| 方法 | 过滤对象 | +|---|---| +| `session/list` | 会话行(`session` 资源) | +| `session/search` | 搜索命中(`session` 资源) | +| `session/control` | baseline 的 projections 与 projection 增量(`session` 资源) | +| `workspace/follow` | baseline、`upsert` 帧与 `order` id 中的工作区(`workspace` 资源)、工作区内会话、archived / pinned 会话集合(`session` 资源)。流记录已下发的工作区;某个工作区在下一次变化或新顺序中已不被允许时,发送一个携带完整过滤后状态的 `replace` 增量帧而不是 `remove`,因为 Client 不会复活已移除的 id,访问恢复后该工作区经普通 `upsert` 重新出现。`remove` 帧表示删除,只发给收到过该工作区的调用方 | + +#### 访问快照流 + +```text +// Remote: remoteAccess/follow (stream; every Peer may read its own snapshot) +type RemoteAccessFrame = + | { readonly type: 'baseline'; readonly owner: true } + | { readonly type: 'baseline'; readonly owner: false; readonly grants: readonly RemoteAccessGrant[] } + | { readonly type: 'upsert'; readonly grant: RemoteAccessGrant } + | { readonly type: 'remove'; readonly resource: RemoteAccessResource } + +interface RemoteAccessGrant { + readonly resource: RemoteAccessResource // global, or one declared-kind resource + readonly actions: readonly string[] // endpoint actions allowed on this resource without args, sorted +} + +/** Window global carrying a page's opening baseline. */ +export const REMOTE_ACCESS_SNAPSHOT_KEY = '__DSH_REMOTE_ACCESS__' +``` + +| 行为 | 说明 | +|---|---| +| 首份快照 | `snapshotFor(principal)` 计算页面打开时的 baseline。remote-access 经 `webserver/index-inject` 为 webServer 渲染的每个 index 加入全局变量 `REMOTE_ACCESS_SNAPSHOT_KEY`,值为 owner baseline `{ type: 'baseline', owner: true }`。渲染非 owner 页面的业务包(例如 owner Host 上的 liveshare)在所有 index 注入行之后放置一条把该全局变量赋值为 `snapshotFor(principal)` 的语句;后写的赋值生效,Client 在挂载时读取该全局变量 | +| 订阅 | 每个 Peer 订阅一条访问快照流,其 baseline 取代注入的快照 | +| 计算 | 取该 principal kind 的 policy 的全部 binding,按资源相等归并;每个资源的 `actions` 为这些 binding 的 role 中、经与调用相同的判定(单个资源、不带 args,condition 以无 args 调用)放行的端点 action;只为至少放行一个端点 action 的资源给出 grant。判定与快照同源:同一 principal、action 与资源(不带参数)上,快照答案与 `decide()` 一致 | +| 只含端点 action | `event:` action 不进入快照;前端只据端点 action 决定入口显隐 | +| 增量 | `invalidate()` 或 policy 注册、撤回时重算并推送 `upsert` / `remove`,放行集合变空的资源推送 `remove`;未变化的资源不推送 | +| 生命周期 | 一代快照流在其 signal abort 或 remote-access 服务卸载时结束,因此重载后的服务从新的 baseline 回答每个读者;Client 把结束视为载体中断并重连 | +| owner | 只推送 `{ owner: true }`,前端全部为 true | +| wire 类型 | `actions` 为 `readonly string[]`:Typert 无法为模板字面量类型生成 codec | + +access hooks 只按 action 与资源判断,不看调用参数;调用时 condition 可额外读取 args 做更细判断,以服务端结果为准。 + +#### Client 面 + +Client 面走框架标准 hook 注入,不引入 React context。组件只经 props 拿到 hook,不 import remote-access 的值。 + +```text +// @deepseek-ai/dsh-api-remote-access/client +interface ClientRemoteAccess { // Client service ctx.remoteAccess + source(kind: TypertResourceKind, id: string, action: string): HostObservable +} +GlobalStandardProps.useRemoteAccess: (action: RemoteEndpoint) => boolean // remote-access; the global resource + +// @deepseek-ai/dsh-client-ui-session (injects remoteAccess) +GlobalStandardProps.useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean +SessionStandardProps.useAccess: (action: RemoteEndpoint) => boolean +SessionMaybeStandardProps.useAccess: (action: RemoteEndpoint) => boolean | undefined + +// @deepseek-ai/dsh-client-ui-workspace (injects remoteAccess) +GlobalStandardProps.useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean + +// RemoteEndpoint = Extract +``` + +| 事项 | 定稿 | +|---|---| +| 快照与挂载 | remote-access Client 插件从 index 注入的 baseline 起步(启动时同步读取并校验),订阅快照流,对每个 baseline 一次构建完成,提供 Client 服务 `ctx.remoteAccess.source(kind, id, action)`(身份稳定的布尔 source)并注入 `global` 资源上的 `useRemoteAccess`。入口模块 import 生成的 `/remote` contribution,连同注入的 baseline 交给 `mountAccess(ctx, contribution, initial)` 经 `ctx.remote.$mount` 自行挂载(单测传入假 contribution),先例为 experimental client-ui-cot-translation、voice-input、claude-code-mods;若由 api/remotes 集中挂载,会形成 ui-session → api/remotes → remote-access → ui-session 的 project reference 环 | +| 会话资源 | ui-session 经 `uiSession.provide({ keyedHooks: ['access'], resolve })` 给出本会话答案,会话由框架绑定,与 `useProjection` 相同;`useSessionAccess` 经 `provideRoot` 注入 | +| 工作区资源 | ui-workspace 经 `provideRoot` 注入 `useWorkspaceAccess` | +| 名称区分 | `global` 答案是 `useRemoteAccess`,会话答案是 `useAccess`,两者互不遮蔽;session-maybe 无会话时 `useAccess` 为 `undefined`,调用方按不允许处理,需要 `global` 判断时读 `useRemoteAccess` | +| 双参数调用 | 框架 keyed hook 的标准双参数:`KeyedStandardSource` 为 `(key, member?) => source`;第二个参数为字符串时 hook 读取 resolver 对 `(key, member)` 返回的 source,不带 selector;resolver 对同一参数返回同一 source。既有 `(key, selector?, equal?)` 与 `(selector, equal?)` 调用不变。禁止柯里化形式 | +| 返回值 | 有资源的答案均为布尔值;action 类型来自 `ctx.remote` 的 namespace 与方法 | +| 不回退 | 会话资源上查询非会话方法(如 `useAccess('session/create')`)读该会话资源上的判定结果,不回退 `global` 答案 | +| 无快照 | owner 为 true,快照流终止失败后亦然;没有注入 baseline 或快照流终止失败的非 owner 页面为 false,终止失败会连同错误与“刷新页面”的提示一并报告;断线重连期间保留上次快照;注入的 baseline 格式错误时挂载失败;Host 仍对每次调用执行检查。hook 从不发请求 | +| 示例消费方 | ui-workspace 会话行菜单的 fork 入口读 `useSessionAccess(sessionId, 'session/fork')`,不允许时不渲染;ui-chat turn tail 的分叉按钮读会话资源上的 `useAccess('session/fork')`,不允许时不渲染。替换 #3854 的 `clientWritePolicy` 留给 #3854 | +| 测试 | `@deepseek-ai/dsh-client-test-runtime` 导出 `allowAllAccess` props 与 `allowAllAccessPlugin`;TestClient 像 api-remotes 一样去掉 remote-access 行并装入全允许服务,SlotTestRuntime 在 ui-session 之前装入 | + +### 安全不变量 + +| 不变量 | 依据与落点 | +|---|---| +| principal 只能由进程内 Host 代码在创建 Peer 时给出 | `PeerScope.principal` 只读;`createPeer()` 不对应任何路由 | +| wire 上任何字段都不能设置或覆盖 principal | principal 不来自 args、envelope、header、query 或 cookie。args 顶层多余字段被 `assertExactArguments` 拒绝;payload 必须恰为 `{ args }`;envelope 未知字段被 [`clientRequestSchema`](../../../../packages/client/connection/src/rpc-schema.ts) 丢弃;请求体内同名字段只是业务数据 | +| 浏览器网络准入为 owner | 通过[浏览器 token 认证](../../implemented/architecture/2026-08-24-browser-token-authentication.zh.md)的连接得到 `browserPeer` | +| 没有隐式 Peer | Gateway 与 Connection 都不补默认 Peer;required 参数让漏传在编译期暴露 | +| 已 dispose 的 Peer 不能再分派 | `gateway/peer-disposed` | +| 非 owner 未经授权不能调用 | 只有 binding 的 role 显式列出该 action 且资源匹配时才放行;没有该 kind 的 policy 即拒;Gateway 依赖 `remoteAccess`,缺失时 Gateway 不启动 | +| `global` 授权不蕴含资源授权 | 资源之间无继承;方法内 `allows()` 对每一项独立判定 | +| 未知 principal kind 不是 owner | 只有 `kind === 'owner'` 是 owner | +| 非 owner 不回答 waterfall | `$events` 不给非 owner waterfall 帧,`$events/result` 拒绝非 owner | +| 非 owner 只收到被授权的通知 | 每个转发帧按 `event:` 与其资源判定,被允许才转发给非 owner | + +### 消息来源落盘 + +[session-controller 类型](../../../../packages/api/session-controller/src/types.ts) 的 `MessageSourceMap` 条目增加可选字段: + +```text +'user-rpc': { + kind: 'user' + rpcId: SessionRequestId + clientTimeZone?: string + /** Non-owner caller that submitted this prompt; absent means the owner. */ + principal?: Principal +} +``` + +在 Remote 调用中构造 `kind: 'user'` + `rpcId` 用户消息 source 的生产者共两处,都在 Remote 方法内调用 `principalOf(this.ctx.invocation)`,返回值非 `undefined` 才写 `principal`: + +| 生产者 | 位置 | +|---|---| +| `SessionController.prompt()` 构造 source,`SessionCommandController.prompt(request, source)` 只负责投递 | [`session-controller/src/index.ts`](../../../../packages/api/session-controller/src/index.ts)、[`commands.ts`](../../../../packages/api/session-controller/src/commands.ts) | +| subagent `prompt()`(`BrowserPromptSource`);没有 role 列出它,非 owner 只能经进程内 Host 代码到达,不能经 Gateway 到达 | [`subagent/src/index.ts`](../../../../packages/subagent/subagent/src/index.ts) | + +业务代码不把 principal 当函数参数逐层传递:身份只在 Remote 方法内从 invocation 读一次,向下传的是已构造的 source。Agent inbox 没有可在消息入队时统一补字段的拦截点(`followup` / `steer` 接受已构造的消息,SDK、调度等进程内生产者不在 Remote 调用中),因此按生产者落实,并由代码评审标准约束新增的生产者。 + +| 事实 | 说明 | +|---|---| +| `kind` 不变 | 仍为 `'user'`;现有 `kind === 'user'` 判断不受影响 | +| owner 消息不变 | owner 调用时不写字段,source 与引入 principal 之前逐字节相同;没有该字段的消息(含全部旧会话)即为 owner | +| 不进入模型请求 | llm 适配器只读 model source([`llm-deepseek/src/replay.ts`](../../../../packages/llm/llm-deepseek/src/replay.ts)) | +| 会话格式 | 不 bump `SESSION_FORMAT_VERSION`;按 [persistence 类型变更 cookbook](../../../../docs/cookbook/reviewing-persistence-type-changes.zh.md) 为该持久化类型变更记录 acknowledgement | + +消费方用法示例(不在本 Note 范围内实现):工具守卫与 pre-step 复检按 turn 读取该 turn 内用户消息的 `source.principal`,任一条带非 owner principal 即按该调用方判定;同一 turn 混有 owner 与读者消息时取最严格者。 + +### 代码评审标准与文档 + +评审标准的正家为 [Remote and API rules](../../../../docs/defensive-patterns/remote-and-api.zh.md) 的「`@Remote` 标在能力方法上」一节:`@Remote({ resources })` 与自动解析必须覆盖方法访问的全部资源;聚合结果逐项经 `allows()` 过滤;调用方身份只读 `invocation.peer.principal`,不从参数读取;每条调用路径显式给出 Peer;业务代码不把 principal 当函数参数传递。 + +| 文档 | 内容 | +|---|---| +| [`docs/subsystems/typert.md`](../../../../docs/subsystems/typert.zh.md)、[`docs/api-gateway.md`](../../../../docs/api-gateway.zh.md) | `PeerScope`、`RemoteInvocation`、`InvokeRemoteRequest`、显式 Peer、`@Remote` 的 `resources`、访问检查顺序 | +| connection、gateway、typert-protocol、typert-registry、api-remotes、remote-access、session-controller、workspace-controller、ui-session、ui-workspace、ui-slots、client-test-runtime 的 README | 以 Peer / `browserPeer` 取代 operator;remote-access 的 role、binding、policy 契约;access hooks | + +### 不在范围与 Future work + +- `/connect` 握手、API Cookie、每条 API 连接一个 Peer、WebSocket 生命周期:归 pr-5945。 +- 浏览器多 Peer(如每标签页一个):所有浏览器连接共享 `browserPeer`。 +- 交互参与(interactions)与按 principal 隔离审批由后续工作引入;目前非 owner 只收被授权的 `$events` 广播帧。 +- `agent-preset/selected` 等其余转发事件未声明资源,按 `global` 判定;读者需要某个会话事件时,#3854 为其声明资源并在 role 中列出 `event:`。 +- liveshare 接入(声明 `share-viewer`、每条读者连接一个 Peer、定义 role 并注册 policy、删除 `origin` / `prompt/origin` / `READ_ENDPOINTS`):#3854 侧。 +- `session/cancel` 归属检查、fork 的 `childSessionId`、`ToolExecutionInput.turn`:不改。 +- 资源识别的精确方式(例如 Typert 生成器按 branded id 类型在请求对象中定位字段并写入描述符):目前用 lookup 与 Context 声明的 `resource`、默认字段加 `resources` 读取函数,以后可替换资源解析而不改 policy 接口。 +- exact Fetch route 对非 owner 一律 403 是临时处理;以后由各路由声明访问规则。 +- subagent `prompt` 的资源声明待定:其请求字段为 `parentSessionId` / `childSessionId`,目前解析为 `global`。生产者为进程内调用方记录 `source.principal`;没有 role 列出它,非 owner 不能经 Gateway 到达。 +- 预定义与聚合 role:出现第二种非 owner principal kind 时,由各领域包以 `@Remote({ roles })` 声明方法所属的通用 role(如 `session:viewer`),各 policy 绑定这些 role;目前 role 由业务包以显式 action 清单定义。 + +### 与现有 Note 的关系 + +- [Remote 双工流](../../implemented/architecture/2026-09-19-remote-duplex-stream.zh.md):部分 supersede。被替换的部分包括:「Peer 不记录身份与权限」「本 Host 只有一个 Peer:operator」「in-process carrier 不给 Peer 即 operator」,以及其替代方案中「Access 机制属于业务语义」的表述;其余(双工流、uplink、`ctx.invocation` 绑定)保持。两篇保持 active 并互链,该 Note 的现行事实已同步更新。 +- [浏览器 token 认证](../../implemented/architecture/2026-08-24-browser-token-authentication.zh.md):不受影响。该 Note 否决「按方法区分特权」针对的是 owner 的认证;通过认证的浏览器仍是 owner 且不受方法级检查。 +- [Typert Remote 方法调用](../../implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md):不受影响;`@Remote` 与 `@RemoteScope` 增加可选的 `resources` 读取函数(`@RemoteScope` 的第二个参数可为 `{ name?, resources? }`),只供 Gateway 的访问检查读取,不改变端点命名、调用模式与生成物。 +- [生产方拥有的消息来源](../../implemented/architecture/2026-09-09-producer-owned-message-sources.zh.md):不受影响;`principal` 由构造 source 的生产者写入。 + +## 考虑过的替代方案 + +**独立 `prompt/origin` 事件并按 requestId 关联(#3854 做法)。** 每条 prompt 都写 required 事件,普通会话也无法被旧版本读取;关联要求全部 prompt 串行并维护来源 fold,fold 窗口外的消息失去发起者。 + +**principal 与 peer 并列挂在 invocation 上。** 同一调用方的身份拆成两处来源,准入点要同时产出并传递两者,任一路径只传其一就会错配。 + +**保留「Peer 不记录身份」的原约定。** 身份由业务插件经 `peer.ctx` 附加时,每个读取方都要知道由谁附加、如何读取,wire 准入路径也无法保证附加发生。 + +**保留默认 Peer 兜底(`request.peer ?? …`)。** 隐式默认让漏传 Peer 的调用静默获得 owner 身份;required 参数把漏传变成编译错误。 + +**新开 source kind。** 现有 `kind === 'user'` 判断都要逐个审视,漏一处就让读者消息在标题、大纲、队列或聊天渲染中消失。 + +**请求体字段携带身份。** 任何能构造请求体的调用方都能伪造或覆盖它。 + +**前端 React context 提供资源。** 业务组件不得读 React context;标准 hook 由框架按资源绑定,组件只经 props 拿到。 + +**由驱动方自行维护端点白名单并裁剪结果(#3854 做法)。** 白名单与结果裁剪都在驱动方内,Gateway 不执行,快照与前端各自推导;role 的 action 清单由通用层执行,列表过滤由方法内的 `allows()` 完成。 + +**业务包实现 `decide()` / `abstain` 的 provider,并合并多个 provider 的答案。** 每个业务包手写 action × 资源的判别分支,另写一份资源列表供快照,两者靠人工保持一致;policy 只给出 binding,判定与快照由同一份 binding 算出,每种 principal kind 只有一个 policy,不需要合并规则。 + +**独立的 `@RemoteAccess` 标记。** 默认拒绝已由 role 的显式 action 清单保证,标记只是第二道同意;一个资源都没解析出的方法落到 `global`,只有显式的全局授权能放行。资源读取函数是方法描述信息,放在 `@Remote` 上。 + +**把无资源的调用称为 `page` 或 `host`。** `page` 描述前端位置而不是资源;`host` 容易被读作这台 Host 上全部资源的上级。`global` 表示不关联任何资源的授权。 + +**资源继承(GCP 式:`global` 或工作区上的授权覆盖下级资源)。** 列表方法被 `global` 授权放行后需要逐项细判,继承会让任何 `global` 授权蕴含全部会话;无继承时每个资源的授权都由 binding 显式给出。 + +**role 注册表与预定义聚合 role。** 目前唯一的非 owner principal 由一个业务包管理,role 作为该包的普通值即可;聚合 role 会让新包声明的方法自动进入分享读者的可访问范围,推迟到出现第二种非 owner principal。 + +**condition 挂在 role 的 action 上。** role 保持为可复用的纯数据;按业务状态细化的判断属于把 role 授予某个 principal 的 binding。 + +**拒绝原因只由 condition 给出。** 没有 binding 时没有 condition 可以给出原因,读者页面无法区分「分享已过期」「分享不允许写入」等情况;`explain` 只在结论已为拒绝后给出原因,不改变结论。 + +**全局资源 resolver 注册表(领域包按字段名注册识别函数)。** 字段识别与方法分离,方法改签名时无人提醒,跨包同名字段也会被误认;资源解析采用方法上的默认约定加 per-method `resources`,领域包只登记自己种类的默认字段。 + +**按点路径声明资源(`'request.address.sessionId'`)。** 路径无法由类型推导约束;`resources` 读取函数的参数类型从被装饰方法推断,拼错即编译失败。 + +**由生成器按 branded id 类型识别资源字段。** 需要改 Typert 生成物与描述符格式,SRC 描述符没有类型信息;留作 Future work。 + +**由 api/remotes 集中挂载 remote-access 的 generated contribution。** remote-access Client 依赖 ui-session,ui-session 依赖 api/remotes,集中挂载形成 project reference 环;改为自挂载。 + +**在 Gateway 内实现判定(带规则的 `check()` 写在 Gateway)。** Gateway 会承载访问控制语义并多出错误码;Gateway 像解析 lookup 一样解析资源,在分派前调用 `remoteAccess.decide()`,规则与拒绝码归 remote-access。 + +**policy 异步判定。** 列表逐项过滤与快照计算需要同步答案;异步状态由业务包预先载入内存。 + +**前端按需查询权限。** 请求量随入口数增长,且与规则变化无法同步;快照流由 Host 主动推送,前端同步读取。 + +**访问检查放在 lookup 解析之后。** lookup 可能激活 Agent,未授权调用会先产生副作用。 + +**在 remote-access 中登记资源字段(`registerScope`)。** 访问控制包要识别参数字段,而 lookup 本已知道哪个 wire 字段指向 Session;typert 把资源种类与 lookups、Contexts 并列保存,Gateway 在一处解析调用与事件,remote-access 不读取任何参数字段。 + +**按 Peer 裁剪转发事件的 payload。** 逐事件投影会在每个通知里重复一遍列表过滤;一个事件至多携带一个资源,整条放行或拒绝,集合类变化走内联过滤的 follow 类流。 + +**首份快照到达前一律回答 true。** 非 owner 页面会短暂显示无权使用的入口;index 携带首份快照,没有快照的页面即无权限。 + +## 后果 + +### 验证 + +| 用例 | 行为 | 测试落点 | +|---|---|---| +| wire 试图设置 principal(args 顶层、payload 并列、envelope、请求体内) | 前两种以参数或 payload 校验拒绝;其余被丢弃或当作业务数据;`invocation.peer` 仍为准入给出的 Peer | Gateway host spec | +| 所有调用路径显式给出 Peer | `InvokeRemoteRequest.peer`、`wireStream.open` 的 peer、`fetch(request, peer)`、`bridge` 的 peer 为 required;Gateway 中不存在兜底;`/api` 路由传递 `admission.peer` | typecheck、Connection host spec | +| 浏览器连接与 `createPeer()` | `browserPeer` 的 principal 为 owner;`createPeer()` 每次返回独立 Peer,随读取 Connection 的 Context 释放;exact route 对非 owner 返回 403 | Connection host spec | +| 已 dispose 的 Peer | 之后的 unary、stream 与 `$events` 分派以 `gateway/peer-disposed` 拒绝,wire payload 畸形时亦然;其名下 stream 结束;运行中的 unary 完成;不影响其他 Peer | Gateway host spec | +| 鉴权顺序 | owner 调用任何方法照常分派且不读取 policy;没有 role 列出的方法对非 owner 以 `remote-access/denied`(`no-allow`)拒绝;访问检查早于 lookup,被拒调用不激活会话 | Gateway host spec | +| policy 注册 | 每个非 owner principal kind 一个 policy,重复注册抛错;disposer 撤回后该 kind 以 `no-allow` 拒绝;随 fiber 撤回 | remote-access host spec | +| 判定 | 每个资源都需要匹配的 binding(AND);role 不含该 action、资源不相等或 condition 拒绝都不放行;拒绝原因按 condition > `explain` > `no-allow` 取;`explain` 不改变结论;condition 在调用时收到 args,在快照与列表过滤时不收;`bindings()`、condition 或 `explain()` 抛错以 `policy-failed` 拒绝,每个 policy 在两次 `invalidate()` 之间只记录一次日志,事件路径上 owner 流与共享事件源照常;`remoteAccess/follow` 对任何 principal 放行 | remote-access host spec、Gateway host spec | +| 资源解析 | 默认字段(顶层或首个对象参数)得到对应资源;声明了该种类 `resource` 的 lookup 参数得到其 wire 身份;Context 方法从 args 中的 Agent Context 身份得到其 session 资源且不激活 Agent,另可经 `resources` 声明其他资源;`resources` 读取函数覆盖该种类,数组为多个资源,`undefined` 为无;都没有为 `[global]`;抛错、空数组或非非空字符串(含 Context 身份)为非法,Gateway 以 `resource-invalid` 拒绝且不询问 `decide()`;未声明 `resource` 的 Context 按普通规则解析;资源与 `args` 读取校验后的参数,未声明的嵌套字段不改变资源、condition 也读不到它,JSON 参数校验失败在访问检查前返回 `gateway/input-invalid` | Gateway host spec(gateway-access) | +| 资源注册表 | `typert.resources` 每个种类登记一个默认字段,拒绝重复登记与 `global`,并随 fiber 撤回;配置的 lookup resolver 保留 provider 的 `resource`;Host Context adapter 保留其 `resource` | typert-registry spec | +| 转发事件 | 非 owner 的 `$events` 流只在 `event:` 于其资源上被授权时整条收到事件;未声明资源的事件按 `global` 判定;读取函数返回 `undefined` 的事件不送达非 owner,且不询问 `decide()`;owner 全部收到 | Gateway host spec(gateway-access) | +| 首份快照 | `snapshotFor()` 返回 owner baseline 或该 principal 的 grant;remote-access 为渲染的每个 index 注入 owner baseline;Client 从注入的 baseline 起步并拒绝格式错误的快照 | remote-access host 与 client spec | +| 类型推断 | `resources` 读取函数的参数类型从方法推断;lookup 位为 wire 类型;访问不存在的属性、参数多于方法参数与 `global` 键编译失败;binding 的资源种类与 role 不符编译失败 | typert-protocol spec、remote-access spec(类型测试) | +| 列表过滤 | `session/list`、`session/search`、`session/control`、`workspace/follow` 对非 owner 只返回 `allows()` 为 true 的项;已下发工作区失去可见性时 `workspace/follow` 发送 `replace` 增量帧,访问恢复后该工作区重新出现;owner 结果不变 | session-controller / workspace-controller host spec | +| 访问快照 | 快照中的允许集合与 Host 对同一 principal、action、资源(不带参数)的判定一致;只含端点 action;没有放行 action 的资源不出 grant,放行集合变空时推送 `remove`;`invalidate()` 与 policy 注册、撤回后推送增量;被变化结束的等待不残留 abort 监听器;卸载服务即结束所有打开的快照流;owner 只收 `{ owner: true }` | remote-access host spec | +| access hooks | `source()`、`useRemoteAccess`、`useAccess`、`useSessionAccess`、`useWorkspaceAccess` 与快照一致;会话资源上的 `useAccess` 读本会话答案;owner 为 true;没有快照或快照流失败的非 owner 为 false;hook 不发请求 | remote-access client spec、ui-session / ui-workspace client spec | +| keyed hook 双参数 | 第二个参数为字符串时读取 `(key, member)` source;既有调用形式不变 | ui-renderer client spec | +| 非 owner 分派 `session/prompt` | 落盘 user 消息 `source.principal` 等于该 principal;冷恢复后仍读到同一值 | session-controller host spec | +| 进程内以非 owner invocation 调用 subagent `prompt` | 子会话落盘的 user 消息 `source.principal` 等于该 principal | subagent host spec | +| owner 消息 | source 与本变更之前逐字节相同;现有 keyless recorded-session snapshot 不变 | `pnpm run test:snapshot` 不需要重录 | +| 持久化记录 | `verify-persistence-changes` 通过 | doc-sync | + +SDK 投影不受影响:TypeScript SDK server 自行构造 `{ kind: 'user' }` 消息([`sdk/server/src/server.ts`](../../../../packages/sdk/server/src/server.ts)),不经 Gateway,也不投影 source;Python SDK 不读取 user source。 + +### 代价与限制 + +- 改动面横跨 protocol、Connection、Gateway、两个领域控制器、subagent、新包与前端,并带破坏性 API 改名;与 pr-5945 在 Connection 与 Gateway 上必然冲突,按迁移清单处理。 +- Gateway 依赖 `remoteAccess`:自建组合若挂载 Gateway 而不挂载 remote-access,Gateway 停在等待状态。 +- Session 日志会保存读者身份;导出或分享会话时随之外传,每个 principal 种类不得携带凭据。 +- 浏览器所有连接共享一个 Peer:dispose 无法按标签页区分,直到浏览器多 Peer 落地。 +- 默认字段约定按字段名读取:方法新增同名字段即访问对应资源,评审需留意;精确识别列为 Future work。 +- 只解析出部分资源的方法会放行越权:未解析的资源不受检查,由方法作者保证 `resources` 完整。 +- role 的 action 是字符串,不与已加载端点核对:拼错或改名后的 action 永不匹配,表现为拒绝。 +- `api-session/added` 携带完整的 `SessionSummary`,因此被允许接收它的非 owner 也会得知该会话的 `parentSessionId`。 +- 被授权会话的 cwd 与工具路径本就包含 owner 的绝对路径,因此 Gateway 不对非 owner 的 `$events` 流单独隐藏 `home`。 +- policy 撤销某个工作区的可见性后,`workspace/follow` 只在该工作区下一次变化或顺序下一次变化时察觉并向非 owner 发送 `replace` 增量帧;仅访问变化不会发送帧。 + +### 留给 pr-5945 的迁移清单 + +| 文件 | 迁移 | +|---|---| +| `packages/client/connection/src/peer.ts` | 取代 `operator-peer.ts`;`OperatorPeer` 构造改为 `new Peer(owner, { principal })` | +| `packages/client/connection/src/rpc-host.ts` | `this.operator` 改为 `browserPeer`,principal 为 `{ kind: 'owner' }` | +| `packages/client/connection/src/rpc.ts` | `HostConnectionHandle` 合并 `admitConnection` 与 `browserPeer` / `createPeer` | +| `packages/api/gateway/src/index.ts` | `$events` 打开处的 `peer ?? this.operatorPeer()` 改为显式 Peer;复用 `gateway/peer-disposed` | +| operator-connect 的 Note、升级指南与 subsystems 改动 | operator 术语改写为 Peer / `browserPeer`;API 连接的 Peer 为 `{ kind: 'owner' }` | diff --git a/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.i18n.yaml new file mode 100644 index 0000000000..be7bf32098 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.i18n.yaml @@ -0,0 +1,31 @@ +# Bilingual-pair consistency record for 2026-10-13-chat-single-fold-motion.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.md +/agent-note-one-fold-motion-for-collapsing-on-the-next-message: + en: 45594545dabfaa78 + zh: a1eac669647b2bac +/agent-note-one-fold-motion-for-collapsing-on-the-next-message/problem: + en: 88faaf0c589e12cb + zh: dda7efc5eaed2788 +/agent-note-one-fold-motion-for-collapsing-on-the-next-message/decision: + en: 972bbe45fe8ac28e + zh: 593b851c142894e1 +/agent-note-one-fold-motion-for-collapsing-on-the-next-message/decision/batches: + en: f44b784545c6d9a2 + zh: 3eb6fcb9f348dec4 +/agent-note-one-fold-motion-for-collapsing-on-the-next-message/decision/landings: + en: b8278678170f1507 + zh: 113a51ba5cf27de1 +/agent-note-one-fold-motion-for-collapsing-on-the-next-message/decision/own-input: + en: ca6a5c372bb3cad1 + zh: 1cd5028269b58c65 +/agent-note-one-fold-motion-for-collapsing-on-the-next-message/testing: + en: 0423a607ee0589b5 + zh: 61df892a01c5aa43 +/agent-note-one-fold-motion-for-collapsing-on-the-next-message/alternatives-considered: + en: d8e1ea66e293cd93 + zh: 0aaa9e693f441673 +/agent-note-one-fold-motion-for-collapsing-on-the-next-message/consequences: + en: 89148df30a9a1772 + zh: d484cc7c9e0cc3f1 diff --git a/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.md b/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.md new file mode 100644 index 0000000000..8c279abb34 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.md @@ -0,0 +1,92 @@ +# Agent Note: One fold motion for collapsing on the next message + +Status: implemented + +English | [中文](2026-10-13-chat-single-fold-motion.zh.md) + +## Problem + +When "When to Collapse Work Details" is "On next message", the previous exchange's process rows close when the next human input arrives. The scroll layer had two fold paths and chose one by `ownInput` ([use-chat-scroll.ts](../../../../packages/client/ui-chat/src/client/chat/use-chat-scroll.ts)): + +- **Input fold path:** when the fold starts, it measures the closing rows, computes the final landing and the required bottom room, and moves the rows and the scrollport on one frame clock to a single landing. +- **Generic path:** scrollTop stays put, the closing height is reserved as bottom room in one block, and following scrolls once more after the fold ends. This holds only when the fold happens below the reader. When the folding region is above the reader, the content the reader is looking at moves up as a whole and the viewport shows only the room. + +`ownInput` holds for three signals only: a local echo in the transcript (`submissionId`), a steer echo (`steeringId`), or a user message as the last list row (`lastIsUser`). In the commit where the Host records the opening input, the Turn's process control is ordered after that input, so `lastIsUser` does not hold. Every human input without a transcript echo on this page therefore took the generic path: + +| Source | Echo on this page | Reader at the bottom | Reader in the middle of the body | +|---|---|---|---| +| Idle send from the main composer | Yes, in the transcript | Correct | Correct | +| Main-Session queued message delivered after the previous Turn | Yes, but in the queue, not consulted | Whole viewport blank | Whole viewport blank | +| Send from another tab | No | Whole viewport blank | Whole viewport blank | +| Send from a subagent child's composer in the right Sidebar | No (the composer skipped `beginSubmission`) | Whole viewport blank | Whole viewport blank | +| Human send after parent messages woke several child Turns | No | Whole viewport blank (about 6000 px of room) | — | + +Notice-woken Turns no longer fold anything ([group-tree positions and exchanges](2026-10-11-chat-group-tree-exchanges.md)); the table above covers the human input that still folded through the generic path. The data comes from [fold-blank-matrix.e2e.ts](../../../../apps/web/tests/fold-blank-matrix.e2e.ts). + +Subagent children showed no local echo because, when echoes were introduced, `subagents.prompt` carried no request id, so an echo could not be matched with the message the child recorded. Subagent control later moved to Remote, and the Host now records `SubagentPromptRequest.requestId` as the child message's `source.rpcId`; the client's `Session.prompt` still minted a separate id. + +## Decision + +"On next message" keeps a single fold motion: [flow-motion.ts](../../../../packages/client/ui-chat/src/client/chat/flow-motion.ts) hands every batch of closing rows to the `InputFoldDriver` that the viewport supplies, and [use-chat-viewport.ts](../../../../packages/client/ui-chat/src/client/chat/use-chat-viewport.ts) chooses one of two landings when the batch begins. + +### Batches + +- Rows that start closing in one commit, together with rows that start revealing in it, form one batch. The driver's four steps are: + - `begin`: measure the boxes and choose the landing; + - `prepare`: grow the room before box writes so the browser does not clamp an intermediate frame; + - `frame`: write box heights and scrollTop together; + - `finish`: install the final room. + + Content added during the batch updates the remaining movement by starting a new segment. +- A row that starts closing or revealing while a batch runs keeps its current size, without a transition of its own, until that batch settles; the next flush drives it in a new batch, or starts a held revealing row's own transition when nothing closes. Rows therefore never change outside a batch while following continues, which would follow the bottom room without the growth that should have consumed it. A batch takes over every member at its current size: a closing row shrinks from the height it has now, and a revealing row grows from the height its own transition already reached, so only the remaining growth enters the landing; a closing row with no size left finishes without animation. +- A batch with only revealing rows needs no room and keeps CSS transitions. When the viewport has no reading policy attached, closing rows also keep CSS transitions, and held rows finish immediately; under reduced motion closing rows finish immediately. +- Closing rows commit their hidden state first, the driver then installs the final geometry, and only then are fold-idle listeners notified (for example, clearing room after a display-mode change). +- The generic path is removed: the `reserve` callback, the `pendingReserve` and `pendingGrowth` reservation arithmetic, `reserveBelow`, and the post-fold `followAfterFold`. `reclaimBelow` (returning room after new content or reader scrolling) and `resetBelow` (clearing room after a display-mode change) remain. + +### Landings + +Let `start` be the scrollTop when the batch begins, `above` the height of closing rows above the viewport top minus revealing rows there, `base = max(0, start − above)`, and `bottom` the floor after the fold. + +| Landing | When | Target scrollTop | After the batch | +|---|---|---|---| +| **Tail** | This client's own input, or the reader was following the tail when the batch began and had no reading movement awaiting sampling | `max(base, bottom)` | Commit follow ownership | +| **Reader content still** | Otherwise | `base` | Keep the reader's reading state and acknowledge the new position | + +- Both landings install only the exact deficit as room: `max(0, target + viewport height − folded height)`. The tail landing reads the committed layout when it finishes: it lands at the larger of the target and the real floor, and keeps only the room that target needs. +- Content never slides back down: content already above the viewport top does not return. When the retained body and the new message cannot fill the viewport, the deficit remains as room until new content fills it. +- Own input during a running batch starts a new segment from the current position toward the tail; it takes over even when a reading gesture cancelled the automatic target. A tail request counts only while a batch runs or is scheduled; rows still closing on CSS transitions after a batch leave own input to ordinary following. +- A reading gesture cancels a tail landing's automatic target and keeps the reader's position. During a reader-content-still landing, the reader's own scrolling between frames moves the position the batch keeps still. +- For the reader-content-still landing, the driver takes the first visible top-level row outside the batch as a reference, found by binary search over the ordered top-level rows. Neighbouring spacing changes instantly when the batch begins and when closing rows commit their hidden state; the driver measures the reference's displacement at those two points and offsets scrollTop by it. +- When a tail landing scrolls forward past closing rows that start below the viewport top, those rows stay opaque, scroll with the content, and clip while they shrink; nested closing rows keep their size until the batch ends. Other closing rows fade in place. A newer tail request that takes over a running batch adds the closing rows still below the viewport top; a row that has already faded becomes opaque again for its remaining height. Scrolling forward therefore never crosses space that is already transparent but not yet closed. + +### Own input + +| Source | Determination | Landing | +|---|---|---| +| Idle send from the main composer | Transcript echo | Tail | +| Idle send from a subagent child's composer | The child also registers a local echo; `subagents.prompt` uses the echo's `requestId` | Tail | +| Queued while running, delivered after the previous Turn | Not own input | Tail when following, otherwise reader content still | +| Send from another tab | Not own input | Same as above | +| Parent Agent messages a child | The notice opens no exchange and folds nothing | — | + +"On completion" is outside the motion scope; its folds apply immediately and are unaffected. + +## Testing + +[fold-blank-matrix.e2e.ts](../../../../apps/web/tests/fold-blank-matrix.e2e.ts) covers 19 main-Session cases and 8 right-Sidebar child cases across reader position (bottom, middle of the body, top of the body, inside the steps, previous Turn), input source (composer, Turn failing before its message is recorded, another tab, queued delivery, steer, parent message, several Turns folding at once), body length, and Standard and "On completion" controls. The layout gap is the tallest band between the scrollport top and the composer that no visible row's box covers: at most 120 px once settled, and over that for at most one animation frame. It counts boxes, so a closing row that has faded but not finished shrinking still covers its space. Tail cases assert the floor landing; reader-content-still cases assert that the reader's anchor row moves at most 2 px on screen. Short-body cases assert only that no room remains after the reply arrives. + +## Alternatives considered + +**Keep the generic path and fix only own-input detection.** The two paths keep diverging geometry rules; for input that is not own input, a reader in the middle is either pulled to the bottom or still sees a blank. + +**Compensate the generic path by reader position but implement it separately from the input path.** The compensation and room geometry would exist twice and could diverge again. + +**Detect own input from the exchange-opening human node and keep children without echoes.** Input from another tab would count as own input and pull a reader in the middle to the bottom; child sends still would not show the sender's message immediately. + +**Let content slide back to fill the deficit a short body leaves.** Content would move up and then down within one animation. + +## Consequences + +- Benefit: the source of whole-viewport blanks (the generic path) no longer exists; the landing depends only on whether the input is own input and whether the reader was following, not on which page sent the message or when it was delivered. Child sends show the sender's message immediately. +- Cost: with a short body, content never sliding back still leaves the deficit until new content fills it. +- When focus is inside a closing row, focus protection keeps that row open. If it is a height-capped process group, its body shrinks to the cap immediately. That shrink happens before the batch begins, so the reader-content-still landing cannot measure it and the reader's content moves once. diff --git a/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.zh.md b/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.zh.md new file mode 100644 index 0000000000..96e36590e7 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.zh.md @@ -0,0 +1,92 @@ +# Agent Note: 「下条消息时收起」只用一条折叠动画 + +Status: implemented + +[English](2026-10-13-chat-single-fold-motion.md) | 中文 + +## Problem + +「何时收起工作详情」选「下条消息时收起」时,上一个交互段的过程行在下一条人类输入到来时收起。滚动层原本有两条折叠路径,按 `ownInput`([use-chat-scroll.ts](../../../../packages/client/ui-chat/src/client/chat/use-chat-scroll.ts))二选一: + +- **输入折叠路径**:折叠开始时测量收起行,算出最终落点和所需的底部补白,收起与滚动在同一个帧时钟上推进,一次落定。 +- **通用路径**:scrollTop 不动,把收起的高度整块预留为底部补白,折叠结束后再按跟随状态补滚一次。它只对折叠发生在读者下方的情况成立。折叠区在读者上方时,读者正在看的内容整体上移,视口里只剩补白。 + +`ownInput` 只在三种信号下成立:对话流里的本地回显(`submissionId`)、steer 回显(`steeringId`)、列表最后一行是用户消息(`lastIsUser`)。Host 写回开场输入的那次提交里,该轮的过程控制行排在输入之后,`lastIsUser` 不成立。于是,凡是不经过本页对话流回显的人类输入都走通用路径: + +| 来源 | 本页回显 | 读者在底部 | 读者在正文中段 | +|---|---|---|---| +| 主会话输入框空闲发送 | 有,在对话流 | 正常 | 正常 | +| 主会话排队消息在上一轮结束后自动投递 | 有,但在排队区,不参与判定 | 整屏空白 | 整屏空白 | +| 另一个标签页发送 | 无 | 整屏空白 | 整屏空白 | +| 右侧栏子会话输入框发送 | 无(composer 跳过 `beginSubmission`) | 整屏空白 | 整屏空白 | +| 子会话里父消息唤醒多轮后人类发送 | 无 | 整屏空白(补白约 6000 px) | — | + +通知唤醒的 Turn 已不再触发折叠([分组树位置与交互段](2026-10-11-chat-group-tree-exchanges.zh.md));上表是仍经由通用路径折叠的人类输入。数据来自 [fold-blank-matrix.e2e.ts](../../../../apps/web/tests/fold-blank-matrix.e2e.ts)。 + +子会话不显示本地回显,是因为引入回显时 `subagents.prompt` 不携带 request id,回显无法与子会话写回的消息对应。此后子 agent 控制迁到 Remote,`SubagentPromptRequest.requestId` 已被 Host 写入子会话用户消息的 `source.rpcId`;客户端 `Session.prompt` 却仍另行生成 id。 + +## Decision + +「下条消息时收起」只保留一条折叠动画:[flow-motion.ts](../../../../packages/client/ui-chat/src/client/chat/flow-motion.ts) 把每一批收起交给 viewport 提供的 `InputFoldDriver`,[use-chat-viewport.ts](../../../../packages/client/ui-chat/src/client/chat/use-chat-viewport.ts) 在批次开始时选择两种落点之一。 + +### 批次 + +- 同一次提交中开始收起的行,连同同批开始展开的行,组成一个批次。驱动器的四步依次为: + - `begin`:测量这些盒子,选定落点; + - `prepare`:在写盒子之前扩大补白,避免中间帧被浏览器截断; + - `frame`:同步写盒子高度和 scrollTop; + - `finish`:安装最终补白。 + + 批次中途新增的内容按分段规则更新剩余运动。 +- 批次运行期间开始收起或展开的行保持当前尺寸、不走自己的过渡,等当前批次落定后由下一次刷新处理:有收起行时进入新批次,只剩展开行时启动它自己的过渡。因此不会出现「批次之外有行在变化、同时跟随照常进行」的情况;否则跟随会把底部空间整块跟进去,而本该抵扣它的新内容没有被计入。批次按每个成员的当前尺寸接管:收起行从现有高度收缩,展开行从它自己的过渡已达到的高度继续增长,只有剩余的增长量计入落点;已无尺寸的收起行直接完成、不做动画。 +- 只含展开行的批次不需要补白,仍用 CSS 过渡。viewport 未挂载阅读策略时,收起行也用 CSS 过渡、被挂起的行立即完成;减少动态效果时收起行立即完成。 +- 收起行先提交 hidden,驱动器再安装最终几何,之后才通知 fold-idle 监听者(例如切换展示模式后清空补白)。 +- 通用路径已删除:`reserve` 回调、`pendingReserve` 与 `pendingGrowth` 的预留计算、`reserveBelow`、折叠结束后再跟随的 `followAfterFold`。保留 `reclaimBelow`(新内容到来或读者滚动后归还补白)与 `resetBelow`(切换展示模式后清空补白)。 + +### 落点 + +记 `start` 为批次开始时的 scrollTop,`above` 为视口顶部以上收起行的高度减去展开行的高度,`base = max(0, start − above)`,`bottom` 为折叠后的底部位置。 + +| 落点 | 何时 | 目标 scrollTop | 结束后 | +|---|---|---|---| +| **到底部** | 本客户端的自己输入,或批次开始时读者正在跟随底部、且没有尚待采样的阅读滚动 | `max(base, bottom)` | 提交跟随状态 | +| **读者内容不动** | 其余情况 | `base` | 保持读者的阅读状态,确认新位置 | + +- 两种落点都只安装精确差额作为补白:`max(0, 目标 + 视口高 − 折叠后高度)`。到底部落点在结束时读取已提交的实际排版:落在目标与真实底部中较大的位置,只保留该目标所需的空间。 +- 「内容只上不下」:已经在视口顶部以上的内容不会回滑。保留的正文和新消息不足一屏时留出差额,新内容到来后填满。 +- 批次进行中出现自己输入时,从当前位置开新段,改为到底部;即使阅读手势已取消自动目标,也由新输入接管。只有批次正在运行或已排定时才接受到底部请求;批次结束后仍在用 CSS 过渡收起的行,不接管自己的输入,交给普通跟随。 +- 读者的滚动手势会取消「到底部」的自动目标,保留读者位置。「读者内容不动」时,帧间读者自己的滚动会并入批次保持的位置。 +- 「读者内容不动」时,驱动器对有序的顶层行二分查找,选取视口里第一行不属于本批次的可见顶层行作为参照。批次开始和收起行提交 hidden 时,相邻行的间距会瞬间改变;驱动器在这两处测量参照行的位移,抵消到 scrollTop 上。 +- 「到底部」且需要向前滚过视口顶部以下的收起行时,这些行保持不透明、随内容滚动,在收缩中裁剪显示,嵌套的收起行在批次结束前保持原尺寸。其余收起行原地淡出。新的到底部请求接管运行中的批次时,会把仍在视口顶部以下的收起行加入随行;已经淡出的行会以剩余高度重新变为不透明。这样向前滚动不会穿过一片已经透明、还没收完的空位。 + +### 自己输入的判定 + +| 来源 | 判定 | 落点 | +|---|---|---| +| 主会话输入框空闲发送 | 对话流回显 | 到底部 | +| 子会话输入框空闲发送 | 子会话同样注册本地回显;`subagents.prompt` 使用回显的 `requestId` | 到底部 | +| 运行中排队、上一轮结束后自动投递 | 不是自己输入 | 跟随底部时到底部,否则内容不动 | +| 另一个标签页发送 | 不是自己输入 | 同上 | +| 父 agent 给子会话发消息 | 通知不开启交互段,不折叠 | — | + +「完成时收起」不在动画作用域内,折叠立即生效,不受影响。 + +## Testing + +[fold-blank-matrix.e2e.ts](../../../../apps/web/tests/fold-blank-matrix.e2e.ts) 覆盖主会话 19 例与右侧栏子会话 8 例,维度包括读者位置(底部、正文中段、正文开头、步骤中间、上一轮)、输入来源(输入框、回合在写入消息前失败、另一个标签页、排队投递、steer、父消息、一次折叠多轮)、正文长短,以及 Standard 与「完成时收起」对照。布局空洞指视口顶部到输入框之间没有任何可见行的盒子覆盖的最大连续高度:落定后不超过 120 px,动画中至多 1 帧超过。它按盒子计算,已经淡出但尚未收完的收起行仍算作覆盖。「到底部」的用例断言落到底部;「内容不动」的用例断言读者锚定行的屏幕位置变化不超过 2 px。正文很短的用例只断言回复到达后不再留空。 + +## Alternatives considered + +**保留通用路径,只修自己输入的识别。** 两条路径的几何规则继续分叉;非自己输入时,中段读者要么被拉到底部,要么仍然看到空白。 + +**通用路径改为按读者位置补偿,但与输入路径分开实现。** 补偿与补白的几何代码重复一份,两边容易再次不一致。 + +**用开启交互段的人类输入节点识别自己输入,子会话不补回显。** 另一个标签页的输入会被当成自己输入,把中段读者拉到底部;子会话发送后仍然不能立即看到自己的消息。 + +**允许内容回滑,以填满短正文留下的差额。** 同一次动画里内容先上后下。 + +## Consequences + +- 收益:整屏空白的来源(通用路径)不再存在;落点只看「是不是自己的输入」和「读者是否在跟随」,与消息从哪个页面、何时投递无关。子会话发送立即显示自己的消息。 +- 代价:正文很短时,「内容只上不下」仍会留出差额,直到新内容填满。 +- 焦点停在正在收起的行里时,焦点保护会让该行保持展开。若它是被封顶高度的过程组,正文会立即缩到封顶高度。这次缩小发生在批次开始之前,「读者内容不动」的落点测不到,读者内容会移动一次。 diff --git a/.agents/notes/implemented/architecture/2026-10-13-context-breakdown-system-prompt-state.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-13-context-breakdown-system-prompt-state.i18n.yaml new file mode 100644 index 0000000000..a5dc5cf3f9 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-13-context-breakdown-system-prompt-state.i18n.yaml @@ -0,0 +1,22 @@ +# Bilingual-pair consistency record for 2026-10-13-context-breakdown-system-prompt-state.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-13-context-breakdown-system-prompt-state.md +/agent-note-context-breakdown-retains-only-system-prompts: + en: 9b155f87202e037e + zh: 8d4190c00412b892 +/agent-note-context-breakdown-retains-only-system-prompts/problem: + en: 7cbbdfbe7da9ae7a + zh: 50368b272d7b5ebc +/agent-note-context-breakdown-retains-only-system-prompts/decision: + en: 52d5c42112c5c6dc + zh: 744dbbcc52193300 +/agent-note-context-breakdown-retains-only-system-prompts/verification: + en: 68a7375ca7fe99e8 + zh: e3c7d108740e8952 +/agent-note-context-breakdown-retains-only-system-prompts/alternatives-considered: + en: 2bd0e83c5298da2b + zh: 336259b6985eb7b5 +/agent-note-context-breakdown-retains-only-system-prompts/consequences: + en: 839a919ff16bc53c + zh: bf0d4f3a783ead1d diff --git a/.agents/notes/implemented/architecture/2026-10-13-context-breakdown-system-prompt-state.md b/.agents/notes/implemented/architecture/2026-10-13-context-breakdown-system-prompt-state.md new file mode 100644 index 0000000000..7d4e69bd0f --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-13-context-breakdown-system-prompt-state.md @@ -0,0 +1,36 @@ +# Agent Note: Context breakdown retains only system prompts + +Status: implemented + +English | [中文](2026-10-13-context-breakdown-system-prompt-state.zh.md) + +## Problem + +`contextBreakdown` kept one `{ seq, heuristicTokens, system }` entry per retained surface node so that any replacement could subtract the exact price of its range and re-select the last nonempty system prompt. Its checkpoint therefore grew with the number of messages in the model context. In a local corpus of 836 Sessions, 151,122 entries made the unit's rows 7.13 MB of the projection cache and up to 103 KB for one Session, and every cached Session listing validated all of them. Long tool-heavy runs below the compaction threshold kept about 2,000 entries each. + +## Decision + +[`breakdown-projection.ts`](../../../../packages/llm/token-meter/src/breakdown-projection.ts) retains the surviving `system/message` nodes in surface order with their heuristic prices, one running surface total, and at most one armed shadow-price claim. State version 6 discards version-5 rows; other projection keys keep their rows. + +- An append adds its price; a `system/message` append also joins the end of the system list. +- `compaction/summary` and `compaction/prune` arm a claim from `shadowedRange`, `shadowedTokenCount`, and the retained system prompts named by `shadowedSeqs`. The next event consumes or expires it. A replacement over the claimed range subtracts the claim price and drops those prompts; a replacement naming another range, or putting a `system/message` in place of a metered range, throws. +- An unmetered replacement of one retained system prompt subtracts that prompt's own price. A `system/message` replacement keeps its position; any other replacement removes it. +- Any other unmetered replacement folds at zero delta, the rule `contextPressure` already applies. + +`systemTokens` is the last retained prompt with a positive price, and `messageTokens` is the surface total minus it; both are floored at zero. `shadowedSeqs` is the authoritative set of shadowed nodes, so membership decides which prompts leave without locating non-system endpoints in surface order. + +## Verification + +Folding all 836 stored logs with the version-5 and version-6 definitions produced identical breakdowns after every event. The logs held 2,206 metered replacements, 106 system-prompt rewrites, and no other replacement. Retained state fell from 7.13 MB to 110 KB, with at most five surviving system prompts in one Session. + +## Alternatives considered + +**Keep per-node entries with a faster validator.** A hand-written node check cut listing validation from 75 ms to 4.5 ms but kept 7.13 MB of rows, an O(surface) array copy per surface event, and checkpoint writes proportional to the message count. + +**Columnar per-node entries.** Parallel arrays shrink rows about 5.7 times but keep O(surface) state for information the shadow-price events already carry. + +**Exact pricing of unmetered non-system replacements.** That requires per-node prices. Current producers meter every non-system replacement, and released formats from v0 carry the shadow-price fields, so the remaining case is a log that breaks producer adjacency; it drifts like `contextPressure`. + +## Consequences + +Checkpoint size and per-event cost follow the number of surviving system prompts rather than messages, and cached listings no longer validate per-message entries. The figures stay exact for metered replacements and system-prompt rewrites, and their sum equals the `measure()` heuristic total under those producers. An unmetered non-system replacement leaves the figures off by that replacement's price difference. A future range replacement that inserts a system prompt is rejected until the fold can order it among the retained prompts. This supersedes the positional-entry choice for this unit in the [in-history system-prompt replacement](../feature/2026-09-02-in-history-system-prompt-replacement.md) note; `measure()` keeps positional nodes for route pricing. diff --git a/.agents/notes/implemented/architecture/2026-10-13-context-breakdown-system-prompt-state.zh.md b/.agents/notes/implemented/architecture/2026-10-13-context-breakdown-system-prompt-state.zh.md new file mode 100644 index 0000000000..bab33f441d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-13-context-breakdown-system-prompt-state.zh.md @@ -0,0 +1,36 @@ +# Agent Note: 上下文构成只保留系统提示词 + +Status: implemented + +[English](2026-10-13-context-breakdown-system-prompt-state.md) | 中文 + +## Problem + +`contextBreakdown` 曾为每个保留的 surface 节点存一条 `{ seq, heuristicTokens, system }`,以便任意替换都能减去其范围的精确价格,并重新选出最后一个非空系统提示词。因此它的检查点随模型上下文中的消息数增长。在一个包含 836 个 Session 的本地语料中,151,122 条条目让该单元的行占用 projection cache 的 7.13 MB,单个 Session 最多 103 KB,而每次缓存会话列表都要校验全部条目。低于压缩阈值、工具调用密集的长时间运行各自保留约 2,000 条条目。 + +## Decision + +[`breakdown-projection.ts`](../../../../packages/llm/token-meter/src/breakdown-projection.ts) 按 surface 顺序保留存活的 `system/message` 节点及其启发式价格、一个 surface 运行总量,以及至多一个已武装的影子价 claim。状态版本 6 丢弃版本 5 的行;其他 projection key 保留各自的行。 + +- 追加加上其价格;`system/message` 追加还会加入系统列表末尾。 +- `compaction/summary` 和 `compaction/prune` 依据 `shadowedRange`、`shadowedTokenCount` 以及 `shadowedSeqs` 中列出的已保留系统提示词武装 claim。下一个事件消费或使其失效。覆盖所 claim 范围的替换减去 claim 价格并移除这些提示词;指向其他范围的替换,或以 `system/message` 取代已计量范围的替换,会抛出错误。 +- 对单个已保留系统提示词的未计量替换减去该提示词自身的价格。`system/message` 替换保持其位置;其他替换将其移除。 +- 其他未计量替换以零增量折叠,这与 `contextPressure` 已采用的规则相同。 + +`systemTokens` 是最后一个价格为正的保留提示词,`messageTokens` 是 surface 总量减去它;两者都不低于零。`shadowedSeqs` 是被遮蔽节点的权威集合,因此由成员关系决定哪些提示词离开,无需在 surface 顺序中定位非系统端点。 + +## Verification + +用版本 5 和版本 6 的定义折叠全部 836 份已存日志,每个事件之后的构成数字都完全相同。这些日志包含 2,206 次已计量替换、106 次系统提示词改写,没有其他替换。保留状态从 7.13 MB 降到 110 KB,单个 Session 最多有五个存活的系统提示词。 + +## Alternatives considered + +**保留逐节点条目,换用更快的校验器。** 手写节点检查把列表校验从 75 ms 降到 4.5 ms,但仍保留 7.13 MB 的行、每个 surface 事件一次 O(surface) 数组复制,以及与消息数成正比的检查点写入。 + +**列式逐节点条目。** 并行数组让行缩小约 5.7 倍,但仍为影子价事件已经携带的信息保留 O(surface) 状态。 + +**精确定价未计量的非系统替换。** 这需要逐节点价格。当前生产方会计量每一次非系统替换,自 v0 起的已发布格式都携带影子价字段,因此剩余情况只是破坏生产方相邻性的日志;它像 `contextPressure` 一样产生偏差。 + +## Consequences + +检查点大小和每个事件的成本取决于存活系统提示词的数量而不是消息数,缓存列表也不再校验逐消息条目。对已计量替换和系统提示词改写,数字保持精确,其和在这些生产方下等于 `measure()` 的启发式总量。未计量的非系统替换会让数字偏离该替换的价格差。未来插入系统提示词的范围替换会被拒绝,直到折叠能确定它在已保留提示词中的顺序。这取代了 [历史内系统提示词替换](../feature/2026-09-02-in-history-system-prompt-replacement.zh.md) 说明中针对该单元的位置条目选择;`measure()` 仍为路由定价保留位置节点。 diff --git a/.agents/notes/implemented/architecture/2026-10-13-team-identity-runtime-context.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-13-team-identity-runtime-context.i18n.yaml new file mode 100644 index 0000000000..65205260c3 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-13-team-identity-runtime-context.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-10-13-team-identity-runtime-context.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-13-team-identity-runtime-context.md +/agent-note-team-identity-in-required-runtime-context: + en: 8c88759320ae8481 + zh: 4068b18723907eb9 +/agent-note-team-identity-in-required-runtime-context/problem: + en: c9a81cb731ddf7b2 + zh: 428c0e6f8937d7c5 +/agent-note-team-identity-in-required-runtime-context/decision: + en: 6c73479973c1e378 + zh: 98677c77f77f8d81 +/agent-note-team-identity-in-required-runtime-context/alternatives-considered: + en: 774566ffbf9e9db8 + zh: ea761d876af462f2 +/agent-note-team-identity-in-required-runtime-context/consequences: + en: 18d1aa67936f1227 + zh: 7e06c1f51553ecdd diff --git a/.agents/notes/implemented/architecture/2026-10-13-team-identity-runtime-context.md b/.agents/notes/implemented/architecture/2026-10-13-team-identity-runtime-context.md new file mode 100644 index 0000000000..39794f02d3 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-13-team-identity-runtime-context.md @@ -0,0 +1,31 @@ +# Agent Note: Team identity in required runtime context + +Status: implemented + +English | [中文](2026-10-13-team-identity-runtime-context.zh.md) + +## Problem + +A teammate identity embedded in its initial task depends on ordinary history retention. Compaction can remove it, and an ordinary fork from that teammate can retain the old role even though the fork is an independent Lead. Identity must reflect current membership while keeping shared system instructions uniform. + +## Decision + +`tool-agent-team` contributes `team:identity` through the Agent-scoped `systemPrompt.context()` registry with `required: true`. Its provider reads the existing Team membership and renders the member's role, name, and addressing instructions. Optional-context suppression cannot remove it. Fixed collaboration policy stays in the system prompt; roster and task state remain tool queries. Initial tasks contain only their supplied content. + +The loop owns logging, retained-snapshot comparison, and re-admission after compaction. A changed role appends the current full runtime-context snapshot after inherited history; unchanged retained context adds no message. No Team-specific retention state, event type, or log migration is introduced. Existing recorded reminders remain historical messages. + +This supersedes only the initial-task identity decision in [Agent Teams](../feature/2026-08-05-agent-teams.md#team-identity); its roster, mailbox, and task decisions remain active. + +## Alternatives considered + +**Keep identity in the initial task.** Preserves a uniform system prompt but cannot restore removed identity or correct a fork's inherited role without another mechanism. + +**Put identity in the system prompt.** Makes system text differ across members and can rewrite the inherited request prefix on routes without in-history system updates. + +**Add custom reminder retention and reinjection.** Duplicates the loop's existing runtime-context projection and recovery responsibilities. + +## Consequences + +Members pay for identity within the full runtime-context snapshot. Other context changes repeat it, while stable retained context adds no new message. Forks preserve inherited history and append current identity; actual provider cache hits remain best-effort. Existing Team authorization remains in tool execution. + +Verification covers fresh and fork teammates, an independent fork from a teammate, optional-context suppression, compaction restoration, cold resume, and registration disposal. The recorded `team-targets` scenario pins Lead context; the keyless headless Team workflow pins teammate context through the shipped profile. diff --git a/.agents/notes/implemented/architecture/2026-10-13-team-identity-runtime-context.zh.md b/.agents/notes/implemented/architecture/2026-10-13-team-identity-runtime-context.zh.md new file mode 100644 index 0000000000..d0e333a71e --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-13-team-identity-runtime-context.zh.md @@ -0,0 +1,31 @@ +# Agent Note: 必需 runtime context 中的 Team 身份 + +Status: implemented + +[English](2026-10-13-team-identity-runtime-context.md) | 中文 + +## Problem + +嵌入初始任务的 teammate 身份依赖普通历史保留。压缩可能移除它;从 teammate 创建的普通 fork 即使已成为独立 Lead,也可能保留旧角色。身份必须反映当前 membership,同时保持共享系统指令一致。 + +## Decision + +`tool-agent-team` 通过 Agent 作用域的 `systemPrompt.context()` 注册 `team:identity`,并设置 `required: true`。提供方读取现有 Team membership,渲染成员角色、名字与寻址说明。关闭可选 context 不能移除它。固定协作策略保留在 system prompt;roster 和 task 状态仍通过工具查询。初始任务只包含传入的正文。 + +Loop 负责日志记录、保留快照比较和压缩后的重新接纳。角色变化会在继承历史之后追加当前完整 runtime-context 快照;保留的 context 未变化时不追加消息。不引入 Team 专属的保留状态、事件类型或日志迁移。已有身份提醒仍作为历史消息保留。 + +本决策仅取代 [Agent Teams](../feature/2026-08-05-agent-teams.zh.md#team-identity) 中的初始任务身份决策;其 roster、mailbox 和 task 决策仍然有效。 + +## Alternatives considered + +**身份留在初始任务中。** 保持 system prompt 一致,但恢复被移除的身份或纠正 fork 继承的角色需要额外机制。 + +**身份放进 system prompt。** 成员之间的 system 文本会不同;不支持历史内 system 更新的路由可能重写继承的请求前缀。 + +**添加自定义提醒保留与重新注入。** 重复了 loop 已有的 runtime-context 投影和恢复职责。 + +## Consequences + +成员承担完整 runtime-context 快照中的身份 token 成本。其他 context 变化会重复身份,保留的 context 稳定时则不追加消息。Fork 保留继承历史并追加当前身份;提供方实际缓存命中仍为尽力而为。现有 Team 授权仍在工具执行时落实。 + +验证覆盖 fresh 和 fork teammate、从 teammate 创建的独立 fork、关闭可选 context、压缩后恢复、冷恢复和注册释放。录制的 `team-targets` 场景固定 Lead context;无密钥 headless Team 工作流通过内置 profile 固定 teammate context。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-29-reused-blank-runs-requested-preset.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-29-reused-blank-runs-requested-preset.i18n.yaml new file mode 100644 index 0000000000..56ecd753a6 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-29-reused-blank-runs-requested-preset.i18n.yaml @@ -0,0 +1,22 @@ +# Bilingual-pair consistency record for 2026-09-29-reused-blank-runs-requested-preset.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# 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-29-reused-blank-runs-requested-preset.md +/agent-note-a-reused-blank-session-runs-the-requested-preset: + en: 06173bc82e1d9f59 + zh: 637352fd92142310 +/agent-note-a-reused-blank-session-runs-the-requested-preset/problem: + en: 7514025504dc2ddd + zh: 96249d3472005023 +/agent-note-a-reused-blank-session-runs-the-requested-preset/decision: + en: e396c291f742a5f3 + zh: c67be0fd305215fe +/agent-note-a-reused-blank-session-runs-the-requested-preset/alternatives-considered: + en: ae99345e8df800e4 + zh: a4d0c257c4216337 +/agent-note-a-reused-blank-session-runs-the-requested-preset/consequences: + en: bc7dda06670b4d96 + zh: 24f381b8cb6ed3f8 +/agent-note-a-reused-blank-session-runs-the-requested-preset/testing: + en: 1aefca569f5dce81 + zh: 527c9aacf5a2f6b8 diff --git a/.agents/notes/implemented/bug-fix/2026-09-29-reused-blank-runs-requested-preset.md b/.agents/notes/implemented/bug-fix/2026-09-29-reused-blank-runs-requested-preset.md new file mode 100644 index 0000000000..0ebcb569a2 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-29-reused-blank-runs-requested-preset.md @@ -0,0 +1,37 @@ +# Agent Note: A reused blank Session runs the requested preset + +Status: implemented + +English | [中文](2026-09-29-reused-blank-runs-requested-preset.zh.md) + +## Problem + +Web New Session, Workspace navigation, and startup restoration reused a blank Session by naming it in `session.create`. The Host resumed that Session under the preset its log recorded, so a new conversation inherited the preset the blank had been created or switched under. When that preset was no longer declared, such as a removed or renamed definition or a legacy `$DSH_HOME/.agent-presets//` directory that nothing reads any more, every New Session in that Workspace and every startup restoration of that draft failed with `agent-preset/not-found` although the default preset was valid (issue #2566). A blank created under an earlier default silently kept that default. The client retried only `session/writer-held`, so the stale blank stayed first in line. + +## Decision + +`session.create` returns a Session for a new conversation. A named `sessionId` is a reuse candidate that the Host verifies: it must not have started a turn, must belong to the requested directory, must be an unarchived member of the requested Workspace, and must not be written by another owner. For a persisted candidate the Host repeats the started check under the writer it acquires. Requests naming one identity run in arrival order, including their preset resolution, and the directory and ownership checks precede that resolution. The reused Session runs the requested `agentPreset`, or the configured default when the request names none; the Host does not resolve the recorded preset for reuse. When the target differs, a live Session switches through the preset registry's `select`, and a persisted Session mounts the target in its unpublished resume setup and appends `agent-preset/selected` there, so the log names the composition before the Agent is published. A candidate that has started, fails placement, or is written by another owner stays unchanged, and the Host creates a new Session; the response names the Session used. An explicit request for an undeclared preset still fails with `agent-preset/not-found`, and resuming a started Session still resolves its recorded identity. + +The Web client names the first eligible blank when it connects a Workspace or prepares an explicit draft, and passes the preset a flow knows before it starts, such as the creator entry's `cordis`; a plain New Session creates a fresh Session instead. Startup restoration names the saved blank with the preset its log recorded, reloading the Session's projections first because listed values are cached hints that can lag the log after an unclean Host exit, so a draft keeps its selection; after `agent-preset/not-found` or `agent-preset/invalid` it asks again without a preset, and the draft continues under the default. When the Host declines a named blank, the client reloads that Session's projections, because stale cached list metadata can still mark a started conversation blank and hide it from the Session tree. + +## Alternatives considered + +**Fall back to the default only when the recorded preset is missing.** This removes the failure but keeps an inherited preset whenever it still resolves, so a New Session after a default change runs the old default. + +**Retry `agent-preset/not-found` in the client.** The stale blank stays the first reuse candidate, so later New Sessions fail once before creating another Session, and startup restoration and other callers still fail. + +**Skip blanks whose preset is missing from the roster.** Navigation would need the roster, stale Sessions would never be repaired, and other callers would still fail. + +**Let the Host choose the blank.** The Host would scan the catalog on every request, while the client already holds the ordered catalog; verification under the writer covers a stale client view. + +**Keep a restored draft's preset on the Host when it resolves.** A request that names a blank without a preset would then mean "keep" for restoration and "default" for New Session. Passing the recorded preset gives each request one meaning. + +## Consequences + +A reused blank runs the default or the preset its flow names, including after the default changes or a definition disappears. A preset picked on a draft survives a page reload but not a later Workspace connection or draft preparation that reuses the draft, and reusing a draft that another tab shows changes that tab's preset too. Writer contention no longer reaches the client: the Host creates the replacement Session. A blank whose first prompt was accepted but whose turn has not started still counts as blank, as it does for `select`. A declined stale blank is corrected only in the client that named it, until the Session is next activated. The preset registry's `select` leaves a blank Session that records the requested preset and runs its current revision unchanged, so a client that also selects that preset after the creation request adds no second record. + +This note replaces the client-side contention fallback of [Writer acquisition for blank Session reuse](../architecture/2026-09-17-process-local-blank-sessions.md); writer acquisition and candidate order remain as decided there. + +## Testing + +Session Controller tests cover live and persisted reuse, a recorded preset that is no longer declared, named presets, started and overtaken Sessions, writer contention, Workspace placement, storage failures, arrival order when an earlier preset resolves later, and directory checks before preset resolution. Workspace navigation tests cover restoration with and without a usable recorded preset, restoration when the listed preset lags the log, connection coalescing that a different preset request supersedes, and the projection reload for a declined blank; the preset plugin tests cover the creator entry, and the registry tests cover a repeated selection with and without a replaced definition. The shipped Web composition test reuses a blank recorded under an undeclared preset under the default when startup connects its Workspace and when startup restores it, keeps a restored draft's declared preset, and checks the durable `agent-preset/selected` record; the stale blank cache scenario checks that startup opens a new Session and still lists the declined conversation. diff --git a/.agents/notes/implemented/bug-fix/2026-09-29-reused-blank-runs-requested-preset.zh.md b/.agents/notes/implemented/bug-fix/2026-09-29-reused-blank-runs-requested-preset.zh.md new file mode 100644 index 0000000000..28c717f958 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-29-reused-blank-runs-requested-preset.zh.md @@ -0,0 +1,37 @@ +# Agent Note: 被复用的空白会话运行请求的 preset + +Status: implemented + +[English](2026-09-29-reused-blank-runs-requested-preset.md) | 中文 + +## Problem + +Web 的新建会话、工作区导航与启动恢复过去通过在 `session.create` 中指明空白会话来复用它。Host 过去按日志记录的 preset 恢复该会话,因此新对话继承了空白会话创建时或切换后的 preset。当该 preset 已不再声明时,例如定义被删除或改名,或是如今已无人读取的旧版 `$DSH_HOME/.agent-presets//` 目录,即使默认 preset 有效,该工作区中的每次新建会话以及该草稿的每次启动恢复都会以 `agent-preset/not-found` 失败(issue #2566)。在更早默认值下创建的空白会话则会静默保留那个旧默认值。客户端只对 `session/writer-held` 重试,因此过期的空白会话始终排在复用候选首位。 + +## Decision + +`session.create` 返回一个用于新对话的会话。请求指明的 `sessionId` 是由 Host 核实的复用候选:它必须尚未开始任何轮次、属于请求的目录、是请求的工作区中未归档的成员,且未被其他写者占用。对于持久化候选,Host 会在取得写锁后再次检查它是否已开始。指明同一标识的请求按到达顺序执行,preset 解析也包含在内,且目录与归属检查先于该解析。被复用的会话运行请求的 `agentPreset`,请求未指明时运行配置的默认 preset;Host 复用时不解析记录的 preset。目标与当前不同时,活动会话通过 preset 注册表的 `select` 切换;持久化会话在尚未发布的恢复 setup 中挂载目标 preset,并在同一处追加 `agent-preset/selected`,使日志在 Agent 发布前就写明其组成。已开始、不满足归属或被其他写者占用的候选保持不变,Host 改为新建会话;响应指明实际使用的会话。显式请求未声明的 preset 仍以 `agent-preset/not-found` 失败,恢复已开始的会话仍解析其记录的标识。 + +Web 客户端在连接工作区或显式准备草稿时指明第一个符合条件的空白会话,并传入流程在开始前已确定的 preset,例如创造模式入口的 `cordis`;普通新建会话则直接新建 Session。启动恢复指明已保存的空白会话及其日志记录的 preset,并先重新加载该会话的投影,因为列表中的值是缓存提示,Host 非正常退出后可能落后于日志,从而使草稿保留其选择;遇到 `agent-preset/not-found` 或 `agent-preset/invalid` 后改为不带 preset 再请求一次,草稿在默认 preset 下继续。Host 拒绝复用某个指明的空白会话时,客户端会重新读取该会话的投影,因为过期的列表缓存元数据可能仍把已开始的对话标为空白,并把它从会话树中隐藏。 + +## Alternatives considered + +**只在记录的 preset 缺失时回退到默认值。** 这能消除失败,但只要继承的 preset 仍可解析就会保留它,因此默认值变更后的新建会话仍运行旧默认值。 + +**在客户端重试 `agent-preset/not-found`。** 过期的空白会话仍是第一个复用候选,之后每次新建会话都会先失败一次再新建,启动恢复和其他调用方仍会失败。 + +**跳过 preset 不在列表中的空白会话。** 导航需要读取 preset 列表,过期会话永远得不到修复,其他调用方仍会失败。 + +**由 Host 选择空白会话。** Host 每次请求都要扫描会话列表,而客户端已持有排好序的列表;在写锁下的核实足以覆盖客户端视图过期的情况。 + +**恢复草稿时由 Host 在记录的 preset 可解析时保留它。** 这样一个指明空白会话但未带 preset 的请求,在恢复时表示「保留」,在新建会话时表示「默认」。传入记录的 preset 让每个请求只有一种含义。 + +## Consequences + +被复用的空白会话运行默认 preset 或其流程指明的 preset,默认值变更或定义消失之后也是如此。在草稿上选择的 preset 在刷新页面后仍保留,但之后复用该草稿的工作区连接或草稿准备不会保留它;复用另一个标签页正在显示的草稿也会改变那个标签页的 preset。写锁争用不再到达客户端:由 Host 新建替代会话。首条提示词已被接受但轮次尚未开始的空白会话仍算空白,与 `select` 一致。被拒绝复用的过期空白会话只在指明它的客户端中得到纠正,直到该会话下次被激活。若空白会话已记录所请求的 preset 且正运行其当前版本,preset 注册表的 `select` 不做改动,因此客户端在创建请求之后再选择同一 preset 也不会追加第二条记录。 + +本注记取代 [复用空白 Session 时获取写锁](../architecture/2026-09-17-process-local-blank-sessions.zh.md) 中的客户端锁争用回退;写锁获取与候选顺序仍按该决策执行。 + +## Testing + +Session Controller 测试覆盖活动会话与持久化会话的复用、记录的 preset 已不再声明、指明的 preset、已开始与被抢先开始的会话、写锁争用、工作区归属、存储失败、较早请求的 preset 解析较慢时仍按到达顺序执行,以及目录检查先于 preset 解析。工作区导航测试覆盖记录的 preset 可用与不可用时的恢复、列表中的 preset 落后于日志时的恢复、被不同 preset 请求取代的连接不再合并,以及被拒绝的空白会话的投影重读;preset 插件测试覆盖创造模式入口,注册表测试覆盖定义未替换与已替换时的重复选择。实际 Web 组合测试在启动连接工作区与启动恢复时,把记录了未声明 preset 的空白会话复用到默认 preset 下,验证恢复的草稿保留已声明的 preset,并检查持久化的 `agent-preset/selected` 记录;过期空白缓存场景验证启动时打开新会话,并仍列出被拒绝复用的对话。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-30-job-session-lifecycle.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-30-job-session-lifecycle.i18n.yaml new file mode 100644 index 0000000000..a46ef9740b --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-30-job-session-lifecycle.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-09-30-job-session-lifecycle.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# 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-30-job-session-lifecycle.md +/agent-note-persist-owned-job-lifecycle-facts: + en: 79d83b765acf6e24 + zh: afe6ea9af6d96d10 +/agent-note-persist-owned-job-lifecycle-facts/problem: + en: b3d0591cc559e466 + zh: aa4c385e84f7eaa1 +/agent-note-persist-owned-job-lifecycle-facts/decision: + en: c982a59cbe5202d3 + zh: 9315962d2e4b8f0a +/agent-note-persist-owned-job-lifecycle-facts/alternatives-considered: + en: 140cf7152a35bb27 + zh: 001aa96687836914 +/agent-note-persist-owned-job-lifecycle-facts/consequences: + en: 3761af53744aa0c7 + zh: 876cb056c248c43c diff --git a/.agents/notes/implemented/bug-fix/2026-09-30-job-session-lifecycle.md b/.agents/notes/implemented/bug-fix/2026-09-30-job-session-lifecycle.md new file mode 100644 index 0000000000..0edf7deeef --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-30-job-session-lifecycle.md @@ -0,0 +1,37 @@ +# Agent Note: Persist owned job lifecycle facts + +Status: implemented + +English | [中文](2026-09-30-job-session-lifecycle.zh.md) + +## Problem + +SDK consumers receive Session events, while job registration and settlement previously existed only in the live registry. A background tool can return before work finishes, and output collection can happen much later or never. Tool invocation times therefore cannot reconstruct a job's execution interval. Runtime-local ids also repeat after a registry restart. + +## Decision + +The local provider captures `job/registered` and `job/settled` facts for owned jobs, retaining the registry's epoch-millisecond timestamps, original owner, producer kind, label, and optional creating tool call. A UUID-backed `JobId` identifies both live controls and historical records across registry restarts and copied history. Unowned work has no Session journal. + +The terminal record distinguishes a producer report from forced bookkeeping: `producerReported` is true when `JobHooks.done` fulfills, and false when that promise rejects or teardown cancellation throws. It reports fulfillment, not independently confirmed resource release. Missing terminal records remain incomplete and are not reconstructed from later reads or process restart. + +Facts are appended synchronously at the registry commit before live hub reactions and completion waiters resume. Recording errors warn independently: already-started work still has its control id, and a failed journal append cannot prevent cleanup or release of waiters. The Session's ordinary persistence and SDK notification paths own delivery after append. SDK `run()` results cover bounded observation windows and can receive a settlement whose registration belongs to an earlier run; settlement records retain the registration facts for that consumer. + +The [registry contract](../../../../packages/jobs/jobs/README.md) and [seam consolidation note](../architecture/2026-09-03-jobs-seam-consolidation.md) own live execution, resource ownership, and event routing. This journal adds durable observations without replacing those responsibilities. + +## Alternatives considered + +**Add timestamps only to `job_output`.** A model may never collect a job or may collect it after a long delay. Logging lifecycle commits records both cases without additional model calls or consuming output. + +**Keep a reusable control id alongside an execution UUID.** Both values would identify one job. A unique `JobId` serves live controls and historical pairing. + +**Add a launcher cleanup registry.** A separate pre-exit disposer path duplicates Cordis resource ownership and adds a launcher requirement to headless. Keeping persistence available through the final job teardown requires a separate cross-fiber lifecycle change; this journal retains the existing disposal mechanism. + +**Defer publication or propagate recording errors.** Deferral lets a synchronous hub subscriber journal a completion notice before the settlement that caused it. Throwing after the starter succeeds can hide an already-running job from its caller. Synchronous, contained recording preserves causal order without abandoning execution. + +## Consequences + +Application teardown can stop Session routing or close its writer before the final job settlement reaches storage. Normal one-shot completion and signal shutdown can therefore leave incomplete history, as can hard process loss. This change does not enforce cross-fiber disposal order or synthesize a missing terminal record. + +The log and both SDKs expose execution intervals without changing tool schemas, output cursors, or job scheduling. Model-facing controls and notices carry the unique JobId; a bounded notice points to job_list when the full id cannot fit. These intervals run from registration commit through registry settlement, including the producer work and cleanup between those points; they are neither CPU time nor a directly additive rollout-latency metric. An append failure can also leave incomplete history. Persisted facts do not resume an external process or its output ring. + +Focused tests cover original timestamps, nested call identity, output preservation, delayed cancellation, producer rejection, forced teardown, late completion, unique control ids, causal publication order, and recording-failure containment. SDK snapshots cover the durable and protocol projections. diff --git a/.agents/notes/implemented/bug-fix/2026-09-30-job-session-lifecycle.zh.md b/.agents/notes/implemented/bug-fix/2026-09-30-job-session-lifecycle.zh.md new file mode 100644 index 0000000000..25cd83d922 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-30-job-session-lifecycle.zh.md @@ -0,0 +1,37 @@ +# Agent Note: 持久化有所有者 job 的生命周期事实 + +Status: implemented + +[English](2026-09-30-job-session-lifecycle.md) | 中文 + +## Problem + +SDK 消费方接收 Session 事件,而 job 注册和结算此前只存在于运行期注册表中。后台工具可能在工作结束前返回,输出收集则可能晚得多,甚至永远不发生。因此工具调用时间无法重建 job 的执行区间。运行期 id 也会在注册表重启后重复。 + +## Decision + +本地提供方为有所有者的 job 捕获 `job/registered` 与 `job/settled` 事实,保留注册表的 Unix 毫秒时间戳、原始所有者、生产方 kind、label 和可选的创建工具调用。由 UUID 构成的 `JobId` 在注册表重启和历史复制后同时标识运行中的控制对象和历史记录。无主工作没有 Session 日志。 + +终态记录区分生产方报告与强制记账:`producerReported` 在 `JobHooks.done` 兑现时为 true,在该 promise 拒绝或销毁取消抛错时为 false。它记录兑现情况,不独立确认资源已释放。缺少终态记录时保持不完整,不根据较晚的读取或进程重启重建。 + +事实在注册表提交时同步追加,先于运行期 hub 的反应和完成等待方恢复。记录错误单独发出警告:已启动的工作仍有可控制的 id,日志追加失败不能阻止清理或释放等待方。追加后的投递由 Session 既有的持久化和 SDK 通知路径负责。SDK `run()` 结果只覆盖有界观察窗口,可能收到注册属于更早运行的结算;结算记录为这个消费方保留注册事实。 + +[注册表约定](../../../../packages/jobs/jobs/README.zh.md)和[seam 收敛说明](../architecture/2026-09-03-jobs-seam-consolidation.zh.md)负责运行期执行、资源归属和事件路由。本日志增加持久化观测,不替代这些职责。 + +## Alternatives considered + +**仅向 `job_output` 添加时间戳。** 模型可能永远不收集某个 job,也可能很晚才收集。记录生命周期提交能覆盖这两种情况,无需额外模型调用或消费输出。 + +**保留可重用的控制 id,另设执行 UUID。** 两个值会标识同一个 job。唯一的 `JobId` 同时服务运行期控制和历史配对。 + +**增加启动器清理注册表。** 独立的退出前 disposer 路径会重复 Cordis 的资源归属机制,并增加 headless 对启动器的要求。让持久化持续可用直至最后一个 job 销毁,需要单独修改跨 fiber 生命周期;本日志保留既有销毁机制。 + +**延后发布或传播记录错误。** 延后发布让同步 hub 订阅者可能先记录完成通知、后记录产生通知的结算事实。starter 成功后抛错可能使调用方拿不到已运行 job 的 id。同步并隔离记录错误既保留因果顺序,也不会放弃执行。 + +## Consequences + +应用销毁可能在最后一条 job 结算写入存储前停止 Session 路由或关闭写句柄。因此正常的一次性运行结束、信号关停和进程硬退出都可能留下不完整历史。本次变更不强制跨 fiber 的销毁顺序,也不虚构缺失的终态记录。 + +日志与两种 SDK 暴露执行区间,不改变工具 schema、输出游标或 job 调度。模型侧控制和通知使用唯一的 JobId;有界通知无法容纳完整 id 时指向 job_list。区间从注册提交延续到注册表结算,包含这两个时刻之间的生产方工作与清理;它既不是 CPU 时间,也不是可直接相加的 rollout 延迟指标。追加失败也可能留下不完整历史。持久化事实不会恢复外部进程或其输出环。 + +定向测试覆盖原始时间戳、嵌套调用标识、输出保留、延后取消、生产方拒绝、强制销毁、较晚完成、唯一控制 id、因果发布顺序,以及记录错误隔离。SDK 快照覆盖持久化与协议投影。 diff --git a/.agents/notes/implemented/bug-fix/2026-10-11-process-specific-prompt-values-in-runtime-context.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-10-11-process-specific-prompt-values-in-runtime-context.i18n.yaml new file mode 100644 index 0000000000..f9ff8a3881 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-10-11-process-specific-prompt-values-in-runtime-context.i18n.yaml @@ -0,0 +1,31 @@ +# Bilingual-pair consistency record for 2026-10-11-process-specific-prompt-values-in-runtime-context.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-10-11-process-specific-prompt-values-in-runtime-context.md +/agent-note-process-specific-checkout-path-and-web-url-move-to-runtime-context: + en: 711ce3e3afb1f905 + zh: 2d4e4aea44043c4a +/agent-note-process-specific-checkout-path-and-web-url-move-to-runtime-context/problem: + en: f4e050c7dbf645da + zh: 722bcdc409d09bec +/agent-note-process-specific-checkout-path-and-web-url-move-to-runtime-context/decision: + en: 5222f00fd05d3801 + zh: fcfa430a0fa5588c +/agent-note-process-specific-checkout-path-and-web-url-move-to-runtime-context/decision/ownership: + en: c23d5c27f1f160f0 + zh: 5c2464666f1e6c29 +/agent-note-process-specific-checkout-path-and-web-url-move-to-runtime-context/decision/new-sessions: + en: 1ee144c39905bb89 + zh: a6669d7ad394e41c +/agent-note-process-specific-checkout-path-and-web-url-move-to-runtime-context/decision/earlier-format-sessions: + en: 27c11e7ced286898 + zh: 944d051494b1498d +/agent-note-process-specific-checkout-path-and-web-url-move-to-runtime-context/alternatives-considered: + en: c324412be4034c40 + zh: 923ad612fc884be1 +/agent-note-process-specific-checkout-path-and-web-url-move-to-runtime-context/consequences: + en: 8aa41ff08b848916 + zh: bc1c1647ca0eb86a +/agent-note-process-specific-checkout-path-and-web-url-move-to-runtime-context/testing: + en: 9667d1749bdf1547 + zh: 598dc39120c91228 diff --git a/.agents/notes/implemented/bug-fix/2026-10-11-process-specific-prompt-values-in-runtime-context.md b/.agents/notes/implemented/bug-fix/2026-10-11-process-specific-prompt-values-in-runtime-context.md new file mode 100644 index 0000000000..86bda9b2a6 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-10-11-process-specific-prompt-values-in-runtime-context.md @@ -0,0 +1,95 @@ +# Agent Note: Process-specific checkout path and Web URL move to runtime context + +Status: implemented + +English | [中文](2026-10-11-process-specific-prompt-values-in-runtime-context.zh.md) + +## Problem + +`dsh web` wrote two process-specific values directly into the system prompt: the implementation checkout path in the `harness:source` section (four directories above the web-app module) and the Web GUI URL in the `app:web-surface` section (the bound port or `publicUrl`). These operations change them without the user perceiving a system-prompt change: + +| Operation | Changed value | +|---|---| +| Every Desktop launch (`--port 0` picks a random port) | Web URL | +| A different `--port`, or alternating A/B checkouts | Web URL, checkout path | +| npm and Desktop installations sharing one `DSH_HOME` | Checkout path | + +On resume the loop renders the prompt again. On a route without `systemPromptUpdate: 'in-history'`, such as a hand-declared pi-ai `anthropic-messages` route, the changed prompt rewrites the surface head node. Claude binds historical thinking-block signatures to the conversation prefix, system prompt included, so the upstream answers 400 (`Invalid signature … bound to a different conversation`) and the Session cannot continue until the environment is restored. In-history routes do not fail, but every change appends a complete prompt. + +## Decision + +Both values become runtime context and the system prompt keeps only value-free guidance; a Session whose log already holds the earlier format keeps its logged paragraphs. + +### Ownership + +| Package | Responsibility | +|---|---| +| `@deepseek-ai/dsh-harness-dev-context` (`packages/context/harness-dev-context`) | Both sections, both contexts, and the earlier-format projection; all module-private, exporting only the plugin protocol (`name`, `inject`, `apply`) | +| `@deepseek-ai/dsh-host-webserver` | `WebServer.getOrigin()` (the bind address's URL form with protocol and bound port) and `bindUrlHost(host)` | +| `@deepseek-ai/dsh-web-app` | The `web-runtime` row provides `webApp`, whose `getAdvertisedUrl()` returns the configured `publicUrl`, otherwise the listener origin, the same value as `DSH_WEB_URL` and the startup URL; the bundle patch mounts the `harness-dev-context` row | +| `@deepseek-ai/dsh-system-prompt` | `CONTEXT_ORDERS.HARNESS_SOURCE = 200` and `WEB_SURFACE = 210` | + +The plugin injects `systemPrompt`, `sessionProjections`, and `webApp` and reads those services in code; its patch row carries no configuration expression. The checkout path is four directories above this package's source or built entry, the same depth as the former web-app location, so source, npm, and Desktop installations resolve the same root. The URL is `webApp.getAdvertisedUrl()`, so a profile that overrides the `web-runtime` row's `publicUrl` reaches the model as well. The plugin reaches the `webApp` type through `import type {} from '@deepseek-ai/dsh-web-app'`; the import is erased at runtime, so the web-app bundle's dependency on this package remains the only runtime edge. + +### New Sessions + +| Registration | Content | +|---|---| +| Section `harness:source` (the `HARNESS_SOURCE` slot) | `The runtime context names the DeepSeek Harness implementation checkout. …`, the remaining guidance unchanged | +| Context `harness:source` (`CONTEXT_ORDERS.HARNESS_SOURCE = 200`) | `Current DeepSeek Harness implementation checkout: "".` | +| Section `app:web-surface` (the `WEB_SURFACE` slot) | `You are interacting with the user through the DeepSeek Harness Web GUI whose URL the runtime context names. …`, the remaining guidance unchanged | +| Context `app:web-surface` (`CONTEXT_ORDERS.WEB_SURFACE = 210`) | `Current DeepSeek Harness Web GUI URL: "".` | + +Both contexts are optional (no `required`). The only complete persona, web-app's minimal preset, also disables optional runtime context, so it still sees neither value. Runtime context follows the existing mechanism: an unchanged value emits no snapshot, a change appends a complete snapshot before the step's user message, and resume compares against the latest logged snapshot, never rewriting a committed message. + +### Earlier-format Sessions + +The plugin registers the host-only session projection `harnessDevLegacyPrompt`, folding every nonempty `system/message` in event order into `{ source, web }`: the paragraphs of that prompt that match an earlier template verbatim, or `null`. + +| Earlier template | Matching rule | +|---|---| +| `harness:source` | Starts with `The DeepSeek Harness implementation checkout is at `, ends with the earlier fixed guidance, no line break between | +| `app:web-surface` | Starts with `You are interacting with the user through the DeepSeek Harness Web GUI at `, ends with `. ` plus the guidance as earlier releases rendered it, frozen as a literal, no line break between | + +Both section text providers read the projection state of `AssembleContext.agent.session`: with an earlier paragraph they return it verbatim (`interpolate: false`), otherwise the current guidance. Diagnostic assemblies without an agent use the current guidance. `sessionProjections` is a required injection and the plugin registers its own unit, so the state is readable whenever an agent exists. + +An earlier-format Session therefore satisfies both requirements: + +| Scenario | System prompt | Runtime context | +|---|---|---| +| First resume after upgrading, same port | Byte-identical to the log; the loop emits no `system/message` | Appends a snapshot with both current values | +| Port, slot, or installation change after upgrading | Still the logged earlier paragraphs | Appends a snapshot with the new values | + +The projection computes only on each committed `system/message`, adding no synchronous read of historical events; its state persists with projection checkpoints. When compaction or a new request series rewrites the head, the rendering still contains the same earlier paragraphs, so the projection state does not change. + +## Alternatives considered + +**Move both values to runtime context without keeping earlier paragraphs.** The upgrade itself changes the prompt text, so every existing Claude Session rewrites its head and fails with 400 on its first turn after the upgrade. + +**Offer `supportsMidConvoSystemMessages` on hand-declared Anthropic routes.** The upstream accepts mid-conversation system messages in a live probe, but the pi-ai catalog already supplies that capability for named Anthropic routes and hand-declared routes withhold catalog fields by convention; in-history routes would still append a complete prompt on every value change. + +**Ask the upstream to drop mismatched thinking blocks (`prefix_mismatch_behavior: "drop_block"`).** It silently loses historical reasoning and covers one vendor. + +**Freeze the whole system prompt per Session.** Legitimate prompt changes such as plan mode, skills, and tool guidance would also be frozen and never reach the model. + +**Keep the implementation in web-app and app-boot.** Development-facing context and earlier-format compatibility would enlarge the public API of two core packages; app-boot's `addHarnessSourceSection` had web-app as its only caller. + +**Read `webStartup.publicUrl`.** It carries only the `--public-url` flag, so a profile override of the `web-runtime` row's `publicUrl` would reach `DSH_WEB_URL` but not the model; the effective value exists only inside web-app, which now provides it as `webApp`. + +**Read the head node text from Session history directly.** The synchronous arbitrary-history read APIs are deprecated and new calls are prohibited; a projection is the established way to obtain that state after resume. + +## Consequences + +- A new Session's system prompt no longer changes with the port, slot, or installation; both current values arrive as runtime-context snapshots, and a change appends a few lines. +- An earlier-format Session keeps the old values of its earlier paragraphs for its whole lifetime; the later `Current …` runtime context in the same request gives the current values. +- Only these two paragraphs are protected. When other prompt text changes between releases, routes without in-history updates still rewrite the head. +- A release without this fix rewrites the head of a new-format Session with the earlier template on resume; both A/B checkouts need this fix before they alternate. +- app-boot no longer exports `addHarnessSourceSection` or `HARNESS_SOURCE_SECTION`; web-app's `surfaceContext` controls only `DSH_WEB_URL`, and disabling the `harness-dev-context` row turns both contexts off. +- Runtime context in recorded Web e2e Sessions gains two lines; the test scaffold normalizes the URL and checkout path to placeholders. + +## Testing + +- `packages/context/harness-dev-context/tests/harness-dev-context.spec.ts`: on a production AgentLoop, an earlier-format Session resumed on another port emits no `system/message`, the request's system prompt is byte-identical to the log, and runtime context carries the current URL; a second port change keeps this; replace-only and in-history routes each run. When a series consolidation only empties a later version, the next turn keeps the earlier paragraphs. Similar text without the fixed ending or broken across lines receives the current guidance. A new Session keeps its system prompt across a port change and only appends a runtime-context snapshot. Sections carry no values, the context carries the advertised URL, and disposing the plugin fiber removes every registration. +- `packages/host/webserver/tests/webserver.spec.ts`: `bindUrlHost` across address forms; `getOrigin()` on HTTP and HTTPS listeners, and its errors before binding and for a zoned non-loopback address. +- `packages/bundle/web-app/tests/web-app.spec.ts`: `webApp.getAdvertisedUrl()` returns the listener origin, or the row's configured `publicUrl`. +- `packages/core/system-prompt/tests/system-prompt.spec.ts`: the added context positions are unique integers. diff --git a/.agents/notes/implemented/bug-fix/2026-10-11-process-specific-prompt-values-in-runtime-context.zh.md b/.agents/notes/implemented/bug-fix/2026-10-11-process-specific-prompt-values-in-runtime-context.zh.md new file mode 100644 index 0000000000..b37e09e6bb --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-10-11-process-specific-prompt-values-in-runtime-context.zh.md @@ -0,0 +1,95 @@ +# Agent Note: 进程相关的 checkout 路径与 Web URL 改走运行时上下文 + +Status: implemented + +[English](2026-10-11-process-specific-prompt-values-in-runtime-context.md) | 中文 + +## 问题 + +`dsh web` 曾把两项随进程变化的值直接写进 system prompt:`harness:source` 段里的实现 checkout 路径(web-app 模块位置上溯四层),以及 `app:web-surface` 段里的 Web GUI URL(实际监听端口或 `publicUrl`)。下列操作都会改变它们,而用户感知不到 system prompt 变了: + +| 操作 | 变化的值 | +|---|---| +| Desktop 每次启动(`--port 0` 取随机端口) | Web URL | +| 换 `--port`、A/B 两个 checkout 交替运行 | Web URL、checkout 路径 | +| npm 安装与 Desktop 安装共用同一个 `DSH_HOME` | checkout 路径 | + +会话恢复时 loop 重新渲染 prompt。对不声明 `systemPromptUpdate: 'in-history'` 的路由(例如手写的 pi-ai `anthropic-messages` 路由),变化的 prompt 会改写 surface 头节点。Claude 的历史 thinking 块签名绑定包括 system prompt 在内的对话前缀,前缀一变上游返回 400(`Invalid signature … bound to a different conversation`),会话在环境恢复原样之前无法继续。声明 in-history 的路由不报错,但每次变化都追加一份完整 prompt。 + +## 决定 + +两项值改为运行时上下文,system prompt 只保留不含值的说明;已经按旧格式写入日志的会话继续沿用日志里的旧段落。 + +### 归属 + +| 包 | 职责 | +|---|---| +| `@deepseek-ai/dsh-harness-dev-context`(`packages/context/harness-dev-context`) | 两个 section、两个 context、旧格式投影;全部模块私有,只导出插件协议(`name`、`inject`、`apply`) | +| `@deepseek-ai/dsh-host-webserver` | `WebServer.getOrigin()`(绑定地址的 URL 形式加协议与实际端口)与 `bindUrlHost(host)` | +| `@deepseek-ai/dsh-web-app` | `web-runtime` 行提供 `webApp`,其 `getAdvertisedUrl()` 返回配置的 `publicUrl`,否则返回监听器 origin,与 `DSH_WEB_URL` 和启动 URL 同值;bundle patch 挂载 `harness-dev-context` 行 | +| `@deepseek-ai/dsh-system-prompt` | `CONTEXT_ORDERS.HARNESS_SOURCE = 200`、`WEB_SURFACE = 210` | + +插件 `inject` 为 `systemPrompt`、`sessionProjections`、`webApp`,在代码里直接读取这些服务;patch 行不写任何配置表达式。checkout 路径取本包源码或构建入口上溯四层,与原 web-app 位置同深度,源码、npm 与 Desktop 安装下结果不变。URL 取 `webApp.getAdvertisedUrl()`,因此 profile 覆盖 `web-runtime` 行的 `publicUrl` 时模型也能看到。插件通过 `import type {} from '@deepseek-ai/dsh-web-app'` 取得 `webApp` 类型;该导入在运行时被擦除,因此 web-app bundle 对本包的依赖仍是唯一的运行时依赖。 + +### 新会话 + +| 注册 | 内容 | +|---|---| +| section `harness:source`(`HARNESS_SOURCE` 位) | `The runtime context names the DeepSeek Harness implementation checkout. …`,其余说明不变 | +| context `harness:source`(`CONTEXT_ORDERS.HARNESS_SOURCE = 200`) | `Current DeepSeek Harness implementation checkout: "".` | +| section `app:web-surface`(`WEB_SURFACE` 位) | `You are interacting with the user through the DeepSeek Harness Web GUI whose URL the runtime context names. …`,其余说明不变 | +| context `app:web-surface`(`CONTEXT_ORDERS.WEB_SURFACE = 210`) | `Current DeepSeek Harness Web GUI URL: "".` | + +两个 context 都是可选项(不设 `required`)。唯一的 complete persona(web-app 的 minimal 预设)同时关闭了可选运行时上下文,因此它仍然看不到这两项。运行时上下文按既有机制工作:值不变不发新快照,变化时在本轮 user 消息前追加完整快照,恢复时从会话日志取上一份快照比较,不改写任何已提交消息。 + +### 旧格式会话 + +插件注册仅限宿主的会话投影 `harnessDevLegacyPrompt`,按事件顺序折叠每个非空 `system/message`,状态为 `{ source, web }`:该 prompt 中按旧模板逐字匹配到的段落,未匹配为 `null`。 + +| 旧模板 | 匹配规则 | +|---|---| +| `harness:source` | `The DeepSeek Harness implementation checkout is at ` 开头、旧版固定说明结尾、中间不含换行 | +| `app:web-surface` | `You are interacting with the user through the DeepSeek Harness Web GUI at ` 开头、`. ` 加早期版本渲染的说明结尾(冻结为字面量)、中间不含换行 | + +两个 section 的文本提供函数读取 `AssembleContext.agent.session` 的投影状态:有旧段落时原样返回该段落(`interpolate: false`),否则返回新说明。没有 agent 的诊断性组装使用新说明。`sessionProjections` 是插件的必需注入,投影单元由插件自己注册,因此存在 agent 时状态必然可读。 + +旧会话因此满足两点: + +| 场景 | system prompt | 运行时上下文 | +|---|---|---| +| 升级后首次恢复,端口不变 | 与日志逐字相同,loop 不产生 `system/message` | 追加含两项当前值的快照 | +| 升级后换端口、slot 或安装位置 | 仍是日志里的旧段落 | 追加新值快照 | + +投影只在每次提交的 `system/message` 上计算,不新增同步读取历史事件的调用;状态随投影检查点持久化。压缩或新请求序列重写头节点时,渲染结果仍含同一旧段落,投影状态不变。 + +## 备选方案 + +**只把两项值移到运行时上下文,不保留旧段落。** 升级本身会改变 prompt 文本,所有既有 Claude 会话在升级后第一轮改写头节点并报 400。 + +**为手写 Anthropic 路由开放 `supportsMidConvoSystemMessages`。** 上游实测接受对话中途的 system 消息,但 pi-ai 目录已为具名 Anthropic 路由提供该能力,手写路由按既有约定不开放目录字段;而且 in-history 路由每次值变化仍追加一份完整 prompt。 + +**请求上游丢弃不匹配的 thinking 块(`prefix_mismatch_behavior: "drop_block"`)。** 会静默丢失历史推理,且只覆盖一个供应商。 + +**会话内冻结整份 system prompt。** plan 模式、技能和工具说明等合法的 prompt 变化也会被冻结,无法到达模型。 + +**留在 web-app 与 app-boot 中实现。** 面向开发的上下文与旧格式兼容逻辑会扩大两个核心包的公开 API;app-boot 的 `addHarnessSourceSection` 当时只有 web-app 一个调用方。 + +**读取 `webStartup.publicUrl`。** 它只携带 `--public-url` 参数,profile 覆盖 `web-runtime` 行的 `publicUrl` 时只会影响 `DSH_WEB_URL`,到不了模型;生效值只存在于 web-app 内部,现在由它以 `webApp` 提供。 + +**直接读取会话历史中的头节点文本。** 同步读取任意历史事件的 API 已弃用,新调用被禁止;投影是恢复后取得该状态的既定方式。 + +## 后果 + +- 新会话的 system prompt 不再随端口、slot 或安装位置变化;两项当前值以运行时上下文快照送达,变化时只追加几行。 +- 旧会话在整个生命周期内保留旧段落中的旧值;同一请求里更晚的 `Current …` 运行时上下文给出当前值。 +- 只保护这两段。其他提示文本在版本之间变化时,不支持 in-history 的路由仍会改写头节点。 +- 不含本修复的旧版本恢复新格式会话时会按旧模板改写头节点;A/B 两个 checkout 都需包含本修复后再交替运行。 +- app-boot 不再导出 `addHarnessSourceSection` 与 `HARNESS_SOURCE_SECTION`;web-app 的 `surfaceContext` 只控制 `DSH_WEB_URL`,关闭这两项上下文改为禁用 `harness-dev-context` 行。 +- Web e2e 录制会话里的运行时上下文多出两行;测试脚手架把 URL 与 checkout 路径规范化为占位符。 + +## 测试 + +- `packages/context/harness-dev-context/tests/harness-dev-context.spec.ts`:真实 AgentLoop 上,旧格式会话在另一端口恢复时不产生 `system/message`、请求中的 system prompt 与日志逐字相同、运行时上下文携带当前 URL;再换一次端口仍成立;不支持与支持 in-history 的路由各跑一遍。series 合并只清空后续版本时,下一轮仍沿用旧段落。缺少固定结尾或跨行的相似文本得到新说明。新会话换端口后 system prompt 不变、只追加运行时上下文快照。section 不含值、context 携带公告 URL,销毁插件 fiber 后全部注册消失。 +- `packages/host/webserver/tests/webserver.spec.ts`:`bindUrlHost` 各地址形式;`getOrigin()` 在 HTTP 与 HTTPS 监听上的值,绑定前与带 zone 的非回环地址抛错。 +- `packages/bundle/web-app/tests/web-app.spec.ts`:`webApp.getAdvertisedUrl()` 返回监听器 origin,或该行配置的 `publicUrl`。 +- `packages/core/system-prompt/tests/system-prompt.spec.ts`:新增的 context 位置唯一且为整数。 diff --git a/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.i18n.yaml b/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.i18n.yaml index 431d5f60ec..529c16087a 100644 --- a/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.i18n.yaml @@ -9,29 +9,29 @@ en: 311bc04b9c36ab53 zh: 0c52e6493e9350a5 /agent-note-configure-subagent-persona-tool-visibility-and-depth/decision: - en: 79b3fcfaaaaccf9e - zh: 0287bfb41c7b9707 + en: 45111f900708aaf9 + zh: e75bed4951516963 /agent-note-configure-subagent-persona-tool-visibility-and-depth/decision/persona-is-a-scoped-shadow: - en: 8b299c99d26aa5c3 - zh: 6956efbd236cef8e + en: f9ee31a1fdcdd38f + zh: c9d05721cef80659 /agent-note-configure-subagent-persona-tool-visibility-and-depth/decision/tool-filtering-is-one-live-global-view-rule: en: a9fe46283ea28722 zh: 7b7d1be7c070b893 /agent-note-configure-subagent-persona-tool-visibility-and-depth/decision/depth-is-an-absolute-tree-cap: - en: 9145bec4e3f7eb5b - zh: 986276925cb064d9 + en: c1d92ec60342c8b7 + zh: 063c9eaba331312b /agent-note-configure-subagent-persona-tool-visibility-and-depth/decision/capability-gating-keeps-providers-honest: - en: 5334afafefdb0f59 - zh: 7e96c9a08bbc3d1a + en: 1669c3372b728fc8 + zh: 349ee461c9307811 /agent-note-configure-subagent-persona-tool-visibility-and-depth/decision/unpublished-setup-makes-the-first-request-correct: - en: 3c988dfff07c8da3 - zh: 8ce2fbcc438e7dd8 + en: 32b0648b901c49ad + zh: b72f29a8be987479 /agent-note-configure-subagent-persona-tool-visibility-and-depth/visibility-is-not-authority: en: d0db5310a87ff8e4 zh: 4b9f502238cd4bae /agent-note-configure-subagent-persona-tool-visibility-and-depth/alternatives-considered: - en: 78ae41331d8fcc3e - zh: a93ee10b3e30c267 + en: cf5006aa2b345a37 + zh: f26a9a1ab9a83a46 /agent-note-configure-subagent-persona-tool-visibility-and-depth/consequences: - en: d5f05d63b7a01dd8 - zh: f0be1e88b06d8cc9 + en: 58a53e46a6ce8307 + zh: 6e552d8b6ff4b186 diff --git a/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md b/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md index f848b92a7a..fabcb81393 100644 --- a/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md +++ b/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md @@ -12,7 +12,7 @@ These controls affect the child's first model request and therefore cannot be in ## Decision -Subagent starts have three independent composition controls: `persona`, `toolFilter`, and `maxDepth`. A provider advertises support for each control, the service rejects unsupported requests before starting a run, and an in-process provider installs the requested composition while the child is still unpublished. +This note retains the depth policy and the persona/tool-filter semantics needed to restore historical child descriptors. New children use [preset-owned composition](../simplification/2026-10-09-preset-owned-subagent-composition.md); new requests do not accept persona or tool filters. Historical overrides are applied while a resumed child is unpublished. The controls answer different questions: @@ -22,16 +22,18 @@ The controls answer different questions: | `toolFilter` | Which deployment-global tools enter this child's visible tool view? | A scoped restriction filters globals before child-local tools are added | | `maxDepth` | How deep may this delegation tree grow? | A start whose child depth exceeds the absolute cap is rejected | -`dsh-tool-subagent` exposes the controls as plugin configuration and copies them into each request it creates. Direct `SubagentRuntime` callers may choose them per request. The provider capability descriptor remains the source of truth for whether a backend can honor each field. +`dsh-tool-subagent` and direct `SubagentRuntime` callers expose `maxDepth`. Its provider capability determines whether a backend can enforce the cap. Persona and tool-filter values come only from historical descriptors during cold resume. ### Persona is a scoped shadow -The persona control changes one child without changing deployment-wide prompt assembly. During unpublished setup, an in-process provider registers a child-scoped section named `deployment:persona-prefix`; ordinary most-specific-wins resolution replaces the global section only in that child's assemblies. +A historical persona changes one resumed child without changing deployment-wide prompt assembly. During unpublished setup, the manager registers a child-scoped section named `deployment:persona-prefix`; ordinary most-specific-wins resolution replaces the inherited section only in that child’s assemblies. -The value has the same strict template semantics as the deployment persona. Omitting it inherits the deployment section through the global layer; an explicit empty string shadows the global persona with an empty section. Parent and sibling personas never enter the child's flat scope. +The value has the same strict template semantics as the deployment persona. Omitting it preserves the composed persona; an explicit empty string shadows that persona with an empty section. This uses the normal system-prompt registration mechanism rather than a second persona channel. The first prompt therefore sees the same named contribution that later prompts and prompt-inspection tools see. + + ### Tool filtering is one live global-view rule The tool filter controls capability visibility and executable lookup together. An in-process provider installs `ToolRuntime.restrict()` in the child's scope before publication, and the registry's single resolver applies the same result to wire tool schemas, lookup, execution, and PTC mode SDK generation. Independently registered system-prompt sections remain owned by their plugins. The filesystem, search, and web tool plugins use the existing `PromptSection.text({ scope })` callback and `ctx.tools.get(name, scope)` to omit guidance for unavailable tools and select applicable cross-tool text. This keeps the original wording and ordering for a supported tool set and works for any agent scope, including underlying PTC capabilities whose wire presentation is `run_code`. It adds no section-ownership metadata or assembly pass; unrelated static prose is not automatically rewritten by `restrict()`. @@ -53,19 +55,19 @@ The depth limit bounds recursive delegation independently of tool visibility. A The effective parent depth is the greater of durable `SessionHeader.delegationDepth` and runtime `AgentOptions.subagentDepth`. An in-process child records its derived depth in the session header, and resume restores that header, so a restart cannot lower the recursion count. -Every public entry validates the domain rather than relying on one model-facing configuration path. Negative values, fractions, negative zero, non-finite values, unsafe integers, malformed stored parent depth, and derived overflow all reject. A direct `SubagentStartRequest` may omit the cap to leave depth unbounded; loader-resolved `dsh-tool-subagent` configuration instead defaults to `3`, accepts a numeric override, and uses explicit `'provider-managed'` to omit the cap for an out-of-process provider whose deployment owns its recursion budget. Three is a small finite default that still permits a root plus three descendant generations, and the [base profile](../../../../packages/bundle/base/cordis.patch.yml) uses that general policy. A numeric tool cap fails at provider mount when the provider lacks `depthLimit`. +Every public entry validates the domain rather than relying on one model-facing configuration path. Negative values, fractions, negative zero, non-finite values, unsafe integers, malformed stored parent depth, and derived overflow all reject. A direct `SubagentStartRequest` may omit the cap to leave depth unbounded; `dsh-tool-subagent` instead reads the live Host depth setting (initially `1`), accepts a numeric override, and uses explicit `'provider-managed'` to omit the cap for an out-of-process provider whose deployment owns its recursion budget. The [base profile](../../../../packages/bundle/base/cordis.patch.yml) uses the Host setting. A numeric tool cap fails at provider mount when the provider lacks `depthLimit`. -A deployment can combine depth and filtering, but the numeric cap does not synthesize a filter. The delegation tool stays visible at the cap because authorization may depend on runtime state; every attempted start checks the calling agent's current durable and runtime depth, and a rejected start returns an errored tool result without publishing a child. A deployment may separately deny delegation tools in children when its visibility policy is static. Neither choice changes the provider's conversation-history behavior. +The numeric cap does not synthesize a filter. The delegation tool stays visible at the cap because authorization may depend on runtime state; every attempted start checks the calling agent’s current durable and runtime depth, and a rejected start returns an errored tool result without publishing a child. A preset can omit delegation tool plugins independently of the depth policy. At the cap, the tool contributes an optional runtime-context notice telling the model to work directly. `includeRuntimeContext: false` suppresses this advisory notice; the runtime depth guard remains active. When enabled, the notice reads the live cap and durable depth at request time to keep resumed and reconfigured agents informed without changing their system prompt or tool schemas; unchanged context is not repeatedly appended. ### Capability gating keeps providers honest -Capabilities separate a requested feature from a provider implementation. `SubagentCapabilities` advertises `persona`, `toolFilter`, and `depthLimit`; `SubagentRuntime.start()` checks every present request field against those flags before calling the provider. +Capabilities separate a requested feature from a provider implementation. `SubagentCapabilities.depthLimit` advertises depth enforcement; `SubagentRuntime.startActivation()` checks a requested cap before calling the provider. This lets spawn and fork providers share the in-process implementation while external providers advertise only what they can enforce. A request never degrades silently: selecting an unsupported control produces `UNSUPPORTED_CAPABILITY`, and no run or lifecycle event exists. ### Unpublished setup makes the first request correct -All child-local composition is complete before the child becomes observable. The in-process provider supplies one setup callback to agent creation; that callback installs persona, tool restriction, and structured-output contributions in the child's scope. Only after setup succeeds does creation publish the session and agent and allow the driver to start. +All child-local composition is complete before the child becomes observable. The manager supplies a setup callback to agent creation that composes the preset, restores historical persona and tool restrictions on cold resume, and installs structured output. Only after setup succeeds does creation publish the session and agent and allow the driver to start. A setup failure rolls back the private child. No observer can acquire a child whose first prompt used the deployment persona or unfiltered tool set and whose later prompts use the requested configuration. @@ -79,7 +81,7 @@ A security design would need a separate authority representation, propagation ru ## Alternatives considered -**Create one provider per persona or tool set.** This multiplies providers that share the same transport and lifecycle implementation, makes dynamic deployment configuration awkward, and still needs a recursion mechanism. Providers remain about execution transport; requests carry per-child composition. +**Create one provider per persona or tool set.** This multiplies providers that share the same transport and lifecycle implementation and still needs a recursion mechanism. Providers remain about execution transport; presets own new child composition, while descriptors retain historical overrides. **Copy the parent's complete tool view.** Registration scope is flat by design, and lifetime ownership does not imply visibility inheritance. Copying a resolved view would also freeze dynamic global registrations and conflate composition with authority without defining either contract fully. @@ -91,6 +93,6 @@ A security design would need a separate authority representation, propagation ru ## Consequences -Contributors can configure child role, visible global tools, and recursion without defining new providers. Capability checks fail before ownership starts, unpublished setup makes the first request consistent, and one tool resolver prevents presentation/execution drift. +Historical child composition survives cold resume without defining new providers. Capability checks fail before ownership starts, unpublished setup makes the first request consistent, and one tool resolver prevents presentation/execution drift. The cost is that deployments must understand live allow/deny behavior and the distinction between visibility and authority. A model may call a visible delegation tool after the current depth policy forbids another child and receive an error. Provider authors must advertise each supported control accurately, and in-process providers must install every requested contribution before publication. The controls deliberately do not solve security confinement or parent-to-child non-escalation. diff --git a/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.zh.md b/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.zh.md index 3360b27c9f..8993d0dc73 100644 --- a/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.zh.md +++ b/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -subagent 启动有三个独立的组合控制:`persona`、`toolFilter` 和 `maxDepth`。提供方声明对每个控制的支持情况,服务在启动运行之前拒绝不受支持的请求,进程内提供方在子 agent 尚未发布时安装所请求的组合。 +本记录保留深度策略,以及恢复历史子级 descriptor 所需的 persona/工具过滤语义。新子级使用 [preset 拥有的组装](../simplification/2026-10-09-preset-owned-subagent-composition.zh.md);新请求不接受 persona 或工具过滤。历史覆盖在恢复的子级尚未发布时应用。 这些控制回答不同的问题: @@ -22,13 +22,13 @@ subagent 启动有三个独立的组合控制:`persona`、`toolFilter` 和 `ma | `toolFilter` | 部署全局工具中哪些进入该子 agent 的可见工具视图? | 一个有作用域的限制在添加子 agent 局部工具之前过滤全局工具 | | `maxDepth` | 这棵委派树最深可以长到多少层? | 子 agent 深度超过绝对上限时,启动请求被拒绝 | -`dsh-tool-subagent` 将这些控制作为插件配置暴露,并复制到它创建的每个请求中。直接调用 `SubagentRuntime` 的调用方可以按请求选择这些控制。提供方的能力描述符仍然是后端能否兑现各字段的真源。 +`dsh-tool-subagent` 和直接调用 `SubagentRuntime` 的调用方公开 `maxDepth`。对应的提供方能力决定后端是否能强制执行上限。persona 和工具过滤值仅在冷恢复时来自历史 descriptor。 ### 人设是有作用域的遮蔽 -人设控制改变一个子 agent 的行为,而不改变部署级的提示词组装。在未发布的设置阶段,进程内提供方在子 agent 作用域中注册一个名为 `deployment:persona-prefix` 的段落;普通的最具体者优先解析规则仅在该子 agent 的组装中替换全局段落。 +历史 persona 改变一个恢复子级的行为,而不改变部署级提示词组装。在未发布的设置阶段,管理器注册子级作用域中名为 `deployment:persona-prefix` 的段落;普通的最具体者优先解析规则只在该子级的组装中替换继承段落。 -其值与部署人设具有相同的严格模板语义。省略时通过全局层继承部署段落;显式空字符串则以空段落遮蔽全局人设。父级和兄弟级的人设永远不会进入子 agent 的扁平作用域。 +该值与部署 persona 具有相同的严格模板语义。省略时保留组装的 persona;显式空字符串则以空段落遮蔽该 persona。 这使用的是常规的系统提示词注册机制,而非第二条人设通道。因此第一次提示词看到的命名贡献与后续提示词和提示词检查工具看到的一致。 @@ -55,19 +55,19 @@ subagent 启动有三个独立的组合控制:`persona`、`toolFilter` 和 `ma 有效父级深度取持久 `SessionHeader.delegationDepth` 与运行时 `AgentOptions.subagentDepth` 中的较大值。进程内子 agent 把推导出的深度记录在会话 header 中,恢复时会重新载入该 header,因此重启无法降低递归计数。 -每个公开入口都自行验证值域,而非依赖单一的面向模型配置路径。负值、小数、负零、非有限值、不安全整数、格式错误的存储父级深度以及推导溢出均被拒绝。直接的 `SubagentStartRequest` 可以省略上限,让此机制不约束深度;经 loader 解析的 `dsh-tool-subagent` 配置则默认值为 `3`、接受数值覆盖,并使用显式的 `'provider-managed'` 来省略由进程外提供方部署拥有递归预算时的上限。三是一个较小的有限默认值,仍允许 root 加三代后代,[base profile](../../../../packages/bundle/base/cordis.patch.yml)采用这项通用策略。提供方缺少 `depthLimit` 时,数值工具上限会在提供方挂载阶段失败。 +每个公开入口都自行验证值域,而非依赖单一的面向模型配置路径。负值、小数、负零、非有限值、不安全整数、格式错误的存储父级深度以及推导溢出均被拒绝。直接的 `SubagentStartRequest` 可以省略上限,让此机制不约束深度;`dsh-tool-subagent` 则读取实时 Host 深度设置(初始值为 `1`)、接受数值覆盖,并使用显式的 `'provider-managed'` 来省略由进程外提供方部署拥有递归预算时的上限。[base profile](../../../../packages/bundle/base/cordis.patch.yml)采用 Host 设置。提供方缺少 `depthLimit` 时,数值工具上限会在提供方挂载阶段失败。 -部署可以组合深度与过滤,但数值上限不会合成过滤器。委派工具在上限处仍然可见,因为授权可能依赖运行时状态;每次尝试启动都会检查调用方 agent 当前的持久与运行时深度,被拒绝的启动返回错误工具结果,且不发布子 agent。可见性策略固定的部署可以另外在子 agent 中 deny 委派工具。两种选择都不改变提供方的对话历史行为。 +数值上限不会合成过滤器。委派工具在上限处仍然可见,因为授权可能依赖运行时状态;每次尝试启动都会检查调用方 agent 当前的持久与运行时深度,被拒绝的启动返回错误工具结果,且不发布子 agent。preset 可独立于深度策略省略委派工具插件。达到上限时,工具贡献一条可选的运行时上下文提示,让模型直接完成工作。`includeRuntimeContext: false` 会抑制这条建议性提示;运行时的深度拦截仍然生效。提示启用时,每次请求读取实时上限与持久深度,使恢复会话或调整配置后的 agent 获知当前限制,同时不改变其系统提示词或工具 schema;未变化的上下文不会重复追加。 ### 能力门控保持提供方诚实 -能力将请求的功能与提供方实现分离。`SubagentCapabilities` 声明 `persona`、`toolFilter` 和 `depthLimit`;`SubagentRuntime.start()` 在调用提供方之前,对照这些标志检查请求中每个存在的字段。 +能力将请求的功能与提供方实现分离。`SubagentCapabilities.depthLimit` 声明深度强制执行能力;`SubagentRuntime.startActivation()` 在调用提供方前检查请求的上限。 这使 spawn 和 fork 提供方可以共享进程内实现,而外部提供方只声明自己能强制执行的部分。请求永远不会静默降级:选择不受支持的控制会产生 `UNSUPPORTED_CAPABILITY`,不会有运行或生命周期事件存在。 ### 未发布设置使第一次请求正确 -所有子 agent 局部的组合在子 agent 变得可观察之前完成。进程内提供方向 agent 创建提供一个设置回调;该回调在子 agent 作用域中安装人设、工具限制和结构化输出贡献。只有设置成功后,创建才发布会话和 agent 并允许驱动器启动。 +所有子级局部组装均在子级可观察前完成。管理器向 agent 创建提供设置回调,组装 preset,在冷恢复时恢复历史 persona 和工具限制,并安装结构化输出。只有设置成功后才发布会话和 agent,并允许驱动器启动。 设置失败会回滚私有子 agent。没有观察者能获取到一个「第一次提示词使用了部署人设或未过滤工具集、后续提示词才使用所请求配置」的子 agent。 @@ -81,7 +81,7 @@ subagent 启动有三个独立的组合控制:`persona`、`toolFilter` 和 `ma ## 曾考虑的替代方案 -**为每种人设或工具集创建一个提供方。** 这会使共享相同传输和生命周期实现的提供方成倍增加,使动态部署配置变得笨拙,且仍需要递归机制。提供方的职责是执行传输;请求承载每个子 agent 的组合。 +**为每种 persona 或工具集创建一个提供方。** 这会使共享同一传输和生命周期实现的提供方成倍增加,且仍需要递归机制。提供方负责执行传输;preset 拥有新子级组装,descriptor 保留历史覆盖。 **复制父级的完整工具视图。** 注册作用域设计上是扁平的,生命周期所有权不意味着可见性继承。复制已解析视图还会冻结动态全局注册,并在未完整定义任一约定的情况下混淆组合与授权。 @@ -93,6 +93,6 @@ subagent 启动有三个独立的组合控制:`persona`、`toolFilter` 和 `ma ## 后果 -贡献者可以配置子 agent 的角色、可见全局工具和递归深度,而无需定义新的提供方。能力检查在所有权开始之前失败,未发布设置使第一次请求一致,单一工具解析器防止呈现/执行漂移。 +历史子级组装在冷恢复后保留,无需定义新的提供方。能力检查在所有权开始前失败,未发布设置使第一次请求一致,单一工具解析器防止呈现与执行不一致。 代价是部署方必须理解活跃的 allow/deny 行为以及可见性与授权的区别。当前深度策略禁止再创建子 agent 后,模型仍可能调用可见的委派工具并收到错误。提供方作者必须准确声明每个受支持的控制,进程内提供方必须在发布前安装所有请求的贡献。这些控制有意不解决安全隔离或父到子的非升权问题。 diff --git a/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml b/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml index aab0051c22..3ef2bd6bfb 100644 --- a/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml @@ -12,11 +12,11 @@ en: 95e7d723d6b00930 zh: d18fdf9b2bf69bdb /agent-note-durable-agent-teams-over-continuable-children/profile-delegation: - en: 3a66308576f74247 - zh: 3453f0bcce88033d + en: 4835fb8c0c0d8a0d + zh: 5f9438a0d58104c4 /agent-note-durable-agent-teams-over-continuable-children/team-identity: - en: 83e446c928a20c47 - zh: b0a2bd24419dd815 + en: f82812d6ac0ac78d + zh: 7dd80a6ad13674ca /agent-note-durable-agent-teams-over-continuable-children/provisioning-and-recovery: en: 4345ac9a6ac7ad04 zh: 1d19ecbb128a75a3 diff --git a/.agents/notes/implemented/feature/2026-08-05-agent-teams.md b/.agents/notes/implemented/feature/2026-08-05-agent-teams.md index 2643101a91..82864e2552 100644 --- a/.agents/notes/implemented/feature/2026-08-05-agent-teams.md +++ b/.agents/notes/implemented/feature/2026-08-05-agent-teams.md @@ -22,11 +22,11 @@ The Lead must wait for required work before its final answer. Process teardown r ## Profile delegation -The [Team profile](../../../../packages/experimental/agent-team-profile/README.md) disables `subagent` and `subagent_fork` together with the overlapping global controls. Direct model delegation uses `spawn_teammate` with fresh or fork context, keeping those children in the durable roster. Workflow remains available through the base profile’s fresh `spawn` provider for scripted orchestration; it cannot inherit a teammate’s conversation identity through the model tool. The Subagent services and providers remain shared infrastructure. Ordinary Session forks retain their history without identity correction. Provider-owned child tool visibility remains a [documented limitation](../../../../packages/experimental/tool-agent-team/README.md#known-limitations-and-deferred-work). +The [Team profile](../../../../packages/experimental/agent-team-profile/README.md) disables `subagent` and `subagent_fork` together with the overlapping global controls. Direct model delegation uses `spawn_teammate` with fresh or fork context, keeping those children in the durable roster. Workflow remains available through the base profile’s fresh `spawn` provider for scripted orchestration; it cannot inherit a teammate’s conversation identity through the model tool. The Subagent services and providers remain shared infrastructure. Ordinary Session forks retain their history; [current identity handling](../architecture/2026-10-13-team-identity-runtime-context.md) supplies the fork’s own role. ## Team identity -The `spawn_teammate` tool prefixes the initial task with a user-role `` stating `You are teammate "".`. Identity and task enter the same durable inbox message. Shared system policy and all tool schemas stay uniform across members; execution owns role restrictions. Team tools resolve the caller’s Team and accept member names, so the model needs no Team id. Identity follows ordinary history through cold recovery and compaction; the plugin does not inspect reminder retention or add replacement messages. Forks inherit the recorded text without a Lead identity correction. Putting identity in the system prompt changes the prefix before inherited history; keeping it in the initial task preserves that prefix without per-step identity bookkeeping. Existing system-embedded identities may require a one-time prompt reconciliation; retained event generations are unchanged. +Shared system policy and all tool schemas stay uniform across members; execution owns role restrictions. Team tools resolve the caller’s Team and accept member names, so the model needs no Team id. [Required runtime-context identity](../architecture/2026-10-13-team-identity-runtime-context.md) owns identity injection, retention, and fork handling. ## Provisioning and recovery diff --git a/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md b/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md index 92af6e1afb..7a292f2d7e 100644 --- a/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md +++ b/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md @@ -22,11 +22,11 @@ Lead 必须等待所需工作后才能给出最终答案。进程 teardown 仍 ## Profile delegation -[Team profile](../../../../packages/experimental/agent-team-profile/README.zh.md) 禁用 `subagent`、`subagent_fork` 以及名称重叠的全局控件。模型直接委派使用支持 fresh 或 fork 上下文的 `spawn_teammate`,使这些子代理进入持久 roster。Workflow 仍通过 base profile 的 fresh `spawn` 提供方执行脚本编排;经模型工具创建的 workflow 子代理不会继承 teammate 的对话身份。Subagent 服务和提供方仍是共享基础设施。普通 Session fork 保留历史,不纠正身份。提供方所拥有的子代理工具可见性仍是[已记录的限制](../../../../packages/experimental/tool-agent-team/README.zh.md#known-limitations-and-deferred-work)。 +[Team profile](../../../../packages/experimental/agent-team-profile/README.zh.md) 禁用 `subagent`、`subagent_fork` 以及名称重叠的全局控件。模型直接委派使用支持 fresh 或 fork 上下文的 `spawn_teammate`,使这些子代理进入持久 roster。Workflow 仍通过 base profile 的 fresh `spawn` 提供方执行脚本编排;经模型工具创建的 workflow 子代理不会继承 teammate 的对话身份。Subagent 服务和提供方仍是共享基础设施。普通 Session fork 保留历史;[当前身份处理](../architecture/2026-10-13-team-identity-runtime-context.zh.md)提供 fork 自己的角色。 ## Team identity -工具 `spawn_teammate` 在初始任务前加上 user-role ``,声明 `You are teammate "".`。身份和任务进入同一条持久化收件箱消息。共享 system 策略和全部工具 schema 在成员间保持一致;执行时检查角色权限。Team 工具根据调用者确定 Team,并接受成员名字,因此模型不需要 Team id。身份随普通历史经历冷恢复和压缩;插件不检查提醒是否保留,也不添加替代消息。fork 继承已记录文本,不补发 Lead 身份修正。把身份放进 system prompt 会在继承历史之前改变前缀;把它留在初始任务中既保留此前缀,也无需每步维护身份提醒。已有的 system 内嵌身份可能需要一次提示词协调;保留的事件格式代际保持不变。 +共享 system 策略和全部工具 schema 在成员间保持一致;执行时检查角色权限。Team 工具根据调用者确定 Team,并接受成员名字,因此模型不需要 Team id。[必需 runtime-context 身份](../architecture/2026-10-13-team-identity-runtime-context.zh.md)负责身份注入、保留与 fork 处理。 ## Provisioning and recovery diff --git a/.agents/notes/implemented/feature/2026-09-02-in-history-system-prompt-replacement.i18n.yaml b/.agents/notes/implemented/feature/2026-09-02-in-history-system-prompt-replacement.i18n.yaml index b8b63582e2..379e4f0ad9 100644 --- a/.agents/notes/implemented/feature/2026-09-02-in-history-system-prompt-replacement.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-02-in-history-system-prompt-replacement.i18n.yaml @@ -18,14 +18,14 @@ en: 554d01427ebebee1 zh: 664c9bf0a9337a04 /agent-note-in-history-system-prompt-replacement-for-cache-stable-prompt-changes/decision/presentation-and-accounting: - en: 00a91b4fb592e1e4 - zh: dac2e4f0a3ee9ecf + en: 800d579e2fcf2cce + zh: 1e92354d9161c50a /agent-note-in-history-system-prompt-replacement-for-cache-stable-prompt-changes/decision/compaction: en: c21a3e7a773860e5 zh: 566669e245ccdbaf /agent-note-in-history-system-prompt-replacement-for-cache-stable-prompt-changes/alternatives-considered: - en: f3dec242b2550529 - zh: 0397fa07cc1806bb + en: 02b49911bc4601d0 + zh: bfa19fd7c4337812 /agent-note-in-history-system-prompt-replacement-for-cache-stable-prompt-changes/consequences: en: beda064bcd194a2a zh: 8a3bb81d5edcd60a diff --git a/.agents/notes/implemented/feature/2026-09-02-in-history-system-prompt-replacement.md b/.agents/notes/implemented/feature/2026-09-02-in-history-system-prompt-replacement.md index 644592d2a1..82402feff9 100644 --- a/.agents/notes/implemented/feature/2026-09-02-in-history-system-prompt-replacement.md +++ b/.agents/notes/implemented/feature/2026-09-02-in-history-system-prompt-replacement.md @@ -43,7 +43,7 @@ An empty head with no active later system node represents no prompt. Dormant emp Web presents an appended in-history node at its own position. `SystemPromptNode` carries `{ seq, time, turn, step, text, update }`, `update` being true for an appended `system/message` that follows an earlier system node in the loaded window. Chat renders a non-empty update as a collapsed `system-prompt` card titled by the locale key `message.systemPromptUpdate`, and a `request/header` in the same turn and step does not repeat the prompt card; `inspectRequestPrompt` reports no system change for a header that follows an update. Trajectory folds an update following a loaded request header into a synthetic request-header fact with `promptChange.kind = 'system'`, so later requests show the effective prompt without a real header change. When the loaded window lacks the earlier system node, the update is presented as an initial prompt. Transcript projections skip it like every `system/message`. -`dsh-token-meter` prices the last nonempty surviving system node in surface order as `contextBreakdown.systemTokens`; every other visible node, including superseded prompts, contributes to `messageTokens`. Empty dormant nodes are ignored. The sum equals the fixed-heuristic surface total after every replacement, whether or not a shadow-price claim exists. Compact retained entries reuse the measurement surface planner: state and transitions cost O(current retained surface), not O(1) or O(total historical log). Replaced entries and message bodies are discarded, and state version 4 rejects scalar checkpoints. `cacheReadTokens` on subsequent assistant usage remains the observable provider-cache effect. +`dsh-token-meter` prices the last nonempty surviving system node in surface order as `contextBreakdown.systemTokens`; every other visible node, including superseded prompts, contributes to `messageTokens`. Empty dormant nodes are ignored. The unit retains only the surviving system prompts and a running surface total, and prices replacements from the adjacent shadow-price event ([system-prompt state](../architecture/2026-10-13-context-breakdown-system-prompt-state.md)). `cacheReadTokens` on subsequent assistant usage remains the observable provider-cache effect. Trajectory chooses the newer of the preceding real header and the preceding synthetic system header as the comparison state. A real header owns configuration and tools; an appended prompt can advance that state without another real header. Comparing only real headers would report A rather than B as the previous prompt for an A → B → C sequence. @@ -73,7 +73,7 @@ Chat and Trajectory interpret the effective prompt through the pure `uiConversat **Clear only the latest system node.** Empty nodes project to no message, so an older prompt would become effective again. Clearing all active versions preserves the meaning of an empty rendering without deleting conversation history. Rejected. -**Keep only scalar totals or prompt ancestry.** A scalar shadow price cannot identify which bucket lost the newest prompt or restore its predecessor. Prompt-only entries cannot locate arbitrary nonprompt replacement endpoints; `sourceEventSeqs` may also cite surviving prompts, and event sequence order differs from surface order after rewrites. Retaining compact current surface entries reuses the existing planner without full-log access, a second validator, or consumer-specific durable events. Summing all surviving prompts as system tokens would change the intended effective-prompt meaning rather than fix classification. +**Keep only scalar totals.** A scalar shadow price cannot identify which bucket lost the newest prompt or restore its predecessor. Summing all surviving prompts as system tokens would change the intended effective-prompt meaning rather than fix classification. Retained system prompts with the shadow-price event's `shadowedSeqs` answer both questions; [the system-prompt state note](../architecture/2026-10-13-context-breakdown-system-prompt-state.md) records that choice over positional entries. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-09-02-in-history-system-prompt-replacement.zh.md b/.agents/notes/implemented/feature/2026-09-02-in-history-system-prompt-replacement.zh.md index b734bc74f7..a2c8c60bbf 100644 --- a/.agents/notes/implemented/feature/2026-09-02-in-history-system-prompt-replacement.zh.md +++ b/.agents/notes/implemented/feature/2026-09-02-in-history-system-prompt-replacement.zh.md @@ -43,7 +43,7 @@ Status: implemented Web 在追加的历史内节点自己的位置呈现它。`SystemPromptNode` 携带 `{ seq, time, turn, step, text, update }`,其中 `update` 对已加载窗口内跟在更早系统节点之后的追加 `system/message` 为真。Chat 把非空的更新渲染为一张折叠的 `system-prompt` 卡片,标题取自 locale 键 `message.systemPromptUpdate`,同一 turn 与 step 内的 `request/header` 不会重复提示词卡片;`inspectRequestPrompt` 对跟在更新之后的 header 不报告系统变更。Trajectory 把跟在已加载请求 header 之后的更新折叠为一条合成的请求 header 事实,`promptChange.kind = 'system'`,因此之后的请求无需真实的 header 变更就能显示有效提示词。已加载窗口缺少更早的系统节点时,更新按初始提示词呈现。转录投影像对待所有 `system/message` 一样跳过它。 -`dsh-token-meter` 把 surface 顺序中最后一个非空且存活的系统节点计入 `contextBreakdown.systemTokens`;其余可见节点(包括被取代的提示词)计入 `messageTokens`。休眠空节点被忽略。每次替换后,两者之和都等于固定启发式 surface 总量,无论是否存在影子价 claim。紧凑的保留条目复用测量服务的 surface 规划器:状态和转换成本为 O(当前保留 surface),不是 O(1) 或 O(完整历史日志)。被替换条目和消息正文被丢弃,状态版本 4 拒绝标量检查点。后续 assistant 用量中的 `cacheReadTokens` 仍是可观察的提供方缓存效果。 +`dsh-token-meter` 把 surface 顺序中最后一个非空且存活的系统节点计入 `contextBreakdown.systemTokens`;其余可见节点(包括被取代的提示词)计入 `messageTokens`。休眠空节点被忽略。该单元只保留存活的系统提示词和一个 surface 运行总量,并依据紧邻的影子价事件为替换定价([系统提示词状态](../architecture/2026-10-13-context-breakdown-system-prompt-state.zh.md))。后续 assistant 用量中的 `cacheReadTokens` 仍是可观察的提供方缓存效果。 Trajectory 选择前一条真实 header 与前一条合成系统 header 中较新的一个作为比较状态。真实 header 拥有配置与工具;追加的提示词可以在没有另一条真实 header 时推进该状态。只比较真实 header 会在 A → B → C 序列中把 A 而不是 B 报告为先前提示词。 @@ -73,7 +73,7 @@ Chat 与 Trajectory 通过纯操作 `uiConversation.inspectSystemPrompt` 解释 **仅清除最新系统节点。** 空节点不投影为消息,因此更早的提示词会重新生效。清除所有生效版本才能保留空渲染文本的含义,同时不删除对话历史。被否决。 -**只保留标量总量或提示词祖先链。** 标量影子价无法判断最新提示词从哪个分类消失,也无法恢复其前一个版本。仅有提示词条目无法定位任意非提示词替换端点;`sourceEventSeqs` 还可能引用存活提示词,改写后的事件序号顺序也不同于 surface 顺序。保留紧凑的当前 surface 条目可以复用现有规划器,无需完整日志访问、第二套验证器或消费方专用持久事件。把所有存活提示词之和归入系统数字会改变有效提示词的含义,而不是修复分类。 +**只保留标量总量。** 标量影子价无法判断最新提示词从哪个分类消失,也无法恢复其前一个版本。把所有存活提示词之和归入系统数字会改变有效提示词的含义,而不是修复分类。保留系统提示词并结合影子价事件的 `shadowedSeqs` 可以回答这两个问题;[系统提示词状态说明](../architecture/2026-10-13-context-breakdown-system-prompt-state.zh.md)记录了为何以这种方式取代位置条目。 ## Consequences diff --git a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml index d74aa0ba75..75bfe1d654 100644 --- a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml @@ -9,8 +9,8 @@ en: 71813b1cb1f750ba zh: 64d2b999933ccd98 /agent-note-web-sidebar-terminals/decision: - en: 8d28949572d990ef - zh: f6e26790415134d2 + en: ef54b8ca31bb71de + zh: 0c05685c25010e8c /agent-note-web-sidebar-terminals/alternatives-considered: en: b0ffb94cd5033e9c zh: 9294bb76503c858a @@ -18,5 +18,5 @@ en: d710787595b5dc89 zh: 4df9518c32698f85 /agent-note-web-sidebar-terminals/related: - en: 94540c743da01fc4 - zh: 53e3d9d2268153d1 + en: 2ae8d413f928803f + zh: 3075efb83112b130 diff --git a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md index 448bcf84c1..39a0a5fff8 100644 --- a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md +++ b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md @@ -22,7 +22,7 @@ Close and replacement remove the tab synchronously and run process cleanup in th The caller retains a terminal ID before creating it. Repeating create with the same Session and open ID does not allocate a second process. Closing an uncertain create uses that ID even when no creation response arrived. The Host records closed IDs before awaiting allocation, preventing a delayed create from reviving a closed terminal. Each attachment begins with a consistent, bounded serialized xterm screen; ordered output follows through the Gateway's existing multiplexed Remote stream. Output and screen snapshots share one operation queue. Followers retain final output on normal closure and fail explicitly on buffer overflow. Browser render acknowledgement prevents React batching from dropping increments. -The latest attachment controls input and dimensions; other attachments remain read-only. Every physical stream opening gets a fresh input attachment identity, including automatic transport recovery. Input RPCs are serialized, and results from a superseded attachment cannot downgrade a replacement connection. Terminal output creates no model input, Agent tool result or Session event. The existing [persistent Agent terminal decision](2026-07-16-persistent-pty-sessions.md) continues to govern model-owned sessions; this feature extends the [portable subprocess provider](../architecture/2026-07-28-portable-execution-world-consumers.md) with terminal environment facts and resize. Control-transfer and process-exit refusals only disable input, preserving the healthy output view. Client-owned error identifiers are translated by the terminal UI, including guidance to close retained exited terminals when the quota is full. +The latest attachment controls input and dimensions; other attachments remain read-only. Each physical `attach` stream owns its input and resize commands, including after automatic transport recovery. The Host orders commands and acknowledges provider acceptance; the Client bounds raw input and complete encoded frames, queues commands when the advertised carrier window is full, and resumes on acknowledgements. Superseded streams cannot write through their replacements, and unacknowledged input is never replayed. Terminal output creates no model input, Agent tool result or Session event. The existing [persistent Agent terminal decision](2026-07-16-persistent-pty-sessions.md) continues to govern model-owned sessions; this feature extends the [portable subprocess provider](../architecture/2026-07-28-portable-execution-world-consumers.md) with terminal environment facts and resize. Control-transfer and process-exit refusals only disable input, preserving the healthy output view. Client-owned error identifiers are translated by the terminal UI, including guidance to close retained exited terminals when the quota is full. ## Alternatives considered @@ -52,4 +52,4 @@ The [two-hour unattended-terminal reclamation](2026-09-14-unattended-browser-ter ## Related -- [Remote duplex stream](../architecture/2026-09-19-remote-duplex-stream.md): the transport-layer uplink under which one `attach` stream replaces the unary `write` and `resize` calls and the `attachmentId` correlation; that terminal change is listed under its Deferred section. +- [Remote duplex stream](../architecture/2026-09-19-remote-duplex-stream.md): the transport-layer uplink carrying terminal input and output under one physical attachment lifetime. diff --git a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md index 81bdf81ed9..6f1249e181 100644 --- a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md +++ b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md @@ -22,7 +22,7 @@ Web 用户需要在 Session 旁使用交互式 shell 检查工作区和运行命 调用者在创建之前保留终端 ID。相同 Session 和未关闭 ID 的重复 create 不再分配进程。创建结果不确定时,关闭仍使用该 ID,即使没有收到创建响应。Host 在等待分配完成前记录已关闭 ID,防止迟到的 create 复活已关闭终端。每次连接先接收一致、有界的 xterm 序列化屏幕,后续有序输出使用 Gateway 已有的复用 Remote stream。输出和屏幕快照共享操作队列。订阅者正常关闭时保留末尾输出,缓存超限时明确失败。浏览器在完成渲染后确认帧,避免 React 批处理丢失增量。 -最新连接控制输入和尺寸,其他连接保持只读。每次物理流建立都创建新的输入连接标识,包括传输自动恢复。输入 RPC 按序发送,旧连接的结果不能把新连接降级为失败。终端输出不产生模型输入、Agent 工具结果或 Session 事件。[Agent 持久终端决策](2026-07-16-persistent-pty-sessions.zh.md)仍约束模型拥有的终端;此功能为[可移植 subprocess provider](../architecture/2026-07-28-portable-execution-world-consumers.zh.md)增加执行环境事实和 resize。 控制权转移和进程退出导致的拒绝只禁用输入,保留健康的输出视图。Client 自产错误标识由终端 UI 翻译,包括名额用满时关闭已保留的退出终端的提示。 +最新连接控制输入和尺寸,其他连接保持只读。每条物理 `attach` 流拥有自己的输入和尺寸命令,传输自动恢复后亦如此。Host 按序执行命令并确认提供者已接受操作;Client 限制原始输入和完整编码帧,在公布的载体窗口满时排队,并在收到确认后继续发送。旧流不能借用新流写入,未确认输入绝不重放。终端输出不产生模型输入、Agent 工具结果或 Session 事件。[Agent 持久终端决策](2026-07-16-persistent-pty-sessions.zh.md)仍约束模型拥有的终端;此功能为[可移植 subprocess provider](../architecture/2026-07-28-portable-execution-world-consumers.zh.md)增加执行环境事实和 resize。 控制权转移和进程退出导致的拒绝只禁用输入,保留健康的输出视图。Client 自产错误标识由终端 UI 翻译,包括名额用满时关闭已保留的退出终端的提示。 ## 考虑过的替代方案 @@ -52,4 +52,4 @@ Agent 终端和可移植执行环境两篇记录仍保留,其所有权与 prov ## 相关 -- [Remote 双工流](../architecture/2026-09-19-remote-duplex-stream.zh.md):传输层上行,让一条 `attach` 流取代 unary `write`、`resize` 与 `attachmentId` 关联;该终端改动列在其「后续」中。 +- [Remote 双工流](../architecture/2026-09-19-remote-duplex-stream.zh.md):传输层上行在同一物理附件生命周期内承载终端输入和输出。 diff --git a/.agents/notes/implemented/feature/2026-09-20-spawn-agent-preset-selection.i18n.yaml b/.agents/notes/implemented/feature/2026-09-20-spawn-agent-preset-selection.i18n.yaml new file mode 100644 index 0000000000..be693bf4cb --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-20-spawn-agent-preset-selection.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-09-20-spawn-agent-preset-selection.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-20-spawn-agent-preset-selection.md +/agent-note-independent-spawn-preset-selection-and-restoration: + en: 2c57fd9b420af1cc + zh: 8d0d7a53fc85f746 +/agent-note-independent-spawn-preset-selection-and-restoration/problem: + en: 62fa53e4befdf947 + zh: 99956ba40cbda1ab +/agent-note-independent-spawn-preset-selection-and-restoration/decision: + en: a321ee6871ef14c0 + zh: 599093af0657a675 +/agent-note-independent-spawn-preset-selection-and-restoration/alternatives-considered: + en: 82fa00b86c8d375b + zh: eb00a537a854dfe4 +/agent-note-independent-spawn-preset-selection-and-restoration/consequences: + en: 57531f2f2eb7da5b + zh: 206564a73e8d7193 diff --git a/.agents/notes/implemented/feature/2026-09-20-spawn-agent-preset-selection.md b/.agents/notes/implemented/feature/2026-09-20-spawn-agent-preset-selection.md new file mode 100644 index 0000000000..e59975ca16 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-20-spawn-agent-preset-selection.md @@ -0,0 +1,33 @@ +# Agent Note: Independent spawn preset selection and restoration + +Status: implemented + +English | [中文](2026-09-20-spawn-agent-preset-selection.zh.md) + +## Problem + +Spawned agents need reusable combinations of persona, tools, and skills without replacing the [implicit parent-preset join](../../archived/bug-fix/2026-08-10-child-agents-join-their-parent-preset.md). Restoration must distinguish an independent selection from historical child logs without changing the Session format or fork behavior. + +## Decision + +An explicit spawn preset id resolves the currently registered definition, even when it equals the parent's id. Omission joins the parent's exact live revision. Selecting a named configuration accepts its current declaration and resolution failures; inheriting an active composition preserves its plugin instances even after that declaration changes or is removed. + +An independently selected child records the existing `agent-preset/selected` event before its first own `subagent/descriptor`. Cold restoration excludes the inherited fork prefix and uses that ordering to identify independent selection, then reads the selected id from the existing `agentPreset` projection. A missing projection rejects restoration rather than silently switching to the parent's composition. + +Historical children without this ordering retain the implicit join. A creation header can contain an inherited preset id, while a fork's seed can contain an ancestor's selection and an old child can record a selection after its descriptor. None of these facts establishes independent selection at child creation. The ordering marker preserves those histories without adding a descriptor field or another event type. + +## Alternatives considered + +**Resolve every child by preset id.** This would make an omitted selection observe an updated definition or fail after its removal, changing the implicit join and historical restoration behavior. + +**Infer independent selection from the header or projection alone.** Both can carry inherited values. A last-value projection also loses the ordering needed to distinguish a creation-time selection from later selections in historical logs. + +**Store a preset id in a new descriptor version.** The existing selection event and projection already persist the id. Another field would duplicate it and require a descriptor migration for a distinction the existing event order can express. + +**Support a nested preset object.** A second configuration spelling adds precedence and validation rules without serving an existing caller. A flat preset id selects composition; fork does not accept independent preset selection. + +## Consequences + +Independent restoration resolves the selected id against the registered definition; it does not preserve a historical revision across restarts. Missing or invalid presets fail explicitly. Implicit children and fork retain their existing composition and cold-restoration behavior. + +Focused continuation tests cover event ordering, inherited prefixes, historical logs, and missing projections. The recorded-session preset scenario covers independent persona and tool selection through the shipped headless composition. diff --git a/.agents/notes/implemented/feature/2026-09-20-spawn-agent-preset-selection.zh.md b/.agents/notes/implemented/feature/2026-09-20-spawn-agent-preset-selection.zh.md new file mode 100644 index 0000000000..8b5e8dbff4 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-20-spawn-agent-preset-selection.zh.md @@ -0,0 +1,33 @@ +# Agent Note: 独立 spawn preset 选择与恢复 + +Status: implemented + +[English](2026-09-20-spawn-agent-preset-selection.md) | 中文 + +## 问题 + +spawn 子 Agent 需要可复用的 persona、工具和技能组合,同时保留[隐式加入父方 preset](../../archived/bug-fix/2026-08-10-child-agents-join-their-parent-preset.md) 的行为。恢复时必须区分独立选择与历史子会话日志,不改变 Session 格式或 fork 行为。 + +## 决策 + +显式指定 spawn preset id 时解析当前注册的定义,即使该 id 与父方相同;省略时加入父方正在运行的确切代际。选择具名配置意味着接受当前声明及其解析失败;继承活跃组装则保留它的插件实例,即使声明已经修改或移除。 + +独立选择的子 Agent 在首条自有 `subagent/descriptor` 之前记录已有的 `agent-preset/selected` 事件。冷恢复排除继承的 fork 前缀,用此顺序识别独立选择,再从已有 `agentPreset` 投影读取所选 id。投影缺失时拒绝恢复,避免静默切换到父方组装。 + +不具备此顺序的历史子会话继续隐式加入父方。创建 header 可能包含继承的 preset id,fork 种子可能包含祖先的选择,旧子会话也可能在 descriptor 之后记录选择;这些事实都不能证明子会话创建时作过独立选择。事件顺序标记保留这些历史语义,无须增加 descriptor 字段或新的事件类型。 + +## 考虑过的替代方案 + +**所有子 Agent 都按 preset id 解析。** 这会让省略选择的子 Agent 读到更新后的定义,或因定义移除而失败,改变隐式加入及历史恢复行为。 + +**仅从 header 或投影推断独立选择。** 两者都可能携带继承值。末值投影还会丢失事件顺序,无法区分创建时选择与历史日志中的后续选择。 + +**在新版本 descriptor 中存储 preset id。** 已有选择事件和投影已经持久化该 id。增加字段会重复存储,并为现有事件顺序已能表达的区别引入 descriptor 迁移。 + +**支持嵌套 preset 对象。** 第二种配置写法会增加优先级与校验规则,却不服务于现有调用方。扁平的 preset id 选择组装;fork 不接受独立 preset 选择。 + +## 后果 + +独立恢复按所选 id 解析已注册定义,不跨重启保存历史代际。preset 缺失或无效时明确失败。隐式子 Agent 与 fork 保留原有组装和冷恢复行为。 + +针对性 continuation 测试覆盖事件顺序、继承前缀、历史日志和缺失投影。会话录制快照通过 shipped headless 组装覆盖独立 persona 与工具选择。 diff --git a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml index 67d1d90e9b..83ef4ebea0 100644 --- a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml @@ -9,14 +9,14 @@ en: e6929494e60041a2 zh: 15bec8e8baabcab2 /agent-note-generated-third-party-notices/decision: - en: 11f3362b0f45debb - zh: 5953b0848b940b67 + en: e0a339aa86f13898 + zh: 9ab0c7de7bb185b5 /agent-note-generated-third-party-notices/testing: en: 194f87f780991068 zh: 58096d2339576d20 /agent-note-generated-third-party-notices/alternatives-considered: - en: 093ddc027e296bf0 - zh: 6b9e64d99dcefa11 + en: 7e9d8d7864503e46 + zh: 5b93a1130cfab260 /agent-note-generated-third-party-notices/consequences: - en: 860b806c5d393c98 - zh: 51b4ca11e2d3802a + en: ed2210ac6c133e6c + zh: 7957cb33da842642 diff --git a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md index 0ebd540939..d233fd1eb4 100644 --- a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md +++ b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md @@ -14,9 +14,7 @@ A hand-written inventory answers none of those durably. Roughly a hundred rows o [`THIRD_PARTY_NOTICES.md`](../../../../THIRD_PARTY_NOTICES.md) is generated by [`scripts/gen-third-party-notices.ts`](../../../../scripts/gen-third-party-notices.ts) from the workspace manifests, `vendor/README.md`, the `pyproject.toml` files, `pnpm-workspace.yaml`, and the [shared runtime lock](../../../../scripts/primary-runtime/lock.json). The root README pair links the file from its License section. -**Freshness is maintained, not merely enforced.** A pre-commit job regenerates the file and stages it whenever a generator input is staged — any manifest, a workspace declaration, the root or shared runtime lock file, `vendor/README.md`, a `pyproject.toml`, the generator itself, or the script holding the build-time pin — so an unrelated dependency edit never has to come back and rerun a generator. The committed bytes are then asserted inside [`scripts/gen-third-party-notices.spec.ts`](../../../../scripts/gen-third-party-notices.spec.ts), which the test lane already runs — the check adds no gate process, no scheduler slot, and no separate CI step. `pnpm run verify-third-party-notices` remains available for a standalone check. - -One trigger gap is accepted rather than worked around: lefthook inspects only files present on disk, so **deleting** a manifest runs no job, and removing a package reaches the assertion in the test lane instead. Reconstructing the staged file list to include deletions does not work — lefthook filters the list against the working tree either way. The assertion is the backstop for exactly this case. +**The test lane enforces freshness.** The committed bytes are asserted inside [`scripts/gen-third-party-notices.spec.ts`](../../../../scripts/gen-third-party-notices.spec.ts), which the test lane already runs, including the required `node 24 / coverage` pull-request lane — the check adds no gate process, no scheduler slot, and no separate CI step. A contributor who changes a generator input — any manifest, a workspace declaration, the root or shared runtime lock file, `vendor/README.md`, a `pyproject.toml`, the generator itself, or the script holding the build-time pin — runs `pnpm run gen-third-party-notices`; `pnpm run verify-third-party-notices` remains available for a standalone check. No Git hook regenerates the file: browser inputs make every package source file a generator input, so a hook would run the 16-second generator on nearly every commit, while few commits change the output. The file discloses **direct** dependencies by default; complete npm and Python SDK closures remain in their lock files. Two separately distributed payload sets receive explicit entries: the official Claude executable packages declared by `@anthropic-ai/claude-agent-sdk`, and every distribution in the shared runtime lock's `pythonPackages`. Bundled entries include normalized names, exact versions, and recorded license metadata; missing metadata, duplicate normalized names and conflicting normalized versions fail generation. The shared wheel set is independent of the `python/` manifests, so scanning only those manifests would omit the packaged Office libraries. Bundled distributions also pass the runtime license check, including the permissive `MIT-CMU` and `PSF-2.0` identifiers declared by Pillow and typing-extensions. @@ -38,7 +36,7 @@ The Claude distribution tests prove that only the exact direct SDK identity bypa **Keep the hand-written file and review it at release time.** Reviewing a hundred derived rows by eye is exactly the work a generator does correctly, and the file's own claim — that it lists every direct dependency — would be unverified between releases. -**Verify through a dedicated `doc-sync` gate.** That is how every other generated artifact here is checked, and it was the first shape of this change. It costs a gate process and a scheduler slot in a matrix that is already long, and — worse — its only failure mode is telling a contributor, minutes after they pushed an unrelated dependency bump, to go rerun a generator. Regenerating at commit time removes the interruption, and the assertion inside a spec the test lane already runs keeps the guarantee at no additional CI cost. +**Verify through a dedicated `doc-sync` gate.** That is how every other generated artifact here is checked, and it was the first shape of this change. It costs a gate process and a scheduler slot in a matrix that is already long, while the assertion inside a spec the test lane already runs reports the same staleness at no additional CI cost. **Enumerate the full transitive closure.** The closure is thousands of packages, already recorded in the lock files with exact versions, and would bury the direct dependencies that a reader actually evaluates. The file points at the lock files and the `pnpm licenses list` renderer instead. @@ -52,7 +50,7 @@ The Claude distribution tests prove that only the exact direct SDK identity bypa ## Consequences -A dependency edit now carries a regenerated notices file into the same commit. Contributors pay one generator run — about a second — on commits that touch a manifest, and nothing on any other commit. Committing with hooks disabled defers the cost to a test-lane failure that names the command. +Commits pay no generator run. A dependency or browser-import edit that leaves the notices stale fails the test lane with an error that names the regeneration command. The generator needs an installed tree, which makes it heavier than a pure-source generator, and a new package with unusable published metadata needs an `OVERRIDES` entry rather than silently rendering a blank license. Both failures are loud and name the remedy. diff --git a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md index 323ad8ea15..b229c16396 100644 --- a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md +++ b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md @@ -14,9 +14,7 @@ Status: implemented [`THIRD_PARTY_NOTICES.md`](../../../../THIRD_PARTY_NOTICES.md) 由 [`scripts/gen-third-party-notices.ts`](../../../../scripts/gen-third-party-notices.ts) 依据各工作区 manifest、`vendor/README.md`、`pyproject.toml`、`pnpm-workspace.yaml` 与 [共享运行时锁文件](../../../../scripts/primary-runtime/lock.json)生成。根 README 双语两侧都从「许可证」一节链到该文件。 -**新鲜度会得到维护,而非仅靠校验。** 只要暂存了生成器的任一输入——任何 manifest、工作区声明、根锁文件或共享运行时锁文件、`vendor/README.md`、某个 `pyproject.toml`、生成器自身,或持有构建期 pin 的脚本——pre-commit 任务就会重新生成该文件并将其暂存,改依赖的人不必事后再折返跑一次生成器。已提交的字节随后由 [`scripts/gen-third-party-notices.spec.ts`](../../../../scripts/gen-third-party-notices.spec.ts) 断言,而测试 lane 本就会跑这个文件——这项校验不增加门禁进程、不占调度位、也不新增 CI 步骤。需要单独校验时,`pnpm run verify-third-party-notices` 仍然可用。 - -有一处触发缺口是接受而非绕过的:lefthook 只检视磁盘上存在的文件,因此**删除** manifest 不会触发任何任务,移除一个包会落到测试 lane 的断言上。重构暂存文件列表以纳入删除的做法不成立——无论怎么给列表,lefthook 都会拿工作树过滤一遍。这个场景正由断言兜底。 +**新鲜度由测试 lane 保证。** 已提交的字节由 [`scripts/gen-third-party-notices.spec.ts`](../../../../scripts/gen-third-party-notices.spec.ts) 断言,而测试 lane 本就会跑这个文件,其中包括 PR 必过的 `node 24 / coverage` lane——这项校验不增加门禁进程、不占调度位、也不新增 CI 步骤。改动生成器任一输入——任何 manifest、工作区声明、根锁文件或共享运行时锁文件、`vendor/README.md`、某个 `pyproject.toml`、生成器自身,或持有构建期 pin 的脚本——的贡献者运行 `pnpm run gen-third-party-notices`;需要单独校验时,`pnpm run verify-third-party-notices` 仍然可用。没有 Git 钩子重新生成该文件:浏览器输入使每个包源码文件都成为生成器输入,钩子会让几乎每个提交都运行约 16 秒的生成器,而改变输出的提交很少。 文件默认只披露**直接**依赖;完整的 npm 与 Python SDK 闭包保留在各自锁文件中。两组独立分发的载荷会明确列出:`@anthropic-ai/claude-agent-sdk` 声明的官方 Claude 可执行包,以及共享运行时锁文件 `pythonPackages` 中的每个分发包。内置条目包含归一化名称、精确版本和已记录的许可证元数据;元数据缺失、归一化后名称重复或版本冲突都会使生成失败。共享 wheel 集合独立于 `python/` 下的 manifest,仅扫描后者会遗漏已打包的 Office 库。内置分发包同样通过运行时许可证检查,其中包括 Pillow 与 typing-extensions 声明的宽松许可证标识 `MIT-CMU` 和 `PSF-2.0`。 @@ -38,7 +36,7 @@ Claude 分发测试证明:只有精确匹配的直接 SDK 身份会绕过通 **保留手写文件,发版时人工过一遍。** 用肉眼审阅上百行推导数据,恰恰是生成器能做对的活;而且在两次发版之间,文件自称「列出全部直接依赖」这句话无人验证。 -**用专门的 `doc-sync`(文档同步门禁)校验。** 仓库里其他生成产物都是这么把关的,本次改动最初也是这个形态。但它要在本已冗长的矩阵里再占一个门禁进程和一个调度位;更糟的是,它唯一的失败方式,就是在别人推完一个无关的依赖升级几分钟后,通知对方回去重跑一次生成器。改为提交时重新生成消除了这次打断,而把断言放进测试 lane 本就会跑的 spec 里,则以零额外 CI 成本保住了这项保证。 +**用专门的 `doc-sync`(文档同步门禁)校验。** 仓库里其他生成产物都是这么把关的,本次改动最初也是这个形态。但它要在本已冗长的矩阵里再占一个门禁进程和一个调度位,而测试 lane 本就会跑的 spec 里的断言能以零额外 CI 成本报告同样的过期。 **列出完整传递闭包。** 闭包有数千个包,锁文件里已带精确版本,铺开只会淹没读者真正要评估的直接依赖。文件转而指向锁文件与 `pnpm licenses list`。 @@ -52,7 +50,7 @@ Claude 分发测试证明:只有精确匹配的直接 SDK 身份会绕过通 ## 后果 -此后改动依赖时,重新生成的披露文件会随同一个提交入库。触及 manifest 的提交多付一次生成器运行——约一秒;其余提交不受影响。若禁用钩子提交,代价推迟为一次测试 lane 失败,其报错会指明补救命令。 +提交不运行生成器。改动依赖或浏览器侧 import 却未更新披露文件时,测试 lane 会失败,其报错会指明重新生成的命令。 生成器需要已安装的依赖树,因此比纯源码生成器更重;发布元数据不可用的新包需要补一条 `OVERRIDES`,而不是默默渲染出空白许可证。这两类失败都会明确报错并指出补救方式。 diff --git a/.agents/notes/implemented/process/2026-10-05-pr-artifact-typechecks.i18n.yaml b/.agents/notes/implemented/process/2026-10-05-pr-artifact-typechecks.i18n.yaml index 79f3339e71..192e40c2d2 100644 --- a/.agents/notes/implemented/process/2026-10-05-pr-artifact-typechecks.i18n.yaml +++ b/.agents/notes/implemented/process/2026-10-05-pr-artifact-typechecks.i18n.yaml @@ -9,8 +9,8 @@ en: e5c984fa32cc5e69 zh: 25d5742b2106d830 /agent-note-pr-artifact-builds-and-aggregate-typescript-checks/decision: - en: 6e84bceaba4573ca - zh: 25a37c147a675643 + en: af93d8849bb8831a + zh: 53e15e801ab844da /agent-note-pr-artifact-builds-and-aggregate-typescript-checks/alternatives-considered: en: f61da066f9ddcd62 zh: 816fd6e0063017cd diff --git a/.agents/notes/implemented/process/2026-10-05-pr-artifact-typechecks.md b/.agents/notes/implemented/process/2026-10-05-pr-artifact-typechecks.md index 7577a7c06e..6c422a612d 100644 --- a/.agents/notes/implemented/process/2026-10-05-pr-artifact-typechecks.md +++ b/.agents/notes/implemented/process/2026-10-05-pr-artifact-typechecks.md @@ -12,13 +12,13 @@ PR jobs need the same compiled packages for benchmarks, compatibility, packaging Required Linux consumer and native Windows build jobs retain normal builds, including both complete aggregate programs. Public `build` and `typecheck` keep the same checks. [Node version ownership](2026-07-06-node-engine-floor.md) and [native Windows ownership](2026-08-08-native-windows-pull-request-ci.md) remain in force. -Checked PR artifact builds compile every existing project reference from each compiler face with one serial `tsc -b` process, then run the ordinary Typert, bundling, Desktop, and Web stages. The helper reads the existing Host and Client aggregates rather than maintaining another package list. It omits only each root's additional test and script program; package diagnostics, emitted outputs, and Host-before-Client ordering remain required. Non-PR release and real-API workflow builds retain their normal checks. +Checked PR artifact builds compile every existing project reference from each compiler face with one serial `tsc -b` process, then run the ordinary Typert, bundling, Desktop, and Web stages. The helper reads the existing Host and Client aggregates rather than maintaining another package list. It omits only each root's additional test and script program; package diagnostics, emitted outputs, and Host-before-Client ordering remain required. Non-PR release and real-API workflow builds retain their normal checks. That artifact build is `pnpm run build:artifacts`; [the build split](2026-10-11-build-artifacts-check-split.md) owns its steps and scheduling. Only the CI benchmark helper selects `tsc -b --noCheck` for package emission. Benchmarks consume JavaScript and Typert runtime metadata; their declaration and build-information files are disposable and never published. Typert analyzes source types independently, while required complete builds own semantic diagnostics. Syntax and configuration failures still reject benchmark preparation. TypeScript records pending checks so a later ordinary incremental build diagnoses the same source without deleting caches or forcing a rebuild. Public benchmark scripts and every release, Python, preview, compatibility, and normal artifact builder keep their checked defaults. -The Python executable builder selects artifact compilation with `--artifacts-only` after verifying its runtime closure. Invalid closure inputs fail before compilation, deployment, or packing; the default complete build and prebuilt `--skip-build` mode keep their existing stages. +The Python executable builder selects `build:artifacts` artifact compilation with `--build-artifacts` after verifying its runtime closure. Invalid closure inputs fail before compilation, deployment, or packing; the default complete build and prebuilt `--skip-build` mode keep their existing stages. -CI invokes the internal build helpers directly and keeps the root package manifest unchanged. Root manifest edits activate the separate native-addon platform matrix even when only build aliases change; avoiding that trigger preserves the existing workflow set and concurrent-job ceiling without weakening native validation. +PR artifact builds in CI invoke the root manifest's `build:artifacts`; a PR that edits these root manifest scripts activates the separate native-addon platform matrix, a trade-off [the build split](2026-10-11-build-artifacts-check-split.md) records. The vendor rehearsal and benchmark lanes invoke the internal build helpers directly. Job inventory, runner selectors, and benchmark sample counts stay fixed; gate and worker ceilings do not increase. The three compatibility cases run one job at a time; the two-target Python runtime matrix starts after the keyless Python SDK job succeeds. Four Linux lanes, the Python SDK, three Windows lanes, and one compatibility job permit at most nine worker jobs initially. Once the SDK job finishes, the remaining seven independent lanes, one compatibility job, and two runtime targets permit at most ten. The runtime plan and wheel stages precede those targets and each use one job. This permits runtime validation to overlap the Node 22 build within the original ten-job ceiling. Coverage partitions reuse compact file-cost metadata within their platform and runner pool. Parallel release packing uses pnpm's existing bounded scheduler; serial publication keeps family order. diff --git a/.agents/notes/implemented/process/2026-10-05-pr-artifact-typechecks.zh.md b/.agents/notes/implemented/process/2026-10-05-pr-artifact-typechecks.zh.md index c0b3c1bcf4..9f25366401 100644 --- a/.agents/notes/implemented/process/2026-10-05-pr-artifact-typechecks.zh.md +++ b/.agents/notes/implemented/process/2026-10-05-pr-artifact-typechecks.zh.md @@ -12,13 +12,13 @@ PR job 需要相同的编译包来运行 benchmark、兼容性、打包和运行 必需的 Linux consumer 与原生 Windows build job 保留普通构建,包括两个完整 aggregate program。公开的 `build` 和 `typecheck` 保留相同检查。[Node 版本职责](2026-07-06-node-engine-floor.zh.md)与[原生 Windows 职责](2026-08-08-native-windows-pull-request-ci.zh.md)仍然有效。 -带检查的 PR 产物构建通过一个串行 `tsc -b` 进程编译各 compiler face 的全部现有 project reference,然后运行普通 Typert、bundling、Desktop 和 Web 阶段。helper 读取现有 Host 与 Client aggregate,而不维护另一份包清单。它只省略各 root 附加的测试和脚本 program;包诊断、emit 输出及 Host 先于 Client 的顺序仍然必需。非 PR 的 release 和真实 API 工作流构建保留普通检查。 +带检查的 PR 产物构建通过一个串行 `tsc -b` 进程编译各 compiler face 的全部现有 project reference,然后运行普通 Typert、bundling、Desktop 和 Web 阶段。helper 读取现有 Host 与 Client aggregate,而不维护另一份包清单。它只省略各 root 附加的测试和脚本 program;包诊断、emit 输出及 Host 先于 Client 的顺序仍然必需。非 PR 的 release 和真实 API 工作流构建保留普通检查。该产物构建即 `pnpm run build:artifacts`,其步骤与调度见[构建拆分](2026-10-11-build-artifacts-check-split.zh.md)。 只有 CI benchmark helper 选择 `tsc -b --noCheck` 来 emit 包。benchmark 消费 JavaScript 和 Typert 运行时元数据;其声明与 build-information 文件是临时产物,不会发布。Typert 独立分析源码类型,语义诊断由必需的完整构建负责。语法与配置错误仍会拒绝 benchmark 准备。TypeScript 记录待检查状态,使后续普通增量构建无需删除缓存或强制重建,就能诊断相同源码。公开 benchmark script,以及所有 release、Python、preview、compatibility 和普通产物 builder,均保留带检查的默认行为。 -Python executable builder 在验证 runtime closure 后,通过 `--artifacts-only` 选择产物编译。无效 closure 在编译、部署或打包前失败;默认完整构建和复用预构建产物的 `--skip-build` 模式保留原有阶段。 +Python executable builder 在验证 runtime closure 后,通过 `--build-artifacts` 选择 `build:artifacts` 产物编译。无效 closure 在编译、部署或打包前失败;默认完整构建和复用预构建产物的 `--skip-build` 模式保留原有阶段。 -CI 直接调用内部 build helper,并保持 root package manifest 不变。即使只修改 build alias,root manifest 改动也会激活独立的 native-addon 平台矩阵;避免这一触发条件,可以在不削弱 native 验证的情况下保留现有 workflow 集合与并发 job 上限。 +CI 的 PR 产物构建调用 root manifest 中的 `build:artifacts`;修改 root manifest 中这些 script 的 PR 会激活独立的 native-addon 平台矩阵,[构建拆分](2026-10-11-build-artifacts-check-split.zh.md)记录这一取舍。vendor 演练与 benchmark lane 直接调用内部 build helper。 job 清单、runner 选择和 benchmark sample 数量保持固定;gate 与 worker 上限不提高。三个 compatibility case 每次运行一个 job;双目标 Python runtime 矩阵在 keyless Python SDK job 成功后启动。四个 Linux lane、Python SDK、三个 Windows lane 和一个 compatibility job,初始最多占用九个 worker job。SDK job 完成后,剩余七个独立 lane、一个 compatibility job 和两个 runtime target,最多占用十个 job。runtime plan 与 wheel 阶段先于这些 target,各占一个 job。这允许 runtime 验证与 Node 22 构建重叠,同时保持原有十个 job 的上限。覆盖率分区在各自的平台和 runner pool 内复用精简的文件成本元数据。并行 release 打包使用 pnpm 现有的有界调度器;串行发布保持 family 顺序。 diff --git a/.agents/notes/implemented/process/2026-10-11-build-artifacts-check-split.i18n.yaml b/.agents/notes/implemented/process/2026-10-11-build-artifacts-check-split.i18n.yaml new file mode 100644 index 0000000000..84099cc7a6 --- /dev/null +++ b/.agents/notes/implemented/process/2026-10-11-build-artifacts-check-split.i18n.yaml @@ -0,0 +1,40 @@ +# Bilingual-pair consistency record for 2026-10-11-build-artifacts-check-split.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-10-11-build-artifacts-check-split.md +/agent-note-split-build-artifacts-and-build-check-with-build-running-both-concurrently: + en: b1a98fdd8008dce1 + zh: 15bdc2f2c2584226 +/agent-note-split-build-artifacts-and-build-check-with-build-running-both-concurrently/problem: + en: bc382ad1f1e71352 + zh: 44189bf0f69a0bf7 +/agent-note-split-build-artifacts-and-build-check-with-build-running-both-concurrently/decision: + en: a15c7c13f167e5ac + zh: 63bcc2ef60c2f344 +/agent-note-split-build-artifacts-and-build-check-with-build-running-both-concurrently/decision/commands: + en: 174241330463841d + zh: 7fe361c07e634053 +/agent-note-split-build-artifacts-and-build-check-with-build-running-both-concurrently/decision/steps-and-dependencies: + en: 43e78e255306cd43 + zh: a741d5ef1c7379f3 +/agent-note-split-build-artifacts-and-build-check-with-build-running-both-concurrently/decision/scheduling-failure-and-output: + en: 4b324f92307d3691 + zh: e0cf4411a747f7e5 +/agent-note-split-build-artifacts-and-build-check-with-build-running-both-concurrently/decision/callers: + en: 55c55eea07fdb459 + zh: ab78cdfa4d04a5e7 +/agent-note-split-build-artifacts-and-build-check-with-build-running-both-concurrently/decision/measurements: + en: b3a753ac2aab6073 + zh: 19cbd0e32751896f +/agent-note-split-build-artifacts-and-build-check-with-build-running-both-concurrently/decision/completeness-and-lossless-evidence: + en: 3cd40e0db9a2c89a + zh: 3f708be239779d0a +/agent-note-split-build-artifacts-and-build-check-with-build-running-both-concurrently/decision/intentional-trade-offs-not-lossless: + en: 14370bc5b54e1278 + zh: 7ac933092d3e9954 +/agent-note-split-build-artifacts-and-build-check-with-build-running-both-concurrently/alternatives-considered: + en: 54905b912d17c74e + zh: 86a298004c8024e3 +/agent-note-split-build-artifacts-and-build-check-with-build-running-both-concurrently/consequences: + en: 083467f68d0f03ed + zh: 93400e9994b8c645 diff --git a/.agents/notes/implemented/process/2026-10-11-build-artifacts-check-split.md b/.agents/notes/implemented/process/2026-10-11-build-artifacts-check-split.md new file mode 100644 index 0000000000..4476424af5 --- /dev/null +++ b/.agents/notes/implemented/process/2026-10-11-build-artifacts-check-split.md @@ -0,0 +1,157 @@ +# Agent Note: Split build:artifacts and build:check, with build running both concurrently + +Status: implemented + +English | [中文](2026-10-11-build-artifacts-check-split.zh.md) + +## Problem + +`pnpm run build` runs the native addon, the package-library build (Host `tsc -b tsconfig.host.json` → Host tsdown → Desktop bundle → Client `tsc -b tsconfig.client.json` → Client tsdown), and the Web build serially. Besides referencing package projects, the Host and Client aggregates each carry a `noEmit` root program covering tests, scripts, benchmarks, and website. These two root programs emit no files and feed no later artifact step, yet sit on the serial critical path: timed alone, the Host root program takes 68.5 s and the Client root program 36.0 s, while the serial package-library build takes about 249 s (median of 2). + +Development iterations need only artifacts; test and script type checks are needed before work is declared complete. The only way to skip the two root programs is the CI-oriented `pnpm run build --artifacts-only`, which has no local development command name and no counterpart that runs only those two checks. Meanwhile, `pnpm run build` must stay complete and detect every type error. + +## Decision + +### Commands + +Each command does only its own job. + +| Command | Responsibility | Steps | Scheduling | Client build record | +|---|---|---|---|---| +| `pnpm run build:artifacts` | Produces every artifact of `build`; package projects are fully type-checked as usual, the two aggregate root programs are not checked | native → host-src → host-bundle → desktop-bundle → client-src → client-bundle → web | One linear chain | Deleted at start, written after every step passes | +| `pnpm run build:check` | Type-checks only the two aggregate root programs | host-check, client-check | Both steps concurrent, no prerequisite steps | Neither read nor written | +| `pnpm run build` | Every step of `build:artifacts` and `build:check` | The dependency graph below | Concurrent through the run-gates scheduler | Deleted at start, written after every step passes | + +`build:check` neither detects, rebuilds, nor validates `build:artifacts` outputs; when prerequisite outputs are missing or stale, the compiler's own diagnostics (for example TS6305 and TS2307) report it, and no command adds prerequisite checks, guards, or fallbacks. `build:artifacts` produces the same artifacts and record as `build` and replaces `--artifacts-only`; that flag and the `libraries` mode of `compile-referenced-projects.ts` are removed, and the Python executable builder uses `--build-artifacts`. `build:official` remains `tsx scripts/build.ts --profile official` and runs the complete `build`. + +### Steps and dependencies + +| Step | Command | `needs` in `build` | +|---|---|---| +| native | `pnpm run build:native-system` | — | +| host-src | `node --max-old-space-size=4096 -b ` | native | +| host-bundle | `tsdown --config-loader native --env.DSH_BUILD_FACE host` (runs Typert) | host-src | +| desktop-bundle | `pnpm --filter @deepseek-ai/dsh-desktop run bundle` | host-bundle | +| client-src | ` -b ` | desktop-bundle | +| client-bundle | `tsdown --config-loader native --env.DSH_BUILD_FACE client` | client-src | +| web | `pnpm run build:web` | client-bundle | +| host-check | `node --max-old-space-size=4096 -p tsconfig.host.json` | host-src | +| client-check | ` -p tsconfig.client.json` | client-src | + +```text +native → host-src ─┬─ host-check ───────────────────────────────────────────────────────┐ + └─ host-bundle → desktop-bundle → client-src ─┬─ client-check ───────┤ → record + └─ client-bundle → web ┘ +``` + +- client-src must follow host-bundle: Client packages import `@deepseek-ai/dsh-*/remote`, whose types `lib/typert.remote-client.d.ts` Typert writes, inside Host tsdown in these scopes and through `scripts/generate-typert.ts` in the typecheck scope. host-check reads no Typert output; clean serial builds have always finished the Host aggregate check before Host tsdown. +- The artifact chain keeps the two non-data edges native → host-src and desktop-bundle → client-src; only the two check steps attach to the chain in parallel. +- [`compile-referenced-projects.ts`](../../../../scripts/compile-referenced-projects.ts) owns the package-library step list `libraryBuildSteps`, the references argument construction `referencedProjectsCompilerArgs` (relative paths keep the command below Windows' command-line limit), and the aggregate-check arguments `aggregateCheckArgs`. The vendor release rehearsal (`host-libraries`) and CI benchmarks (`--noCheck`) run the same step list serially. +- The check steps use project mode (`tsc -p`): it reads the declarations that references already emitted and compiles no reference, writing incremental state to the aggregates' existing `tsconfig.{host,client}.tsbuildinfo`. `build` and `build:check` run the same command, so their diagnostics are identical. + +### Scheduling, failure, and output + +- [`scripts/build.ts`](../../../../scripts/build.ts) owns the step table and the three scopes and executes them through `runAggregate` from [`scripts/run-gates.ts`](../../../../scripts/run-gates.ts): each step is a gate with `needs`, and shell-free spawning, per-step buffered output, fail-fast process-tree termination, Windows `taskkill`, and host signal forwarding come from the run-gates implementation. `build:artifacts` is `tsx scripts/build.ts --scope artifacts`, `build:check` is `--scope check`, and `build` omits `--scope` and runs both as one graph; `--profile` applies only to artifact scopes, and combining it with `--scope check` fails argument parsing. +- The concurrency limit is `DSH_GATE_CONCURRENCY`, defaulting to the available CPU count; the dependency graph is at most 3 wide (client-check and client-bundle can start while host-check is still running). Master serial reference lanes set `DSH_GATE_CONCURRENCY=1`, so `build` runs serially in topological order there, consistent with the [serial reference decision](2026-07-21-serial-cross-platform-ci-reference.md) and without workflow edits. +- By default (locally), every dependent of a failed step is recorded as skipped, while running steps and steps unrelated to the failure run to completion; the executed set depends only on step outcomes, so one run reports every independent type error. With `DSH_GATE_FAIL_FAST=1` (set in every building PR lane), the first failure terminates the process trees of the other running steps and records every unfinished step as skipped. +- A failed step prints `== FAILED ==`, its command, the exit reason, and the step's complete buffered output, and the final summary lists failed and skipped steps; a passing step prints only its PASS line, and `DSH_GATE_VERBOSE=1` prints all output. The exit code is 1 when any step failed or was skipped, and the client build record is not written. Every step inherits the public Client environment selected by `clientBuildProcessEnvironment`. +- In CI, `build` itself is one gate of an outer run-gates aggregate; outer fail-fast termination propagates to the inner scheduler. `check:node-compat` and `check:ci:lint:contracts-ready` already nest the same way. + +### Callers + +| Caller | Command | Aggregate root-program checks | +|---|---|---| +| `build`, `build:official`, `Makefile` `build`, `scripts/publish-npm-baseline.ts`, `apps/desktop/scripts/package-target.ts`, `scripts/dev-bundle-registry.ts`, `release-publish.yml`, `sandbox.yml`, and the non-PR branches of `e2e.yml` and `release.yml` | `build` / `build:official` | Yes | +| `test:web`, `test:web:refresh`, `test:web:perf`, `test:web:stress`, `apps/desktop` `test:updates:local`, `test:bench` / `build:bench` | `build`; `build:bench` runs `build:native-system`, `build:lib`, and the benchmark tsdown | Yes | +| `typecheck` (run by the pre-push hook) | `build.ts --scope typecheck`: host-src beside host-typert (the bundle-free, source-only Typert pass `generate-typert.ts`) → client-src, with `tsc -b tsconfig.{host,client}.json` after each face's package compilation; no bundle | Yes | +| `dev:web` ([`scripts/dev-web.ts`](../../../../scripts/dev-web.ts)), `dev:desktop` (`apps/desktop/scripts/dev.ts`) | `build:artifacts` | None before launch; after `dev:web` launches, its `tsc -b tsconfig.client.json --watch` keeps checking the Client aggregate root program | +| Prerequisite step of `lint`, `lint:fix`, `doc-typecheck`, and the CI Typert contracts gate | Host package compilation beside the bundle-free Typert pass (`compile-referenced-projects.ts host-contracts`), without any bundle | None | +| CI node 24 / snapshots and artifacts (`check:ci:consumers`), windows node 24 / build & observational (`check:ci:windows-blocking`) | `build` | Yes, the two required lanes | +| CI node-compat 22.19 | `build:artifacts` (through run-gates) | None | +| CI python runtime | `build-exe-for-python-sdk.ts --build-artifacts` | None | +| PR rehearsals in `e2e.yml` and `release.yml` | `pnpm run build:artifacts --profile official` | None | +| `build-preview-cloudflare.yml` | `pnpm run build:artifacts` | None | +| CI node 24 / benchmarks | `run-ci-bench.ts` (`tsc -b --noCheck`) | None | +| PR rehearsal in `release-vendor.yml` | `compile-referenced-projects.ts host-libraries` | None | +| Non-PR branch of `release-vendor.yml`, `release-vendor-publish.yml` | `build:lib:host` | Host aggregate root program only | + +`lint`, `lint:fix`, `doc-typecheck`, and the Typert contracts gate read only Typert outputs and package declarations: both aggregates are `noEmit`, oxlint builds its own programs, `doc-typecheck` references only package projects, and none of them reads a bundle. Their prerequisite step therefore skips the Host aggregate check and every bundle; skipping the Host aggregate check and the Desktop bundle alone took it from 159.8 s to 90.9 s (see the measurements below). + +The consumers lane's HMR Web test starts `dev:web` and therefore uses `build:artifacts` inside that lane; the same lane's `build` gate still runs both aggregate checks. CI does not call `build:check` separately: the two required lanes already get the same checks through `build`. The [PR artifact build decision](2026-10-05-pr-artifact-typechecks.md) keeps its required complete builds, benchmark `--noCheck`, job and worker ceilings, serial node-compat Vitest, coverage weights, and pnpm packing checks; its PR artifact build is `build:artifacts`. + +### Measurements + +Prototype conditions: a shared 256 vCPU Linux host (load average 23–70), Node v24.18.0, TypeScript 6.0.3, React type packages hoisted to the root, without the Typert changes of [the lossless speed-ups note](2026-10-11-lossless-build-speedups.md); scope is the package-library build (excluding native and web). Every round starts from `pnpm run clean`, running once each in the order serial, concurrent, `build:lib`, `build:lib`, concurrent, serial; `/usr/bin/time -v` times each step, and summed process-tree RSS is sampled every 250 ms. The prototype runs the aggregate checks as `tsc -b tsconfig.{host,client}.json` once references are up to date, when build mode builds only the aggregate root program. + +| Mode (medians of 2) | Wall (s) | CPU user+sys (s) | Process-tree RSS peak (GiB) | +|---|---|---|---| +| Serial `pnpm run build:lib` | 249.4 | 378.0 | 5.95 | +| Each step run separately in sequence | 255.1 | 373.8 | 5.82 | +| Concurrent by dependency graph | 172.1 | 392.1 | 8.00 | + +Median wall falls from 249.4 s to 172.1 s (−77.3 s, −31%). The two runs of each step separately differ by 28 s, so the overhead of running the steps as separate processes is not resolvable. CPU grows by about 14 s (+3.7%), mostly Host aggregate check user time under contention; the RSS peak grows by about 2.1 GiB where the Host aggregate check (about 4 GiB) overlaps Host tsdown (about 5.7 GiB). The concurrent critical path is host-src → host-bundle → desktop-bundle → client-src → client-check; host-check finishes about 33 s before the end and client-bundle about 21 s before. + +Final measurements: master (the merge base) and this branch run interleaved, each from clean, in a 64-core cgroup; values are medians of 2 samples unless marked n=1. + +| Scenario | master | This branch | CPU user+sys (master → branch) | Process-tree RSS peak (master → branch) | +|---|---|---|---|---| +| Complete build `pnpm run build` | 317.1 s | 169.1 s (−46.7%) | 423.1 → 374.4 s (−11.5%) | 6.11 → 7.53 GiB (+1.42) | +| Serial branch build `DSH_GATE_CONCURRENCY=1 pnpm run build` (n=1) | — | 266.7 s | 389.5 s | 6.27 GiB | +| Artifact build: master `build --artifacts-only`, branch `build:artifacts` | 185.8 s | 153.6 s (−17.3%) | 256.2 → 231.2 s | 6.58 → 6.18 GiB | +| Development iteration: master `build`, branch `build:artifacts` | 317.1 s | 153.6 s (−51.6%) | 423.1 → 231.2 s (−45.4%) | 6.11 → 6.18 GiB | +| `pnpm run build:check` after `build:artifacts` | — | 67.7 s | 144.6 s | 5.33 GiB | +| `pnpm run build:check` no-op rerun | — | 19.4 s | 33.1 s | Not measured | +| Prerequisite of `lint` and `doc-typecheck`: master `build:lib:host`, branch `host-libraries` (since replaced by `host-contracts`, see [local gate speed-ups](2026-10-12-local-gate-speedups.md)) | 159.8 s | 90.9 s (−43.2%) | 234.7 → 136.0 s | Not measured | + +The serial branch sample overlapped other work on the host (742 s of foreign CPU), so its wall likely overstates the serial cost. In the serial build segments (master → branch, one sample each), Host tsdown falls from 22.5 to 11.7 s, Client package compilation from 55.6 to 48.7 s, the Client aggregate check from 68.2 to 35.9 s, and Client tsdown from 13.9 to 10.5 s; the segments sum to 308.5 s and 254.9 s. Those reductions come from [the lossless speed-ups note](2026-10-11-lossless-build-speedups.md); the further reduction from 254.9 s of serial segments to the 169.1 s concurrent build comes from this note's scheduling, which takes the Host aggregate check (about 66 s) off the critical path. In the concurrent build, the critical path is native → host-src → host-bundle → desktop-bundle → client-src → client-check, and host-check ends about 30 s before the last step (one earlier sample). `build:check` runs the Host and Client aggregate checks concurrently, so its wall is the Host check's (about 65 s). + +### Completeness and lossless evidence + +`build` runs the same set of compiler programs as the serial build: host-src and client-src compile every package project of both faces, and host-check and client-check check both aggregate root programs with the same commands as `build:check`. Across the prototype's six rounds, the 11,580 files of `{packages,apps,vendor,native}/**/lib/**` (excluding `*.tsbuildinfo`) are byte-identical between serial and concurrent runs, and after every round `tsc -b --dry tsconfig.host.json tsconfig.client.json` reports no out-of-date project. tsbuildinfo writers are host-src (292), client-src (76, exactly the client-only projects), and one per aggregate check; client-src rewrites no tsbuildinfo or declaration that host-check reads, so the graph has no write race. The fixture tests `checks only the aggregate program in project mode` and `reports missing package declarations without compiling references` show that `tsc -b --dry` treats the tsbuildinfo written by `tsc -p` as up to date and that project mode compiles no reference. In this implementation, the concurrent `build` and `DSH_GATE_CONCURRENCY=1 pnpm run build` produce byte-identical `lib/` (11,584 files), `apps/web/dist` (156 files), and client build record; afterwards `tsc -b --dry` reports all 372 projects of both aggregates up to date, with none to build. + +| Error | `build:artifacts` | `build:check` | `build` | `typecheck` | Required CI lanes | +|---|---|---|---|---|---| +| Package source type error | host-src / client-src | Not reported (reads existing declarations) | host-src / client-src | Reported | Reported | +| Type error only in an aggregate root program's tests, scripts, benchmarks, or website | Not reported | host-check / client-check | host-check / client-check | Reported | consumers, windows-build | +| Package tsconfig configuration error | host-src / client-src | Not reported (`tsc -p` does not validate referenced projects' tsconfig options or syntax) | host-src / client-src | Reported | Reported | +| Typert analysis error | host-bundle | Not reported | host-bundle | Reported (through host-typert) | Reported | +| Missing prerequisite output | — | Compiler diagnostic (TS6305, TS2307) | — | — | — | + +Negative controls: after a type error (TS2322) is added to `scripts/pnpm-invocation.spec.ts`, which only the Host aggregate root program contains, `build:artifacts` exits 0; `build:check` exits 1 with the Host aggregate check failing and the Client aggregate check passing; `build` exits 1 with 8 of 9 steps passing and 1 failing, and writes no client build record. In a scratch fixture whose referenced project's tsconfig has an invalid `target`, a JSON syntax error, or an unknown compiler option, `tsc -p` exits 0, while `tsc -b` reports TS6046, TS5092 with TS1012, and TS5023 respectively and exits 1; when the referenced project lacks `composite`, both report TS6306. + +CI evidence: on PR #6005 both required lanes that run the concurrent `pnpm run build` passed, `node 24 / snapshots and artifacts` (`check:ci:consumers` on `blacksmith-16vcpu-ubuntu-2404`) and `windows node 24 / build & observational` (`check:ci:windows-blocking` on `blacksmith-16vcpu-windows-2025`); the GitHub-hosted 4 vCPU benchmarks lane does not run `build`. The inner concurrency follows `DSH_GATE_CONCURRENCY`: master serial lanes set 1, the consumers lane sets 10 under self-hosted failover, and an unset value selects the available CPU count, capped at the 9 steps. + +### Intentional trade-offs (not lossless) + +- **Some commands skip the aggregate checks:** `build:artifacts`, the launch builds of `dev:web` and `dev:desktop`, and the prerequisite steps of `lint`, `lint:fix`, `doc-typecheck`, and the Typert contracts gate do not type-check the two aggregate root programs, and these prerequisite steps produce declarations and Typert outputs but no bundle. `build`, `build:check`, `typecheck` (pre-push hook), and the two required CI lanes own completeness; `dev:web` and `dev:desktop` do not refuse to launch because of test type errors. +- **`build:check` does not check freshness:** When sources change after `build:artifacts`, `build:check` checks against stale declarations, including Typert Remote declarations; rerun `build:artifacts` first. The complete `build` is unaffected. +- **Concurrent `build` raises the memory peak:** This branch's complete `build` peaks at 7.53 GiB of process-tree RSS against 6.11 GiB for the master serial build (+1.42 GiB, medians of 2) and 6.27 GiB for the branch's serial build (n=1); CPU meanwhile falls from 423.1 s to 374.4 s because [the lossless speed-ups note](2026-10-11-lossless-build-speedups.md) removes more work than concurrent contention adds. In the prototype (without the Typert changes), concurrent scheduling alone raises the RSS peak by about 2.1 GiB (5.9 → 8.0 GiB) and CPU by about 3.7%. `DSH_GATE_CONCURRENCY` bounds it; under memory pressure use `DSH_GATE_CONCURRENCY=1 pnpm run build`, or run `build:artifacts` and `build:check` separately. In the windows-build lane, `build` runs concurrently with `docs:build`. +- **Passing steps' output is hidden by default:** Only failed steps print their complete output. +- **`tsc -p` does not skip an up-to-date root program:** An immediate rerun of `build:check` without changes still takes 19.4 s and 33.1 s of CPU because both programs are parsed again; `tsc -b` skips an up-to-date root. +- **On many-core cold builds `build:artifacts` is only 15.5 s faster than `build`:** This branch's cold `build` takes 169.1 s and `build:artifacts` 153.6 s (medians of 2), because host-check is off the critical path and client-check is only about 16 s longer than client-bundle plus web. The rest of `build:artifacts`'s saving is CPU and memory: 374.4 → 231.2 s of CPU and 7.53 → 6.18 GiB of peak RSS; on few-core machines and in incremental rebuilds this also turns into wall time. +- **CI invokes public commands in the root manifest:** PR artifact builds invoke `build:artifacts` rather than an internal helper flag; a PR that edits these root manifest scripts activates the separate Node Addon System platform matrix. + +## Alternatives considered + +- **Keep the aggregate checks only in `typecheck`:** `pnpm run build` must stay complete and detect every error. +- **Keep `--artifacts-only` and add a `build:artifacts` alias:** One operation would have two names. +- **Name the commands `build:src` and `build:test`:** `build:artifacts` produces every artifact, including the tsdown bundles, the Desktop bundle, and Web, not only compiled sources; `build:check` checks scripts, benchmarks, and website code as well as tests. +- **Name the commands after compiler terms (`build:b`/`build:p`, `build:project`, `build:emit`) or `build:dist`:** tsc runs in only two artifact steps, and `-p` is an implementation choice for the check steps; TypeScript calls the `tsc -p` side the project; `emit` reads as the emit-only `--noCheck` mode although package projects stay fully checked; package outputs live under `lib/`, and `dist` suggests release packaging. +- **`build:check` runs `tsc -b `:** Build mode compiles out-of-date references, doing `build:artifacts`'s work. +- **`build:check` first completes or validates prerequisite outputs:** It violates each command doing only its own job and adds prerequisite logic; compiler diagnostics already name missing outputs. +- **A new scheduler (shell `&`/`wait`, `concurrently`, turbo/nx):** It duplicates run-gates' failure summary, process-tree termination, and Windows handling. +- **Expand `build` into several gates in CI's run-gates:** `pnpm run build` must be a complete command usable directly locally and by release workflows, and it writes the record. +- **Relax the non-data edges in the artifact chain (native ∥ host-src, desktop-bundle ∥ client-src):** Not measured; the Desktop bundle itself takes only 4.0–4.5 s. +- **Move tests into a new tsconfig:** The existing aggregates already separate package references from the root program. +- **Split the aggregate root programs into sub-programs:** Each sub-program must inject the 242 files carrying module augmentations, CPU grows by 9–57%, and only wall falls (see [the lossless speed-ups note](2026-10-11-lossless-build-speedups.md)). +- **Run `build:check` separately in CI:** The required lanes already get the same checks through `build`; a separate run repeats the work. + +## Consequences + +Development iterations can skip the two aggregate root programs with `build:artifacts` (51.6% faster cold than master's `build`) and complete the checks with `build:check` or `build` before declaring work complete. `build` stays complete: the Host aggregate check is off the critical path, the Client aggregate check runs alongside the Client bundle and web, and a cold build is 46.7% faster than master (including the lossless speed-ups note's changes; medians of 2). The costs are a higher memory peak, a little extra CPU from concurrent contention, less passing output by default, and several development and prerequisite commands that do not check test types (see the trade-offs above). + +Removing or changing `build` in the two required lanes, consumers and windows-build, requires first transferring both aggregate checks to another blocking owner. The typecheck scope and the lint prerequisites generate Typert contracts without bundling ([local gate speed-ups](2026-10-12-local-gate-speedups.md)); the artifact scopes keep generating them inside Host tsdown. + +client-src's dependency on host-bundle guarantees the "Host and Remote artifacts complete before Client compilation" ordering that [Typert Remote method calls](../architecture/2026-08-02-typert-remote-method-calls.md) describes. + +Verification: `scripts/build.spec.ts` covers the type-stripping check, rejection of the removed flag and invalid scopes, the conflict between `--profile` and `--scope check`, scope selection, step dependencies, and the environment; `compile-referenced-projects.spec.ts` covers project mode checking only the aggregate program, the diagnostic for missing declarations, and `tsc -b --dry` accepting the tsbuildinfo that `tsc -p` writes; failure propagation, output buffering, and process-tree termination come from the run-gates scheduler and its tests; `run-gates.spec.ts`, `ci-workflow.spec.ts`, `preview-workflow.spec.ts`, `build-exe-for-python-sdk.spec.ts`, `dev-web.spec.ts`, and `tests/ci-release-selfhosted.spec.ts` pin each caller's command. diff --git a/.agents/notes/implemented/process/2026-10-11-build-artifacts-check-split.zh.md b/.agents/notes/implemented/process/2026-10-11-build-artifacts-check-split.zh.md new file mode 100644 index 0000000000..4929e28574 --- /dev/null +++ b/.agents/notes/implemented/process/2026-10-11-build-artifacts-check-split.zh.md @@ -0,0 +1,157 @@ +# Agent Note: 拆分 build:artifacts 与 build:check,build 并发执行两者 + +Status: implemented + +[English](2026-10-11-build-artifacts-check-split.md) | 中文 + +## 问题 + +`pnpm run build` 串行执行 native addon、包库构建(Host `tsc -b tsconfig.host.json` → Host tsdown → Desktop bundle → Client `tsc -b tsconfig.client.json` → Client tsdown)和 Web 构建。Host 与 Client 聚合配置除了引用各包 project,还各自带有一个 `noEmit` 根程序,覆盖 tests、scripts、benchmarks 与 website。这两个根程序不产出任何文件,也不是后续产物步骤的输入,却位于串行关键路径上:单独计时时 Host 根程序为 68.5 s、Client 根程序为 36.0 s,串行包库构建约为 249 s(2 个样本的中位数)。 + +开发迭代只需要产物,测试与脚本的类型检查在声明完成前才需要。能跳过两个根程序的只有面向 CI 的 `pnpm run build --artifacts-only`;它没有本地开发命令名,也没有只做这两项检查的对应命令。同时,`pnpm run build` 必须保持完整,能检测出所有类型错误。 + +## 决策 + +### 命令 + +每个命令只做自己的工作。 + +| 命令 | 职责 | 步骤 | 调度 | client build record | +|---|---|---|---|---| +| `pnpm run build:artifacts` | 产出 `build` 的全部产物;包 project 照常完整类型检查,不检查两个聚合根程序 | native → host-src → host-bundle → desktop-bundle → client-src → client-bundle → web | 一条线性链 | 开始时删除,全部通过后写入 | +| `pnpm run build:check` | 只类型检查两个聚合根程序 | host-check、client-check | 两步并发,无前置步骤 | 不读写 | +| `pnpm run build` | `build:artifacts` 与 `build:check` 的全部步骤 | 下文依赖图 | run-gates 调度器并发执行 | 开始时删除,全部通过后写入 | + +`build:check` 不检测、不重建、也不校验 `build:artifacts` 的产物;前置产物缺失或过期时,由编译器自身的诊断(例如 TS6305、TS2307)报告,任何命令都不增加前置检查、守卫或回退逻辑。`build:artifacts` 产出的产物与 record 和 `build` 相同,并取代 `--artifacts-only`;该参数与 `compile-referenced-projects.ts` 的 `libraries` 模式已删除,Python executable builder 使用 `--build-artifacts`。`build:official` 仍是 `tsx scripts/build.ts --profile official`,执行完整的 `build`。 + +### 步骤与依赖 + +| 步骤 | 命令 | `build` 中的 `needs` | +|---|---|---| +| native | `pnpm run build:native-system` | — | +| host-src | `node --max-old-space-size=4096 -b ` | native | +| host-bundle | `tsdown --config-loader native --env.DSH_BUILD_FACE host`(运行 Typert) | host-src | +| desktop-bundle | `pnpm --filter @deepseek-ai/dsh-desktop run bundle` | host-bundle | +| client-src | ` -b ` | desktop-bundle | +| client-bundle | `tsdown --config-loader native --env.DSH_BUILD_FACE client` | client-src | +| web | `pnpm run build:web` | client-bundle | +| host-check | `node --max-old-space-size=4096 -p tsconfig.host.json` | host-src | +| client-check | ` -p tsconfig.client.json` | client-src | + +```text +native → host-src ─┬─ host-check ───────────────────────────────────────────────────────┐ + └─ host-bundle → desktop-bundle → client-src ─┬─ client-check ───────┤ → record + └─ client-bundle → web ┘ +``` + +- client-src 必须在 host-bundle 之后:Client 包导入 `@deepseek-ai/dsh-*/remote`,其类型 `lib/typert.remote-client.d.ts` 由 Typert 写出:这些 scope 在 Host tsdown 中生成,typecheck scope 经 `scripts/generate-typert.ts` 生成。host-check 不读取 Typert 输出,干净的串行构建一直在 Host tsdown 之前完成 Host 聚合检查。 +- 产物链保留 native → host-src 与 desktop-bundle → client-src 这两条非数据依赖;只有两个 check 步骤并行挂接到链上。 +- [`compile-referenced-projects.ts`](../../../../scripts/compile-referenced-projects.ts) 拥有包库步骤列表 `libraryBuildSteps`、references 参数构造 `referencedProjectsCompilerArgs`(相对路径传参,避开 Windows 命令行长度上限)与聚合检查参数 `aggregateCheckArgs`。vendor 发布演练(`host-libraries`)与 CI benchmark(`--noCheck`)串行执行同一步骤列表。 +- check 步骤使用 project mode(`tsc -p`):它读取 references 已有的声明而不编译任何 reference,incremental 状态写入聚合配置已有的 `tsconfig.{host,client}.tsbuildinfo`。`build` 与 `build:check` 使用同一条命令,因此诊断相同。 + +### 调度、失败与输出 + +- [`scripts/build.ts`](../../../../scripts/build.ts) 拥有步骤表与三个范围,执行复用 [`scripts/run-gates.ts`](../../../../scripts/run-gates.ts) 的 `runAggregate`:每个步骤是一个带 `needs` 的 gate,shell-free 启动、按步骤缓冲输出、fail-fast 进程树终止、Windows `taskkill` 与宿主信号转发都沿用 run-gates 的实现。`build:artifacts` 为 `tsx scripts/build.ts --scope artifacts`,`build:check` 为 `--scope check`,`build` 不带 `--scope`,把两者作为一张图执行;`--profile` 只适用于产生产物的范围,与 `--scope check` 同用时参数解析失败。 +- 并发上限取 `DSH_GATE_CONCURRENCY`,未设置时为可用 CPU 数;依赖图宽度最多为 3(host-check 尚未结束时,client-check 与 client-bundle 可能已开始)。master 串行参考 lane 设置 `DSH_GATE_CONCURRENCY=1`,`build` 在那里按拓扑顺序串行执行,符合[串行参考决策](2026-07-21-serial-cross-platform-ci-reference.zh.md),无需修改 workflow。 +- 缺省(本地)时,失败步骤的所有依赖方记为 skipped,正在运行的步骤和与失败无关的步骤继续运行到结束;执行集合只取决于各步骤的结果,因此一次运行报告所有相互独立的类型错误。`DSH_GATE_FAIL_FAST=1`(所有构建型 PR lane 已设置)时,第一个失败终止其余正在运行步骤的进程树,所有未完成步骤记为 skipped。 +- 失败步骤打印 `== FAILED <步骤> ==`、命令、退出原因与该步骤完整的缓冲输出,结尾汇总列出 failed 与 skipped 步骤;成功步骤只打印 PASS 行,`DSH_GATE_VERBOSE=1` 打印全部输出。任一步骤 failed 或 skipped 时退出码为 1,且不写 client build record。所有步骤继承 `clientBuildProcessEnvironment` 选定的公开 Client 环境。 +- CI 中 `build` 本身是外层 run-gates 的一个 gate;外层 fail-fast 终止会转发到内层调度。`check:node-compat` 与 `check:ci:lint:contracts-ready` 已使用同样的嵌套。 + +### 调用方 + +| 调用方 | 命令 | 聚合根程序检查 | +|---|---|---| +| `build`、`build:official`、`Makefile` `build`、`scripts/publish-npm-baseline.ts`、`apps/desktop/scripts/package-target.ts`、`scripts/dev-bundle-registry.ts`、`release-publish.yml`、`sandbox.yml`、`e2e.yml` 与 `release.yml` 的非 PR 分支 | `build` / `build:official` | 有 | +| `test:web`、`test:web:refresh`、`test:web:perf`、`test:web:stress`、`apps/desktop` `test:updates:local`、`test:bench` / `build:bench` | `build`;`build:bench` 运行 `build:native-system`、`build:lib` 与 benchmark tsdown | 有 | +| `typecheck`(pre-push hook 运行它) | `build.ts --scope typecheck`:host-src 与 host-typert(不打包、只读源码的 Typert 生成 `generate-typert.ts`)并行 → client-src,各 face 包编译后运行 `tsc -b tsconfig.{host,client}.json`;不含任何 bundle | 有 | +| `dev:web`([`scripts/dev-web.ts`](../../../../scripts/dev-web.ts))、`dev:desktop`(`apps/desktop/scripts/dev.ts`) | `build:artifacts` | 启动前无;`dev:web` 启动后的 `tsc -b tsconfig.client.json --watch` 持续检查 Client 聚合根程序 | +| `lint`、`lint:fix`、`doc-typecheck` 与 CI 的 Typert contracts gate 的前置步骤 | Host 包编译与不打包的 Typert 生成并行(`compile-referenced-projects.ts host-contracts`),不含任何 bundle | 无 | +| CI node 24 / snapshots and artifacts(`check:ci:consumers`)、windows node 24 / build & observational(`check:ci:windows-blocking`) | `build` | 有,两个必需 lane | +| CI node-compat 22.19 | `build:artifacts`(经 run-gates) | 无 | +| CI python runtime | `build-exe-for-python-sdk.ts --build-artifacts` | 无 | +| `e2e.yml`、`release.yml` 的 PR 演练 | `pnpm run build:artifacts --profile official` | 无 | +| `build-preview-cloudflare.yml` | `pnpm run build:artifacts` | 无 | +| CI node 24 / benchmarks | `run-ci-bench.ts`(`tsc -b --noCheck`) | 无 | +| `release-vendor.yml` 的 PR 演练 | `compile-referenced-projects.ts host-libraries` | 无 | +| `release-vendor.yml` 的非 PR 分支、`release-vendor-publish.yml` | `build:lib:host` | 仅 Host 聚合根程序 | + +`lint`、`lint:fix`、`doc-typecheck` 与 Typert contracts gate 只读取 Typert 输出与包声明:两个聚合配置都是 `noEmit`,oxlint 自行构建程序,`doc-typecheck` 只引用包 project,它们都不读取 bundle。它们的前置步骤因此跳过 Host 聚合检查与所有 bundle;仅跳过 Host 聚合检查与 Desktop bundle 就使耗时从 159.8 s 降到 90.9 s(见下文测量)。 + +consumers lane 的 HMR Web 测试启动 `dev:web`,因此在该 lane 内使用 `build:artifacts`;同一 lane 的 `build` gate 仍运行两个聚合检查。CI 不单独调用 `build:check`:两个必需 lane 已通过 `build` 获得同一组检查。[PR 产物构建决策](2026-10-05-pr-artifact-typechecks.zh.md)的必需完整构建、benchmark `--noCheck`、job 与 worker 上限、node-compat 串行 Vitest、覆盖率权重与 pnpm 打包检查仍然有效;其中的 PR 产物构建由 `build:artifacts` 执行。 + +### 测量 + +原型测量的条件:256 vCPU 共享 Linux 主机(load average 23–70),Node v24.18.0、TypeScript 6.0.3,React 类型包已提升到根,不含[无损提速 Note](2026-10-11-lossless-build-speedups.zh.md) 的 Typert 改动;范围是包库构建(不含 native 与 web)。每轮从 `pnpm run clean` 开始,按 串行、并发、`build:lib`、`build:lib`、并发、串行 的顺序各运行一次;每个步骤由 `/usr/bin/time -v` 计时,进程树 RSS 每 250 ms 采样求和。原型中的聚合检查以 `tsc -b tsconfig.{host,client}.json` 在 references 已是最新时运行,此时 build mode 只构建聚合根程序。 + +| 方式(2 个样本的中位数) | wall(s) | CPU user+sys(s) | 进程树 RSS 峰值(GiB) | +|---|---|---|---| +| 串行 `pnpm run build:lib` | 249.4 | 378.0 | 5.95 | +| 各步骤依次单独运行 | 255.1 | 373.8 | 5.82 | +| 按依赖图并发 | 172.1 | 392.1 | 8.00 | + +wall 中位数从 249.4 s 降到 172.1 s(−77.3 s,−31%)。各步骤单独运行的两次样本相差 28 s,因此把步骤拆成独立进程的开销无法分辨。CPU 增加约 14 s(+3.7%),主要是争用下 Host 聚合检查增加的 user 时间;RSS 峰值增加约 2.1 GiB,出现在 Host 聚合检查(约 4 GiB)与 Host tsdown(约 5.7 GiB)重叠时。并发时的关键路径是 host-src → host-bundle → desktop-bundle → client-src → client-check;host-check 在结束前约 33 s 完成,client-bundle 在结束前约 21 s 完成。 + +最终测量:master(合并基点)与本分支交替运行,每次均从 clean 开始,在 64 核 cgroup 中运行;除标注 n=1 者外,数值均为 2 个样本的中位数。 + +| 场景 | master | 本分支 | CPU user+sys(master → 本分支) | 进程树 RSS 峰值(master → 本分支) | +|---|---|---|---|---| +| 完整构建 `pnpm run build` | 317.1 s | 169.1 s(−46.7%) | 423.1 → 374.4 s(−11.5%) | 6.11 → 7.53 GiB(+1.42) | +| 本分支串行构建 `DSH_GATE_CONCURRENCY=1 pnpm run build`(n=1) | — | 266.7 s | 389.5 s | 6.27 GiB | +| 产物构建:master `build --artifacts-only`,本分支 `build:artifacts` | 185.8 s | 153.6 s(−17.3%) | 256.2 → 231.2 s | 6.58 → 6.18 GiB | +| 开发迭代:master `build`,本分支 `build:artifacts` | 317.1 s | 153.6 s(−51.6%) | 423.1 → 231.2 s(−45.4%) | 6.11 → 6.18 GiB | +| `pnpm run build:check`(`build:artifacts` 之后) | — | 67.7 s | 144.6 s | 5.33 GiB | +| `pnpm run build:check` 无改动重跑 | — | 19.4 s | 33.1 s | 未测量 | +| `lint` 与 `doc-typecheck` 的前置步骤:master `build:lib:host`,本分支 `host-libraries`(后由 `host-contracts` 取代,见[本地门禁提速](2026-10-12-local-gate-speedups.zh.md)) | 159.8 s | 90.9 s(−43.2%) | 234.7 → 136.0 s | 未测量 | + +本分支串行样本运行时主机上有其他工作(外来 CPU 742 s),因此它的 wall 可能高估串行成本。串行构建的各段中(master → 本分支,各一个样本),Host tsdown 从 22.5 s 降到 11.7 s,Client 包编译从 55.6 s 降到 48.7 s,Client 聚合检查从 68.2 s 降到 35.9 s,Client tsdown 从 13.9 s 降到 10.5 s;各段合计分别为 308.5 s 与 254.9 s。这些缩短来自[无损提速 Note](2026-10-11-lossless-build-speedups.zh.md);从 254.9 s 的串行各段合计到 169.1 s 的并发构建,其余缩短来自本 Note 的调度,它把 Host 聚合检查(约 66 s)移出关键路径。并发构建的关键路径是 native → host-src → host-bundle → desktop-bundle → client-src → client-check,host-check 在最后一步结束前约 30 s 完成(取自更早的一个样本)。`build:check` 并发运行 Host 与 Client 聚合检查,因此其 wall 等于 Host 聚合检查的 wall(约 65 s)。 + +### 完整性与无损依据 + +`build` 运行的编译器程序集合与串行构建相同:host-src 与 client-src 编译两个 face 的全部包 project,host-check 与 client-check 检查两个聚合根程序,且使用与 `build:check` 相同的命令。原型的 6 轮运行中,`{packages,apps,vendor,native}/**/lib/**`(排除 `*.tsbuildinfo`)的 11,580 个文件在串行与并发之间逐字节一致,每轮之后 `tsc -b --dry tsconfig.host.json tsconfig.client.json` 报告没有过期 project。tsbuildinfo 写入方归属为:host-src 292 个,client-src 76 个(恰好是 client-only project),两个聚合检查各 1 个;client-src 不改写 host-check 读取的任何 tsbuildinfo 或声明,因此该依赖图中不存在写入竞争。fixture 测试 `checks only the aggregate program in project mode` 表明 `tsc -p` 写出的 tsbuildinfo 被 `tsc -b --dry` 视为最新,`reports missing package declarations without compiling references` 表明 project mode 不编译 references。本实现中,并发 `build` 与 `DSH_GATE_CONCURRENCY=1 pnpm run build` 产出逐字节一致的 `lib/`(11,584 个文件)、`apps/web/dist`(156 个文件)与 client build record;之后 `tsc -b --dry` 对两个聚合配置报告 372 个 project 全部为最新,没有需要构建的 project。 + +| 错误 | `build:artifacts` | `build:check` | `build` | `typecheck` | 必需 CI lane | +|---|---|---|---|---|---| +| 包源码类型错误 | host-src / client-src | 不报告(读取已有声明) | host-src / client-src | 报告 | 报告 | +| 只属于聚合根程序的测试、脚本、benchmark、website 类型错误 | 不报告 | host-check / client-check | host-check / client-check | 报告 | consumers、windows-build | +| 包 tsconfig 配置错误 | host-src / client-src | 不报告(`tsc -p` 不校验被引用 project 的 tsconfig 选项与语法) | host-src / client-src | 报告 | 报告 | +| Typert 分析错误 | host-bundle | 不报告 | host-bundle | 报告(经 host-typert) | 报告 | +| 前置产物缺失 | — | 编译器诊断(TS6305、TS2307) | — | — | — | + +负对照:在只属于 Host 聚合根程序的 `scripts/pnpm-invocation.spec.ts` 中加入类型错误(TS2322)后,`build:artifacts` 以 0 退出;`build:check` 以 1 退出,其中 Host 聚合检查失败、Client 聚合检查通过;`build` 以 1 退出,9 个步骤中 8 个通过、1 个失败,且不写 client build record。在临时 fixture 中,被引用 project 的 tsconfig 含无效 `target`、JSON 语法错误或未知编译选项时,`tsc -p` 以 0 退出,`tsc -b` 分别报告 TS6046、TS5092 与 TS1012、TS5023 并以 1 退出;被引用 project 缺少 `composite` 时,两者都报告 TS6306。 + +CI 证据:PR #6005 上,两个运行并发 `pnpm run build` 的必需 lane 均通过:`node 24 / snapshots and artifacts`(`check:ci:consumers`,运行于 `blacksmith-16vcpu-ubuntu-2404`)与 `windows node 24 / build & observational`(`check:ci:windows-blocking`,运行于 `blacksmith-16vcpu-windows-2025`);GitHub 托管的 4 vCPU benchmarks lane 不运行 `build`。内层并发取 `DSH_GATE_CONCURRENCY`:master 串行 lane 设为 1,consumers lane 在 self-hosted failover 时设为 10,未设置时取可用 CPU 数,且不超过 9 个步骤。 + +### 有意的取舍(非无损) + +- **部分命令跳过聚合检查:** `build:artifacts`、`dev:web` 与 `dev:desktop` 的启动构建,以及 `lint`、`lint:fix`、`doc-typecheck` 与 Typert contracts gate 的前置步骤,都不类型检查两个聚合根程序,这些前置步骤只产出声明与 Typert 输出,不打包。完整性由 `build`、`build:check`、`typecheck`(pre-push hook)与两个必需 CI lane 负责;`dev:web` 与 `dev:desktop` 不会因测试类型错误而拒绝启动。 +- **`build:check` 不检查新鲜度:** 在 `build:artifacts` 之后修改源码时,`build:check` 基于旧声明(包括 Typert Remote 声明)检查;需要先重跑 `build:artifacts`。完整 `build` 不受影响。 +- **并发 `build` 提高内存峰值:** 本分支完整 `build` 的进程树 RSS 峰值为 7.53 GiB,master 串行构建为 6.11 GiB(+1.42 GiB,2 个样本的中位数),本分支串行构建为 6.27 GiB(n=1);同时 CPU 从 423.1 s 降到 374.4 s,因为[无损提速 Note](2026-10-11-lossless-build-speedups.zh.md) 省去的工作多于并发争用增加的工作。原型(不含 Typert 改动)中,单独的并发调度使 RSS 峰值增加约 2.1 GiB(5.9 → 8.0 GiB)、CPU 增加约 3.7%。上限由 `DSH_GATE_CONCURRENCY` 控制;内存紧张时使用 `DSH_GATE_CONCURRENCY=1 pnpm run build`,或分别运行 `build:artifacts` 与 `build:check`。windows-build lane 中 `build` 与 `docs:build` 同时运行。 +- **成功步骤的输出默认不显示:** 只有失败步骤打印完整输出。 +- **`tsc -p` 不跳过已是最新的根程序:** 无改动时立即重跑 `build:check` 仍需 19.4 s 与 33.1 s CPU,因为两个程序都要重新解析;`tsc -b` 会跳过已是最新的根。 +- **多核冷启动时 `build:artifacts` 只比 `build` 快 15.5 s:** 本分支冷启动 `build` 为 169.1 s,`build:artifacts` 为 153.6 s(2 个样本的中位数),因为 host-check 不在关键路径上,client-check 只比 client-bundle 与 web 之和长约 16 s。`build:artifacts` 的其余收益是 CPU 与内存:CPU 从 374.4 s 降到 231.2 s,RSS 峰值从 7.53 GiB 降到 6.18 GiB;在少核机器与增量重建中,这部分也转化为 wall。 +- **CI 调用根 manifest 中的公开命令:** PR 产物构建调用 `build:artifacts`,而不是内部 helper 参数;修改根 manifest 中这些 script 的 PR 会激活独立的 Node Addon System 平台矩阵。 + +## 考虑过的替代方案 + +- **只在 `typecheck` 中保留聚合检查:** `pnpm run build` 必须完整,能检测出所有错误。 +- **保留 `--artifacts-only` 并新增 `build:artifacts` 别名:** 同一件事有两个名字。 +- **命名为 `build:src` 与 `build:test`:** `build:artifacts` 产出全部产物,包括 tsdown bundle、Desktop bundle 与 Web,而不只是编译后的源码;`build:check` 除测试外还检查脚本、benchmarks 与 website 代码。 +- **按编译器术语命名(`build:b`/`build:p`、`build:project`、`build:emit`)或命名为 `build:dist`:** tsc 只出现在两个产物步骤中,`-p` 只是 check 步骤的实现选择;TypeScript 把 `tsc -p` 一侧称为 project;`emit` 容易被理解为只 emit 的 `--noCheck` 模式,而包 project 仍完整检查;包产物位于 `lib/`,`dist` 则让人联想到发布打包。 +- **`build:check` 使用 `tsc -b `:** build mode 会编译过期的 references,即执行 `build:artifacts` 的工作。 +- **`build:check` 先补齐或校验前置产物:** 违背每个命令只做自己的工作,并增加前置逻辑;编译器诊断已经指明缺失的输出。 +- **新的调度实现(shell `&`/`wait`、`concurrently`、turbo/nx):** 重复 run-gates 已有的失败汇总、进程树终止与 Windows 处理。 +- **在 CI 的 run-gates 中把 `build` 展开为多个 gate:** `pnpm run build` 必须是本地与 release workflow 可直接使用的完整命令,record 也由它写入。 +- **放宽产物链内的非数据依赖(native ∥ host-src、desktop-bundle ∥ client-src):** 未测量;Desktop bundle 本身只占 4.0–4.5 s。 +- **把测试移入新的 tsconfig:** 现有聚合配置已经把包 references 与根程序分开。 +- **把聚合根程序拆成多个子程序:** 每个子程序都要注入 242 个含模块增强的文件,CPU 增加 9–57%,只降低 wall(见[无损提速 Note](2026-10-11-lossless-build-speedups.zh.md))。 +- **CI 单独运行 `build:check`:** 必需 lane 已通过 `build` 获得同一组检查,再单独运行是重复工作。 + +## 后果 + +开发迭代可以用 `build:artifacts` 跳过两个聚合根程序(冷启动比 master 的 `build` 快 51.6%),在声明完成前用 `build:check` 或 `build` 补齐检查。`build` 保持完整:Host 聚合检查不在关键路径上,Client 聚合检查与 Client bundle、web 并行,冷启动比 master 快 46.7%(含无损提速 Note 的改动,2 个样本的中位数)。代价是更高的内存峰值、并发争用带来的少量额外 CPU、默认更少的成功输出,以及多个开发与前置命令不检查测试类型(见上文取舍)。 + +删除或改变 consumers 与 windows-build 两个必需 lane 的 `build`,必须先把两个聚合检查转交另一处阻塞型 owner。typecheck scope 与 lint 类前置步骤不打包即生成 Typert 约定([本地门禁提速](2026-10-12-local-gate-speedups.zh.md));产物 scope 仍在 Host tsdown 中生成。 + +[Typert Remote 方法调用](../architecture/2026-08-02-typert-remote-method-calls.zh.md)描述的“Client 编译前先完成 Host 与 Remote 产物”由 client-src 对 host-bundle 的依赖保证。 + +验证:`scripts/build.spec.ts` 覆盖类型剥离检查、已删除参数与非法范围的拒绝、`--profile` 与 `--scope check` 的冲突、范围选择、步骤依赖与环境;`compile-referenced-projects.spec.ts` 覆盖 project mode 只检查聚合程序、缺失声明的诊断,以及 `tsc -b --dry` 接受 `tsc -p` 写出的 tsbuildinfo;失败传播、输出缓冲与进程树终止沿用 run-gates 的调度实现及其测试;`run-gates.spec.ts`、`ci-workflow.spec.ts`、`preview-workflow.spec.ts`、`build-exe-for-python-sdk.spec.ts`、`dev-web.spec.ts` 与 `tests/ci-release-selfhosted.spec.ts` 固定各调用方的命令。 diff --git a/.agents/notes/implemented/process/2026-10-11-lossless-build-speedups.i18n.yaml b/.agents/notes/implemented/process/2026-10-11-lossless-build-speedups.i18n.yaml new file mode 100644 index 0000000000..41ef49d183 --- /dev/null +++ b/.agents/notes/implemented/process/2026-10-11-lossless-build-speedups.i18n.yaml @@ -0,0 +1,46 @@ +# Bilingual-pair consistency record for 2026-10-11-lossless-build-speedups.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-10-11-lossless-build-speedups.md +/agent-note-build-speed-ups-that-keep-outputs-unchanged: + en: 48682b646b8f993a + zh: 4c26cb4aab5cd08c +/agent-note-build-speed-ups-that-keep-outputs-unchanged/problem: + en: 46551eb7e33c40ca + zh: f8c1fc063fa77062 +/agent-note-build-speed-ups-that-keep-outputs-unchanged/decision: + en: 3948d6fc3fabf32e + zh: c3b8dcdfe8168275 +/agent-note-build-speed-ups-that-keep-outputs-unchanged/decision/measurement-conditions: + en: 2ab844073ec7c336 + zh: b25e39f2f80b0d7e +/agent-note-build-speed-ups-that-keep-outputs-unchanged/decision/1-only-the-root-manifest-declares-react-type-packages: + en: 1101c94d04f9c7e9 + zh: 413b7b15f98a5f62 +/agent-note-build-speed-ups-that-keep-outputs-unchanged/decision/2-typert-selects-packages-by-manifest-first: + en: 1ae4b65792db0975 + zh: a11c0d90842a3b55 +/agent-note-build-speed-ups-that-keep-outputs-unchanged/decision/3-typert-does-not-expand-aggregate-include: + en: 0c8ab2d412113914 + zh: 01ba4f5042422d77 +/agent-note-build-speed-ups-that-keep-outputs-unchanged/decision/4-typert-parses-package-tsconfigs-lazily: + en: cd6e05c61a7cc495 + zh: 480d2d85d78d2d7f +/agent-note-build-speed-ups-that-keep-outputs-unchanged/decision/5-the-typert-workspace-root-also-requires-packages: + en: 8b89436df201141c + zh: 2fab82419c8ba704 +/agent-note-build-speed-ups-that-keep-outputs-unchanged/decision/6-typert-generates-in-a-worker-thread: + en: 0362483a79c07881 + zh: d84a2390b49c4012 +/agent-note-build-speed-ups-that-keep-outputs-unchanged/decision/7-the-typert-transform-hook-filters-by-module-id: + en: b5ad6b5506c150d5 + zh: 25f09e4ab74696ad +/agent-note-build-speed-ups-that-keep-outputs-unchanged/decision/8-client-tsdown-presets-read-the-manifest-from-the-package-directory: + en: 80c2ddc5fdff0b5f + zh: f5ce309a2e88ff4f +/agent-note-build-speed-ups-that-keep-outputs-unchanged/alternatives-considered: + en: 4372c82ef0c944dd + zh: 5fb70d499baadea7 +/agent-note-build-speed-ups-that-keep-outputs-unchanged/consequences: + en: 93a96d3ed398f348 + zh: 93b7c7405be981a6 diff --git a/.agents/notes/implemented/process/2026-10-11-lossless-build-speedups.md b/.agents/notes/implemented/process/2026-10-11-lossless-build-speedups.md new file mode 100644 index 0000000000..021fe5b241 --- /dev/null +++ b/.agents/notes/implemented/process/2026-10-11-lossless-build-speedups.md @@ -0,0 +1,136 @@ +# Agent Note: Build speed-ups that keep outputs unchanged + +Status: implemented + +English | [中文](2026-10-11-lossless-build-speedups.zh.md) + +## Problem + +A cold build carries three fixed costs that do not affect output contents: + +- **JSX signatures in the Client test aggregate program.** The Check phase of `tsc -p tsconfig.client.json` takes 59.1 s, more than half of it computing the `@types/react` module specifier for every JSX file that imports React. TypeScript's `computeModuleSpecifiers` visits every symlink location of that declaration file and scans all 491 tsconfig `paths` entries at each location. pnpm creates one `node_modules/@types/react` link for each workspace package that declares `@types/react` itself; 66 manifests declare it, so the Client test aggregate program sees 65 locations. Counters measured 88 JSX files × 2 modules (`index.d.ts`, `jsx-runtime.d.ts`) × 65 locations: 11,457 `tryGetModuleNameFromPaths` calls and 5,623,782 entry scans, 32.6 s in total. A dose-response run at five truncation levels of the visible location count N (N = 1 to 65; Check 26.92 s to 58.40 s) fits Check ≈ 26.9 + 0.494·N s. +- **Typert inside the Host tsdown pass.** The Host tsdown pass takes about 21.6–22.6 s. Typert discovers and generates artifacts synchronously inside the first `writeBundle`; the CPU profile shows it blocking the tsdown main thread for about 12.4 s while every package build waits. Discovery takes 8.06 s: it parses all 362 package tsconfigs referenced by the two face aggregates (about 3.8 s), expands the Host aggregate's 2,064 literal `include` entries on every parse (1.6 s; the analyzer never reads those file names), and walks the public-export surface of 322 registrations (2.7–3.2 s). Only the 28 packages that declare a Typert export produce Host artifacts. +- **Manifest lookup in the Client tsdown pass.** The Client tsdown pass takes about 11.7 s with a saturated main thread (7.1% idle); 34.5% of it (3.95 s) is `workspaceManifest`: every package config runs `globSync('packages/*/*/package.json')` for its package name and parses manifests until the name matches. The module-level cache has no effect because tsdown's native config loader imports every package config through `import-without-cache`, creating a fresh module instance per config. Each glob takes 40–125 ms and runs inside the `neverBundle`/`alwaysBundle` callbacks of `tsdown:deps`, blocking every build. + +#5849 merged Host fixture type narrowing and Typert memoization; this note's baseline is the master that follows it. + +## Decision + +The following eight speed-ups change neither the bytes of `lib/` and `apps/web/dist` outputs nor the set of type diagnostics that repository builds report. The only change in diagnostic ownership comes from item 4: Typert does not validate tsconfigs of packages it does not analyze, and only `tsc -b` reports those errors. [The build split note](2026-10-11-build-artifacts-check-split.md) records the command split and concurrent scheduling. + +| # | Decision | Affected stage | Measured median | Outputs and diagnostics | Intended behavior difference | +|---|---|---|---|---|---| +| 1 | Only the root manifest declares React type packages | Client type checking | Test aggregate Check 59.1 → 26.3 s | Program file count and diagnostics unchanged | Package manifests do not list React type packages | +| 2 | Typert selects packages by manifest first | Host tsdown | 22.62 → 19.94 s | Byte-identical | None | +| 3 | Typert does not expand aggregate `include` | Host tsdown | 19.94 → 16.99 s | Byte-identical; registration inventory identical | None | +| 4 | Typert parses package tsconfigs lazily | Host tsdown | 16.99 → 16.25 s (within noise); Typert discovery 5.9–7.1 → 0.36–0.40 s | Byte-identical; registration inventory identical | Only `tsc -b` reports tsconfig errors in unanalyzed packages | +| 5 | The Typert workspace root also requires `packages/` | Host tsdown | Within noise | Byte-identical | Dual-face packages get artifacts in `package` mode | +| 6 | Typert generates in a Worker Thread | Host tsdown | 15.39 → 10.25 s | Byte-identical; failures still fail tsdown | Host tsdown max RSS about +0.6 GiB | +| 7 | The Typert `transform` hook filters by module id | Host tsdown | Within noise; hook calls 1,566 → 195 | Byte-identical | None | +| 8 | Client tsdown presets read the manifest from the package directory | Client tsdown | 12.78 → 8.73 s | Byte-identical; external decisions identical | The presets' first argument is the package directory | + +The published `./tsdown` export `typertPlugin()` changes with items 5–7 (`transform` as `{ filter, handler }`, an asynchronous `writeBundle`, and a workspace root that must contain `packages/`); rolldown and tsdown consumers accept both hook forms, and the analyzer already required `/packages`, so no external workspace that worked before stops working. + +### Measurement conditions + +All numbers come from one shared Linux host (256 vCPU, 4 TB memory, load average 23–111 during runs) with Node v24.18.0, TypeScript 6.0.3, and tsdown 0.22.2 (rolldown 1.1.1). + +- **Host tsdown:** `node node_modules/tsdown/dist/run.mjs --config-loader native --env.DSH_BUILD_FACE host`, deleting every `lib/typert.*` before each run; variants run interleaved and sequentially, 3–4 samples per group. Items 2–4 come from the first round (starting from the baseline) and items 5–7 from the second round (starting from the code with items 2–4). Output comparison covers SHA-256 of the 743 top-level files of `packages/*/*/lib`, `apps/*/lib`, and `vendor/*/lib` (140 of them Typert artifacts). Medians of two groups running identical code differ by 0–0.11 s; samples within a group spread by up to 2.5 s. +- **Client test aggregate:** `tsc -p tsconfig.client.json --extendedDiagnostics` with prebuilt references and a fresh tsBuildInfo file; six samples of identical code spread by 3.8 s in Check (about 6%). +- **Client tsdown:** `node node_modules/tsdown/dist/run.mjs --config-loader native --env.DSH_BUILD_FACE client` on a built tree. The item 8 prototype ran three rounds in rotated order with a baseline spread of 1.56 s; the final implementation and the original ran interleaved, three samples each. Output comparison covers every file of `{packages,apps,vendor,native}/**/lib/**`, excluding `*.tsbuildinfo`. + +### 1. Only the root manifest declares React type packages + +- **Purpose:** Reduce the number N of `@types/react` locations that the Client test aggregate program sees from 65 to 1, removing module-specifier work that grows linearly with N. +- **Change:** The root `package.json` declares `@types/react` `~18.3.1` and `@types/react-dom` `~18.3.0` in `devDependencies`, the ranges every package used before; manifests under `apps/*`, `packages/*/*`, and `vendor/*` declare neither in any dependency section. TypeScript resolves the root `node_modules/@types/react` from every package. Packages still declare runtime `react` and `react-dom` under the [Client dependency declaration rules](../../../../packages/client/AGENTS.md#dependency-declaration); `packages/client/ui-slots`, which references React only through `import type`, declares `react` in `devDependencies`. pnpm `dedupeDirectDeps` stays off. Lockfile changes are confined to the importers section, plus one reordered `papaparse` entry with the same integrity. +- **Regression guard:** [`verify-package-dependencies`](../../../../scripts/verify-package-dependencies.ts) runs in `hygiene` and CI. It requires the root manifest's `devDependencies` to declare both packages and rejects either one in the `dependencies`, `devDependencies`, `optionalDependencies`, or `peerDependencies` of any manifest under `apps/*`, `packages/*/*`, or `vendor/*`; the diagnostic names the entry to delete and links Client dependency declaration rule 8. The rule matches only these two exact names; other `@types/*` packages remain DefinitelyTyped type providers. Dependency-section inference does not treat these two packages as type providers for `react`/`react-dom`: when a package lacks a `react` declaration, the verifier reports `react` as missing and `--fix` refuses to repair it until a version range is declared; `--fix` never writes back or deletes the two packages. [`verify-package-dependencies.spec.ts`](../../../../scripts/verify-package-dependencies.spec.ts) covers rejection in each dependency section, a root manifest missing the declarations, the accepted layout, and the type-provider exclusion. +- **Performance:** + + | Measurement | Per-package declarations (N = 65) | Root manifest declaration (N = 1) | + |---|---|---| + | Client test aggregate Check | 59.08 s (median of 3) | 26.28 s (median of 5), −56% | + | Client test aggregate wall | 68–71 s | 33.6–34.9 s | + | Client test aggregate user CPU | 79–83 s | 44.5–45.3 s | + | Check sum of the 76 client-only package projects | 34.6 s (median of 2) | 26.5 s (median of 2), −23% | + | Wall of the 76 client-only package projects | 55.7 s (median of 2) | 44.5 s (median of 2) | + +- **Why it is lossless:** The 66 package-level links and the root link share one realpath (`.pnpm/@types+react@18.3.31`), so the program reads the same declarations; the program keeps 2,613 files, and `tsc -b --force tsconfig.client.json` exits 0 without diagnostics. `devDependencies` are not installed with published packages, so the [published dependency faces](2026-08-26-published-dependency-faces.md) conclusion that independent TypeScript consumers install referenced type packages themselves is unchanged; runtime resolution never reads `@types/*`. +- **Trade-off:** Package manifests do not list their own React type dependency, and every workspace package shares one React types version; a package that needs a different version requires a new Agent Note overturning this item. The check covers only `apps/*`, `packages/*/*`, and `vendor/*`; other pnpm members such as `website`, `benchmarks`, `native/system`, and `python/sdk-runtime` are out of scope, and none of them currently declares React type packages. + +### 2. Typert selects packages by manifest first + +- **Purpose:** Workspace mode generates artifacts only for packages that declare a Typert export, so it skips the surface walk for every other candidate. +- **Change:** `WorkspaceAnalyzer.discoverPackages(includeManifest?)` and `WorkspaceTypertGenerator.discover(faces?, includeManifest?)` accept an optional `package.json` predicate applied before the surface walk; a rejected package has neither its sources read nor its project config parsed. `WorkspaceTypertGenerator.generateContributors(faces?)` passes `hasTypertExport` (`exports` contains `./typert`, `./client/typert`, or `./remote`). Callers that pass no predicate (the Cordis catalog, `generate()` without packages) behave as before. +- **Performance:** Host tsdown 22.62 s → 19.94 s (medians of 3). +- **Why it is lossless:** The predicate is the filter previously applied after discovery, and a package it rejects never produced artifacts; the 743 output files are byte-identical except the Typert generator's own `lib/index.js`, whose source is this change. + +### 3. Typert does not expand aggregate `include` + +- **Purpose:** The analyzer reads only an aggregate's compiler options and project references; expanding `include` only fills `fileNames`, which it never uses. +- **Change:** `WorkspaceCaches.aggregate(path)` returns and caches `AggregateConfig { path, options, projectReferences }`. An aggregate that declares its own `references` is parsed with a `ParseConfigHost` whose `readDirectory` returns an empty list; an aggregate without `references` is expanded as usual. +- **Performance:** Host tsdown 19.94 s → 16.99 s (medians of 3). +- **Why it is lossless:** TypeScript reports TS18003 (no inputs) only for a config with neither `files` nor `references`, so an aggregate that declares `references` yields the same diagnostics, options, and references without expansion. The registration inventory dumped after forcing every lazy config (408 registrations, including `fileNames` and options, plus the aggregates' options and references) is byte-identical to the baseline, and the outputs are byte-identical. The tests `reads aggregate options and references without expanding include patterns` and `reports an aggregate without references or inputs` pin both kinds of config. + +### 4. Typert parses package tsconfigs lazily + +- **Purpose:** Parse only the tsconfigs of packages selected for analysis, not every package that an aggregate references. +- **Change:** `PackageRegistration.config` is a getter over `caches.config(configPath)` that parses on first read. The registration inventory keeps its order, faces, names, roots, and export subpaths. +- **Performance:** Host tsdown 16.99 s → 16.25 s (medians of 3), within the 2.5 s within-group spread. Together, items 2–4 reduce Typert discovery in an isolated benchmark from 5.9–7.1 s to 0.36–0.40 s, tsconfig parses from 362 to 28, surface walks from 322 to 28, and total Typert time from 9.7–11.8 s to 4.4–4.5 s. +- **Why it is lossless:** The inventory comparison is the one in item 3 (byte-identical), and the outputs are byte-identical. The test `reads only the project configs of manifests selected for discovery` pins that only selected configs are parsed. +- **Trade-off (not lossless):** Typert does not parse tsconfigs of packages it does not analyze, so a syntax or configuration error in such a tsconfig does not fail Typert. In the same build, the Host package compilation `tsc -b` that runs before the Host bundle and the Client package compilation `tsc -b` parse every referenced tsconfig and reject those errors; the `PackageRegistration.config` JSDoc and the [Typert generator README](../../../../packages/typert/generator/README.md) state this ownership. A consumer that runs Typert without `tsc -b` does not receive those diagnostics. + +### 5. The Typert workspace root also requires `packages/` + +- **Purpose:** Correct workspace-root resolution. 24 dual-face packages carry their own `tsconfig.host.json` leaf; searching upward for `tsconfig.host.json` alone took each of them as a root, so workspace mode ran one empty discovery per such package per build, and `package` mode silently produced no artifacts for dual-face packages. +- **Change:** The workspace root is the nearest ancestor of the bundle output that contains both `tsconfig.host.json` and `packages/`. +- **Performance:** Host tsdown 14.82 s → 15.39 s (medians of 3), within noise. +- **Why it is lossless:** The repository root [`tsdown.config.ts`](../../../../tsdown.config.ts) uses only workspace mode, and the 743 output files are byte-identical. The test `resolves the workspace root above a dual-face package leaf` pins the rule. +- **Behavior difference:** A `package`-mode consumer building a dual-face package receives that package's Typert artifacts; repository builds do not use `package` mode. + +### 6. Typert generates in a Worker Thread + +- **Purpose:** Overlap Typert analysis with bundling on the tsdown main thread instead of blocking every package build inside the first `writeBundle`. +- **Change:** [`src/worker.ts`](../../../../packages/typert/generator/src/worker.ts) is the Worker Thread entry; it runs `generateContributors(faces)` and posts one `artifacts` or `failure` message. In workspace mode, the build's first `buildStart` locates the workspace root from `options.cwd` and starts generation; `writeBundle` is asynchronous, the first finishing bundle claims emission and awaits the result, and later bundles return immediately. An analysis error, a worker crash or early exit, a malformed or undeserializable response, or a worker that cannot be terminated fails the claiming bundle with a `TypertWorkerError` naming the workspace and cause, which fails the whole tsdown pass; the worker is terminated before the outcome settles, and an unclaimed outcome never becomes an unhandled rejection. Built output starts the worker from `./worker.js`; source runs (tests) load `worker.ts` through `tsx/esm/api`. `package` mode still generates on the main thread. +- **Performance:** Host tsdown 15.39 s → 10.25 s (medians of 3). Profile of the final configuration (items 2–7): main thread 10.1 s, first `buildStart` at 4.18 s (tsdown config resolution), `writeBundle` on the main thread only 0.37 s; worker 5.0 s (module load about 0.4 s, discovery 0.28 s, generation 3.88 s including `createProgram` 2.21 s), ending at about 9.3 s. Host tsdown wall is about max(bundling, 4.2 s + Typert). +- **Why it is lossless:** All 54 second-round runs exit 0, and each run's 743 output files match the baseline except the generator's own `lib/index.js`. The test `writes the artifacts in-process generation produces` compares worker and in-process artifacts; in a scratch workspace with one package's `files` broken, tsdown exits 1 within 2.4 s, reporting `TypertWorkerError` with the worker's `TypertAnalysisError`, and writes no artifacts. The `typertPlugin workspace mode` test group covers each failure path. +- **Trade-off:** The worker heap is about 300–420 MiB; the final configuration's median Host tsdown max RSS is 6,296–6,408 MiB against 5,676 MiB at baseline. Under heavy shared-host load the worker can land on the critical path: one sample took 21.91 s with the artifact-owning package build waiting about 14 s for the worker, not reproduced in 10 later samples; at load average about 100, two runs took 17.86 s and 14.3 s. The worker also completes under a 1,024 MiB heap limit, so GC pressure is not the cause; attribution to host contention is unproven. + +### 7. The Typert `transform` hook filters by module id + +- **Purpose:** Stop every module from crossing from rolldown into the JavaScript `transform` hook only to be rejected by an extension check. +- **Change:** `transform` declares `filter: { id: /^[^?]*\.[cm]?tsx?(?:\?|$)/ }`, and its handler still checks the id with the same regular expression. +- **Performance:** Hook calls per Host build fall from 1,566 to 195 (195 TypeScript modules, 4 of them with decorators). Wall medians are 11.07 s (3 samples) and 9.98 s (4 samples) against 10.25 s for item 6, while the identical-code control groups give 11.07 s and 9.87 s; the difference is within noise. +- **Why it is lossless:** The regular expression uses only ASCII constructs and matches the same id set in rolldown's Rust regex engine and in JavaScript; the handler applies the same check, so hosts that ignore filters behave the same. Outputs are byte-identical; the test `filters transform calls to TypeScript module ids` pins the filter. + +### 8. Client tsdown presets read the manifest from the package directory + +- **Purpose:** Each package config reads only its own `package.json`, removing the glob over every manifest by package name. +- **Change:** The first argument of `clientBundle`, `staticLinked`, and `clientLibrary` in [`packages/client/tsdown.client.ts`](../../../../packages/client/tsdown.client.ts) is the package directory, normally the config file's `import.meta.dirname`; the preset reads `/package.json` once and uses its `name` as the plugin id and diagnostic name; when that manifest is missing, unreadable, or has no `name`, config loading fails with an error naming the path and cause. 82 package configs call these presets. The presets keep no module-level manifest cache; the Node half's production-dependency external list is computed from that manifest on first resolve and cached in that config's closure. +- **Performance:** Client tsdown falls from 12.78 s to 8.73 s (medians of 3), −4.05 s (−31.7%), with disjoint sample ranges; user CPU falls from 26.1 s to 25.2 s and sys from 5.9 s to 4.9 s, so the saving comes mainly from unblocking the main thread rather than from less total CPU. The prototype (one glob building a name index) measured 11.72 s → 7.46 s (−36%). In Host tsdown only 5 `hostPhase` packages reach the lookup; one sample pair measured 11.92 s → 10.71 s, with low confidence. +- **Why it is lossless:** The 11,566 lib files are byte-identical across 11 runs. After the 82 call sites switched to package directories, no resolved package name differs from the name previously written at the call site. An in-process comparison of the external decisions for 82 configs × 3 faces × 1,600 specifiers matches the original implementation exactly; a negative control with one extra external changes 75 decisions. `client-bundle-purity.spec.ts` covers these manifest errors. +- **Behavior difference:** The presets' calling convention changes from a package name to a package directory. + +## Alternatives considered + +- **Enable pnpm `dedupeDirectDeps` (item 1):** It applies to every direct dependency and changes the link layout of every workspace package, a global side effect. +- **Patch TypeScript (item 1):** An upstream-style patch (precomputed candidate endings and cached normalized `paths` patterns) lowers Check by 31.9 s, comparable to the hoist; on the hoisted tree it adds nothing (references Check 25.76 s against 26.47 s) and must be maintained across every TypeScript upgrade. +- **Alias `react` in tsconfig `paths` (item 1):** tsx, vitest, and vite also read `paths`, so the alias changes their resolution of runtime `react`. +- **Narrow `paths` to each face's reference closure (item 1):** The 328-entry version that keeps wildcard keys checks in 55.73 s against 59.08 s (one sample), within noise; the version without wildcard keys produces 18 diagnostics. +- **Split the Client test aggregate program (item 1):** Every sub-program must inject the 242 files carrying module augmentations to pass, and the visible location count stays at 33–49; splitting into 2, 4, or 8 groups leaves the Check sum nearly flat while user CPU rises by 9%, 30%, and 57%, lowering only wall, and the 4- and 8-group splits each produce 1–2 diagnostics. +- **Cache resolution results and make the discovery queue O(1) (Typert):** After items 2–4 only 28 surface walks remain, about 0.18 s, below noise. +- **Move Typert into a separate build step in artifact builds (Typert):** After item 6 the worker already overlaps bundling, leaving at most about 1 s. Type checks, which need no bundle, run a source-only generation step ([local gate speed-ups](2026-10-12-local-gate-speedups.md)). +- **Filter `transform` by code content (item 7):** rolldown interprets `^` and `\s` with Rust regex semantics, which differ from JavaScript on lone CR, U+2028, and BOM, so a decorated module could be missed. +- **Build a process-wide manifest index on `globalThis` (item 8):** The prototype saves about as much time as item 8, but it is process-global state without an owner, violating the shared-state ownership rule in the [package rules](../../../../packages/AGENTS.md). +- **Disable tsdown's gzip report (item 8):** `--no-report` lowers Client tsdown by 0.95 s, within the 1.56 s baseline spread. +- **Add rolldown filters to DSH `resolveId`/`load` hooks (item 8):** Lowers it by 1.16 s, within noise. +- **Memoize the input-isolation check per module id (item 8):** Lowers it by 0.93 s, within noise; on top of the manifest index it saves only another 0.18 s. + +## Consequences + +Client type checking spends substantially less CPU and wall time, and the Host and Client tsdown passes take substantially less wall time, without changing runtime resolution, published installation semantics, or any output byte. In the serial build segments (master → branch, one sample each), Host tsdown falls from 22.5 to 11.7 s, Client package compilation from 55.6 to 48.7 s, the Client aggregate check from 68.2 to 35.9 s, and Client tsdown from 13.9 to 10.5 s; the serial segments sum to 308.5 s on master and 254.9 s on the branch (see [the build split note](2026-10-11-build-artifacts-check-split.md)). Host tsdown lies on the critical path of both `build` and `build:artifacts`, Client tsdown on that of `build:artifacts`, and the Client aggregate check is the last step of the `build` critical path. + +Costs: every workspace package shares one React types version; Typert does not validate tsconfigs of packages it does not analyze, and `tsc -b` owns those errors; Host tsdown max RSS grows by about 0.6 GiB, and under heavy shared-host load the worker can become Host tsdown's critical path. + +Reevaluation conditions: remeasure item 1 when migrating to TypeScript 7, and reconsider per-package declarations if the location count no longer affects Check time. tsdown's own config loading still takes 1.8–2.3 s per Client build (one uncached module instance per config) and needs an upstream change. The first `buildStart` arrives about 4.2 s into the Host build; starting the worker at plugin construction with an explicit root would start Typert earlier. + +Verification: the Typert generator's `tsdown-plugin.spec.ts`, `tsdown-plugin-worker.spec.ts`, and `type-model.spec.ts` pin the behavior of items 2–7; the generator tests report 209 passed and 28 skipped, and `tsc -b tsconfig.host.json` exits 0. The repository coverage gate excludes `packages/typert/*/src/**`; under a scratch coverage config, `tsdown-plugin.ts` reaches 100% statements, branches, functions, and lines. Five negative controls (the old root rule, eager package configs, an expanded aggregate, no emission claim, a skipped worker termination) each fail the test that guards them; the real tsdown failure path exits 1 within 2.4 s and reports `TypertWorkerError`. Item 1: `verify-package-dependencies` exits 0 (74 packages), its spec passes 47/47, `verify-client-packages` exits 0, `tsc -b tsconfig.client.json` reports no diagnostics, and the Client aggregate Check takes 26.48 s. Item 8: all 72 focused specs pass. diff --git a/.agents/notes/implemented/process/2026-10-11-lossless-build-speedups.zh.md b/.agents/notes/implemented/process/2026-10-11-lossless-build-speedups.zh.md new file mode 100644 index 0000000000..7ccfe2d3c9 --- /dev/null +++ b/.agents/notes/implemented/process/2026-10-11-lossless-build-speedups.zh.md @@ -0,0 +1,136 @@ +# Agent Note: 不改变产物的构建提速 + +Status: implemented + +[English](2026-10-11-lossless-build-speedups.md) | 中文 + +## 问题 + +冷启动构建中有三处与产物内容无关的固定开销: + +- **Client 测试聚合程序的 JSX 签名。** `tsc -p tsconfig.client.json` 的 Check 阶段为 59.1 s,其中一半以上用于为每个导入 React 的 JSX 文件计算 `@types/react` 的 module specifier。TypeScript 的 `computeModuleSpecifiers` 遍历该声明文件的每个符号链接位置,并在每个位置扫描全部 491 条 tsconfig `paths`。pnpm 为每个自行声明 `@types/react` 的 workspace 包创建一个 `node_modules/@types/react` 链接;66 个 manifest 声明了它,Client 测试聚合程序因此看到 65 个位置。计数器测得 88 个 JSX 文件 × 2 个模块(`index.d.ts`、`jsx-runtime.d.ts`)× 65 个位置,共 11,457 次 `tryGetModuleNameFromPaths` 调用、5,623,782 次条目扫描,合计 32.6 s。按可见位置数 N 截断的剂量实验在五个截断级别(N 从 1 到 65,Check 从 26.92 s 到 58.40 s)上拟合出 Check ≈ 26.9 + 0.494·N s。 +- **Host tsdown 阶段中的 Typert。** Host tsdown 阶段约 21.6–22.6 s。Typert 在第一个 `writeBundle` 中同步发现并生成产物,CPU profile 显示它阻塞 tsdown 主线程约 12.4 s,期间所有包构建都在等待。发现阶段占 8.06 s:它解析两个 face 聚合配置所引用的 362 个包 tsconfig(约 3.8 s),每次解析 Host 聚合配置都展开 2,064 条字面 `include`(1.6 s,分析器从不读取这些文件名),并对 322 个注册项做公开导出表面遍历(2.7–3.2 s);最终只有 28 个声明 Typert 导出的包产生 Host 产物。 +- **Client tsdown 阶段的 manifest 查找。** Client tsdown 阶段约 11.7 s,主线程饱和(空闲仅 7.1%),其中 34.5%(3.95 s)在 `workspaceManifest`:每个包配置按包名 `globSync('packages/*/*/package.json')` 并逐个解析 manifest,直到名字匹配。模块级缓存无效,因为 tsdown 的 native 配置加载器通过 `import-without-cache` 为每个包配置创建新的模块实例;每次 glob 耗时 40–125 ms,且发生在 `tsdown:deps` 的 `neverBundle`/`alwaysBundle` 回调中,阻塞全部构建。 + +#5849 已合并 Host fixture 类型收窄与 Typert memoization;本 Note 的基线是其后的 master。 + +## 决策 + +以下八项提速不改变 `lib/`、`apps/web/dist` 的产物字节,也不改变仓库构建报告的类型诊断集合。唯一的诊断归属变化来自第 4 项:Typert 不校验它不分析的包 tsconfig,这些错误只由 `tsc -b` 报告。命令拆分与并发调度由[构建拆分 Note](2026-10-11-build-artifacts-check-split.zh.md)记录。 + +| # | 决策 | 受影响阶段 | 实测中位数 | 产物与诊断 | 有意的行为差异 | +|---|---|---|---|---|---| +| 1 | React 类型包只由根 manifest 声明 | Client 类型检查 | 测试聚合 Check 59.1 → 26.3 s | 程序文件数与诊断不变 | 包 manifest 不列出 React 类型包 | +| 2 | Typert 先按 manifest 选择包 | Host tsdown | 22.62 → 19.94 s | 逐字节一致 | 无 | +| 3 | Typert 不展开聚合配置的 `include` | Host tsdown | 19.94 → 16.99 s | 逐字节一致;注册清单一致 | 无 | +| 4 | Typert 惰性解析包 tsconfig | Host tsdown | 16.99 → 16.25 s(噪声内);Typert 发现 5.9–7.1 → 0.36–0.40 s | 逐字节一致;注册清单一致 | 未分析包的 tsconfig 错误只由 `tsc -b` 报告 | +| 5 | Typert 工作区根目录同时要求 `packages/` | Host tsdown | 噪声内 | 逐字节一致 | `package` 模式下双 face 包获得产物 | +| 6 | Typert 在 Worker Thread 中生成 | Host tsdown | 15.39 → 10.25 s | 逐字节一致;失败仍使 tsdown 失败 | Host tsdown 最大 RSS 约 +0.6 GiB | +| 7 | Typert `transform` 钩子按模块 id 过滤 | Host tsdown | 噪声内;钩子调用 1,566 → 195 | 逐字节一致 | 无 | +| 8 | Client tsdown 预设按包目录读取 manifest | Client tsdown | 12.78 → 8.73 s | 逐字节一致;external 判定一致 | 预设第一个参数是包目录 | + +第 5–7 项改变了发布的 `./tsdown` 导出 `typertPlugin()`(`transform` 为 `{ filter, handler }`、`writeBundle` 为异步、工作区根目录必须包含 `packages/`);rolldown 与 tsdown 的使用方接受两种钩子形式,分析器本来就要求 `/packages`,因此此前可用的外部工作区都不会失效。 + +### 测量条件 + +所有数字取自同一台共享 Linux 主机(256 vCPU、4 TB 内存,运行期间 load average 23–111),Node v24.18.0、TypeScript 6.0.3、tsdown 0.22.2(rolldown 1.1.1)。 + +- **Host tsdown:** `node node_modules/tsdown/dist/run.mjs --config-loader native --env.DSH_BUILD_FACE host`,每次运行前删除全部 `lib/typert.*`;各变体交替顺序运行,每组 3–4 个样本。第 2–4 项取自第一轮测量(起点为基线),第 5–7 项取自第二轮测量(起点为第 2–4 项合入后的代码)。产物比较覆盖 `packages/*/*/lib`、`apps/*/lib`、`vendor/*/lib` 顶层的 743 个文件(含 140 个 Typert 产物)的 SHA-256。相同代码的两组中位数相差 0–0.11 s,组内样本跨度最大 2.5 s。 +- **Client 测试聚合:** references 预先构建后运行 `tsc -p tsconfig.client.json --extendedDiagnostics`,使用全新的 tsBuildInfo 文件;相同代码 6 个样本的 Check 跨度为 3.8 s(约 6%)。 +- **Client tsdown:** 已构建的树上运行 `node node_modules/tsdown/dist/run.mjs --config-loader native --env.DSH_BUILD_FACE client`。第 8 项的原型 3 轮轮换顺序,基线跨度 1.56 s;最终实现与原实现交替运行,各 3 个样本。产物比较覆盖 `{packages,apps,vendor,native}/**/lib/**`(排除 `*.tsbuildinfo`)的全部文件。 + +### 1. React 类型包只由根 manifest 声明 + +- **目的:** 把 Client 测试聚合程序看到的 `@types/react` 位置数 N 从 65 降到 1,消除随 N 线性增长的 module specifier 计算。 +- **改动:** 根 `package.json` 的 `devDependencies` 声明 `@types/react` `~18.3.1` 与 `@types/react-dom` `~18.3.0`,沿用各包原先统一的版本范围;`apps/*`、`packages/*/*` 与 `vendor/*` 的 manifest 在任何依赖区段都不声明它们。TypeScript 从任一包向上查找时都解析到根 `node_modules/@types/react`。runtime `react` 与 `react-dom` 仍按 [Client 依赖声明规则](../../../../packages/client/AGENTS.md#dependency-declaration)由各包声明;只通过 `import type` 引用 React 的 `packages/client/ui-slots` 在 `devDependencies` 中声明 `react`。pnpm `dedupeDirectDeps` 保持关闭。lockfile 的变化集中在 importers 区段(另有一条 integrity 相同的 `papaparse` 条目重排)。 +- **防回退:** [`verify-package-dependencies`](../../../../scripts/verify-package-dependencies.ts) 在 `hygiene` 与 CI 中要求根 manifest 的 `devDependencies` 声明这两个包,并拒绝 `apps/*`、`packages/*/*`、`vendor/*` 中任一 manifest 在 `dependencies`、`devDependencies`、`optionalDependencies` 或 `peerDependencies` 中声明它们;诊断指明要删除的条目,并链接 Client 依赖声明规则第 8 条。规则只匹配这两个精确包名,其他 `@types/*` 包仍按 DefinitelyTyped 类型提供方处理。依赖区段推导不把这两个包视为 `react`/`react-dom` 的类型提供方:包缺少 `react` 声明时,验证器报告 `react` 缺失,`--fix` 拒绝修复并要求先声明版本范围;`--fix` 也不写回或删除这两个包。[`verify-package-dependencies.spec.ts`](../../../../scripts/verify-package-dependencies.spec.ts) 覆盖各依赖区段的拒绝、根 manifest 缺失声明、被接受的布局,以及类型提供方排除。 +- **性能:** + + | 测量项 | 各包自声明(N = 65) | 根 manifest 声明(N = 1) | + |---|---|---| + | Client 测试聚合 Check | 59.08 s(3 个样本的中位数) | 26.28 s(5 个样本的中位数),−56% | + | Client 测试聚合 wall | 68–71 s | 33.6–34.9 s | + | Client 测试聚合 user CPU | 79–83 s | 44.5–45.3 s | + | 76 个 client-only 包项目的 Check 总和 | 34.6 s(2 个样本的中位数) | 26.5 s(2 个样本的中位数),−23% | + | 76 个 client-only 包项目的 wall | 55.7 s(2 个样本的中位数) | 44.5 s(2 个样本的中位数) | + +- **无损依据:** 66 个包级链接与根链接指向同一个 realpath(`.pnpm/@types+react@18.3.31`),因此程序读取的声明内容相同;程序文件数保持 2,613,`tsc -b --force tsconfig.client.json` 以 0 退出且无诊断。`devDependencies` 不随已发布的包安装,[发布依赖面](2026-08-26-published-dependency-faces.zh.md)中独立 TypeScript 消费者自行安装所引用类型包的结论不变;runtime 解析不读取 `@types/*`。 +- **取舍:** 包 manifest 不列出自己的 React 类型依赖,所有 workspace 包共用同一个 React 类型版本;某个包需要不同版本时,需要新的 Agent Note 推翻本项。检查只覆盖 `apps/*`、`packages/*/*` 与 `vendor/*`;`website`、`benchmarks`、`native/system` 与 `python/sdk-runtime` 等其他 pnpm 成员不在范围内,目前它们都不声明 React 类型包。 + +### 2. Typert 先按 manifest 选择包 + +- **目的:** workspace 模式只为声明 Typert 导出的包生成产物,避免对其余候选包做表面遍历。 +- **改动:** `WorkspaceAnalyzer.discoverPackages(includeManifest?)` 与 `WorkspaceTypertGenerator.discover(faces?, includeManifest?)` 接受可选的 `package.json` 谓词,在表面遍历之前应用;被拒绝的包既不读取源码,也不解析项目配置。`WorkspaceTypertGenerator.generateContributors(faces?)` 以 `hasTypertExport`(`exports` 含 `./typert`、`./client/typert` 或 `./remote`)为谓词。不传谓词的调用方(Cordis 目录、不指定包的 `generate()`)行为不变。 +- **性能:** Host tsdown 22.62 s → 19.94 s(各 3 个样本的中位数)。 +- **无损依据:** 谓词就是原先在发现之后应用的同一过滤条件,被它拒绝的包本来就不产生产物;743 个产物文件逐字节一致(Typert generator 自身的 `lib/index.js` 除外,其源码即本项改动)。 + +### 3. Typert 不展开聚合配置的 `include` + +- **目的:** 分析器只读取聚合配置的编译选项与 project references,展开 `include` 只为填充从不使用的 `fileNames`。 +- **改动:** `WorkspaceCaches.aggregate(path)` 返回并缓存 `AggregateConfig { path, options, projectReferences }`。解析自身声明 `references` 的聚合配置时,使用 `readDirectory` 返回空列表的 `ParseConfigHost`;不声明 `references` 的聚合配置照常展开。 +- **性能:** Host tsdown 19.94 s → 16.99 s(各 3 个样本的中位数)。 +- **无损依据:** TypeScript 只对既无 `files` 也无 `references` 的配置报告 TS18003(无输入),因此声明 `references` 的聚合配置在不展开时得到相同的诊断、选项与 references。强制读取每个惰性配置后导出的注册清单(408 个注册项,含 `fileNames` 与选项,以及聚合配置的选项和 references)与基线逐字节一致;产物逐字节一致。测试 `reads aggregate options and references without expanding include patterns` 与 `reports an aggregate without references or inputs` 固定这两种配置的行为。 + +### 4. Typert 惰性解析包 tsconfig + +- **目的:** 只解析被选中分析的包的 tsconfig,而不是聚合配置引用的全部包。 +- **改动:** `PackageRegistration.config` 是 `caches.config(configPath)` 的 getter,首次读取时解析。注册清单的顺序、face、包名、根目录与导出子路径不变。 +- **性能:** Host tsdown 16.99 s → 16.25 s(各 3 个样本的中位数),在 2.5 s 的组内跨度以内。第 2–4 项合计在隔离基准中把 Typert 发现从 5.9–7.1 s 降到 0.36–0.40 s,tsconfig 解析从 362 次降到 28 次,表面遍历从 322 次降到 28 次,Typert 总耗时从 9.7–11.8 s 降到 4.4–4.5 s。 +- **无损依据:** 注册清单比较与第 3 项相同(逐字节一致),产物逐字节一致。测试 `reads only the project configs of manifests selected for discovery` 固定只解析所选包的配置。 +- **取舍(非无损):** Typert 不解析它不分析的包的 tsconfig,因此这类 tsconfig 的语法或配置错误不会使 Typert 失败。同一构建中,先于 Host bundle 运行的 Host 包编译 `tsc -b` 与 Client 包编译 `tsc -b` 解析每个被引用的 tsconfig,并拒绝其中的错误;`PackageRegistration.config` 的 JSDoc 与 [Typert generator README](../../../../packages/typert/generator/README.zh.md) 写明这一归属。单独调用 Typert 而不运行 `tsc -b` 的使用方不会得到这些诊断。 + +### 5. Typert 工作区根目录同时要求 `packages/` + +- **目的:** 修正工作区根目录的判定。24 个双 face 包带有自己的 `tsconfig.host.json` 叶子配置;只按 `tsconfig.host.json` 向上查找时,每个这样的包都被当作根目录,workspace 模式每次构建为它们各做一次空发现,`package` 模式下双 face 包得不到产物且没有报错。 +- **改动:** 工作区根目录是打包输出最近的、同时包含 `tsconfig.host.json` 与 `packages/` 的祖先目录。 +- **性能:** Host tsdown 14.82 s → 15.39 s(各 3 个样本的中位数),在噪声内。 +- **无损依据:** 仓库根 [`tsdown.config.ts`](../../../../tsdown.config.ts) 只使用 workspace 模式,743 个产物文件逐字节一致。测试 `resolves the workspace root above a dual-face package leaf` 固定该判定。 +- **行为差异:** `package` 模式的使用方构建双 face 包时得到该包的 Typert 产物;仓库构建不使用 `package` 模式。 + +### 6. Typert 在 Worker Thread 中生成 + +- **目的:** 让 Typert 分析与 tsdown 主线程上的打包重叠,而不是在第一个 `writeBundle` 中阻塞全部包构建。 +- **改动:** [`src/worker.ts`](../../../../packages/typert/generator/src/worker.ts) 是 Worker Thread 入口,运行 `generateContributors(faces)` 并回传一条 `artifacts` 或 `failure` 消息。workspace 模式下,本次构建的第一个 `buildStart` 以 `options.cwd` 定位工作区根目录并启动生成;`writeBundle` 是异步的,第一个完成的打包认领写入并等待结果,之后的打包立即返回。分析错误、worker 崩溃或提前退出、格式错误或无法反序列化的响应、worker 无法终止,都以指明工作区与原因的 `TypertWorkerError` 使认领写入的打包失败,从而使整个 tsdown 阶段失败;结果敲定前 worker 已终止,未被认领的结果不会成为未处理的 rejection。构建产物从 `./worker.js` 启动 worker;源码运行(测试)通过 `tsx/esm/api` 加载 `worker.ts`。`package` 模式仍在主线程中生成。 +- **性能:** Host tsdown 15.39 s → 10.25 s(各 3 个样本的中位数)。最终配置(第 2–7 项)的 profile:主线程 10.1 s,第一个 `buildStart` 出现在 4.18 s(tsdown 配置解析),主线程上的 `writeBundle` 只占 0.37 s;worker 5.0 s(模块加载约 0.4 s、发现 0.28 s、生成 3.88 s,其中 `createProgram` 2.21 s),约在 9.3 s 结束。Host tsdown 的 wall 约为 max(打包, 4.2 s + Typert)。 +- **无损依据:** 第二轮的 54 次运行全部以 0 退出,每次的 743 个产物文件均与基线一致(generator 自身的 `lib/index.js` 除外)。测试 `writes the artifacts in-process generation produces` 比较 worker 与进程内生成的产物;在临时工作区中把一个包的 `files` 改坏后,tsdown 在 2.4 s 内以 1 退出,报告 `TypertWorkerError` 及 worker 内的 `TypertAnalysisError`,不写入任何产物。各失败路径由 `typertPlugin workspace mode` 测试组覆盖。 +- **取舍:** worker 堆约 300–420 MiB;最终配置的 Host tsdown 最大 RSS 中位数为 6,296–6,408 MiB,基线为 5,676 MiB。共享主机高负载时 worker 可能落在关键路径上:一次样本为 21.91 s,拥有产物的包构建等待 worker 约 14 s,其后 10 个样本未复现;load average 约 100 时两次运行为 17.86 s 与 14.3 s。worker 在 1,024 MiB 堆上限下也能完成,因此不是 GC 压力;归因于主机争用尚未证实。 + +### 7. Typert `transform` 钩子按模块 id 过滤 + +- **目的:** 避免每个模块都从 rolldown 进入 JavaScript `transform` 钩子,只为被扩展名检查拒绝。 +- **改动:** `transform` 声明 `filter: { id: /^[^?]*\.[cm]?tsx?(?:\?|$)/ }`,处理函数仍以同一正则检查 id。 +- **性能:** 每次 Host 构建的钩子调用从 1,566 次降到 195 次(195 个 TypeScript 模块,其中 4 个含装饰器)。wall 中位数为 11.07 s(3 个样本)与 9.98 s(4 个样本),第 6 项为 10.25 s,相同代码的对照组为 11.07 s 与 9.87 s,差异在噪声内。 +- **无损依据:** 该正则只含 ASCII 构造,在 rolldown 的 Rust 正则与 JavaScript 中匹配相同的 id 集合;处理函数应用同一检查,因此不读取过滤器的宿主行为相同。产物逐字节一致;测试 `filters transform calls to TypeScript module ids` 固定过滤器。 + +### 8. Client tsdown 预设按包目录读取 manifest + +- **目的:** 每个包配置只读取自己的 `package.json`,消除按包名遍历全部 manifest 的 glob。 +- **改动:** [`packages/client/tsdown.client.ts`](../../../../packages/client/tsdown.client.ts) 的 `clientBundle`、`staticLinked` 与 `clientLibrary` 的第一个参数是包目录(通常为配置文件的 `import.meta.dirname`),预设读取一次 `/package.json`,以其中的 `name` 作为插件 id 与诊断名称;该 manifest 缺失、不可读或缺少 `name` 时,配置加载以指明路径与原因的错误失败。82 个包配置调用这些预设。预设不保留模块级 manifest 缓存;Node 半边的生产依赖 external 列表在首次 resolve 时由该 manifest 计算,并缓存在该配置的闭包中。 +- **性能:** Client tsdown 从 12.78 s 降到 8.73 s(各 3 个样本的中位数),−4.05 s(−31.7%),两组样本区间不重叠;user CPU 从 26.1 s 降到 25.2 s,sys 从 5.9 s 降到 4.9 s,因此收益主要来自解除主线程阻塞,而不是减少 CPU 总量。原型(一次 glob 建立按包名索引)为 11.72 s → 7.46 s(−36%)。Host tsdown 中只有 5 个 `hostPhase` 包经过该查找,一对样本为 11.92 s → 10.71 s,置信度低。 +- **无损依据:** 11 次运行的 11,566 个 lib 文件逐字节一致。82 个调用处改为传入包目录后,没有一个解析出的包名与原先手写的包名不同。进程内比较 82 个配置 × 3 个 face × 1,600 个 specifier 的 external 判定,结果与原实现完全一致;负对照(多加一个 external)使 75 个判定不同。`client-bundle-purity.spec.ts` 覆盖这些 manifest 错误。 +- **行为差异:** 预设的调用约定从包名改为包目录。 + +## 考虑过的替代方案 + +- **开启 pnpm `dedupeDirectDeps`(第 1 项):** 作用于所有直接依赖,改变每个 workspace 包的链接布局,副作用是全局的。 +- **给 TypeScript 打补丁(第 1 项):** 上游式补丁(预先计算候选扩展名并缓存规范化的 `paths` 模式)使 Check 降低 31.9 s,与提升相当;在已提升的树上不再带来收益(refs Check 25.76 s 对 26.47 s),且需随每次 TypeScript 升级维护。 +- **在 tsconfig `paths` 中为 `react` 加别名(第 1 项):** tsx、vitest 与 vite 也读取 `paths`,别名会改变它们对 runtime `react` 的解析。 +- **把 `paths` 收窄为各 face 的 reference closure(第 1 项):** 保留通配键的 328 条版本 Check 为 55.73 s 对 59.08 s(1 个样本),在噪声内;去掉通配键的版本产生 18 个诊断。 +- **拆分 Client 测试聚合程序(第 1 项):** 每个子程序都必须注入 242 个含模块增强的文件才能通过检查,可见位置数仍为 33–49;拆成 2、4、8 组时 Check 总和基本不变,user CPU 分别增加 9%、30%、57%,只降低 wall,且 4 组与 8 组各出现 1–2 个诊断。 +- **缓存解析结果并把发现队列改为 O(1)(Typert):** 第 2–4 项之后只剩 28 次表面遍历,约 0.18 s,低于噪声。 +- **在产物构建中把 Typert 移到独立构建步骤(Typert):** 第 6 项之后 worker 已与打包重叠,剩余收益不超过约 1 s。不需要 bundle 的类型检查运行只读源码的生成步骤([本地门禁提速](2026-10-12-local-gate-speedups.zh.md))。 +- **按代码内容过滤 `transform`(第 7 项):** rolldown 以 Rust 正则语义解释 `^` 与 `\s`,在孤立 CR、U+2028 与 BOM 上与 JavaScript 不同,可能漏掉含装饰器的模块。 +- **在 `globalThis` 上建立进程级 manifest 索引(第 8 项):** 原型收益与第 8 项相当,但它是没有 owner 的进程全局状态,违反[包规则](../../../../packages/AGENTS.md)中的共享状态归属要求。 +- **关闭 tsdown 的 gzip 报告(第 8 项):** `--no-report` 使 Client tsdown 降低 0.95 s,在 1.56 s 的基线跨度内。 +- **为 DSH 的 `resolveId`/`load` 钩子加 rolldown 过滤器(第 8 项):** 降低 1.16 s,在噪声内。 +- **按模块 id 缓存输入隔离检查(第 8 项):** 降低 0.93 s,在噪声内;叠加在 manifest 索引之上只再降低 0.18 s。 + +## 后果 + +Client 类型检查的 CPU 与 wall 时间、Host tsdown 与 Client tsdown 的 wall 时间都明显下降,且不改变 runtime 解析、发布安装语义或任何产物字节。串行构建的各段中(master → 本分支,各一个样本),Host tsdown 从 22.5 s 降到 11.7 s,Client 包编译从 55.6 s 降到 48.7 s,Client 聚合检查从 68.2 s 降到 35.9 s,Client tsdown 从 13.9 s 降到 10.5 s;串行各段合计在 master 上为 308.5 s,在本分支上为 254.9 s(见[构建拆分 Note](2026-10-11-build-artifacts-check-split.zh.md))。Host tsdown 位于 `build` 与 `build:artifacts` 的关键路径上,Client tsdown 位于 `build:artifacts` 的关键路径上,Client 聚合检查是 `build` 关键路径的最后一步。 + +代价:所有 workspace 包共用一个 React 类型版本;Typert 不校验它不分析的包 tsconfig,这些错误由 `tsc -b` 负责;Host tsdown 的最大 RSS 约增加 0.6 GiB,共享主机高负载时 worker 可能成为 Host tsdown 的关键路径。 + +重新评估条件:迁移到 TypeScript 7 时重新测量第 1 项,若位置数不再影响 Check 时间,可以重新评估包级声明。tsdown 的配置加载本身每次 Client 构建仍占 1.8–2.3 s(每个配置一个无缓存的模块实例),需要上游改动。第一个 `buildStart` 在 Host 构建开始约 4.2 s 后才出现;在插件构造时以显式根目录启动 worker 可以进一步提前 Typert。 + +验证:Typert generator 的 `tsdown-plugin.spec.ts`、`tsdown-plugin-worker.spec.ts` 与 `type-model.spec.ts` 固定第 2–7 项的行为;generator 测试 209 个通过、28 个跳过,`tsc -b tsconfig.host.json` 以 0 退出。仓库覆盖率 gate 不包含 `packages/typert/*/src/**`,在临时覆盖率配置下 `tsdown-plugin.ts` 的 statements、branches、functions 与 lines 均为 100%。五个负对照(旧根目录规则、立即解析包配置、展开聚合配置、不认领写入、跳过 worker 终止)各自使守护它的测试失败;真实 tsdown 失败路径在 2.4 s 内以 1 退出并报告 `TypertWorkerError`。第 1 项:`verify-package-dependencies` 以 0 退出(74 个包),其 spec 47/47 通过,`verify-client-packages` 以 0 退出,`tsc -b tsconfig.client.json` 无诊断,Client 聚合 Check 为 26.48 s。第 8 项:72 个相关 spec 全部通过。 diff --git a/.agents/notes/implemented/process/2026-10-12-local-gate-speedups.i18n.yaml b/.agents/notes/implemented/process/2026-10-12-local-gate-speedups.i18n.yaml new file mode 100644 index 0000000000..80284ccdd5 --- /dev/null +++ b/.agents/notes/implemented/process/2026-10-12-local-gate-speedups.i18n.yaml @@ -0,0 +1,49 @@ +# Bilingual-pair consistency record for 2026-10-12-local-gate-speedups.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-10-12-local-gate-speedups.md +/agent-note-local-gate-hook-and-test-speed-ups: + en: d8edb2c26730e9af + zh: fb69bf2e7b602d4c +/agent-note-local-gate-hook-and-test-speed-ups/problem: + en: 1e2163ec378c44ff + zh: d93cb5970896fccb +/agent-note-local-gate-hook-and-test-speed-ups/decision: + en: 9984b4ad646bfaf2 + zh: 753287ae67e29605 +/agent-note-local-gate-hook-and-test-speed-ups/decision/measurement-conditions: + en: 7430353afba96352 + zh: 6d2a09619a67ecba +/agent-note-local-gate-hook-and-test-speed-ups/decision/1-type-checks-generate-typert-declarations-without-bundling: + en: 4dc48e24a3323b7d + zh: 7c88325160a41d12 +/agent-note-local-gate-hook-and-test-speed-ups/decision/2-the-pre-commit-hook-does-not-regenerate-third-party-notices: + en: e2e344f184621172 + zh: 97d8785aaf9105bf +/agent-note-local-gate-hook-and-test-speed-ups/decision/3-local-default-worker-budget: + en: e9649c40d67e26e0 + zh: c99f22a41c4fc4af +/agent-note-local-gate-hook-and-test-speed-ups/decision/4-repository-reference-candidates-resolve-against-one-object-listing: + en: 2c945250337c88cf + zh: d4bfa34cc858782b +/agent-note-local-gate-hook-and-test-speed-ups/decision/5-documentation-gates-parse-each-document-once: + en: c22d38399a2bb87f + zh: 9ae97e5a6e3fbf48 +/agent-note-local-gate-hook-and-test-speed-ups/decision/6-repository-file-walks-list-each-directory-once: + en: 2f0d35b0a688c938 + zh: 7184fd260c066a01 +/agent-note-local-gate-hook-and-test-speed-ups/decision/7-cli-help-reads-run-concurrently-doc-graph-probes-are-memoized: + en: 965503ba8a31c039 + zh: fe02a394a677d018 +/agent-note-local-gate-hook-and-test-speed-ups/decision/8-type-model-spec-ts-runs-against-an-in-memory-workspace: + en: d519ec362903aa2f + zh: f49ee6547cb954f6 +/agent-note-local-gate-hook-and-test-speed-ups/decision/repository-wide-comparison: + en: 7c9388dc859e0f93 + zh: 5df1fc58eb7a46e8 +/agent-note-local-gate-hook-and-test-speed-ups/alternatives-considered: + en: 763d94734b8967cf + zh: abe980d486e1d7da +/agent-note-local-gate-hook-and-test-speed-ups/consequences: + en: ca00b04567d09408 + zh: 97011043c60f4bc9 diff --git a/.agents/notes/implemented/process/2026-10-12-local-gate-speedups.md b/.agents/notes/implemented/process/2026-10-12-local-gate-speedups.md new file mode 100644 index 0000000000..22144834ef --- /dev/null +++ b/.agents/notes/implemented/process/2026-10-12-local-gate-speedups.md @@ -0,0 +1,158 @@ +# Agent Note: Local gate, hook, and test speed-ups + +Status: implemented + +English | [中文](2026-10-12-local-gate-speedups.zh.md) + +## Problem + +On the baseline, master at the merge of #6013, local runs of the Git hooks, `pnpm run test`, `test:docs`, and `doc-sync` spend most of their wall time on work that their results do not depend on: + +- **Typert declarations from the bundler.** `typecheck`, the command the pre-push hook runs, executes `build:lib:host` (Host `tsc -b tsconfig.host.json`, the Host tsdown pass, and the Desktop bundle) before `tsc -b tsconfig.client.json`. The prerequisite of `lint`, `lint:fix`, `doc-typecheck`, and the CI `typert-contracts` gate runs Host package compilation and then the Host tsdown pass (`compile-referenced-projects.ts host-libraries`). Type checks read only package declarations and Typert declarations, yet `typecheck` with nothing to recheck takes 34.1/35.0 s and the prerequisite takes 52.2/29.6 s. +- **Third-party notices on every commit.** The pre-commit hook regenerates `THIRD_PARTY_NOTICES.md` whenever a staged file is a generator input. To find browser-side third-party imports, the generator test-bundles 75 Client packages and runs one Vite build, so every package source file is an input; on a commit that stages one file under a package `src/` subdirectory, the hooks take 16.5/17.8 s, of which the notices job takes 12.4/13.4 s. +- **Host CPUs as the local worker count.** `os.availableParallelism()` reports the host's CPUs (256 on the measurement host) rather than the container's CPU quota (64). With that default, `pnpm run test` starts 255 Vitest forks, peaks at 55.3 GiB RSS, and fails 12 additional files (14 timeouts) intermittently, while `doc-sync`, `test:docs`, `hygiene`, and `check:all` run at most 4 gates concurrently. +- **One Git prefix lookup per candidate.** `verify-repository-references` resolves each of about 26,000 abbreviated hexadecimal candidates with a Git prefix lookup through `git cat-file --batch-check`; the lookups take 135.7 s of the gate's 166.8 s. +- **Repeated Markdown parsing.** markdown links parses each anchor target twice more and probes target existence without a cache; translation pairing globs four times and parses each document again after link normalization; the package README model-experience and limitations checks each parse every README three times; type equivalence parses all 1,835 documents; Session format references parses each pair four times. +- **Repeated directory reads.** `uniqueRepoFiles`, the walker behind markdown wrap, doc refs, and package paths, reads each directory once per pattern and `**` level and resolves every matched file with `realpath`. +- **Serial help launches and repeated compiler probes.** `gen-cli-help` launches its 12 top-level source-mode `dsh … --help` processes one after another, each waiting mostly on profile boot I/O (49.9/53.9 s); `gen-doc-graphs` builds several compiler hosts and programs that repeat the same `ts.sys` file-system probes (48.7/44.7 s). +- **A test file bound by file-system probes.** Each of the 61 cases of the Typert generator's `type-model.spec.ts` copies its fixture to disk and runs a full analysis whose four TypeScript programs each load the DOM library, `@types/node`, and zod v4, about 22,000 stat calls per analysis. The file takes 316.3/329.1 s and is the long tail of `pnpm run test`. + +## Decision + +The eight decisions below shorten those runs. Each item states the evidence that its outputs and diagnostics stay the same, or the intended difference it accepts. + +| # | Decision | Affected commands | Measured wall | Outputs and diagnostics | Intended behavior difference | +|---|---|---|---|---|---| +| 1 | Type checks generate Typert declarations without bundling | `typecheck`, `lint`, `lint:fix`, `doc-typecheck`, CI `typert-contracts` | `typecheck` 34.1/35.0 → 21.9/20.0 s; prerequisite 52.2/29.6 → 12.5/11.3 s | 140 Typert files byte-identical; the same programs are checked | No runnable bundle and no client build record | +| 2 | The pre-commit hook does not regenerate third-party notices | `pre-commit` | 16.5/17.8 → 4.3/4.5 s | Freshness asserted in the test lane | Stale notices fail in the test lane, not at commit | +| 3 | Local default worker budget | run-gates aggregates, `build`, `typecheck`, `pnpm run test`, Oxlint, publint | `test` at 255 workers: 243.7 s, 55.3 GiB; `test:docs` 74.7 → 50.9 s | Same checks | Local defaults ignore CPUs above 12 or 48 | +| 4 | Repository-reference candidates resolve against one object listing | `verify-repository-references` | 166.8 → 15.7 s | 0 classification differences over 27,050 candidates | None | +| 5 | Documentation gates parse each document once | markdown links, translation pairing, README model experience and limitations, type equivalence, Session format references | markdown links 53.4/51.6 → 25.9/25.1 s; type equivalence 23.0/22.5 → 6.0/5.5 s | Byte-identical gate output | None | +| 6 | Repository file walks list each directory once | markdown wrap, doc refs, package paths | doc refs 8.8/7.0 → 3.2/3.4 s | Identical file lists | doc refs does not read nested `lib` directories | +| 7 | CLI help reads run concurrently; doc-graph probes are memoized | `verify-cli-help`, `verify-doc-graphs` | 49.9/53.9 → 6.9/7.2 s; 48.7/44.7 → 32.7/34.2 s | Byte-identical pages | CLI help uses about 3.6 GB at peak | +| 8 | `type-model.spec.ts` runs against an in-memory workspace | `pnpm run test` | 316.3/329.1 → 14.65/15.04 s | Same 61 cases and assertions; one snapshot line differs | Stubs replace zod, `@types/node`, and the DOM library outside the Emitter case | + +### Measurement conditions + +All numbers are wall times from one shared Linux container with a 64-CPU quota on a host that reports 256 CPUs, at a one-minute load average of 20–150, so samples carry noise. The working tree lives on a network filesystem, where each file-system probe costs more than on a local disk. `a/b` lists two samples, and `x → y` compares the baseline with this implementation. + +### 1. Type checks generate Typert declarations without bundling + +- **Purpose:** Give `typecheck` and the prerequisite of `lint`, `lint:fix`, `doc-typecheck`, and the CI `typert-contracts` gate the declarations they read, package declarations from `tsc` and Typert declarations, without the Host tsdown pass or the Desktop bundle. +- **Change:** `typecheck` is `tsx scripts/build.ts --scope typecheck`, which runs the steps below through the run-gates scheduler ([`scripts/build.ts`](../../../../scripts/build.ts)). The scope builds no native addon, tsdown bundle, Desktop bundle, or Web output, writes no client build record, and leaves runnable `lib/` output as the last `build:artifacts` left it; run `build:artifacts` before launching or testing built output. The prerequisite of `lint`, `lint:fix`, `doc-typecheck`, and `typert-contracts` is `compile-referenced-projects.ts host-contracts`, which runs host-src beside host-typert ([`compile-referenced-projects.ts`](../../../../scripts/compile-referenced-projects.ts)). `build`, `build:artifacts`, benchmark builds, and the PR rehearsal in `release-vendor.yml` run Typert inside the Host tsdown pass. + +| Step | Command | `needs` | +|---|---|---| +| host-src | `node --max-old-space-size=4096 -b ` | — | +| host-typert | `node --import tsx/esm scripts/generate-typert.ts` | — | +| client-src | ` -b ` | host-src, host-typert | +| host-check | `node --max-old-space-size=4096 -b tsconfig.host.json` | host-src | +| client-check | ` -b tsconfig.client.json` | client-src | + +- **Shared generation:** [`scripts/generate-typert.ts`](../../../../scripts/generate-typert.ts) calls `WorkspaceTypertGenerator.generateContributors()` for `WORKSPACE_TYPERT_FACES` ([`scripts/typert-faces.ts`](../../../../scripts/typert-faces.ts)), the faces the Host tsdown plugin also generates, and writes the result with `writeWorkspaceArtifacts()` from [`artifact-files.ts`](../../../../packages/typert/generator/src/artifact-files.ts), the writer the plugin uses. Like the plugin, it skips TypeScript diagnostics, which Host package compilation reports; a Typert analysis error fails host-typert as it fails the Host bundle in `build`. host-typert runs under Node with tsx's ESM hook rather than through `pnpm exec`, whose startup would lengthen the path to Client compilation. +- **Aggregate checks in build mode:** host-check and client-check start after their own face's package compilation, so build mode finds every reference up to date, checks only the aggregate root program, and skips a root program whose tsbuildinfo is current. +- **Source-only analysis:** host-typert can run beside host-src because its Host program reads no workspace build output. `tsconfig.base.json` maps `@deepseek-ai/dsh-client-file-upload/types` to source; without that alias the specifier resolves through package `exports` to `lib/types/types.d.ts`. With it, the Host program loads 1,289 files, none of them under a workspace package's `lib/`. `generate-typert.ts` enables `rejectBuildOutputs`: [`build-output-guard.ts`](../../../../packages/typert/generator/src/build-output-guard.ts) fails analysis with a `TypertAnalysisError` when a face program loads a file from a workspace package's `lib/` directory, or cannot resolve an import of a workspace package, as on a tree whose packages are not yet compiled, naming each such import specifier and the `paths` entry that maps it to source. Either way a missing alias fails generation whether or not host-src has run. The tsdown plugin leaves the option off because in `build` Typert always runs after Host package compilation. The [Typert generator README](../../../../packages/typert/generator/README.md) documents the writers and the option. +- **Why it is lossless:** No workspace tsconfig sets `allowJs`, every `exports` `types` condition points into `lib/types/` or at `lib/typert.*`, and every tsdown config sets `dts: false`, so no type check reads tsdown output. The 140 Typert files (28 packages × 5 outputs) are byte-identical across the baseline tsdown pass, the current tsdown pass, `generate-typert.ts` after every Typert output is deleted, and `generate-typert.ts` with all 344 `lib/types` directories hidden; with one Remote method temporarily added to `packages/goal/goal`, the tsdown pass and `generate-typert.ts` also write identical files. `typecheck` checks every package project of both faces and both aggregate root programs, the programs that `build` checks. +- **Performance:** `typecheck` with nothing to recheck takes 34.1/35.0 → 21.9/20.0 s. When both aggregate root programs must be rechecked, it takes 83.5 → 76.0/72.1 s, and its critical path is the Host aggregate check at about 66–70 s. The prerequisite of `lint` and `doc-typecheck` takes 52.2/29.6 → 12.5/11.3 s. +- **Trade-off:** `typecheck`, `lint`, `lint:fix`, `doc-typecheck`, and `typert-contracts` write Typert declarations but no bundle, so runnable `lib/` JavaScript can lag behind those declarations until the next `build:artifacts`. +- **Relation to the build:** [The lossless build speed-ups note](2026-10-11-lossless-build-speedups.md) rejects moving Typert out of the Host tsdown pass for builds, where its Worker Thread already overlaps bundling and a separate step saves at most about 1 s. Type-check commands run no bundler, so generating without one removes the whole Host tsdown pass and the Desktop bundle from their critical path. [The build split note](2026-10-11-build-artifacts-check-split.md) lists every caller and its command. +- **Known gap:** Incremental `tsc -b` does not record `lib/typert.remote-client.d.ts`, which Client sources reach through package `exports`, as an input of the consuming project; when a Remote signature changes and no Client source does, an incremental check does not recheck the consumers. The gap is independent of which pass writes the declarations; Issue #6022 tracks it. + +### 2. The pre-commit hook does not regenerate third-party notices + +- **Change:** The `pre-commit` hook in [`lefthook.yml`](../../../../lefthook.yml) has no third-party notices job. [`gen-third-party-notices.spec.ts`](../../../../scripts/gen-third-party-notices.spec.ts) asserts the committed bytes of `THIRD_PARTY_NOTICES.md` in the test lane, including the required `node 24 / coverage` lane, and names `pnpm run gen-third-party-notices` when they are stale. [The generated third-party notices note](2026-07-30-generated-third-party-notices.md) owns this freshness decision. +- **Performance:** A commit that stages one file under a package `src/` subdirectory spends 4.3/4.5 s in the hooks instead of 16.5/17.8 s, of which the notices job took 12.4/13.4 s. +- **Trade-off:** A commit that leaves the notices stale fails in the test lane after push rather than being corrected at commit time. + +### 3. Local default worker budget + +- **Purpose:** Size default parallelism for a shared workstation or container, where Node reports host CPUs rather than the CPU quota. +- **Change:** [`scripts/parallelism.ts`](../../../../scripts/parallelism.ts) owns the default. With `CI=true` it is the CPU count Node reports. Otherwise it is capped at 12 (`LOCAL_PARALLELISM_CAP`) for the run-gates scheduler, `build`, `typecheck`, Oxlint, and publint, and at 48 (`LOCAL_TEST_PARALLELISM_CAP`) for Vitest forks and the local coverage budget. `doc-sync`, `test:docs`, `hygiene`, and `check:all`, whose gates each build a full `ts.Program`, run at most 8 gates concurrently, in CI as well. Explicit `DSH_GATE_CONCURRENCY`, `DSH_OXLINT_THREADS`, `DSH_COVERAGE_MAX_WORKERS`, and Vitest `--maxWorkers` take precedence; in CI, Vitest and Oxlint keep their own defaults unless the run-gates CI environment sets these variables. The [development guide](../../../../docs/development.md) states the defaults for contributors. + +`pnpm run test` at four fork counts, with the disk-backed `type-model.spec.ts` that item 8 replaces, which sets the wall of the 32-worker run: + +| Vitest workers | Wall | Peak RSS | Additional intermittent failures | Every file except the slowest finished at | +|---|---|---|---|---| +| 255 (the reported CPU count) | 243.7 s | 55.3 GiB | 12 files, 14 timeouts | about 135–170 s | +| 32 | 310.4 s | 15.7 GiB | 4 files, 4 timeouts | 193 s | +| 24 | 337.2 s | 13.2 GiB | 5 files, 5 timeouts | 233 s | +| 12 | 410.5 s | 8.4 GiB | 2 files, 3 timeouts | 406 s | + +- **Oxlint:** Oxlint's own default is 64 threads, read from the cgroup quota; at 12 threads its wall stays about 87–90 s with about 15% less CPU, and at 8 threads it takes about 4 s longer. +- **Documentation aggregates:** `test:docs` takes 50.9 s at 8 concurrent gates against 74.7 s at 4. `doc-sync`, measured without item 4, takes 341.8, 295.0, and 253.2 s at 4, 8, and 12 concurrent gates, with peak RSS of 10.0, 13.5, and 16.3 GiB. +- **Trade-off:** A machine whose CPU quota exceeds the caps gets no more default parallelism than 12 or 48 and needs the override variables; a container whose quota is below a cap still oversubscribes, because Node reports host CPUs. At 8 concurrent gates `doc-sync` peaks at 13.5 GiB RSS against 10.0 GiB at 4; 12 concurrent gates finish 41.8 s sooner at 16.3 GiB. The CI documentation workflow also runs `doc-sync` at 8 concurrent gates. + +### 4. Repository-reference candidates resolve against one object listing + +- **Change:** [`verify-repository-references.ts`](../../../../scripts/verify-repository-references.ts) lists every object name once with `git cat-file --batch-all-objects --batch-check=%(objectname)`, about 1.2 million objects in 0.6 s, sorts the list, and binary-searches each lowercase candidate prefix in memory; one `--batch-check=%(objectname) %(objecttype)` call then reads the types of the matched objects. A candidate is a commit reference only when its prefix matches exactly one available object and that object is a commit, the rule Git's own prefix lookup applies; the listing covers unreachable and alternate objects, as Git's lookup does. +- **Performance:** 166.8 → 15.7 s. +- **Why it is lossless:** Git's per-candidate prefix lookup and the in-memory search classify all 27,050 real and synthetic candidates identically, including ambiguous prefixes, blob and tree prefixes, and random strings. +- **Trade-off:** The listing is about 49 MiB of Git output for 1.2 million objects, read under a 1 GiB output limit. + +### 5. Documentation gates parse each document once + +- **Change:** Each gate below parses a Markdown document once and passes the tree to every consumer in that run. + +| Gate | Single-parse mechanism | Wall | +|---|---|---| +| markdown links ([`verify-md-links.ts`](../../../../scripts/verify-md-links.ts)) | One per-run cache parses each file once for its destinations and anchors and probes each resolved target once | 53.4/51.6 → 25.9/25.1 s | +| translation pairing ([`verify-translation-pairing.ts`](../../../../scripts/verify-translation-pairing.ts)) | One glob covers every scope pattern; the record reuses the authored tree under the guard below | 51.0/47.1 → 30.3/29.3 s | +| package README model experience, package README limitations | Each check parses a README once and passes the tree to `markdownProseLines()` and `markdownHeadingLines()` | about 12 → 5.9 s each | +| type equivalence ([`verify-type-equiv.ts`](../../../../scripts/verify-type-equiv.ts)) | A text prefilter skips documents that cannot hold a source-equivalence fence; each manifest TypeScript source is parsed once | 23.0/22.5 → 6.0/5.5 s | +| Session format references | The persistence renderer passes the trees it parses to the pairing record | 10.2 → 5.3 s | + +- **Pairing reuse guard:** `normalizedTopLevelBlocks()` in [`translation-pairing-record.ts`](../../../../scripts/translation-pairing-record.ts) reuses the authored tree for normalized text only when every link-normalization edit replaces a complete, title-free destination that does not start a line, the authored text equals the node's `url`, and both the authored and the replacement text contain only `[A-Za-z0-9._/:#%-]`. Those characters cannot end a destination or start or end another construct, so the normalized text parses to the same nodes with shifted offsets. Any other edit falls back to a full parse of the normalized text. +- **Type-equivalence prefilter:** [`type-equiv-prefilter.ts`](../../../../scripts/type-equiv-prefilter.ts) keeps a document for parsing when it contains `type-equiv` or `public-api`, or when a line containing `` ``` `` or `~~~` also contains `&` or `\`. Parsing changes an info string only by decoding character references and backslash escapes, so `ts type-equiv` and `ts type\-equiv` keep their documents; [`type-equiv-prefilter.spec.ts`](../../../../scripts/type-equiv-prefilter.spec.ts) covers both forms. +- **Why it is lossless:** Every gate's output on the repository is byte-identical, and seeded faults are reported identically. For pairing, the top-level blocks of all 1,886 corpus documents and the records of all 943 pairs match those of a full reparse, as do randomized destinations. + +### 6. Repository file walks list each directory once + +- **Change:** `uniqueRepoFiles()` in [`scripts/repo-files.ts`](../../../../scripts/repo-files.ts) shares one directory-listing cache across all patterns of a call, so each directory is read at most once, and calls `realpath` only for matches reached through a symbolic link; a regular file reached through real directories canonicalizes to the repository's realpath joined with its relative path. Its optional `skipDirectory` predicate keeps the walker out of a directory whose every path the caller excludes; [`verify-doc-refs.ts`](../../../../scripts/verify-doc-refs.ts) skips every `lib` directory below the first path segment, whose files it excludes in any case. +- **Performance:** markdown wrap 25.8/25.0 → 21.3/21.2 s; doc refs 8.8/7.0 → 3.2/3.4 s; package paths 9.9/8.1 → 7.2/5.5 s. +- **Why it is lossless:** All three gates receive identical file lists, and a broken or cyclic symbolic link still fails through `realpath`. +- **Behavior difference:** doc refs does not list a skipped `lib` directory, so an unreadable one does not fail the gate with EACCES. + +### 7. CLI help reads run concurrently; doc-graph probes are memoized + +- **CLI help:** `prefetchHelp()` in [`scripts/gen-cli-help.ts`](../../../../scripts/gen-cli-help.ts) starts the 12 top-level `dsh … --help` reads (the launcher, `plugin`, and each profile's shorthand and `--profile` forms) within the default worker budget, each with its own temporary `DSH_HOME` and `DSH_AGENTS_HOME`, and the in-order traversal consumes them; nested reads share the same limit. A failed read reaches the traversal when it requests that invocation, and `close()` aborts queued and running reads and waits for every started child. The gate takes 49.9/53.9 → 6.9/7.2 s. +- **Doc graphs:** `withMemoizedFileSystemProbes()` in [`scripts/gen-doc-graphs.ts`](../../../../scripts/gen-doc-graphs.ts) memoizes `ts.sys` `fileExists`, `directoryExists`, `realpath`, `readDirectory`, and `getDirectories` while `renderDocs` runs, so every compiler host and config parse in one run shares one view of the file system. The workspace must not change during the run, and the original methods are restored before pages are written. The gate takes 48.7/44.7 → 32.7/34.2 s. +- **Why it is lossless:** Both generators produce byte-identical pages. +- **Trade-off:** The concurrent help processes use about 3.6 GB of memory combined at peak. + +### 8. `type-model.spec.ts` runs against an in-memory workspace + +- **Change:** [`type-model.spec.ts`](../../../../packages/typert/generator/tests/type-model.spec.ts) mocks `node:fs` with `memoryFileSystem()` and the `ts.sys` host from [`type-model-workspace.ts`](../../../../packages/typert/generator/tests/type-model-workspace.ts), so fixture copies, case edits, write-mode annotations, tsconfig expansion, and module resolution under the in-memory root never touch the disk. Each workspace mounts minimal `zod` and `@types/node` declaration stubs; only the Emitter case keeps real zod. The fixture's `tsconfig.base.json` sets `lib: ["ES2024"]`, which excludes the DOM library, and cases that read the unmodified fixture share one deep-frozen analysis. The generator source has no test-specific path. +- **Performance:** The file alone takes 316.3/329.1 → 14.65/15.04 s. +- **Why the behavior coverage stays:** The 61 case names and their assertions are the same. The snapshot differs in one line, the external zod symbol path, which names the stub (`:node_modules/zod/index.d.ts#ZodType`) rather than a pnpm store version. +- **Trade-off:** Outside the Emitter case, the spec does not exercise the analyzer against the real file system, the DOM library, or the real zod and `@types/node` declarations. The memory file system implements only the `node:fs` calls the analyzer makes (`existsSync`, `readFileSync`, `realpathSync`, and `writeFileSync`); another call on an in-memory path reaches the real file system, where the path does not exist. + +### Repository-wide comparison + +Both trees were measured in the same session, alternating baseline and implementation runs of each command after `pnpm run build:artifacts` (`pnpm run test` fails the same environment-dependent files on both trees, 42 on the baseline and a subset of 28 here): + +| Command | Baseline | This implementation | Change | Items | +|---|---|---|---|---| +| `pre-commit` hooks, one staged file under a package `src/` subdirectory | 16.5/17.8 s | 4.3/4.5 s | −75% | 2 | +| `pnpm run typecheck` (pre-push), nothing to recheck | 29.7/23.3 s | 14.2/16.1 s | −43% | 1, 3 | +| `pnpm run lint` | 110.0 s | 106.7 s | −3% | 1 | +| `pnpm run doc-typecheck` | 45.2 s | 36.1 s | −20% | 1 | +| `pnpm run test:docs` | 102.4/104.0 s | 26.8/29.3 s | −73% | 3–6 | +| `pnpm run doc-sync` | 186.8 s | 70.3 s | −62% | 3–7 | +| `pnpm run test` | 222.7 s (255 workers) | 148.1 s (48 workers) | −34% | 3, 8 | + +`lint` time is Oxlint itself; its prerequisite falls as item 1 states. CPU time falls with wall: `typecheck` user time 39.5 → 14.2 s, `test:docs` 212.6 → 126.3 s, `doc-sync` 628.2 → 503.2 s, and `test` 3,417 → 3,014 s. + +## Alternatives considered + +- **Cap every local default at 12, including Vitest forks (item 3):** `pnpm run test` takes 410.5 s at 12 workers against 243.7 s at 255, 68% longer. +- **Use half of the cgroup CPU quota as the local budget (item 3):** Reading the quota is a larger change and helps only inside containers. +- **Parse documents in parallel `worker_threads` (item 5):** It adds more complexity than parsing each document once, which removes the repeated work itself. +- **Regenerate third-party notices in the pre-commit hook (item 2):** Browser inputs make every package source file a generator input, so the hook runs the 16-second generator on nearly every commit while few commits change its output; [the generated third-party notices note](2026-07-30-generated-third-party-notices.md) owns the decision. + +## Consequences + +The pre-push `typecheck` with nothing to recheck takes about 14–22 s, the `lint` and `doc-typecheck` prerequisite about 12 s, and the hooks of a one-file commit about 4 s. `pnpm run test` starts at most 48 forks locally, and `test:docs` and `doc-sync` run up to 8 gates at once. Typert outputs, generated pages, file lists, and reference classifications are byte-identical to the baseline. + +Costs: type-check commands do not refresh runnable bundles; a stale notices file fails in the test lane after push; local defaults ignore CPUs above the caps and still oversubscribe a container whose quota is below them; 8 concurrent documentation gates raise the `doc-sync` RSS peak; CLI help uses about 3.6 GB at peak; doc graphs and markdown links assume the tree does not change during a run; `type-model.spec.ts` exercises stubs rather than real zod, `@types/node`, the DOM library, and disk I/O outside the Emitter case; and incremental `typecheck` inherits the Remote-declaration gap that Issue #6022 tracks. + +Verification: `generate-typert.spec.ts` pins byte-identical files against the workspace-mode plugin, rejection of a Host program that loads a compiled workspace declaration with the missing alias named, and each failure stage; `compile-referenced-projects.spec.ts` pins the `host-contracts` steps and their failure reporting; `build.spec.ts` pins the typecheck scope's steps; `parallelism.spec.ts`, `run-gates.spec.ts`, and `run-oxlint.spec.ts` pin the CI and local budgets and the overrides; `repo-files.spec.ts`, `markdown.spec.ts`, `translation-pairing.spec.ts`, `type-equiv-prefilter.spec.ts`, `verify-md-links.spec.ts`, `gen-cli-help.spec.ts`, and `gen-doc-graphs.spec.ts` pin single directory listing, parse reuse, the pairing guard, the prefilter, the prefetch order and failure path, and probe memoization with restoration. diff --git a/.agents/notes/implemented/process/2026-10-12-local-gate-speedups.zh.md b/.agents/notes/implemented/process/2026-10-12-local-gate-speedups.zh.md new file mode 100644 index 0000000000..faf66b3153 --- /dev/null +++ b/.agents/notes/implemented/process/2026-10-12-local-gate-speedups.zh.md @@ -0,0 +1,158 @@ +# Agent Note: 本地门禁、钩子与测试提速 + +Status: implemented + +[English](2026-10-12-local-gate-speedups.md) | 中文 + +## 问题 + +在基线(合并 #6013 时的 master)上,本地运行 Git 钩子、`pnpm run test`、`test:docs` 与 `doc-sync`(文档同步门禁)时,大部分 wall 时间花在结果并不依赖的工作上: + +- **经打包器获得 Typert 声明。** pre-push 钩子运行的 `typecheck` 先运行 `build:lib:host`(Host `tsc -b tsconfig.host.json`、Host tsdown 阶段与 Desktop bundle),再运行 `tsc -b tsconfig.client.json`。`lint`、`lint:fix`、`doc-typecheck` 与 CI `typert-contracts` 门禁的前置步骤先运行 Host 包编译,再运行 Host tsdown 阶段(`compile-referenced-projects.ts host-libraries`)。类型检查只读取包声明与 Typert 声明,但无需重查时 `typecheck` 仍需 34.1/35.0 s,前置步骤需 52.2/29.6 s。 +- **每次提交都生成第三方声明。** 只要暂存文件是生成器的输入,pre-commit 钩子就重新生成 `THIRD_PARTY_NOTICES.md`。为找出浏览器侧的第三方 import,生成器对 75 个 Client 包试打包并运行一次 Vite 构建,因此每个包源码文件都是它的输入;暂存一个位于包 `src/` 子目录下的文件的提交,钩子合计 16.5/17.8 s,其中第三方声明任务占 12.4/13.4 s。 +- **以宿主 CPU 数作为本地 worker 数。** `os.availableParallelism()` 报告的是宿主 CPU 数(测量主机上为 256),而不是容器的 CPU 配额(64)。按此默认值,`pnpm run test` 启动 255 个 Vitest fork,RSS 峰值 55.3 GiB,并偶发地多失败 12 个文件(14 次超时);同时 `doc-sync`、`test:docs`、`hygiene` 与 `check:all` 最多只并发 4 个门禁。 +- **每个候选一次 Git 前缀查找。** `verify-repository-references` 通过 `git cat-file --batch-check` 对约 26,000 个缩写十六进制候选逐个做 Git 前缀查找;这些查找占该门禁 166.8 s 中的 135.7 s。 +- **重复解析 Markdown。** markdown links 对每个锚点目标再多解析两次,且探测目标是否存在时不缓存;translation pairing 做四次 glob,并在链接归一化后再次解析每个文档;包 README 的 model experience 与 limitations 检查各自把每个 README 解析三次;type equivalence 解析全部 1,835 个文档;Session format references 把每个配对解析四次。 +- **重复读取目录。** markdown wrap、doc refs 与 package paths 背后的遍历器 `uniqueRepoFiles` 对每个 pattern 与每层 `**` 各读取一次每个目录,并对每个匹配文件调用 `realpath`。 +- **串行启动 help 与重复的编译器探测。** `gen-cli-help` 依次启动 12 个顶层源码模式的 `dsh … --help` 进程,每个进程大部分时间在等待 profile 启动的 I/O(49.9/53.9 s);`gen-doc-graphs` 构建多个编译器 host 与 program,重复执行同样的 `ts.sys` 文件系统探测(48.7/44.7 s)。 +- **受文件系统探测拖累的测试文件。** Typert generator 的 `type-model.spec.ts` 的 61 个用例各自把 fixture(测试前置数据)复制到磁盘并运行一次完整分析,分析中的四个 TypeScript program 各自加载 DOM lib、`@types/node` 与 zod v4,每次分析约 22,000 次 stat 调用。该文件耗时 316.3/329.1 s,是 `pnpm run test` 的长尾。 + +## 决策 + +以下八项决策缩短上述运行。每一项都给出其产物与诊断保持不变的依据,或它接受的有意差异。 + +| # | 决策 | 受影响的命令 | 实测 wall | 产物与诊断 | 有意的行为差异 | +|---|---|---|---|---|---| +| 1 | 类型检查不打包地生成 Typert 声明 | `typecheck`、`lint`、`lint:fix`、`doc-typecheck`、CI `typert-contracts` | `typecheck` 34.1/35.0 → 21.9/20.0 s;前置步骤 52.2/29.6 → 12.5/11.3 s | 140 个 Typert 文件逐字节一致;检查的程序相同 | 不产出可运行 bundle,不写 client build record | +| 2 | pre-commit 钩子不重新生成第三方声明 | `pre-commit` | 16.5/17.8 → 4.3/4.5 s | 新鲜度由测试 lane 断言 | 第三方声明过期时在测试 lane 失败,而不是在提交时 | +| 3 | 本地默认 worker 预算 | run-gates 聚合、`build`、`typecheck`、`pnpm run test`、Oxlint、publint | 255 个 worker 时 `test` 为 243.7 s、55.3 GiB;`test:docs` 74.7 → 50.9 s | 检查不变 | 本地默认值不使用超过 12 或 48 的 CPU | +| 4 | 仓库引用候选对照一次对象列表解析 | `verify-repository-references` | 166.8 → 15.7 s | 27,050 个候选的判定 0 差异 | 无 | +| 5 | 文档门禁每个文档只解析一次 | markdown links、translation pairing、README model experience 与 limitations、type equivalence、Session format references | markdown links 53.4/51.6 → 25.9/25.1 s;type equivalence 23.0/22.5 → 6.0/5.5 s | 门禁输出逐字节一致 | 无 | +| 6 | 仓库文件遍历每个目录只列一次 | markdown wrap、doc refs、package paths | doc refs 8.8/7.0 → 3.2/3.4 s | 文件列表一致 | doc refs 不读取嵌套的 `lib` 目录 | +| 7 | CLI(命令行界面)help 读取并发运行;doc graphs 的探测做 memo | `verify-cli-help`、`verify-doc-graphs` | 49.9/53.9 → 6.9/7.2 s;48.7/44.7 → 32.7/34.2 s | 页面逐字节一致 | CLI help 峰值约用 3.6 GB 内存 | +| 8 | `type-model.spec.ts` 在内存工作区上运行 | `pnpm run test` | 316.3/329.1 → 14.65/15.04 s | 61 个用例与断言不变;快照有一行不同 | 除 Emitter 用例外,桩替代 zod、`@types/node` 与 DOM lib | + +### 测量条件 + +所有数字都是 wall 时间,取自同一个共享 Linux 容器:CPU 配额 64 核,宿主报告 256 个 CPU,一分钟 load average 为 20–150,因此样本带有噪声。工作树位于网络文件系统上,每次文件系统探测的代价都高于本地磁盘。`a/b` 表示两个样本,`x → y` 比较基线与本实现。 + +### 1. 类型检查不打包地生成 Typert 声明 + +- **目的:** 让 `typecheck` 以及 `lint`、`lint:fix`、`doc-typecheck` 与 CI `typert-contracts` 门禁的前置步骤得到它们读取的声明,即 `tsc` 产出的包声明与 Typert 声明,而不运行 Host tsdown 阶段或 Desktop bundle。 +- **改动:** `typecheck` 是 `tsx scripts/build.ts --scope typecheck`,它通过 run-gates 调度器运行下表中的步骤([`scripts/build.ts`](../../../../scripts/build.ts))。该 scope 不构建 native addon、tsdown bundle、Desktop bundle 或 Web 产物,不写 client build record,可运行的 `lib/` 产物保持上一次 `build:artifacts` 的状态;启动或测试构建产物之前先运行 `build:artifacts`。`lint`、`lint:fix`、`doc-typecheck` 与 `typert-contracts` 的前置步骤是 `compile-referenced-projects.ts host-contracts`,它让 host-src 与 host-typert 并行([`compile-referenced-projects.ts`](../../../../scripts/compile-referenced-projects.ts))。`build`、`build:artifacts`、benchmark 构建与 `release-vendor.yml` 的 PR(Pull Request)演练在 Host tsdown 阶段内运行 Typert。 + +| 步骤 | 命令 | `needs` | +|---|---|---| +| host-src | `node --max-old-space-size=4096 -b ` | — | +| host-typert | `node --import tsx/esm scripts/generate-typert.ts` | — | +| client-src | ` -b ` | host-src、host-typert | +| host-check | `node --max-old-space-size=4096 -b tsconfig.host.json` | host-src | +| client-check | ` -b tsconfig.client.json` | client-src | + +- **共用生成:** [`scripts/generate-typert.ts`](../../../../scripts/generate-typert.ts) 对 `WORKSPACE_TYPERT_FACES`([`scripts/typert-faces.ts`](../../../../scripts/typert-faces.ts),即 Host tsdown 插件同样生成的 face)调用 `WorkspaceTypertGenerator.generateContributors()`,并用 [`artifact-files.ts`](../../../../packages/typert/generator/src/artifact-files.ts) 中插件同样使用的 `writeWorkspaceArtifacts()` 写出结果。与插件一样,它跳过 TypeScript 诊断,这些诊断由 Host 包编译报告;Typert 分析错误使 host-typert 失败,正如它在 `build` 中使 Host bundle 失败。host-typert 由 Node 加 tsx 的 ESM 钩子运行,而不经 `pnpm exec`,因为后者的启动开销会拉长通往 Client 编译的路径。 +- **以 build mode 运行聚合检查:** host-check 与 client-check 在本侧 face 的包编译之后才开始,因此 build mode 发现所有 references 都是最新的,只检查聚合根程序,并在根程序的 tsbuildinfo 为最新时跳过它。 +- **只读源码的分析:** host-typert 能与 host-src 并行,是因为它的 Host program 不读取任何工作区构建产物。`tsconfig.base.json` 把 `@deepseek-ai/dsh-client-file-upload/types` 映射到源码;没有这条别名时,该说明符经包 `exports` 解析到 `lib/types/types.d.ts`。有了它,Host program 加载 1,289 个文件,其中没有一个位于工作区包的 `lib/` 下。`generate-typert.ts` 开启 `rejectBuildOutputs`:face program 加载工作区包 `lib/` 目录下的文件,或无法解析对工作区包的 import(例如包尚未编译的工作区)时,[`build-output-guard.ts`](../../../../packages/typert/generator/src/build-output-guard.ts) 以 `TypertAnalysisError` 使分析失败,并指出每个这样的 import 说明符,以及把它映射到源码的 `paths` 条目。因此无论 host-src 是否已运行,缺失的别名都会使生成失败。tsdown 插件不开启该选项,因为在 `build` 中 Typert 总在 Host 包编译之后运行。[Typert generator README](../../../../packages/typert/generator/README.zh.md) 记录了这些写入函数与该选项。 +- **无损依据:** 没有任何工作区 tsconfig 设置 `allowJs`,每个 `exports` 的 `types` 条件都指向 `lib/types/` 内或 `lib/typert.*`,每个 tsdown 配置都设置 `dts: false`,因此没有类型检查读取 tsdown 产物。140 个 Typert 文件(28 个包 × 5 个输出)在以下场景中逐字节一致:基线 tsdown 阶段、当前 tsdown 阶段、删除全部 Typert 输出后运行 `generate-typert.ts`、隐藏全部 344 个 `lib/types` 目录后运行 `generate-typert.ts`;临时给 `packages/goal/goal` 加一个 Remote 方法时,tsdown 阶段与 `generate-typert.ts` 写出的文件也相同。`typecheck` 检查两个 face 的全部包 project 与两个聚合根程序,即 `build` 检查的程序。 +- **性能:** 无需重查时 `typecheck` 为 34.1/35.0 → 21.9/20.0 s。两个聚合根程序都需要重查时为 83.5 → 76.0/72.1 s,其关键路径是约 66–70 s 的 Host 聚合检查。`lint` 与 `doc-typecheck` 的前置步骤为 52.2/29.6 → 12.5/11.3 s。 +- **取舍:** `typecheck`、`lint`、`lint:fix`、`doc-typecheck` 与 `typert-contracts` 写出 Typert 声明但不写 bundle,因此在下一次 `build:artifacts` 之前,可运行的 `lib/` JavaScript 可能落后于这些声明。 +- **与构建的关系:** [无损构建提速 Note](2026-10-11-lossless-build-speedups.zh.md) 否决了在构建中把 Typert 移出 Host tsdown 阶段:构建中它的 Worker Thread 已与打包重叠,独立步骤最多只能省约 1 s。类型检查命令不运行打包器,因此不打包地生成会把整个 Host tsdown 阶段与 Desktop bundle 移出它们的关键路径。[构建拆分 Note](2026-10-11-build-artifacts-check-split.zh.md) 列出每个调用方及其命令。 +- **已知缺口:** 增量 `tsc -b` 不把 Client 源码经包 `exports` 访问到的 `lib/typert.remote-client.d.ts` 记为消费方 project 的输入;Remote 签名改变而 Client 源码未变时,增量检查不会重查消费方。这一缺口与由哪一种生成写出声明无关,由 Issue #6022 跟踪。 + +### 2. pre-commit 钩子不重新生成第三方声明 + +- **改动:** [`lefthook.yml`](../../../../lefthook.yml) 中的 `pre-commit` 钩子没有第三方声明任务。[`gen-third-party-notices.spec.ts`](../../../../scripts/gen-third-party-notices.spec.ts) 在测试 lane(包括必过的 `node 24 / coverage` lane)中断言 `THIRD_PARTY_NOTICES.md` 已提交的字节,过期时指出 `pnpm run gen-third-party-notices`。[生成的第三方声明 Note](2026-07-30-generated-third-party-notices.zh.md) 拥有这一新鲜度决策。 +- **性能:** 暂存一个位于包 `src/` 子目录下的文件的提交,钩子耗时由 16.5/17.8 s 降为 4.3/4.5 s,其中第三方声明任务占 12.4/13.4 s。 +- **取舍:** 使第三方声明过期的提交会在推送后于测试 lane 失败,而不是在提交时被修正。 + +### 3. 本地默认 worker 预算 + +- **目的:** 按共享工作站或容器确定默认并行度;在这类环境中 Node 报告的是宿主 CPU 数而不是 CPU 配额。 +- **改动:** [`scripts/parallelism.ts`](../../../../scripts/parallelism.ts) 拥有默认值。`CI=true` 时它是 Node 报告的 CPU 数。否则,run-gates 调度器、`build`、`typecheck`、Oxlint 与 publint 的上限为 12(`LOCAL_PARALLELISM_CAP`),Vitest fork 与本地覆盖率预算的上限为 48(`LOCAL_TEST_PARALLELISM_CAP`)。`doc-sync`、`test:docs`、`hygiene` 与 `check:all` 的每个门禁都构建完整的 `ts.Program`,它们最多并发 8 个门禁,CI 中同样如此。显式的 `DSH_GATE_CONCURRENCY`、`DSH_OXLINT_THREADS`、`DSH_COVERAGE_MAX_WORKERS` 与 Vitest `--maxWorkers` 优先;CI 中,除非 run-gates 的 CI 环境设置了这些变量,Vitest 与 Oxlint 使用各自的默认值。[开发指南](../../../../docs/development.zh.md)向贡献者说明这些默认值。 + +四种 fork 数下的 `pnpm run test`,运行的是第 8 项所替换的落盘版 `type-model.spec.ts`,32 个 worker 那次的 wall 由它决定: + +| Vitest worker 数 | wall | RSS 峰值 | 额外的偶发失败 | 除最慢文件外其余文件完成于 | +|---|---|---|---|---| +| 255(报告的 CPU 数) | 243.7 s | 55.3 GiB | 12 个文件、14 次超时 | 约 135–170 s | +| 32 | 310.4 s | 15.7 GiB | 4 个文件、4 次超时 | 193 s | +| 24 | 337.2 s | 13.2 GiB | 5 个文件、5 次超时 | 233 s | +| 12 | 410.5 s | 8.4 GiB | 2 个文件、3 次超时 | 406 s | + +- **Oxlint:** Oxlint 自身的默认值是 64 线程(读取 cgroup 配额);12 线程时其 wall 保持约 87–90 s,CPU 少约 15%;8 线程时慢约 4 s。 +- **文档聚合:** 并发 8 个门禁时 `test:docs` 为 50.9 s,并发 4 个时为 74.7 s。不含第 4 项时测得的 `doc-sync` 在并发 4、8、12 个门禁时分别为 341.8、295.0、253.2 s,RSS 峰值分别为 10.0、13.5、16.3 GiB。 +- **取舍:** CPU 配额超过上限的机器,默认并行度也不超过 12 或 48,需要使用覆盖变量;配额低于上限的容器仍会超额订阅,因为 Node 报告的是宿主 CPU 数。并发 8 个门禁时 `doc-sync` 的 RSS 峰值为 13.5 GiB,并发 4 个时为 10.0 GiB;并发 12 个门禁快 41.8 s,但峰值为 16.3 GiB。CI 的文档 workflow 运行 `doc-sync` 时同样最多并发 8 个门禁。 + +### 4. 仓库引用候选对照一次对象列表解析 + +- **改动:** [`verify-repository-references.ts`](../../../../scripts/verify-repository-references.ts) 用 `git cat-file --batch-all-objects --batch-check=%(objectname)` 一次列出全部对象名(约 120 万个对象,0.6 s),排序后在内存中对每个小写候选前缀做二分查找;随后一次 `--batch-check=%(objectname) %(objecttype)` 调用读取命中对象的类型。只有当候选前缀恰好匹配一个可用对象且该对象是 commit 时,它才算 commit 引用,这与 Git 自身前缀查找的规则相同;与 Git 的查找一样,该列表也包含不可达对象与 alternate 对象。 +- **性能:** 166.8 → 15.7 s。 +- **无损依据:** 在 27,050 个真实与合成候选上(包括歧义前缀、blob 与 tree 前缀以及随机字符串),Git 的逐候选前缀查找与内存查找的判定完全一致。 +- **取舍:** 120 万个对象的列表约为 49 MiB 的 Git 输出,读取时的输出上限为 1 GiB。 + +### 5. 文档门禁每个文档只解析一次 + +- **改动:** 下表中的每个门禁对一个 Markdown 文档只解析一次,并把语法树传给本次运行中的所有使用方。 + +| 门禁 | 单次解析机制 | wall | +|---|---|---| +| markdown links([`verify-md-links.ts`](../../../../scripts/verify-md-links.ts)) | 一个按运行划分的缓存对每个文件只解析一次,同时取得其 destination 与锚点,并对每个解析后的目标只探测一次 | 53.4/51.6 → 25.9/25.1 s | +| translation pairing([`verify-translation-pairing.ts`](../../../../scripts/verify-translation-pairing.ts)) | 一次 glob 覆盖所有范围 pattern;record 在下述守卫条件下复用原文语法树 | 51.0/47.1 → 30.3/29.3 s | +| package README model experience、package README limitations | 每项检查对一个 README 只解析一次,并把语法树传给 `markdownProseLines()` 与 `markdownHeadingLines()` | 各约 12 → 5.9 s | +| type equivalence([`verify-type-equiv.ts`](../../../../scripts/verify-type-equiv.ts)) | 文本预筛跳过不可能含源码等价围栏的文档;manifest(元数据清单)中的每个 TypeScript 源文件只解析一次 | 23.0/22.5 → 6.0/5.5 s | +| Session format references | persistence 渲染器把它解析出的语法树传给配对 record | 10.2 → 5.3 s | + +- **配对复用守卫:** [`translation-pairing-record.ts`](../../../../scripts/translation-pairing-record.ts) 中的 `normalizedTopLevelBlocks()` 仅在以下条件同时成立时,才为归一化后的文本复用原文语法树:每处链接归一化编辑都替换一个完整、无 title 且不在行首开始的 destination,原文等于该节点的 `url`,且原文与替换文本都只含 `[A-Za-z0-9._/:#%-]`。这些字符既不能结束 destination,也不能开始或结束其他语法结构,因此归一化后的文本解析出相同的节点,只是偏移量随之移动。其他任何编辑都回退为对归一化文本的完整解析。 +- **type equivalence 预筛:** [`type-equiv-prefilter.ts`](../../../../scripts/type-equiv-prefilter.ts) 在文档含 `type-equiv` 或 `public-api`,或者某个含 `` ``` `` 或 `~~~` 的行同时含 `&` 或 `\` 时,保留该文档进行解析。解析只会通过解码字符引用与反斜杠转义改变信息字符串,因此 `ts type-equiv` 与 `ts type\-equiv` 所在的文档都会被保留;[`type-equiv-prefilter.spec.ts`](../../../../scripts/type-equiv-prefilter.spec.ts) 覆盖这两种写法。 +- **无损依据:** 每个门禁在仓库上的输出逐字节一致,人为注入的错误也被同样报告。对配对而言,语料中全部 1,886 个文档的顶层块与全部 943 个配对的 record 都与完整重新解析的结果一致,随机 destination 的结果也一致。 + +### 6. 仓库文件遍历每个目录只列一次 + +- **改动:** [`scripts/repo-files.ts`](../../../../scripts/repo-files.ts) 中的 `uniqueRepoFiles()` 在一次调用的所有 pattern 之间共享一个目录列表缓存,使每个目录最多读取一次,并且只对经符号链接到达的匹配调用 `realpath`;只经真实目录到达的普通文件,规范路径为仓库 realpath 拼接其相对路径。它的可选谓词 `skipDirectory` 让遍历器不进入调用方排除其下全部路径的目录;[`verify-doc-refs.ts`](../../../../scripts/verify-doc-refs.ts) 跳过第一段路径之后的每个 `lib` 目录,其下文件本来就全部被它排除。 +- **性能:** markdown wrap 25.8/25.0 → 21.3/21.2 s;doc refs 8.8/7.0 → 3.2/3.4 s;package paths 9.9/8.1 → 7.2/5.5 s。 +- **无损依据:** 三个门禁得到的文件列表逐项相同,损坏或循环的符号链接仍会经 `realpath` 失败。 +- **行为差异:** doc refs 不列出被跳过的 `lib` 目录,因此不可读的此类目录不会以 EACCES 使门禁失败。 + +### 7. CLI help 读取并发运行;doc graphs 的探测做 memo + +- **CLI help:** [`scripts/gen-cli-help.ts`](../../../../scripts/gen-cli-help.ts) 中的 `prefetchHelp()` 在默认 worker 预算内启动 12 个顶层 `dsh … --help` 读取(启动器、`plugin`,以及每个 profile 的简写与 `--profile` 形式),每个读取使用独立的临时 `DSH_HOME` 与 `DSH_AGENTS_HOME`,并由按原顺序进行的遍历消费;嵌套读取共享同一上限。失败的读取在遍历请求该调用时才交给遍历;`close()` 中止排队与运行中的读取,并等待每个已启动的子进程结束。该门禁为 49.9/53.9 → 6.9/7.2 s。 +- **doc graphs:** [`scripts/gen-doc-graphs.ts`](../../../../scripts/gen-doc-graphs.ts) 中的 `withMemoizedFileSystemProbes()` 在 `renderDocs` 运行期间对 `ts.sys` 的 `fileExists`、`directoryExists`、`realpath`、`readDirectory` 与 `getDirectories` 做 memo,使一次运行中的所有编译器 host 与配置解析共享同一个文件系统视图。运行期间工作区不得改变,写出页面之前恢复原方法。该门禁为 48.7/44.7 → 32.7/34.2 s。 +- **无损依据:** 两个生成器生成的页面逐字节一致。 +- **取舍:** 并发的 help 进程合计峰值约占 3.6 GB 内存。 + +### 8. `type-model.spec.ts` 在内存工作区上运行 + +- **改动:** [`type-model.spec.ts`](../../../../packages/typert/generator/tests/type-model.spec.ts) 用 [`type-model-workspace.ts`](../../../../packages/typert/generator/tests/type-model-workspace.ts) 中的 `memoryFileSystem()` mock `node:fs` 与 `ts.sys` host,使内存根目录下的 fixture 复制、用例编辑、写模式注解、tsconfig 展开与模块解析都不触及磁盘。每个工作区挂载最小的 `zod` 与 `@types/node` 声明桩;只有 Emitter 用例保留真实 zod。fixture 的 `tsconfig.base.json` 设置 `lib: ["ES2024"]`,不含 DOM lib;读取未修改 fixture 的用例共享一份深度冻结的分析结果。generator 源码没有任何测试专用路径。 +- **性能:** 单独运行该文件为 316.3/329.1 → 14.65/15.04 s。 +- **行为覆盖保持不变的依据:** 61 个用例名及其断言相同。快照只有一行不同,即外部 zod 符号路径,它指向桩(`:node_modules/zod/index.d.ts#ZodType`),而不是某个 pnpm store 版本。 +- **取舍:** 除 Emitter 用例外,该 spec 不针对真实文件系统、DOM lib 以及真实 zod 与 `@types/node` 声明检验分析器。内存文件系统只实现分析器使用的 `node:fs` 调用(`existsSync`、`readFileSync`、`realpathSync` 与 `writeFileSync`);对内存路径的其他调用会落到真实文件系统,而该路径并不存在。 + +### 全仓对比 + +两棵树在同一时段测量,每个命令在 `pnpm run build:artifacts` 之后交替运行基线与本实现(`pnpm run test` 在两棵树上失败的都是依赖环境的文件,基线 42 个,本实现是其中的 28 个): + +| 命令 | 基线 | 本实现 | 变化 | 对应项 | +|---|---|---|---|---| +| `pre-commit` 钩子,暂存一个位于包 `src/` 子目录下的文件 | 16.5/17.8 s | 4.3/4.5 s | −75% | 2 | +| `pnpm run typecheck`(pre-push),无需重查 | 29.7/23.3 s | 14.2/16.1 s | −43% | 1、3 | +| `pnpm run lint` | 110.0 s | 106.7 s | −3% | 1 | +| `pnpm run doc-typecheck` | 45.2 s | 36.1 s | −20% | 1 | +| `pnpm run test:docs` | 102.4/104.0 s | 26.8/29.3 s | −73% | 3–6 | +| `pnpm run doc-sync` | 186.8 s | 70.3 s | −62% | 3–7 | +| `pnpm run test` | 222.7 s(255 个 worker) | 148.1 s(48 个 worker) | −34% | 3、8 | + +`lint` 的耗时主要是 Oxlint 本身,其前置步骤的下降见第 1 项。CPU 时间随 wall 一同下降:`typecheck` 的 user 时间 39.5 → 14.2 s,`test:docs` 212.6 → 126.3 s,`doc-sync` 628.2 → 503.2 s,`test` 3,417 → 3,014 s。 + +## 考虑过的替代方案 + +- **所有本地默认值一律以 12 为上限,包括 Vitest fork(第 3 项):** 12 个 worker 时 `pnpm run test` 为 410.5 s,255 个时为 243.7 s,慢 68%。 +- **以 cgroup CPU 配额的一半作为本地预算(第 3 项):** 读取配额的改动更大,且只在容器内有意义。 +- **用 `worker_threads` 并行解析文档(第 5 项):** 它带来的复杂度高于每个文档只解析一次,而后者直接消除了重复工作。 +- **在 pre-commit 钩子中重新生成第三方声明(第 2 项):** 浏览器输入使每个包源码文件都成为生成器输入,钩子会让几乎每次提交都运行约 16 秒的生成器,而改变输出的提交很少;[生成的第三方声明 Note](2026-07-30-generated-third-party-notices.zh.md) 拥有该决策。 + +## 后果 + +无需重查时 pre-push `typecheck` 约 14–22 s,`lint` 与 `doc-typecheck` 的前置步骤约 12 s,改动 1 个文件的提交的钩子约 4 s。`pnpm run test` 在本地最多启动 48 个 fork,`test:docs` 与 `doc-sync` 最多同时运行 8 个门禁。Typert 输出、生成的页面、文件列表与引用判定都与基线逐字节一致。 + +代价:类型检查命令不刷新可运行的 bundle;第三方声明过期时在推送后于测试 lane 失败;本地默认值不使用超过上限的 CPU,而配额低于上限的容器仍会超额订阅;并发 8 个文档门禁抬高 `doc-sync` 的 RSS 峰值;CLI help 峰值约用 3.6 GB 内存;doc graphs 与 markdown links 假定运行期间工作树不变;除 Emitter 用例外,`type-model.spec.ts` 检验的是桩,而不是真实 zod、`@types/node`、DOM lib 与磁盘 I/O;增量 `typecheck` 继承 Issue #6022 跟踪的 Remote 声明缺口。 + +验证:`generate-typert.spec.ts` 固定与 workspace 模式插件逐字节一致的文件、对加载已编译工作区声明的 Host program 的拒绝(并指出缺失的别名),以及每个失败阶段;`compile-referenced-projects.spec.ts` 固定 `host-contracts` 的步骤及其失败报告;`build.spec.ts` 固定 typecheck scope 的步骤;`parallelism.spec.ts`、`run-gates.spec.ts` 与 `run-oxlint.spec.ts` 固定 CI 与本地预算以及覆盖变量;`repo-files.spec.ts`、`markdown.spec.ts`、`translation-pairing.spec.ts`、`type-equiv-prefilter.spec.ts`、`verify-md-links.spec.ts`、`gen-cli-help.spec.ts` 与 `gen-doc-graphs.spec.ts` 固定目录只列一次、语法树复用、配对守卫、预筛、预取顺序与失败路径,以及探测 memo 及其恢复。 diff --git a/.agents/notes/implemented/simplification/2026-10-09-preset-owned-subagent-composition.i18n.yaml b/.agents/notes/implemented/simplification/2026-10-09-preset-owned-subagent-composition.i18n.yaml new file mode 100644 index 0000000000..4b3621d217 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-10-09-preset-owned-subagent-composition.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-10-09-preset-owned-subagent-composition.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-10-09-preset-owned-subagent-composition.md +/agent-note-preset-owned-subagent-composition: + en: 08d7745426af0373 + zh: 26f9afb3cc941a9a +/agent-note-preset-owned-subagent-composition/problem: + en: 97cd4f941fe8eaa5 + zh: b3dabbd7020a8991 +/agent-note-preset-owned-subagent-composition/decision: + en: 7dfefd4689562847 + zh: 0bbfd0a8ce41c9ac +/agent-note-preset-owned-subagent-composition/alternatives-considered: + en: c87c4638a36cc45d + zh: ee2c8e044108f0eb +/agent-note-preset-owned-subagent-composition/consequences: + en: a7404d17d0a62ad9 + zh: 783a490d3aafc5ca diff --git a/.agents/notes/implemented/simplification/2026-10-09-preset-owned-subagent-composition.md b/.agents/notes/implemented/simplification/2026-10-09-preset-owned-subagent-composition.md new file mode 100644 index 0000000000..88f2b5ab9d --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-10-09-preset-owned-subagent-composition.md @@ -0,0 +1,31 @@ +# Agent Note: Preset-owned subagent composition + +Status: implemented + +English | [中文](2026-10-09-preset-owned-subagent-composition.zh.md) + +## Problem + +Agent Presets already compose persona and tool plugins. Independent child persona and tool-filter inputs create a second place to configure that composition. The shipped consumers of child filtering are the six schedule deny lists in the spawn and fork rows of the standard, PTC, and Cordis presets; reminder executors already refuse delegated callers. + +## Decision + +New subagent requests select an Agent Preset where the provider supports it. Persona and tools come from its plugin list. Fork inherits the parent's preset revision; model and effort selection keep their existing configuration. No preset-level filter or finer plugin split is added without a concrete consumer. + +`dsh-tool-subagent` rejects removed `persona` and `toolFilter` keys at load, including empty values, because deleting Schemastery fields alone does not reject unknown configuration. The [upgrade guide](../../../../docs/upgrade-guide/v0.2.1-alpha.1/subagent-preset-composition/guide.md) states the lost independent override capability and the migration. + +Released child descriptors keep their persona and filter fields, parsing, and cold-resume application. The [original composition note](../feature/2026-07-12-subagent-persona-tool-filter-and-depth.md) remains active for these historical semantics and the independent depth policy. The [spawn preset decision](../feature/2026-09-20-spawn-agent-preset-selection.md) continues to own selection and restoration identity. + +The shipped child schedule deny lists are absent. Reminder schemas remain visible when inherited from the preset; each executor rejects delegated callers. This avoids that source of parent/fork schema differences. Children receive four reminder schemas whose executors always refuse them. Neither their prompt cost nor a provider cache-hit improvement has been measured; other request inputs can still differ. + +## Alternatives considered + +**Move the filter into each preset.** This preserves the schedule deny lists and independent fork tool trimming, but retains two configuration mechanisms for tool availability. New children use plugin composition; fork deliberately inherits the parent's tools. + +**Keep per-request overrides alongside presets.** This retains independent fork overrides, their precedence rules, and provider capability flags. A single composition mechanism is preferred over that flexibility for new children. + +**Delete historical descriptor fields with the request fields.** This would change or reject restored sessions. New-request simplification does not authorize rewriting released Session data. + +## Consequences + +Preset selection owns new child persona and tools. Fork cannot independently replace persona or trim inherited tools, and JavaScript configuration with the removed keys fails explicitly. Historical restoration retains its previous semantics. Focused tests cover removed-key diagnostics, historical persona/filter restoration, inherited reminder schemas and executor refusal; recorded sessions cover preset-owned tool availability, fork reminder refusals, and messaging guidance. diff --git a/.agents/notes/implemented/simplification/2026-10-09-preset-owned-subagent-composition.zh.md b/.agents/notes/implemented/simplification/2026-10-09-preset-owned-subagent-composition.zh.md new file mode 100644 index 0000000000..c81919e7d8 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-10-09-preset-owned-subagent-composition.zh.md @@ -0,0 +1,31 @@ +# Agent Note: 由 preset 拥有 subagent 组装 + +Status: implemented + +[English](2026-10-09-preset-owned-subagent-composition.md) | 中文 + +## 问题 + +Agent Preset 已经组合 persona 和工具插件。独立的子级 persona 和工具过滤输入又增加了一处组装配置。已交付的子级过滤消费者是 standard、PTC 和 Cordis preset 中 spawn 与 fork 配置行的六份 schedule deny 列表;提醒执行器已经拒绝委派调用方。 + +## 决策 + +新 subagent 请求在提供方支持时选择 Agent Preset。persona 和工具来自其插件列表。fork 继承父级的 preset 代际;model 和 effort 选择保留现有配置。没有具体消费者时,不添加 preset 级过滤器或更细的插件拆分。 + +`dsh-tool-subagent` 在加载时拒绝已删除的 `persona` 和 `toolFilter` 键,包括空值,因为仅删除 Schemastery 字段不会拒绝未知配置。[升级指南](../../../../docs/upgrade-guide/v0.2.1-alpha.1/subagent-preset-composition/guide.zh.md)说明失去的独立覆盖能力及迁移方法。 + +已发布的子级 descriptor 保留 persona 和 filter 字段、解析及冷恢复应用。[原组装记录](../feature/2026-07-12-subagent-persona-tool-filter-and-depth.zh.md)继续保留,以说明这些历史语义及独立的深度策略。[spawn preset 决策](../feature/2026-09-20-spawn-agent-preset-selection.zh.md)继续拥有选择与恢复身份的依据。 + +已交付配置中不含子级 schedule deny 列表。从 preset 继承的提醒 schema 仍然可见;每个执行器拒绝委派调用方。这消除了一处父级与 fork 子级 schema 差异的来源。子级会收到四个始终拒绝其调用的提醒 schema;尚未测量它们的提示成本或提供方缓存命中率改善,其他请求输入仍可能不同。 + +## 曾考虑的替代方案 + +**把过滤器移入各 preset。** 这保留了 schedule deny 列表和独立裁剪 fork 工具的能力,但仍有两种配置工具可用性的机制。新子级使用插件组装;fork 明确继承父级工具。 + +**在 preset 之外保留逐请求覆盖。** 这保留独立的 fork 覆盖、覆盖优先级规则和提供方能力标志。对新子级选择单一组装机制,放弃这项灵活性。 + +**随请求字段一起删除历史 descriptor 字段。** 这会改变或拒绝恢复的会话。简化新请求不意味着可以重写已发布的 Session 数据。 + +## 后果 + +preset 选择拥有新子级的 persona 和工具。fork 不能独立替换 persona 或裁剪继承工具,含已删除键的 JavaScript 配置会明确报错。历史恢复保留原有语义。针对性测试覆盖删除键的诊断、历史 persona/filter 恢复、继承的提醒 schema 及执行器拒绝;会话录制覆盖由 preset 控制的工具可用性、fork 提醒拒绝及消息指引。 diff --git a/.agents/notes/implemented/testing/2026-09-14-desktop-installed-update-materials.i18n.yaml b/.agents/notes/implemented/testing/2026-09-14-desktop-installed-update-materials.i18n.yaml index e5f30837c8..9f7d9ba29e 100644 --- a/.agents/notes/implemented/testing/2026-09-14-desktop-installed-update-materials.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-09-14-desktop-installed-update-materials.i18n.yaml @@ -9,8 +9,8 @@ en: 8d63b992ff6d3161 zh: 86d333255a5400d3 /agent-note-isolate-installed-update-materials-from-releases-and-user-data/decision: - en: 815a1c062134d958 - zh: 05d7d89099e6911c + en: 4b2464efa4292cfd + zh: 449d6ea0d903d7e7 /agent-note-isolate-installed-update-materials-from-releases-and-user-data/alternatives-considered: en: c608f186cc94a9d3 zh: 39750c8347e8bece diff --git a/.agents/notes/implemented/testing/2026-09-14-desktop-installed-update-materials.md b/.agents/notes/implemented/testing/2026-09-14-desktop-installed-update-materials.md index 24209c1cce..88129f2cc2 100644 --- a/.agents/notes/implemented/testing/2026-09-14-desktop-installed-update-materials.md +++ b/.agents/notes/implemented/testing/2026-09-14-desktop-installed-update-materials.md @@ -10,7 +10,7 @@ An installed-update walkthrough needs two increasing versions with a shared appl ## Decision -Version derivation follows the [release version reference](../../../../apps/desktop/README.md); this record continues to govern material, application identity, and user-data isolation. +Version derivation follows the [release version reference](../../../../apps/desktop/packaging/README.md#release-versions); this record continues to govern material, application identity, and user-data isolation. The [material preparer](../../../../apps/desktop/scripts/prepare-installed-update-runtime.ts) accepts a retained test-only manifest with a random identity and distribution namespace. It copies a verified source runtime into separate derived test versions, changes only release-family versions and matching dependency references, and seals and verifies both inventories. The original runtime is verified again and its descriptor hash must remain unchanged. Existing output directories are not overwritten; a failed copy retains a failure record and cannot create a completion receipt. diff --git a/.agents/notes/implemented/testing/2026-09-14-desktop-installed-update-materials.zh.md b/.agents/notes/implemented/testing/2026-09-14-desktop-installed-update-materials.zh.md index 22ceec3f7b..d0f9aa2bae 100644 --- a/.agents/notes/implemented/testing/2026-09-14-desktop-installed-update-materials.zh.md +++ b/.agents/notes/implemented/testing/2026-09-14-desktop-installed-update-materials.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -版本派生遵循[发布版本说明](../../../../apps/desktop/README.zh.md);本记录继续规定物料、应用身份和用户数据的隔离。 +版本派生遵循[发布版本说明](../../../../apps/desktop/packaging/README.zh.md#release-versions);本记录继续规定物料、应用身份和用户数据的隔离。 [物料准备器](../../../../apps/desktop/scripts/prepare-installed-update-runtime.ts)接受保留的 test 专用清单,其中包含随机身份和分发命名空间。它将已验证的原始运行时复制为独立的派生测试版本,仅修改发布家族版本和匹配的依赖引用,再生成并验证两个完整性清单。原始运行时再次验证,其描述文件哈希必须保持不变。已有输出目录不被覆盖;复制失败保留失败记录,不能生成完成回执。 diff --git a/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.i18n.yaml b/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.i18n.yaml new file mode 100644 index 0000000000..b25c6213ba --- /dev/null +++ b/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.i18n.yaml @@ -0,0 +1,31 @@ +# Bilingual-pair consistency record for 2026-10-11-user-text-credential-matching.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.md +/agent-note-linear-credential-matching-and-performance-gates-for-user-text: + en: 4d3e2c3ad91a1a03 + zh: d08dd77dd0bd849c +/agent-note-linear-credential-matching-and-performance-gates-for-user-text/problem: + en: ae449178436337ea + zh: 9eced057b7d9ef3d +/agent-note-linear-credential-matching-and-performance-gates-for-user-text/decision: + en: a15c7c13f167e5ac + zh: ff261e3705c47ff0 +/agent-note-linear-credential-matching-and-performance-gates-for-user-text/decision/algorithm-what-each-part-does-and-costs: + en: aa1d65cd64a2a895 + zh: 4bf32f20d14ce452 +/agent-note-linear-credential-matching-and-performance-gates-for-user-text/decision/memory: + en: b91fc88cdd488ce3 + zh: 318db3f522681cca +/agent-note-linear-credential-matching-and-performance-gates-for-user-text/decision/trade-offs: + en: f07bb6fbbdd01fe4 + zh: 9313a0ef2a22af12 +/agent-note-linear-credential-matching-and-performance-gates-for-user-text/decision/performance-gate: + en: 29b8f8c1ad73c77b + zh: 8293b21ec08d3896 +/agent-note-linear-credential-matching-and-performance-gates-for-user-text/alternatives-considered: + en: 7600c1671b5dc73c + zh: b38e3ae76a5a32d1 +/agent-note-linear-credential-matching-and-performance-gates-for-user-text/consequences: + en: 8f9d25b72d3e7660 + zh: e5c6ae0194905876 diff --git a/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.md b/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.md new file mode 100644 index 0000000000..b8bcd21b3a --- /dev/null +++ b/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.md @@ -0,0 +1,70 @@ +# Agent Note: Linear credential matching and performance gates for user text + +Status: implemented + +English | [中文](2026-10-11-user-text-credential-matching.zh.md) + +## Problem + +[ui-secret-mask](../../../../packages/client/ui-secret-mask/README.md) recognizes credentials in every sent user text. The UserText scheduler queries the matcher again after each accepted node, so a text costs the sum of all its queries. Most text contains no credential, so the first goal is that such text pays as little as possible; second, text with credentials pays in proportion to their number and length; adversarial input must stay linear. Constraints: no regex backtracking, prefix pruning, linear total over all queries of one text, and no allocation before a node is returned. + +## Decision + +### Algorithm: what each part does and costs + +| Part | Method | Cost over all queries of one text | +|---|---|---| +| Finding format starts | All starting literals (`sk-`, `sk_live_` and kin, `AIza`, `ghp_` and kin, `github_pat_`, `glpat-`, `npm_`, `hf_`, `AKIA`/`ASIA`, `LTAI`, `AKID`, `xox?-`, `whsec_`, `eyJ`, `-----BEGIN `, `://`) form one literal-only alternation grouped by shared leading characters, scanned with `test` | Θ(n) native comparisons; no quantifier, so no backtracking; two adjacent queries reread at most one literal | +| Overlapping literals | Where a literal's suffix begins another literal (the `-` of `sk-`, `glpat-`, `xox?-` and `-----BEGIN `), the scan resumes at that suffix; resume offsets are computed from the literal table at module load | At most one character back per hit | +| Identifying the hit | A table keyed by the hit's last two code units; only the three shared endings (`t_`, `e_`, `IA`) are told apart with `startsWith` | O(1) per hit | +| Format checks | Word boundaries, lengths, digit position, and the `.eyJ` separator are numeric comparisons; a private-key label reads at most 52 characters and a scheme at most 16 back; character runs are measured natively with sticky `[class]*` patterns, which stop at the first character outside the class and have nothing after them to backtrack into | O(1) per hit plus run measurement | +| Runs, next digit, next `END` line | Each matcher instance memoizes per text in one `Int32Array`: four slots for `[A-Za-z0-9_-]` (a JWT uses three runs at once), one slot per other class, the next digit, and the next valid `END` line. These are facts of the text, reused across queries and cleared when a query names another text | Each character measured at most once per class | +| Address passwords | When the `://` precedes the query offset and the password follows it, the matcher first confirms that `:`, a nonempty password, and `@` follow the offset, and only then walks back over the user name; an offset inside `://` is checked directly | At most 258 characters back, only where `:password@` follows the offset | +| Joining candidates | Candidates are processed in start order; an address password starts after its `://` and waits until the scan passes its start, and no other `://` can occur before then because user names and passwords exclude `/`; overlapping candidates join the earliest and take its mask; a range that reaches the end of the text ends the scan | O(1) per candidate, no list | +| Masks | A range of prefixed formats only is ASCII without line breaks, so its mask is a slice of its first 8 characters plus a slice of a shared mark string; a private-key body skips ordinary characters natively and takes one slice of a shared "marks plus line break" string per line; address passwords and other mixed ranges count code points character by character | Θ(masked characters + lines), the order of the output | + +### Memory + +- Nothing is allocated before a node is returned: `test` creates no match array, memo slots are a preallocated integer array, loops use indexes instead of iterators, and candidate ranges live in local numbers. +- Each credential allocates only its result: the node, its `data` object, and the mask. A one-line mask is the concatenation of two slices; a multi-line mask adds one slice and one concatenation per line. +- Each instance keeps a reference to the last queried text until a query names another text. + +### Trade-offs + +- **Scanning is native, decisions are JavaScript**: in V8 a per-character JavaScript loop is slower than a native literal scan or native run measurement, so all work proportional to input length (finding starts, measuring runs, skipping key bodies) uses non-backtracking native patterns, and JavaScript makes a constant number of decisions per hit. +- **Pruning depends on the formats only**: starts come from format literals, not from anchors chosen by character frequency in natural text, so text with any character distribution pays the same linear cost. +- **Memo per text, not per query**: a memo inside one query cannot stop later queries from measuring the same characters again; keyed by text and owned by the instance, cleared on a new text, it costs one retained string reference. +- **Masks use shared-string slices**: faster than writing into a buffer and decoding it; a multi-line mask allocates in proportion to its lines, which is part of the output. + +### Performance gate + +[benchmarks/user-text](../../../../benchmarks/user-text/user-text.bench.client.ts) runs in the required `node 24 / benchmarks` lane. Its compiled worker uses the production UserText registry and every shipped matcher (session, file, folder, skill, command, whitespace, credential) under plain Node, in two processes: timing and allocation. + +| Item | Content | +|---|---| +| Workloads | Without credentials: ordinary messages, and long prose, code, base64, Markdown, log, JSON, and shell text. With credentials: messages each carrying one kind (model key, GitHub token, cloud key ID, JWT, address password, private-key block), and batches of `.env` keys. Adversarial: repeated input shaped against each format's prefix, length, and separator rules, at two sizes. All generated from constants; credential samples are assembled at run time | +| Timing | Ten unmeasured pairs warm each workload with the same evaluation count as measured pairs. Five measured pairs alternate baseline-first and matcher-first order; each evaluation builds a fresh registry, document, and matcher instance and times evaluation only. A single-document sample averages twenty evaluations; a 5,000-message sample evaluates the batch once. Each side selects its minimum sample mean, with the mean process CPU time from that sample. The worker JSON in CI logs includes every measured pair and averaging count; the HTML report shows the selected with-credential mean. Registry disposal completes outside the timer before the next evaluation, preventing cleanup and retained registries from accumulating. Warm-up retains no matcher memo | +| Allocation | A separate process with `--expose-gc` and a fixed semi-space larger than any evaluation; after warm-up and GC, the matcher alone answers every query once and the young-generation growth is taken; GC event timestamps confirm no GC ran inside the interval | +| Assertions | Without credentials: absolute budgets and a cap on the with/without time ratio; with credentials: absolute budgets and the time ratio when the key count doubles; adversarial: absolute budgets and doubling ratios; allocation: a floor plus caps per credential, per line break inside masks, and per masked character | +| Excluded | Cold code compilation, React rendering, browser paint and layout; allocation results are asserted and printed to the console, not added to the HTML report | + +Expectations come from an x64 development machine. CI time budgets are converted with the shared scale and headroom; time ratios, doubling ratios, and allocation budgets do not scale with machine speed. Negative controls: the same benchmark fails when pointed at the regular-expression implementation or at an implementation that rebuilds its memo per query. Equivalence tests compare this implementation with the regular-expression one ([regex-oracle.client.ts](../../../../packages/client/ui-secret-mask/tests/regex-oracle.client.ts)) node by node through the production scheduler and from every query offset. + +The reference constants are calibrated for minima of five single evaluations. Minimum-of-five sample means use the existing absolute headroom without recalibration. Averaging can move the ratio of independently selected minima in either direction; a threefold matcher-work negative control verifies that the JSON ratio limit still rejects extra scanning. + +## Alternatives considered + +- **Unwarmed, separate baseline and matcher batches**: sub-millisecond baseline measurements can reach optimized code before the matcher’s newly exercised paths do, and separate batches amplify clock-speed drift. Per-workload warm-up, adjacent pairs, and fixed batches compare warmed code while limiting noise in short intervals and preserving fresh documents and matcher state. +- **One quantified pattern per format, run from every query**: patterns without a hit scan to the end of the text each time, so cost grows quadratically with credentials, and length quantifiers backtrack on failure. +- **One large quantified pattern for all formats**: one scan finds the leftmost match, but branch quantifiers still backtrack on failure. +- **Scanning for rare characters first, then looking back for prefixes**: faster on ordinary text, but cost depends on character distribution, and text dense in `-` or `_` becomes clearly slower. +- **A pure JavaScript Aho–Corasick automaton**: allocation-free, but per-character table lookups carry a state dependency and are slower than the native literal scan, so the most common text, without credentials, gets slower. +- **A memo rebuilt per query**: with accepted tokens between digit-free `sk-` prefixes, every query's digit search reaches the end of the text, which grows quadratically. +- **Building private-key masks in one buffer with `TextDecoder`**: one string per block, but each decode's fixed cost exceeds shared-string slices. +- **`exec` for hits and `for…of` over memo slots**: they allocate match arrays and iterator results. + +## Consequences + +- The added cost on text without credentials is one native scan plus checks at literals; it cannot drop further without depending on character distribution. +- A new format adds its literal and a forward-only check to the format table; resume offsets and the hit table are derived. The literal-pattern unit test rejects quantifiers, and the benchmark rejects superlinear scans and extra allocation. +- The plugin creates and owns the matcher instance in `apply`; tests and the benchmark create one per sample and share no memo. diff --git a/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.zh.md b/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.zh.md new file mode 100644 index 0000000000..96307eb19b --- /dev/null +++ b/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.zh.md @@ -0,0 +1,70 @@ +# Agent Note: 用户文本凭据识别的线性匹配与性能门禁 + +Status: implemented + +[English](2026-10-11-user-text-credential-matching.md) | 中文 + +## 问题 + +[ui-secret-mask](../../../../packages/client/ui-secret-mask/README.zh.md) 在每条已发送用户文本上识别凭据。UserText 调度器在每个被采纳的节点之后再查询一次匹配器,一段文本的成本是它全部查询之和。大部分文本不含凭据,所以首要目标是让不含凭据的文本尽量少付成本;其次是含凭据文本的成本只与凭据个数和长度成正比;对抗输入必须保持线性。约束:正则不回溯;按前缀剪枝;一段文本的全部查询合计线性;返回节点之前不分配内存。 + +## 决定 + +### 算法:每部分的做法与复杂度 + +| 部分 | 做法 | 一段文本全部查询的代价 | +|---|---|---| +| 找格式起点 | 所有起始字面量(`sk-`、`sk_live_` 等、`AIza`、`ghp_` 等、`github_pat_`、`glpat-`、`npm_`、`hf_`、`AKIA`/`ASIA`、`LTAI`、`AKID`、`xox?-`、`whsec_`、`eyJ`、`-----BEGIN `、`://`)按共同前缀分组成一个只含字面量的交替正则,用 `test` 扫描 | Θ(n) 次原生比较,没有量词因而不回溯;相邻两次查询至多重读一个字面量 | +| 重叠的字面量 | 字面量的后缀若是另一个字面量的开头(`sk-`、`glpat-`、`xox?-` 的 `-` 与 `-----BEGIN `),扫描从该后缀处继续;续扫位置在模块加载时由字面量表算出 | 每个命中至多回退一个字符 | +| 识别命中的字面量 | 命中末尾两个码元查表;只有共享结尾的三组(`t_`、`e_`、`IA`)再用 `startsWith` 区分 | 每个命中 O(1) | +| 格式校验 | 单词边界、长度、数字位置、`.eyJ` 分隔用数值比较;私钥标签至多读 52 个字符,协议名至多回看 16 个字符;字符段用粘性 `[字符类]*` 原生测量,模式在第一个不属于该类的字符处停止,后面没有内容,不回溯 | 每个命中 O(1) 加字符段测量 | +| 字符段、下一个数字、下一行 `END` | 每个匹配器实例用一个 `Int32Array` 按文本记忆:`[A-Za-z0-9_-]` 四个槽(JWT 同时用三段),其余字符类各一个槽,另记下一个数字和下一行有效 `END` 的位置;这些是文本本身的事实,跨查询复用,查询换成另一段文本时清空 | 每类字符每个字符至多测量一次 | +| 地址密码 | `://` 在查询起点之前、密码在起点之后时,先确认起点后面紧跟 `:`、非空密码和 `@`,才回看用户名找 `://`;起点落在 `://` 中间时直接检查 | 回看至多 258 个字符,且只在 `:密码@` 紧跟起点时发生 | +| 合并候选 | 候选按起点顺序处理;地址密码在其 `://` 之后开始,延后到扫描越过它的起点,此前不会出现另一个 `://`(用户名和密码不含 `/`);重叠的候选并入最早的候选并使用其遮挡方式;范围到文本末尾即停止扫描 | 每个候选 O(1),不建列表 | +| 遮挡串 | 只由前缀格式组成的范围全是 ASCII 且没有换行,遮挡串是前 8 个字符的切片加一个共享星号串的切片;私钥正文用原生模式跳过普通字符,每行取一个"星号加换行"共享串的切片;地址密码和其他混合范围逐字符计数码点 | Θ(遮挡字符数 + 行数),与输出同阶 | + +### 内存 + +- 返回节点之前不分配:`test` 不产生匹配数组,记忆槽是预先分配的整数数组,遍历用下标而不是迭代器,候选范围存在局部数值里。 +- 每个凭据只分配结果:节点、`data` 对象和遮挡串。单行凭据的遮挡串是两个切片的拼接;多行遮挡每行再加一个切片和一次拼接。 +- 每个实例保留最近一次查询的文本引用,直到查询另一段文本。 + +### 取舍 + +- **扫描交给原生正则,判断交给 JavaScript**:V8 中逐字符的 JavaScript 循环比原生字面量扫描和原生字符段测量慢;凡是与输入长度成正比的工作(找起点、量字符段、跳过私钥正文)都用不回溯的原生模式,JavaScript 只做每个命中常数次的判断。 +- **只依赖格式本身的剪枝**:起点由格式字面量决定,不按字符在自然文本中的频率挑锚点,因此任何字符分布的文本都付同样的线性成本。 +- **记忆按文本而不按查询**:一次查询内的记忆不能阻止后续查询重新测量同一段字符;以文本为键、由实例持有,换文本即清空,代价只是保留一个字符串引用。 +- **遮挡串用共享串切片**:比先写入缓冲区再整体解码更快;多行遮挡的分配与行数成正比,仍属于输出本身。 + +### 性能门禁 + +[benchmarks/user-text](../../../../benchmarks/user-text/user-text.bench.client.ts) 在 `node 24 / benchmarks` 必跑 lane 中运行。编译后的 worker 使用生产的 UserText 注册表和全部已发布匹配器(会话、文件、文件夹、skill、命令、空白、凭据),在纯 Node 下分两个进程运行:计时与分配。 + +| 项 | 内容 | +|---|---| +| 负载 | 无凭据:普通消息,以及散文、代码、base64、Markdown、日志、JSON、shell 长文本。含凭据:每条消息含一种凭据(模型密钥、GitHub 令牌、云密钥 ID、JWT、地址密码、私钥块),以及成批的 `.env` 密钥。对抗:针对每种格式的前缀、长度与分隔规则构造的重复输入,两种长度。全部由常量生成,凭据样本运行时拼接 | +| 计时 | 每个负载先用十组未计入结果的配对预热,每组的求值次数与测量配对相同。五组测量交替以基线或凭据匹配器开头;每次求值新建注册表、文档和匹配器实例,只计求值。单文档样本取二十次求值的平均值,5,000 条消息的样本则整批求值一次。两侧各取样本均值的最小值,并保留对应样本的平均进程 CPU 时间。CI 日志中的 worker JSON 包含全部测量配对和求平均值的次数,HTML 报告展示选中的带凭据匹配器均值。每次求值在计时结束后等待注册表清理完成,再开始下一次,避免清理工作和未释放注册表积累。预热不保留匹配器记忆 | +| 分配 | 单独进程,`--expose-gc`,semi-space 固定为大于任何一次求值的大小;预热后 GC,再单独驱动匹配器回答全部查询一遍,取新生代增量;用 GC 事件的时间戳确认区间内没有 GC | +| 断言 | 无凭据:绝对预算,以及"有/无凭据匹配器"的耗时比上限;含凭据:绝对预算,密钥数翻倍时的耗时比;对抗:绝对预算与翻倍耗时比;分配:底噪加每个凭据、每个遮挡内换行和每个遮挡字符的上限 | +| 排除 | 冷代码编译、React 渲染、浏览器绘制与布局;分配结果只用于断言并输出到控制台,不进 HTML 报告 | + +预期值来自 x64 开发机。CI 时间预算按共享比例与余量换算;耗时比、翻倍比和分配预算不随机器缩放。负对照:同一 benchmark 指向正则实现或"每次查询新建记忆"的实现时都会失败。等价性测试经生产调度器逐节点比较本实现与正则实现([regex-oracle.client.ts](../../../../packages/client/ui-secret-mask/tests/regex-oracle.client.ts)),并对每个查询起点单独比较。 + +参考常量按五次单独求值的最小值校准;五组样本均值的最小值使用既有绝对耗时余量,不重新校准。求平均值可能使两侧独立最小值的比值向任一方向变化;将匹配器工作量增至三倍的负对照验证 JSON 比值上限仍能拒绝额外扫描。 + +## 考虑过的替代方案 + +- **不预热、分批测量基线和匹配器**:亚毫秒基线测量可能先进入优化后的代码,而匹配器新触及的路径还未完成优化;分批测量也会放大时钟频率漂移。逐负载预热、相邻配对和固定批次比较预热后的代码,降低短区间测量的噪声,同时保持文档和匹配器状态全新。 +- **每次查询对每个格式各跑一个带量词的正则**:没有命中的正则每次都扫到文末,凭据多时平方增长;长度量词在失败时回溯。 +- **把全部格式合成一个带量词的大正则**:一次扫描能找到最左匹配,但各分支的量词失败时仍回溯。 +- **先找稀有字符再回看前缀**:普通文本扫描更快,但成本取决于字符分布,`-`、`_` 密集的文本明显变慢。 +- **纯 JavaScript 的 Aho–Corasick 自动机**:可以零分配,但逐字符查表有状态依赖,比原生字面量扫描慢,最常见的无凭据文本反而变慢。 +- **每次查询新建记忆**:被接受的 token 之间夹着无数字的 `sk-` 时,每次查询的数字查找都扫到文末,平方增长。 +- **用 `TextDecoder` 从缓冲区一次生成私钥遮挡串**:每块只分配一个字符串,但每次解码的固定开销高于共享串切片。 +- **用 `exec` 取命中、用 `for…of` 遍历记忆槽**:分别分配匹配数组和迭代器结果。 + +## 影响 + +- 无凭据文本的额外成本是一遍原生扫描加字面量处的校验;不依赖字符分布就无法再降。 +- 新增格式要在格式表里加字面量和只向前的校验;续扫位置与命中识别表自动生成。字面量正则的单测拒绝量词,benchmark 拒绝超线性扫描和多余分配。 +- 匹配器实例由插件在 `apply` 中创建并持有;测试和 benchmark 每个样本新建实例,不共享记忆。 diff --git a/.agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata.i18n.yaml b/.agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata.i18n.yaml new file mode 100644 index 0000000000..e780a00501 --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata.i18n.yaml @@ -0,0 +1,34 @@ +# Bilingual-pair consistency record for 2026-09-20-unified-typert-remote-metadata.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata.md +/agent-note-unified-typert-remote-metadata: + en: 463246d8c8732192 + zh: cdb9f8ca8cfe4635 +/agent-note-unified-typert-remote-metadata/problem: + en: 3aa575e4c0778df1 + zh: 5277fc49b040da50 +/agent-note-unified-typert-remote-metadata/technical-architecture: + en: eb12e56cdcc7c76b + zh: bebff68d354c1ba7 +/agent-note-unified-typert-remote-metadata/proposal: + en: f5dcecbcae90a898 + zh: 923cf3a3074a3a2d +/agent-note-unified-typert-remote-metadata/proposal/shared-generation-two-access-modes: + en: 733398b2346d7438 + zh: d4e827b9dbd16d67 +/agent-note-unified-typert-remote-metadata/proposal/downstream-migration-targets: + en: 261dab3eb480a0c0 + zh: 13c3226d316d8d5e +/agent-note-unified-typert-remote-metadata/proposal/paired-usage-examples: + en: 7ef7dd38e2015801 + zh: b97cd179bf828406 +/agent-note-unified-typert-remote-metadata/alternatives-considered: + en: 151b1f66af030bc5 + zh: 44fea0898acfbdcb +/agent-note-unified-typert-remote-metadata/acceptance-criteria: + en: d011ec1516706b20 + zh: 945c6e3eba101368 +/agent-note-unified-typert-remote-metadata/risks: + en: a770478eac8f5fd1 + zh: f24aa378f875f47f diff --git a/.agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata.md b/.agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata.md new file mode 100644 index 0000000000..c99fd9effd --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata.md @@ -0,0 +1,191 @@ +# Agent Note: Unified Typert/Remote metadata + +Status: proposed + +English | [中文](2026-09-20-unified-typert-remote-metadata.zh.md) + +## Problem + +CLI help, argument parsing, and MCP tool discovery need local method descriptions, named-input parsers, tool JSON Schemas, and result metadata before connecting to a controlled Host. Browser, Node, and independent consumers also need typed invocations. Tool consumers need stricter input-object validation without changing ordinary Host dispatch. These requirements do not imply runtime TypeScript analysis, Host Service activation, or another business schema maintained by each consumer. + +The original #4644 source-definition decision delivered this information through `emitSourceDef`, committed generated files, and `./remote-source`. Host bindings carried those definitions; source generation had its own inventory, freshness, publication, and exclusion rules. This note replaces that delivery mechanism and makes input strictness consumer-owned. + +Both delivery paths derive from the same [compiler-independent model](../../implemented/architecture/2026-07-27-compiler-independent-typert-model.md). Separate Host and Client Programs require generated cross-face declarations, not committed generated source. An explicit prebuild is acceptable when exact schemas are needed; ordinary ESM artifacts can supply offline metadata without a live Host. The [targeted-call decision](../../implemented/architecture/2026-08-02-typert-remote-method-calls.md) still owns receiver lookup, Gateway dispatch, and contribution lifetimes. + +## Technical architecture + +| Export | Responsibility | Registration | +|---|---|---| +| Host `./typert` | `TYPERT`: local reflection, schemas, and an `invocations` getter exposing a private info instance's descriptors. | Loader imports and validates the contribution, then calls `ctx.typert.register(TYPERT)`. | +| Host-for-consumer `./remote` | Default export and `TYPERT_REMOTE`: a `TypertRemoteInfo` with descriptors and typed `methods`, plus generated Remote declarations/maps. | Harness metadata consumers call `ctx.typert.remotes.register(TYPERT_REMOTE)`; live consumers use `ctx.remote.$mount(TYPERT_REMOTE)`, which already registers it. Independent readers need no Registry. | +| Client `./client/typert` | The Client face's own local reflection and schemas. | Its local Registry; this is not the Host Remote projection. | + +[Registry](../../../../packages/typert/registry/src/service.ts) instances belong to their Cordis Context and process. Host and Client entries share implementation, not state. Existing local/Remote `get()` and `list()` preserve the registered descriptor objects; registration retains atomic duplicate checks, notifications, tombstones, and fiber-owned disposal. No Registry API, store, subscription, metadata RPC, or cross-process synchronization is added. Separate Programs and processes do not share object identity. + +The [emitter](../../../../packages/typert/generator/src/emitter.ts) writes one static `TypertRemoteMethodSpec` per endpoint and calls `createTypertRemoteInfo({ package, methods }, z)` from the published pure `@deepseek-ai/dsh-typert-protocol/remote-info` entry. The endpoint key supplies `namespace` and `method`, and `id` combines it with the package. Each spec keeps the service, receiver, ordered parameters, single `result` codec, and optional stream, scope, uplink, cancellation, documentation, and source-location fields. Every parameter has both `codec` and `strictCodec` from the same checker-resolved type model, with no per-method policy in the analyzer or model. Parameter `wire` defaults to `name`; parameter documentation stays on `parameters[].description`. There is no nested `metadata`, separate `output` or `parameterDescriptions`, or top-level `REMOTE_METHODS` export. + +The [factory](../../../../packages/typert/protocol/src/remote-info.ts) returns an actual class instance, not a class constructor or eager object literal. Construction retains only the spec and the Zod runtime explicitly supplied by the generated artifact, without constructing schemas. The first `descriptors` or `methods` read, including mounting or querying, builds and caches one shared index. Each `methods[endpoint]` aliases the exact direct descriptor object; Context-only receivers remain in descriptors and scoped declarations. Direct methods with scoped projections retain their scope fields but their generic named invoker requires an unscoped Remote. + +A direct method's `input(options?: TypertRemoteInputOptions)` returns a cached `TypertRemoteValue`; `strict?: boolean` is the only option. `method.input()` and `{ strict: false }` use normal codecs: declared objects accept and strip unknown fields. `{ strict: true }` rejects undeclared fields recursively. Both named-argument envelopes reject extra arguments; explicit JSON and dictionary keys stay open, and constrained dictionary values still validate. Consumers select one view for matching `input.create().parse(raw)` and `input.jsonSchema`. Host dispatch and the single `result` codec retain normal behavior, including binary `encode`/`decode`. + +Each input-policy view, its named parser, and its JSON Schema projection are independently lazy and cached. Individual generated Zod factories are also lazy and success-cached. The raw spec requires Zod factories internally, while consumer-facing `TypertSchema` promises only `parse(value: unknown): T`. JSON Schema remains an optional generator projection, not runtime `z.toJSONSchema()` conversion. Generated artifacts import and supply Zod, including Remote endpoints without parameters or exported schemas. The pure leaf retains only Zod type imports and has no runtime module dependencies; the protocol root re-exports the factory and imports Cordis without adding Zod to every protocol consumer's static module graph. Imports and metadata/schema access never invoke the network. + +Registry queries return base descriptors. `isTypertRemoteMethod(descriptor)` from the pure leaf or protocol root narrows direct capabilities with `object`/`unknown` types; exact endpoint types come from `TYPERT_REMOTE.methods`, not dynamic lookup. Unary `mode` is omitted; streaming `mode` is `'stream'`. `invoke(remote, input, signal?)` resolves the mounted namespace method at call time, preserving its `this`, optional positional holes, and signal. It forwards the same full `Promise>` or `RemoteStreamHandle` without an async wrapper; stream iteration, `send`, `end`, and `dispose` remain available. The fifth `TypertRemoteMethod` generic retains the carrier-decoded unary byte type independently of the result parser's authored output. + +Documentation and JSON Schema are optional. Each unsupported input variant or result projection records its own `jsonSchemaError` while preserving parsers and independent documentation; only the dedicated projection error is caught. A consumer explicitly selecting a tool requires its description and supported input JSON Schema. Output parsing remains required even when optional output JSON Schema is absent. Native `dsh-tools` registration additionally requires `output.schema`: a parser alone does not satisfy it. These consumer requirements must not prevent an otherwise valid Remote method from being generated. + +## Proposal + +Complete the ordinary-artifact migration in the downstream Session-client, CLI, MCP, and native-tool packages. This note remains proposed because that migration is not complete. + +### Shared generation, two access modes + +Framework consumers load ordinary metadata into their own Registry. Independent consumers may import `./remote` directly without Cordis, Host Service activation, or a connection. Both read the same generated business definition. Keep separate Host/Client Programs and analyze all contributors of each face so focused emission retains external declaration augmentations. Registration does not expose every reflected method as a callable endpoint or tool. + +Strict source startup explicitly prebuilds and loads ordinary `./typert` through Loader. Limited descriptor-free fallback still supports basic arguments, signals, cancellation, streams, JSON conversion, and unary byte attachments, but cannot recover erased fields, constraints, or tool documentation. Once a strict local descriptor has been observed, its withdrawal must refuse invocation rather than downgrade to fallback. `@Remote` accepts only `name` and `mode`; consumers choose input policy without a separate developer annotation. + +Host metadata readiness remains composition-owned. A Host requiring strict validation must await local registration before serving calls; `hasSeen()` does not protect an endpoint before its first registration. Returning a lazy info instance neither completes asynchronous Loader discovery nor establishes readiness. Loader validates static codec specs and callable `input`/`invoke` without calling the selector, parser factories, or projections to register a contribution. + +### Downstream migration targets + +The inspected #4111 tree contains Session-client, CLI, MCP, and native-tool consumers that are not present as complete packages on this branch. Its Session client selects 13 Session methods plus Workspace listing from source exports. Migrating that tree requires updating selection, connection-time mounts, offline CLI initialization, MCP presentation, and native-tool registration together; deleting source exports alone is insufficient. + +| Consumer | Metadata adaptation | Execution retained | +|---|---|---| +| Browser / Node Remote callers | Import ordinary `./remote`; mount once in the live Context. | Generated namespaces, Gateway, authenticated Connection, and target controllers. | +| Offline CLI help / parsing | Register before target discovery in the CLI profile's metadata Context; select `input({ strict: true })`. | No connection for help; a separate live Context mounts when calling. | +| MCP discovery / calls | Read selected Registry entries with `input({ strict: true })`; retain consumer-owned tool descriptions and envelopes. | `tools/list` is offline; `tools/call` uses the same Session client. External MCP clients receive protocol JSON, not Registry modules. | +| Native tools | Read Host local method info; require the tool registration's `output.schema`. | Parse at the tool boundary, then call same-process controllers directly. | +| Independent adapters | Read typed `TYPERT_REMOTE.methods` directly. | Use their chosen Remote runtime only when invoking. | + +Current-session selection and attachment preparation remain consumer-owned; CLI/MCP intake validates completed business arguments with the same strict input view used during selection. The downstream `follow` adapter's `activate: false` policy applies where that API exists; this branch's current `follow` has no such option. Aborting observation does not cancel accepted Session work. + +### Paired usage examples + +The old examples are historical comparisons of source-export consumers in the dependent changes. The new examples use ordinary artifacts. These fragments omit surrounding plugin declarations and include removed APIs. `raw`, `request`, `signal`, and an authenticated, mounted, unscoped `remote` are caller inputs, not new framework APIs. + +**Operation owner and strict Host.** Preserve the business signature; leave generated registration to ordinary Typert Loader instead of the Service constructor. + +```text +// Old source delivery. +import { TYPERT_REMOTE } from './remote.generated.ts' +// Constructor: super(ctx, 'sessionController', { namespace: 'session', definitions: TYPERT_REMOTE }) +@Remote({ name: 'page', emitSourceDef: true, rejectUnknownFields: true }) +page(request: SessionPageRequest, signal: AbortSignal): Promise { + return this.history.page(request, signal) +} + +// Ordinary delivery; no generated-source import or constructor definitions. +// Constructor: super(ctx, 'sessionController', { namespace: 'session' }) +@Remote({ name: 'page' }) +page(request: SessionPageRequest, signal: AbortSignal): Promise { + return this.history.page(request, signal) +} +``` + +**Browser and Node invocation.** A source-export consumer changes `/remote-source` to `/remote`; existing ordinary consumers already use this path. The live Context mounts once, without separate Registry registration. + +```text +import { TYPERT_REMOTE } from '@deepseek-ai/dsh-api-session-controller/remote' +await ctx.remote.$mount(TYPERT_REMOTE) +const result = await ctx.remote.session.page(request, signal) +const stream = ctx.remote.session.follow(followRequest, signal) +try { + for await (const frame of stream) consume(frame) +} finally { + await stream.dispose() +} +``` + +**Offline CLI and framework metadata.** The old selection reads a source table; the replacement reads the profile-owned Registry before target discovery. The two snippets are alternatives, not simultaneous declarations. + +```text +// Old selection. +import { REMOTE_METHODS } from '@deepseek-ai/dsh-api-session-controller/remote-source' +const page = REMOTE_METHODS['session/page'] +``` + +```text +// Metadata plugin: inject: ['typert']; no Gateway, Connection, or Host controller. +import { TYPERT_REMOTE } from '@deepseek-ai/dsh-api-session-controller/remote' +import { isTypertRemoteMethod } from '@deepseek-ai/dsh-typert-protocol/remote-info' +ctx.typert.remotes.register(TYPERT_REMOTE) +const page = ctx.typert.remotes.get('session/page') +if (page === undefined || !isTypertRemoteMethod(page)) { + throw new Error('session/page generated direct method is not loaded') +} +``` + +The registration belongs to that plugin's fiber. Help uses `description` and `input({ strict: true })`; the typed facade can retain type-only imports from ordinary generated declarations. Explicit tool selection does not mutate registered metadata. + +**CLI input and dispatch.** After local `current` resolution and attachment preparation, either metadata access path uses the same invocation sequence: + +```text +if (page.mode === 'stream') throw new Error('session/page must be unary') +const input = page.input({ strict: true }) +const arguments_ = input.create().parse(raw) +const result = await page.invoke(remote, arguments_, signal) +if (!result.ok) throw result.error +const output = page.result.create().parse(result.value) +``` + +**MCP discovery and calls.** Selection validates tool-specific requirements; generation itself does not require them. + +```text +const input = page.input({ strict: true }) +if (!page.description) throw new Error('session/page has no tool description') +if (input.jsonSchema === undefined) throw new Error('session/page has no supported tool input schema') +const tool = { + name: 'remote_session_page', + description: page.description, + inputSchema: input.jsonSchema, + ...(page.result.jsonSchema === undefined ? {} : { outputSchema: page.result.jsonSchema }), +} +``` + +This compact object shows only the metadata dependency. The real MCP adapter retains its `{ arguments, current, content }` input and `{ ok, value }` / failure output envelopes. Listing requires no connection; calling performs intake, local preparation, parsing through `input.create()`, invocation, and output parsing. Optional output projection does not remove output validation. + +**Native tools.** Query `ctx.typert.local.get('session/page')` and narrow it with `isTypertRemoteMethod` instead of reading the source table. Native tools can select `method.input({ strict: true })` for matching parsing and input projection. Native registration still requires supported input and result projections, including core Tool's mandatory `output.schema`; optional MCP output projection is not sufficient here. Once the facade obtains a typed request, both designs execute the same local call: + +```text +const value = await ctx.sessionController.page(request, signal) +``` + +**Independent consumers.** Read the ordinary info instance's typed index; no Registry is required: + +```text +import { TYPERT_REMOTE } from '@deepseek-ai/dsh-api-session-controller/remote' +const page = TYPERT_REMOTE.methods['session/page'] +const arguments_ = page.input().create().parse(raw) +const result = await page.invoke(remote, arguments_, signal) +``` + +**Strict source startup.** Build ordinary artifacts, then use the supported source launcher: + +```sh +pnpm run build +pnpm dsh --profile +``` + +## Alternatives considered + +- **Committed source:** meets offline needs but adds a redundant delivery and maintenance path; explicit ordinary prebuild is sufficient. +- **Nested descriptor metadata and a separate method table:** duplicate result and parameter facts and add another identity to track. One spec derives direct helpers on the descriptor itself; `TypertRemoteInfo` extends the existing contribution and retains `descriptors` so Gateway and Registry keep their current registration APIs. +- **Mandatory Registry for every importer:** adds lifecycle requirements without information for standalone consumers. +- **Fallback only:** cannot supply erased validation or tool documentation; retain it only for its supported subset. +- **Handwritten adapter schemas or runtime TypeScript analysis:** duplicate ownership or introduce runtime compiler requirements already handled by build-time extraction. + +## Acceptance criteria + +- Ordinary artifacts emit one static method spec per endpoint and return lazy class instances; direct method indexes alias descriptor objects, with full stream/binary typing, separate compiler faces, and full-face augmentation retention. +- Descriptor indexes and each input-policy view, parser, and projection are independently lazy and cached; Loader validates static specs and callable helpers without calling selectors, parsers, or projections. +- Every input parameter emits normal and strict codecs from the same model; consumers select policy without a method annotation. Both envelopes reject extra named arguments; strict input recursively closes declared objects without closing JSON or dictionary keys or changing Host dispatch and results. +- Protocol source and built entries have no Zod runtime dependency; generated artifacts explicitly supply Zod, zero-argument endpoints create empty inputs that reject extra fields, and the [production headless-profile Loader regression](<../../../../packages/context/time-context/tests/time-context.e2e.ts>) asserts actual Session events. +- Registry identity, atomic registration, withdrawal, and disposal remain intact. +- Missing documentation or unsupported optional projection does not disable generation; selected tools reject missing requirements and still parse outputs. +- Strict source/artifact invocation and limited fallback have separate tests; a withdrawn strict descriptor never enables fallback. +- Source static checks do not consume stale generated declarations; artifact checks declare and rebuild their inputs. No source-definition opt-in, source export, inventory, or freshness rule remains in production. + +## Risks + +Full downstream profile composition remains follow-up work in those packages; the generated metadata is not evidence that every #4111 operation or CLI/MCP application has migrated. The deliberate trade-off is an explicit prebuild for full source-runtime metadata. Missing or stale artifacts require regeneration, not a validation downgrade or a second handwritten schema. diff --git a/.agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata.zh.md b/.agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata.zh.md new file mode 100644 index 0000000000..9370060e1e --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-09-20-unified-typert-remote-metadata.zh.md @@ -0,0 +1,191 @@ +# Agent Note: 统一 Typert/Remote 元数据 + +Status: proposed + +[English](2026-09-20-unified-typert-remote-metadata.md) | 中文 + +## Problem + +CLI 帮助、参数解析和 MCP 工具发现需要在连接受控 Host 前取得本地方法说明、具名输入解析器、工具 JSON Schema 和结果元数据。Browser、Node 与独立消费方还需要类型化调用。工具消费方需要更严格的输入对象校验,但不能改变普通 Host 分发。这些需求不意味着运行时 TypeScript 分析、Host Service 激活,或每个消费方另行维护业务 schema。 + +#4644 原有的源码定义决策通过 `emitSourceDef`、提交的生成文件和 `./remote-source` 交付这些信息;Host 绑定携带定义,源码生成另有清单、新鲜度、发布和排除规则。本 note 替代该交付机制,并将输入严格性改为由消费方选择。 + +两种交付路径均来自同一个[编译器无关模型](../../implemented/architecture/2026-07-27-compiler-independent-typert-model.zh.md)。独立的 Host 与 Client Program 要求生成跨编译面声明,而非提交生成源码。需要精确 schema 时,显式预构建是可接受的;普通 ESM 产物无需运行中的 Host 即可提供离线元数据。[定向调用决策](../../implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md)仍负责接收者查找、Gateway 分发和贡献生命周期。 + +## 技术架构 + +| 导出 | 职责 | 注册方式 | +|---|---|---| +| Host `./typert` | `TYPERT`:本地反射、schema,以及暴露私有 info 实例描述符的 `invocations` getter。 | Loader 导入并验证 contribution,再调用 `ctx.typert.register(TYPERT)`。 | +| 面向消费方的 Host `./remote` | 默认导出和 `TYPERT_REMOTE`:带有描述符和类型化 `methods` 的 `TypertRemoteInfo`,以及生成的 Remote 声明/映射。 | Harness 元数据消费方调用 `ctx.typert.remotes.register(TYPERT_REMOTE)`;实际调用方使用 `ctx.remote.$mount(TYPERT_REMOTE)`,它已经包含注册。独立读取方无需 Registry。 | +| Client `./client/typert` | Client 编译面自己的本地反射与 schema。 | 本地 Registry;这不是 Host Remote 投影。 | + +[Registry](../../../../packages/typert/registry/src/service.ts) 实例属于各自的 Cordis Context 和进程。Host 与 Client 入口共享实现,而非状态。既有本地/Remote `get()` 与 `list()` 保留已注册的描述符对象;注册保留原子重复检查、通知、tombstone 和 fiber 管理的释放。不新增 Registry API、存储、订阅、元数据 RPC 或跨进程同步。独立 Program 和进程不共享对象身份。 + +[发射器](../../../../packages/typert/generator/src/emitter.ts)为每个端点写入一份静态 `TypertRemoteMethodSpec`,并调用已发布纯入口 `@deepseek-ai/dsh-typert-protocol/remote-info` 提供的 `createTypertRemoteInfo({ package, methods }, z)`。端点键提供 `namespace` 和 `method`,`id` 由包名与端点键组合。每份 spec 保留 service、接收者、有序参数、唯一的 `result` codec,以及可选的流模式、scope、uplink、取消、文档和源码位置字段。每个参数都有从同一 checker 解析后类型模型派生的 `codec` 和 `strictCodec`,分析器与模型不携带逐方法策略。参数 `wire` 默认使用 `name`;参数文档保留在 `parameters[].description`。不存在嵌套 `metadata`、独立 `output` 或 `parameterDescriptions`,也不导出顶层 `REMOTE_METHODS`。 + +[factory](../../../../packages/typert/protocol/src/remote-info.ts)返回真正的类实例,而非类构造函数或立即求值的对象字面量。构造时只保留 spec 和生成产物显式传入的 Zod 运行时,不构造 schema。首次读取 `descriptors` 或 `methods` 时(包括挂载或查询时)才构建并缓存一个共享索引。每个 `methods[endpoint]` 就是同一个直接方法描述符对象;仅使用 Context 接收者的方法保留在描述符和作用域声明中。带作用域投影的直接方法保留 scope 字段,但其通用具名调用器要求无作用域 Remote。 + +直接方法的 `input(options?: TypertRemoteInputOptions)` 返回缓存的 `TypertRemoteValue`;唯一选项是 `strict?: boolean`。`method.input()` 与 `{ strict: false }` 使用普通 codec:已声明对象接受并剥离未知字段。`{ strict: true }` 递归拒绝未声明字段。两种具名参数封装都拒绝额外参数;显式 JSON 与字典键保持开放,有约束的字典值仍然校验。消费方选择一个视图,通过 `input.create().parse(raw)` 与 `input.jsonSchema` 取得匹配的解析和投影。Host 分发与唯一的 `result` codec 保留普通行为,包括二进制 `encode`/`decode`。 + +每种输入策略的视图、具名解析器及 JSON Schema 投影都独立惰性求值并缓存。生成的独立 Zod factory 同样惰性求值且只缓存成功结果。原始 spec 内部要求 Zod factory,而面向消费方的 `TypertSchema` 只承诺 `parse(value: unknown): T`。JSON Schema 仍是可选的生成器投影,而非运行时 `z.toJSONSchema()` 转换。生成产物导入并传入 Zod,包括没有参数或导出 schema的 Remote 端点。纯叶入口只保留 Zod 类型导入,没有运行时模块依赖;协议根入口重新导出 factory 并导入 Cordis,但不会因此将 Zod 加入所有协议消费方的静态模块图。导入和元数据/schema 读取绝不发起网络调用。 + +Registry 查询返回基础描述符。纯叶入口或协议根入口的 `isTypertRemoteMethod(descriptor)` 以 `object`/`unknown` 类型收窄直接调用能力;精确端点类型来自 `TYPERT_REMOTE.methods`,而非动态查询。一元 `mode` 省略;流式 `mode` 为 `'stream'`。`invoke(remote, input, signal?)` 在调用时解析已挂载的命名空间方法,保留其 `this`、可选位置参数空位和 signal。它不使用 async 包装,直接转发同一个完整 `Promise>` 或 `RemoteStreamHandle`;流迭代、`send`、`end` 和 `dispose` 均保留。`TypertRemoteMethod` 的第五个泛型参数独立于结果解析器的原始输出,保留载体解码后的一元字节类型。 + +文档和 JSON Schema 可选。每个不受支持的输入变体或结果投影记录各自的 `jsonSchemaError`,同时保留解析器及独立文档;只捕获专用投影错误。显式选择工具的消费方要求工具说明和受支持的输入 JSON Schema。即使缺少可选输出 JSON Schema,输出解析仍然必需。原生 `dsh-tools` 注册额外要求 `output.schema`:仅有解析器并不满足要求。这些消费方要求不得阻止本来有效的 Remote 方法生成。 + +## Proposal + +在下游 Session-client、CLI、MCP 和原生工具包中完成普通产物迁移。本 note 保持 proposed,因为该迁移尚未完成。 + +### 共享生成,两种访问方式 + +框架消费方将普通元数据加载到自己的 Registry。独立消费方可以直接导入 `./remote`,无需 Cordis、Host Service 激活或连接。两者读取同一份生成业务定义。保留独立的 Host/Client Program,并分析每个编译面的所有贡献方,使局部生成保留外部声明合并。注册不会将每个反射方法都暴露为可调用 endpoint 或工具。 + +严格源码启动显式预构建,并通过 Loader 加载普通 `./typert`。有限的无 descriptor fallback 仍支持基本参数、signal、取消、流式调用、JSON 转换和一元字节附件,但无法恢复被擦除的字段、约束或工具文档。一旦观察到严格的本地 descriptor,撤销后必须拒绝调用,而不是降级到 fallback。`@Remote` 只接受 `name` 和 `mode`;消费方选择输入策略,无需另加开发者注解。 + +Host 元数据就绪仍由组合负责。要求严格校验的 Host 必须等待本地注册完成后再接受调用;`hasSeen()` 不会保护首次注册之前的端点。返回惰性 info 实例既不完成异步 Loader 发现,也不建立就绪保证。Loader 校验静态 codec spec 及可调用的 `input`/`invoke`,注册贡献时不调用选择器、解析器 factory 或投影。 + +### 下游迁移目标 + +已检查的 #4111 树包含 Session-client、CLI、MCP 和原生工具消费方,当前分支并不包含这些完整 package。其 Session client 从源码导出选择 13 个 Session 方法及 Workspace 列举。迁移该树需要一并更新选择、连接时挂载、离线 CLI 初始化、MCP 呈现及原生工具注册;仅删除源码导出并不足够。 + +| 消费方 | 元数据适配 | 保留的执行方式 | +|---|---|---| +| Browser / Node Remote 调用方 | 导入普通 `./remote`;在实际调用 Context 中挂载一次。 | 生成 namespace、Gateway、认证 Connection 和目标 controller。 | +| 离线 CLI 帮助 / 解析 | 在 CLI profile 的元数据 Context 中,于目标发现之前注册;选择 `input({ strict: true })`。 | 帮助不连接;调用时由独立的实际调用 Context 挂载。 | +| MCP 发现 / 调用 | 读取选中的 Registry 条目并选择 `input({ strict: true })`;保留消费方自有工具说明和 envelope。 | `tools/list` 离线;`tools/call` 使用相同 Session client。外部 MCP 客户端收到协议 JSON,而不是 Registry 模块。 | +| 原生工具 | 读取 Host 本地方法信息;要求工具注册的 `output.schema`。 | 在工具边界解析,然后直接调用同进程 controller。 | +| 独立适配器 | 直接读取类型化 `TYPERT_REMOTE.methods`。 | 仅调用时使用其选择的 Remote 运行时。 | + +当前 Session 选择和附件准备仍由消费方拥有;CLI/MCP 输入接收使用选择时的同一严格输入视图验证补全后的业务参数。下游 `follow` 适配器的 `activate: false` 策略只适用于具有该 API 的版本;当前分支的 `follow` 没有此选项。中止观察不等于取消已接受的 Session 工作。 + +### 新旧接入示例 + +旧示例是依赖改动中源码导出消费方的历史对照。新示例使用普通产物。这些片段省略外围插件声明,且包含已移除的 API。`raw`、`request`、`signal` 和已认证、已挂载、无作用域的 `remote` 是调用方输入,不是新增框架 API。 + +**操作拥有者与严格 Host。** 保留业务签名;将生成定义注册交给普通 Typert Loader,而非 Service 构造函数。 + +```text +// Old source delivery. +import { TYPERT_REMOTE } from './remote.generated.ts' +// Constructor: super(ctx, 'sessionController', { namespace: 'session', definitions: TYPERT_REMOTE }) +@Remote({ name: 'page', emitSourceDef: true, rejectUnknownFields: true }) +page(request: SessionPageRequest, signal: AbortSignal): Promise { + return this.history.page(request, signal) +} + +// Ordinary delivery; no generated-source import or constructor definitions. +// Constructor: super(ctx, 'sessionController', { namespace: 'session' }) +@Remote({ name: 'page' }) +page(request: SessionPageRequest, signal: AbortSignal): Promise { + return this.history.page(request, signal) +} +``` + +**Browser 与 Node 调用。** 源码导出消费方将 `/remote-source` 改为 `/remote`;已有普通消费方本来就使用此路径。实际调用 Context 挂载一次,不额外向 Registry 注册。 + +```text +import { TYPERT_REMOTE } from '@deepseek-ai/dsh-api-session-controller/remote' +await ctx.remote.$mount(TYPERT_REMOTE) +const result = await ctx.remote.session.page(request, signal) +const stream = ctx.remote.session.follow(followRequest, signal) +try { + for await (const frame of stream) consume(frame) +} finally { + await stream.dispose() +} +``` + +**离线 CLI 与框架元数据。** 旧选择读取源码方法表;替代方式在目标发现之前读取 profile 拥有的 Registry。两个片段是不同选择,不是同时声明。 + +```text +// Old selection. +import { REMOTE_METHODS } from '@deepseek-ai/dsh-api-session-controller/remote-source' +const page = REMOTE_METHODS['session/page'] +``` + +```text +// Metadata plugin: inject: ['typert']; no Gateway, Connection, or Host controller. +import { TYPERT_REMOTE } from '@deepseek-ai/dsh-api-session-controller/remote' +import { isTypertRemoteMethod } from '@deepseek-ai/dsh-typert-protocol/remote-info' +ctx.typert.remotes.register(TYPERT_REMOTE) +const page = ctx.typert.remotes.get('session/page') +if (page === undefined || !isTypertRemoteMethod(page)) { + throw new Error('session/page generated direct method is not loaded') +} +``` + +注册属于该插件的 fiber。帮助使用 `description` 和 `input({ strict: true })`;类型化 facade 可以保留对普通生成声明的 type-only 导入。显式工具选择不修改已注册元数据。 + +**CLI 输入与分发。** 本地 `current` 解析和附件准备之后,两种元数据访问方式使用同样的调用步骤: + +```text +if (page.mode === 'stream') throw new Error('session/page must be unary') +const input = page.input({ strict: true }) +const arguments_ = input.create().parse(raw) +const result = await page.invoke(remote, arguments_, signal) +if (!result.ok) throw result.error +const output = page.result.create().parse(result.value) +``` + +**MCP 发现与调用。** 选择时验证工具特有要求;生成过程本身不要求它们。 + +```text +const input = page.input({ strict: true }) +if (!page.description) throw new Error('session/page has no tool description') +if (input.jsonSchema === undefined) throw new Error('session/page has no supported tool input schema') +const tool = { + name: 'remote_session_page', + description: page.description, + inputSchema: input.jsonSchema, + ...(page.result.jsonSchema === undefined ? {} : { outputSchema: page.result.jsonSchema }), +} +``` + +这个简化对象只展示元数据依赖。真实 MCP 适配器保留 `{ arguments, current, content }` 输入及 `{ ok, value }` / 失败输出 envelope。列举无需连接;调用执行输入接收、本地准备、通过 `input.create()` 解析、调用及输出解析。可选输出投影不会移除输出验证。 + +**原生工具。** 查询 `ctx.typert.local.get('session/page')`,并用 `isTypertRemoteMethod` 收窄,而非读取源码方法表。原生工具可以选择 `method.input({ strict: true })`,取得匹配的解析与输入投影。原生注册仍要求受支持的输入与结果投影,包括 core Tool 必需的 `output.schema`;MCP 的可选输出投影在这里并不足够。facade 取得类型化请求后,两种设计执行同样的本地调用: + +```text +const value = await ctx.sessionController.page(request, signal) +``` + +**独立消费方。** 读取普通 info 实例的类型化索引,不要求 Registry: + +```text +import { TYPERT_REMOTE } from '@deepseek-ai/dsh-api-session-controller/remote' +const page = TYPERT_REMOTE.methods['session/page'] +const arguments_ = page.input().create().parse(raw) +const result = await page.invoke(remote, arguments_, signal) +``` + +**严格源码启动。** 构建普通产物,再使用受支持的源码启动器: + +```sh +pnpm run build +pnpm dsh --profile +``` + +## Alternatives considered + +- **提交源码:** 满足离线需求,但增加冗余交付和维护路径;显式普通预构建已经足够。 +- **嵌套描述符元数据与独立方法表:** 重复结果和参数事实,并增加需要跟踪的对象身份。一份 spec 直接在描述符上派生调用辅助能力;`TypertRemoteInfo` 扩展既有 contribution 并保留 `descriptors`,让 Gateway 与 Registry 沿用现有注册 API。 +- **所有导入方强制使用 Registry:** 为独立消费方增加生命周期要求,却不增加信息。 +- **仅用 fallback:** 无法提供被擦除的验证或工具文档;仅为受支持子集保留。 +- **手写适配器 schema 或运行时 TypeScript 分析:** 重复承担所有权,或引入构建期提取已解决的运行时编译器要求。 + +## Acceptance criteria + +- 普通产物为每个端点生成一份静态方法 spec,并返回惰性类实例;直接方法索引引用描述符对象,保留完整流式/二进制类型、独立编译面及完整编译面的声明合并。 +- 描述符索引与每种输入策略的视图、解析器和投影都独立惰性求值并缓存;Loader 校验静态 spec 及可调用辅助能力,不调用选择器、解析器或投影。 +- 每个输入参数从同一模型生成普通与严格 codec;消费方无需方法注解即可选择策略。两种封装都拒绝额外具名参数;严格输入递归关闭已声明对象,但不关闭 JSON 或字典键,也不改变 Host 分发和结果。 +- 协议源码与构建入口没有 Zod 运行时依赖;生成产物显式提供 Zod,无参端点也能创建拒绝额外字段的空输入对象;[生产 headless profile 的 Loader 回归](<../../../../packages/context/time-context/tests/time-context.e2e.ts>)断言真实 Session 事件。 +- Registry 身份、原子注册、撤销和释放保持不变。 +- 缺少文档或不支持可选投影不阻止生成;选中的工具拒绝缺少必需信息的条目,并仍然解析输出。 +- 严格源码/产物调用与有限 fallback 分别测试;撤销严格 descriptor 绝不启用 fallback。 +- 源码静态检查不消费过期生成声明;产物检查声明并重建输入。生产代码不保留源码定义 opt-in、源码导出、清单或新鲜度规则。 + +## Risks + +完整下游 profile 组合仍需在相应 package 中继续完成;生成的元数据不代表每个 #4111 操作或 CLI/MCP 应用都已迁移。明确接受的取舍是完整源码运行时元数据需要显式预构建。缺失或过期产物要求重新生成,而不是降低验证或另建一套手写 schema。 diff --git a/.agents/skills/dsh-client-ui-ux/SKILL.md b/.agents/skills/dsh-client-ui-ux/SKILL.md index b37a1ca4ba..4fbfa1a876 100644 --- a/.agents/skills/dsh-client-ui-ux/SKILL.md +++ b/.agents/skills/dsh-client-ui-ux/SKILL.md @@ -54,6 +54,10 @@ Every menu, popover, and tooltip must be verified for all three before merge: Scrollbars stay inside their container: use the shared scrollbar styles rather than component-specific selectors, and verify the bar neither overflows the container nor gets clipped by its rounded corners or edges — keep a small inset instead of letting it sit flush against the container edge. +## Responsive layouts + +Use [dsh-responsive-ui](../dsh-responsive-ui/SKILL.md) for responsive layouts, text truncation, title wrapping, icon-only controls, and focus during width changes. + ## Platform window adaptation - Any new header element or full page must account for macOS and Windows window chrome. On macOS the titlebar clearance is `--dsh-frame-top-clearance`, consumed by Menu, Modal, and overlay primitives as their top safe margin — **never change the global variable to fix one page**. Add a scoped `[data-platform='darwin']` override on the page's own inset instead, and assert the override in the page's stylesheet spec. diff --git a/.agents/skills/dsh-pre-push-checks/SKILL.md b/.agents/skills/dsh-pre-push-checks/SKILL.md index 6567dde5dc..3d55a9dece 100644 --- a/.agents/skills/dsh-pre-push-checks/SKILL.md +++ b/.agents/skills/dsh-pre-push-checks/SKILL.md @@ -37,6 +37,7 @@ When the outgoing change adds or changes a resource-owning or asynchronous test, - **Expected-output placement:** a test whose selected recorded Session generation is replay input and expected persisted output belongs under top-level `snapshots/`, with `snapshot.yml` naming its shipped `dsh` profile and composition/header pin. Canonical parent files are `session[.vN].jsonl`, children are `session.[.vN].jsonl`, and the harness selects the highest generation per role. ARIA, geometry, generator, CLI, and unit expectations without that Session round trip stay beside their owning test under `tests/expected/`; do not place them in `snapshots/` or give them a `*.snapshot.ts` owner. Use the owning `test:expected`, `test:web`, or `test` lane. - **Profile and configuration placement:** cross-package behavior of a shipped `dsh` profile belongs under `apps/cli/tests/profiles/`; a package-specific Loader composition belongs under that package's `tests/fixtures/`. User-facing optional overlays live under `apps/cli/config/examples/` and pair with a guide under `docs/user/`. - **Package manifests, public exports, build configuration, worker/bin entries, or built runtime paths:** run `pnpm run build`, the relevant hygiene checks, and the owning built-artifact smoke. +- **Repository test and script types:** `pnpm run build:artifacts` checks package projects only. Before claiming completion of TypeScript changes, run `pnpm run build:check` after the latest `build:artifacts`, or run `pnpm run build`; `build:check` reads the existing `build:artifacts` output and never rebuilds it. - **Real provider or agent behavior:** run the relevant `pnpm run test:e2e` target when credentials are available; never print secrets. Do not manually repeat a passing check merely because commit or push follows. In particular, do not run typecheck immediately before pushing solely to duplicate the pre-push hook. diff --git a/.agents/skills/dsh-responsive-ui/SKILL.md b/.agents/skills/dsh-responsive-ui/SKILL.md new file mode 100644 index 0000000000..a9ee2f3a37 --- /dev/null +++ b/.agents/skills/dsh-responsive-ui/SKILL.md @@ -0,0 +1,54 @@ +--- +name: dsh-responsive-ui +description: Use when designing, implementing, or reviewing responsive client layouts, text truncation, title wrapping, icon-only controls, mobile hover, tooltips and keyboard behavior, and focus during width changes in DeepSeek Harness. +--- + +# Responsive client UI + +## 1. Minimum viewport + +Use a viewport width of 390 CSS px as the primary acceptance minimum. There must be no unintended overlap, clipping, page-wide horizontal scrolling, or inaccessible primary actions. Below 390px, layout requirements may be relaxed. + +## 2. Decision order + +1. First adjust space allocation or wrap elements onto another line; then consider text truncation, icon-only controls, or moving icons into the More menu. +2. Primary actions and primary states must not be omitted. +3. Do not hide conflicts through global clipping, reducing all font sizes, or shrinking click targets. +4. Reduce font sizes only when specifically required by product design. + +## 3. Text truncation + +1. Truncated content must be fully available on a secondary page or by opening a menu. Example: Session statistics below the input box. +2. Truncation must not lose errors or other key information. +3. Prefer single-line truncation for tab navigation, visible selectors, buttons, tags, and similar components. +4. Important information that is not available on another page must be displayed in full without truncation. + +## 4. Titles + +1. Prefer displaying page titles in full. Move the action area onto another line when necessary. Example: the Plugins page's title wrapping behavior. +2. Prefer natural wrapping for card titles. If the number of lines is limited, the full title must remain available to view. + +## 5. Icon-only controls + +1. Reducing a control to an icon requires both: + - The icon clearly conveys its meaning, or the operation is familiar to users. + - A tooltip appears on hover; add one if needed. +2. Do not reduce “More account information” to an icon, because it navigates to a separate page. +3. The model selector in the input box can be reduced to an icon because users see it frequently and can open its menu to switch models. +4. Another example is the linked conversation in an automation task. + +## 6. Mobile hover behavior + +1. Enable mouse-hover effects only on devices that support hovering. Use `@media (hover: hover)`, not viewport width, to determine this. +2. Tapping on a phone must not leave behind backgrounds, highlights, or action-button visibility intended only for hover. +3. Preserve normal selected, enabled, and pressed states, as well as focus indicators for keyboard interaction. +4. Try to preserve focus when the width changes, unless the focused element disappears from the page as a result. + +## 7. Mobile tooltips + +1. Do not show tooltips that only provide auxiliary information such as action names or keyboard shortcuts on mobile. For example, omit the “Copy” tooltip on a copy button; tapping still performs the original action directly. +2. Tooltips that provide supplementary explanations must support tap-to-show on mobile. For example, the information tooltip beside the Plugins page description. + +## 8. Mobile keyboard + +On mobile, do not raise the input keyboard automatically unless it is explicitly intended. diff --git a/.github/AGENTS.md b/.github/AGENTS.md index 502c1333ea..cb71142444 100644 --- a/.github/AGENTS.md +++ b/.github/AGENTS.md @@ -3,3 +3,5 @@ Run jobs on Windows runners (`windows-*` labels) under native `pwsh`. Native Windows build and process checks contribute to the pull-request `all checks passed` verdict; Wine runs Windows Node on hosted Linux only in `ci-master.yml`. Python runtime CI checks Linux/Windows x64 on pull requests and Linux ARM64 plus both macOS architectures on master pushes; releases retain all five targets ([platform policy](workflows/ci-master.yml)). `ci.yml` is pull-request-only. Master-only platform checks, Linux/Windows self-hosted standbys, and manual runner benchmarks live in `ci-master.yml`, which listens to master pushes and `workflow_dispatch`, not `pull_request`; separating workflow triggers keeps master-only jobs out of PR check panels. The master standbys validate the self-hosted failover targets; preserve the existing per-platform switches (values `selfhosted` for the in-house standbys and `blacksmith` for Blacksmith's hosted runners) and the Dependabot hosted fallback under the `selfhosted` values ([failover runbook](../.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md), [blacksmith failover leg note](../.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.md)). The manual [Windows packaging workflow](workflows/windows-package.yml) uses the dedicated self-hosted Windows x64 label `dsh-win-package-trial` under native `pwsh`; preserve this routing separately from the automatic CI lanes. + +The Desktop publication workflow routes each retained build through `DESKTOP_PUBLISH_RUNNERS` to its original machine's unique label. Keep Windows run steps on native PowerShell and Mac run steps on the default POSIX shell; shared entrypoints are Node scripts. Test and production jobs use separate `desktop-test` and `desktop-production` approval Environments after a credential-free preparation job. diff --git a/.github/workflows/build-exe-for-python-sdk.yml b/.github/workflows/build-exe-for-python-sdk.yml index 2d4d3f59f6..0c869d008a 100644 --- a/.github/workflows/build-exe-for-python-sdk.yml +++ b/.github/workflows/build-exe-for-python-sdk.yml @@ -266,7 +266,7 @@ jobs: DSH_BUILD_CLIENT_PROFILE: official run: >- pnpm exec tsx scripts/build-exe-for-python-sdk.ts --targets=${{ matrix.target }} - ${{ inputs.ci && github.event_name == 'pull_request' && '--artifacts-only' || '' }} + ${{ inputs.ci && github.event_name == 'pull_request' && '--build-artifacts' || '' }} - name: Resolve platform outputs (POSIX) id: runtime-posix diff --git a/.github/workflows/build-preview-cloudflare.yml b/.github/workflows/build-preview-cloudflare.yml index e8ad9bd2ec..c2ff5d3231 100644 --- a/.github/workflows/build-preview-cloudflare.yml +++ b/.github/workflows/build-preview-cloudflare.yml @@ -71,7 +71,7 @@ jobs: # build:preview packs the image through the packer's installed bin # (lib/bin.js), so neither half exists before the artifact build runs. - name: Build workspace - run: pnpm run build --artifacts-only + run: pnpm run build:artifacts - name: Build the preview page and pack the VFS image env: diff --git a/.github/workflows/desktop-publish.yml b/.github/workflows/desktop-publish.yml new file mode 100644 index 0000000000..8e494386e2 --- /dev/null +++ b/.github/workflows/desktop-publish.yml @@ -0,0 +1,131 @@ +name: Publish Desktop · COS + +on: + workflow_run: + workflows: ['Package Desktop · macOS', 'Package Desktop · Windows'] + types: [completed] + +permissions: + contents: read + actions: read + +jobs: + route: + if: github.event.workflow_run.conclusion == 'success' + runs-on: ubuntu-latest + outputs: + publish: ${{ steps.route.outputs.publish }} + route: ${{ steps.route.outputs.route }} + runner: ${{ steps.route.outputs.runner }} + preset: ${{ steps.route.outputs.preset }} + steps: + - uses: actions/checkout@v7.0.1 + with: + persist-credentials: false + - uses: pnpm/action-setup@v5.0.0 + - uses: actions/setup-node@v7.0.0 + with: + node-version: '24' + - run: pnpm install --frozen-lockfile + - name: Resolve original package and machine + id: route + env: + GH_TOKEN: ${{ github.token }} + DESKTOP_PUBLISH_RUNNERS: ${{ vars.DESKTOP_PUBLISH_RUNNERS }} + run: node --import tsx apps/desktop/scripts/resolve-desktop-publish-ci.ts + + prepare: + needs: route + if: needs.route.outputs.publish == 'true' + runs-on: [self-hosted, '${{ needs.route.outputs.runner }}'] + timeout-minutes: 30 + outputs: + approval: ${{ steps.check.outputs.approval }} + label: ${{ steps.check.outputs.label }} + env: + DESKTOP_PUBLISH_ROUTE: ${{ needs.route.outputs.route }} + DSH_TELEMETRY_DISABLED: '1' + steps: + - uses: actions/checkout@v7.0.1 + with: + persist-credentials: false + clean: false + path: desktop-publisher + - uses: pnpm/action-setup@v5.0.0 + with: + package_json_file: desktop-publisher/package.json + dest: ${{ runner.temp }}/publish-pnpm-${{ github.run_id }}-${{ github.run_attempt }} + - uses: actions/setup-node@v7.0.0 + with: + node-version: '24' + - run: pnpm install --frozen-lockfile + working-directory: desktop-publisher + - name: Verify local package without upload credentials + id: check + working-directory: desktop-publisher + run: node --import tsx apps/desktop/scripts/publish-desktop-ci.ts --check + + publish: + name: Approve ${{ needs.prepare.outputs.label }} + needs: [route, prepare] + if: needs.route.outputs.publish == 'true' + runs-on: [self-hosted, '${{ needs.route.outputs.runner }}'] + environment: ${{ needs.route.outputs.preset == 'production' && 'desktop-production' || 'desktop-test' }} + concurrency: + group: desktop-publish-${{ needs.route.outputs.runner }}-${{ needs.route.outputs.preset }} + cancel-in-progress: false + timeout-minutes: 90 + env: + DESKTOP_APPROVED_PACKAGE: ${{ needs.prepare.outputs.approval }} + DESKTOP_PUBLISH_ROUTE: ${{ needs.route.outputs.route }} + DSH_TELEMETRY_DISABLED: '1' + steps: + - uses: actions/checkout@v7.0.1 + with: + persist-credentials: false + clean: false + path: desktop-publisher + - uses: pnpm/action-setup@v5.0.0 + with: + package_json_file: desktop-publisher/package.json + dest: ${{ runner.temp }}/publish-pnpm-${{ github.run_id }}-${{ github.run_attempt }} + - uses: actions/setup-node@v7.0.0 + with: + node-version: '24' + - run: pnpm install --frozen-lockfile + working-directory: desktop-publisher + - name: Verify local package without upload credentials + id: check + working-directory: desktop-publisher + run: node --import tsx apps/desktop/scripts/publish-desktop-ci.ts --check + - name: Read machine GitHub App key + if: needs.route.outputs.preset == 'production' + id: key + env: + APP_KEY_FILE: ${{ steps.check.outputs.app-key-file }} + working-directory: desktop-publisher + run: node apps/desktop/scripts/read-desktop-app-key.mjs + - name: Create short-lived tag token + if: needs.route.outputs.preset == 'production' + id: app + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 + with: + client-id: ${{ steps.check.outputs.app-client-id }} + private-key: ${{ steps.key.outputs.private-key }} + owner: ${{ github.repository_owner }} + repositories: ${{ github.event.repository.name }} + permission-contents: write + - name: Publish files, update feeds and record release tag + id: publish + working-directory: desktop-publisher + env: + DESKTOP_TAG_TOKEN: ${{ steps.app.outputs.token }} + run: node --import tsx apps/desktop/scripts/publish-desktop-ci.ts --publish + - name: Retain publication audit + if: always() && steps.publish.outputs.record != '' + uses: actions/upload-artifact@v7.0.1 + with: + name: desktop-publication-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ steps.publish.outputs.record }}/ + if-no-files-found: error + retention-days: 30 diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 397ac07b45..726bd070a5 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -107,7 +107,7 @@ jobs: # the built artifact under plain Node, resolving plugins through real package # exports — the shape a real consumer runs. That requires a prior build. - name: Build (lib for the e2e example bins) - run: ${{ github.event_name == 'pull_request' && 'pnpm run build --artifacts-only --profile official' || 'pnpm run build:official' }} + run: ${{ github.event_name == 'pull_request' && 'pnpm run build:artifacts --profile official' || 'pnpm run build:official' }} - name: Prepare Office runtime run: pnpm run prepare:primary-runtime --target linux-x64 --output .cache/e2e-office diff --git a/.github/workflows/macos-package.yml b/.github/workflows/macos-package.yml new file mode 100644 index 0000000000..c88de36c66 --- /dev/null +++ b/.github/workflows/macos-package.yml @@ -0,0 +1,145 @@ +name: Package Desktop · macOS + +on: + workflow_dispatch: + inputs: + preset: + description: 'Machine preset (configured on the packaging runner)' + type: choice + options: [test, production] + default: test + required: true + skip_notarization: + description: 'Skip notarization/stapling: internal debugging only, not for distribution or updates' + type: boolean + default: false + required: true + publish: + description: 'Publish successful signed packages and update the feed; test and production require approval' + type: boolean + default: false + required: true + test_release_id: + description: '测试环境的前缀,用于隔离外部用户访问,留空使用默认配置;测试连续升级时需使用相同值;建议定期轮换默认值。' + type: string + default: '' + required: false + source_ref: + description: 'Source: master or a dsh-v* tag containing the Desktop CI adapters' + type: string + default: master + required: true + update_channel: + description: 'Installer update channel; automatic updates stay on this channel' + type: choice + options: [nightly, latest] + default: nightly + required: true + version_mode: + description: 'default: production follows product, test auto; manual uses build_version' + type: choice + options: [default, product, auto, manual] + default: default + required: true + build_version: + description: 'Manual version only: product version or its supported prerelease suffix' + type: string + default: '' + required: false + +permissions: + contents: read + +concurrency: + group: macos-desktop-package + cancel-in-progress: false + +jobs: + package: + runs-on: [self-hosted, macOS, ARM64, dsh-mac-package-trial] + timeout-minutes: 180 + defaults: + run: + shell: bash + env: + PACKAGE_SKIP_NOTARIZATION: ${{ inputs.skip_notarization }} + PACKAGE_SOURCE_REF: ${{ inputs.source_ref }} + PACKAGE_VERSION_MODE: ${{ inputs.version_mode }} + DSH_DESKTOP_UPDATE_CHANNEL: ${{ inputs.update_channel }} + DSH_TELEMETRY_DISABLED: '1' + DSH_PACKAGING_PRESET: ${{ inputs.preset }} + PACKAGE_PUBLISH: ${{ inputs.publish }} + PACKAGE_TEST_RELEASE_ID: ${{ inputs.test_release_id }} + steps: + - uses: actions/checkout@v7.0.1 + id: checkout + with: + persist-credentials: false + clean: true + ref: ${{ inputs.source_ref }} + - name: Validate selected source and adapter support + run: >- + node -e "const fs=require('node:fs'); const ref=process.env.PACKAGE_SOURCE_REF; + if(ref!=='master' && !/^dsh-v[0-9][0-9A-Za-z.-]*$/.test(ref)) throw Error('Select master or dsh-v*'); + const v=JSON.parse(fs.readFileSync('package.json','utf8')).version; + if(ref!=='master' && ref!=='dsh-v'+v) throw Error('Source tag must match product version'); + if(!fs.readFileSync('apps/desktop/scripts/desktop-ci-version.mjs','utf8').includes('PACKAGE_VERSION_MODE')) throw Error('Source lacks Desktop channel/version CI support; select a newer release');" + - uses: pnpm/action-setup@v5.0.0 + with: + dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.run_attempt }} + - uses: actions/setup-node@v7.0.0 + with: + node-version: '24' + - name: Install locked dependencies + run: pnpm install --frozen-lockfile + - name: Validate preset and resolve build version + id: settings + env: + PACKAGE_BUILD_VERSION: ${{ inputs.build_version }} + run: node apps/desktop/scripts/prepare-macos-ci.mjs + - name: Check packaging configuration and toolchain + env: + PACKAGE_VERSION: ${{ steps.settings.outputs.version }} + run: | + args=() + if [[ "$PACKAGE_SKIP_NOTARIZATION" = true ]]; then args+=(--skip-notarization); fi + pnpm run package:desktop:mac:arm64 --check --build-version "$PACKAGE_VERSION" "${args[@]}" + - name: Build signed installers + env: + PACKAGE_VERSION: ${{ steps.settings.outputs.version }} + run: | + args=() + if [[ "$PACKAGE_SKIP_NOTARIZATION" = true ]]; then args+=(--skip-notarization); fi + pnpm run package:desktop:mac:arm64 --build-version "$PACKAGE_VERSION" "${args[@]}" + - name: Retain complete package outside the checkout + id: retained + env: + PACKAGE_SOURCE_SHA: ${{ steps.checkout.outputs.commit }} + PACKAGE_VERSION: ${{ steps.settings.outputs.version }} + run: node --import tsx apps/desktop/scripts/retain-desktop-ci.ts + - name: Retain packaging diagnostics + if: always() && steps.checkout.outcome == 'success' + uses: actions/upload-artifact@v7.0.1 + with: + name: macos-package-logs-${{ github.run_id }}-${{ github.run_attempt }} + include-hidden-files: true + path: apps/desktop/.desktop-build/packaging-runs/ + if-no-files-found: warn + retention-days: 14 + - name: Retain installers for the selected mode + if: success() + uses: actions/upload-artifact@v7.0.1 + with: + name: macos-arm64-${{ steps.settings.outputs.mode }}-${{ steps.settings.outputs.version }}-${{ github.run_id }}-${{ github.run_attempt }} + include-hidden-files: true + path: ${{ steps.retained.outputs.directory }}/* + if-no-files-found: error + retention-days: 14 + - name: Retain publication request + if: success() && inputs.publish + uses: actions/upload-artifact@v7.0.1 + with: + name: desktop-publish-request-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ steps.retained.outputs.directory }}/manifest.json + if-no-files-found: error + retention-days: 14 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7fd8c9ab8a..b9def1dec1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -146,7 +146,7 @@ jobs: run: pnpm run release:verify --family dsh - name: Build - run: ${{ github.event_name == 'pull_request' && 'pnpm run build --artifacts-only --profile official' || 'pnpm run build:official' }} + run: ${{ github.event_name == 'pull_request' && 'pnpm run build:artifacts --profile official' || 'pnpm run build:official' }} # Concurrency here is rehearsal-only: the credentialed publish workflows # invoke release:pack without the flag and keep the strictly serial path. diff --git a/.github/workflows/windows-package.yml b/.github/workflows/windows-package.yml index 56bcb47a96..320f77265b 100644 --- a/.github/workflows/windows-package.yml +++ b/.github/workflows/windows-package.yml @@ -1,26 +1,51 @@ -name: Windows installer (unsigned) +name: Package Desktop · Windows on: workflow_dispatch: inputs: - build_version: - description: 'Build version: auto generates a version; otherwise enter a full version' - type: string - default: auto - required: true - deployment: - description: Update policy deployment + preset: + description: 'Machine preset (configured on the packaging runner)' type: choice options: [test, production] default: test required: true - policy_origin: - description: 'Required update policy HTTPS origin (no path)' - type: string + signing: + description: 'unsigned: test installer; signed: use the configured EV hardware token' + type: choice + options: [unsigned, signed] + default: unsigned required: true - login_origins: - description: 'Required for test: comma-separated allowed login HTTPS origins' + publish: + description: 'Publish successful signed packages and update the feed; test and production require approval' + type: boolean + default: false + required: true + test_release_id: + description: '测试环境的前缀,用于隔离外部用户访问,留空使用默认配置;测试连续升级时需使用相同值;建议定期轮换默认值。' type: string + default: '' + required: false + source_ref: + description: 'Source: master or a dsh-v* tag containing the Desktop CI adapters' + type: string + default: master + required: true + update_channel: + description: 'Installer update channel; automatic updates stay on this channel' + type: choice + options: [nightly, latest] + default: nightly + required: true + version_mode: + description: 'default: production follows product, test auto; manual uses build_version' + type: choice + options: [default, product, auto, manual] + default: default + required: true + build_version: + description: 'Manual version only: product version or its supported prerelease suffix' + type: string + default: '' required: false permissions: @@ -38,13 +63,39 @@ jobs: run: shell: pwsh env: + PACKAGE_SOURCE_REF: ${{ inputs.source_ref }} + PACKAGE_VERSION_MODE: ${{ inputs.version_mode }} + DSH_DESKTOP_UPDATE_CHANNEL: ${{ inputs.update_channel }} DSH_TELEMETRY_DISABLED: '1' + DSH_PACKAGING_PRESET: ${{ inputs.preset }} + PACKAGE_PUBLISH: ${{ inputs.publish }} + PACKAGE_TEST_RELEASE_ID: ${{ inputs.test_release_id }} + PACKAGE_SIGNING: ${{ inputs.signing }} steps: + - name: Reset packaging checkout + working-directory: ${{ runner.temp }} + run: | + $workspace = [IO.Path]::GetFullPath($env:GITHUB_WORKSPACE) + $parent = [IO.Path]::GetFullPath($env:RUNNER_WORKSPACE) + if ([IO.Path]::GetDirectoryName($workspace) -ne $parent) { throw 'Checkout must be a direct child of RUNNER_WORKSPACE' } + for ($item = [IO.DirectoryInfo]::new($workspace); $null -ne $item; $item = $item.Parent) { + if ($item.Exists -and ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) { throw 'Checkout ancestors cannot be links' } + } + node -e "const fs = require('node:fs'); fs.rmSync(process.argv[1], { recursive: true, force: true, maxRetries: 3 }); fs.mkdirSync(process.argv[1])" $workspace + if ($LASTEXITCODE -ne 0) { throw 'Checkout cleanup failed' } - uses: actions/checkout@v7.0.1 id: checkout with: persist-credentials: false clean: true + ref: ${{ inputs.source_ref }} + - name: Validate selected source and adapter support + run: >- + node -e "const fs=require('node:fs'); const ref=process.env.PACKAGE_SOURCE_REF; + if(ref!=='master' && !/^dsh-v[0-9][0-9A-Za-z.-]*$/.test(ref)) throw Error('Select master or dsh-v*'); + const v=JSON.parse(fs.readFileSync('package.json','utf8')).version; + if(ref!=='master' && ref!=='dsh-v'+v) throw Error('Source tag must match product version'); + if(!fs.readFileSync('apps/desktop/scripts/desktop-ci-version.mjs','utf8').includes('PACKAGE_VERSION_MODE')) throw Error('Source lacks Desktop channel/version CI support; select a newer release');" - uses: pnpm/action-setup@v5.0.0 with: dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.run_attempt }} @@ -55,26 +106,33 @@ jobs: run: | pnpm install --frozen-lockfile exit $LASTEXITCODE - - name: Validate inputs and prepare public package settings + - name: Validate preset and resolve build version id: settings env: PACKAGE_BUILD_VERSION: ${{ inputs.build_version }} - PACKAGE_DEPLOYMENT: ${{ inputs.deployment }} - PACKAGE_POLICY_ORIGIN: ${{ inputs.policy_origin }} - PACKAGE_LOGIN_ORIGINS: ${{ inputs.login_origins }} run: node apps/desktop/scripts/prepare-windows-ci.mjs - name: Check packaging configuration and toolchain env: PACKAGE_VERSION: ${{ steps.settings.outputs.version }} run: | - pnpm run package:desktop:win:x64:unsigned --check --build-version "$env:PACKAGE_VERSION" + $packageArgs = @('run', 'package:desktop:win:x64', '--check', '--build-version', $env:PACKAGE_VERSION) + if ($env:PACKAGE_SIGNING -eq 'unsigned') { $packageArgs += '--unsigned' } + pnpm @packageArgs exit $LASTEXITCODE - name: Build installer and verify packaged runtime env: PACKAGE_VERSION: ${{ steps.settings.outputs.version }} run: | - pnpm run package:desktop:win:x64:unsigned --build-version "$env:PACKAGE_VERSION" + $packageArgs = @('run', 'package:desktop:win:x64', '--build-version', $env:PACKAGE_VERSION) + if ($env:PACKAGE_SIGNING -eq 'unsigned') { $packageArgs += '--unsigned' } + pnpm @packageArgs exit $LASTEXITCODE + - name: Retain complete package outside the checkout + id: retained + env: + PACKAGE_SOURCE_SHA: ${{ steps.checkout.outputs.commit }} + PACKAGE_VERSION: ${{ steps.settings.outputs.version }} + run: node --import tsx apps/desktop/scripts/retain-desktop-ci.ts - name: Retain packaging diagnostics if: always() && steps.checkout.outcome == 'success' uses: actions/upload-artifact@v7.0.1 @@ -84,17 +142,29 @@ jobs: path: apps/desktop/.desktop-build/packaging-runs/ if-no-files-found: warn retention-days: 14 - - name: Retain verified installer + - name: Retain verified unsigned installer + if: success() && inputs.signing == 'unsigned' uses: actions/upload-artifact@v7.0.1 with: name: windows-x64-unsigned-${{ steps.settings.outputs.version }}-${{ github.run_id }}-${{ github.run_attempt }} include-hidden-files: true - path: apps/desktop/.desktop-build/targets/win-x64/unsigned-artifacts/*.exe + path: ${{ steps.retained.outputs.directory }}/* + if-no-files-found: error + retention-days: 14 + - name: Retain verified signed installer + if: success() && inputs.signing == 'signed' + uses: actions/upload-artifact@v7.0.1 + with: + name: windows-x64-signed-${{ steps.settings.outputs.version }}-${{ github.run_id }}-${{ github.run_attempt }} + include-hidden-files: true + path: ${{ steps.retained.outputs.directory }}/* + if-no-files-found: error + retention-days: 14 + - name: Retain publication request + if: success() && inputs.publish + uses: actions/upload-artifact@v7.0.1 + with: + name: desktop-publish-request-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ steps.retained.outputs.directory }}/manifest.json if-no-files-found: error retention-days: 14 - - name: Remove generated package settings - if: always() && steps.checkout.outcome == 'success' - run: | - if (Test-Path -LiteralPath apps/desktop/.env.windows) { - Remove-Item -LiteralPath apps/desktop/.env.windows - } diff --git a/AGENTS.md b/AGENTS.md index f1bfcf9aeb..7bae0a79c1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -92,7 +92,9 @@ pnpm run test:snapshot:record # re-record expected outputs (needs key) pnpm run typecheck pnpm run lint pnpm run duplication # cross-file TypeScript clone detection -pnpm run build # tsc emits lib/types, tsdown bundles runtime +pnpm run build # build:artifacts + build:check, concurrently +pnpm run build:artifacts # artifacts: tsc emits lib/types, tsdown bundles runtime, Web +pnpm run build:check # typecheck aggregate tests/scripts against current build:artifacts output pnpm run hygiene # publint + workspace/package/dependency checks + NodeNext consumer check pnpm run check:windows-wine # ONLY when diagnosing a known Windows failure (needs wine); CI owns this signal pnpm run doc-sync # documentation gates (scripts/run-gates.ts) @@ -100,7 +102,7 @@ pnpm run test:docs # quick documentation checks (no build; doc-quick aggreg pnpm run website:build # VitePress build (doubles as dead-link check) pnpm dsh --profile headless "task" # run one task from source (needs DEEPSEEK_API_KEY) pnpm run demo:ptc -- "task" # headless PTC mode run (needs key) -pnpm run dev:web | dev:desktop # build, then launch; Web also rebuilds client bundles on edits. start:web | start:desktop skip the build +pnpm run dev:web | dev:desktop # build:artifacts, then launch; Web also rebuilds client bundles on edits. start:web | start:desktop skip the build make web|dev-web|desktop|dev-desktop|build # the same commands; ARGS='--no-open' forwards options ``` @@ -115,11 +117,12 @@ Before pushing, follow [dsh-pre-push-checks](.agents/skills/dsh-pre-push-checks/ - Match evidence to the surface: focused behavior tests, model/user-output snapshots, `doc-sync` for docs, built smokes for published paths, and real-API e2e for providers. - Never default to the full suite or repeat a passing check for commit or push. CI owns exhaustive coverage and the platform matrix; rehearse all locally only by explicit request, for CI diagnosis, or for an irreducibly repository-wide change. - `test:coverage`, not `test`, is the CI coverage gate ([why](docs/testing.md)). +- Develop with `build:artifacts`; before claiming completion, run `build:check` after the latest `build:artifacts`, or `build` ([layout](docs/development.md#typescript-project-layout)). - **Web browser automation and GIF recording:** launch with `pnpm dsh web --patch apps/web/tests/pin-browse-picker.overlay.yml` to use the [in-page directory picker](apps/web/tests/pin-browse-picker.overlay.yml); omit this override only when testing native picker behavior explicitly. ## Secrets / .env -Windows packaging/signing: [required reading](apps/desktop/README.md#windows-ev-signing). +Windows packaging/signing: [required reading](apps/desktop/windows-signing/README.md). Real-API tests/demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, and root `.env`. cordis.yml allows `!!js` (never `!js`) under plugin `config` and entry `disabled`; other metadata stays literal, so conditional composition also uses overlays ([primer](docs/cordis-primer.md#loader-configuration)). Never commit credentials. CI e2e skips without a key; [testing.md](docs/testing.md) owns key policy. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 2dbbb5cd40..6a2fbda5eb 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -5,7 +5,7 @@ DeepSeek Harness is licensed under [MIT](LICENSE). It depends on the third-party software listed below. Each project remains under its own license; nothing in this file changes those terms. -This file lists **direct** dependencies declared by the workspace, the explicitly disclosed official Claude Code platform payload closure, and the Bundled Python distributions. It is generated by `scripts/gen-third-party-notices.ts`: a pre-commit hook regenerates it whenever a staged file changes one of its inputs, and `scripts/gen-third-party-notices.spec.ts` asserts in the test lane that the committed bytes match. Deleting a manifest runs no hook, so that case is caught by the assertion instead. Run `pnpm run verify-third-party-notices` for the standalone check. +This file lists **direct** dependencies declared by the workspace, the explicitly disclosed official Claude Code platform payload closure, and the Bundled Python distributions. It is generated by `scripts/gen-third-party-notices.ts`, and `scripts/gen-third-party-notices.spec.ts` asserts in the test lane that the committed bytes match. Run `pnpm run gen-third-party-notices` after changing one of its inputs, or `pnpm run verify-third-party-notices` for the standalone check. The complete npm transitive closure, including the Landlock launcher workspace, is recorded with exact pinned versions in [`pnpm-lock.yaml`](pnpm-lock.yaml) — inspect it with `pnpm licenses list`. The Python SDK closure is recorded separately in [`python/sdk/uv.lock`](python/sdk/uv.lock). @@ -114,6 +114,7 @@ External packages installed for runtime use or distributed inside the prebuilt b | [`node-addon-require-builtin`](https://www.npmjs.com/package/node-addon-require-builtin) | MIT | | [`node-pty`](https://github.com/microsoft/node-pty) | MIT | | [`open`](https://github.com/sindresorhus/open) | MIT | +| [`p-limit`](https://github.com/sindresorhus/p-limit) | MIT | | [`papaparse`](https://github.com/mholt/PapaParse) | MIT | | [`pdfjs-dist`](https://github.com/mozilla/pdf.js) | Apache-2.0 | | [`picomatch`](https://github.com/micromatch/picomatch) | MIT | @@ -141,11 +142,12 @@ External packages installed for runtime use or distributed inside the prebuilt b pnpm applies local patches to the following packages at install time, so shipped artifacts carry modified copies; each patch file is the complete record of the modification: -- `@earendil-works/pi-ai@1.0.2` — [`patches/@earendil-works__pi-ai@1.0.2.patch`](patches/@earendil-works__pi-ai@1.0.2.patch) +- `@earendil-works/pi-ai@1.1.0` — [`patches/@earendil-works__pi-ai@1.1.0.patch`](patches/@earendil-works__pi-ai@1.1.0.patch) - `@electron/osx-sign@1.3.3` — [`patches/@electron__osx-sign@1.3.3.patch`](patches/@electron__osx-sign@1.3.3.patch) - `@fortune-sheet/core@1.0.4` — [`patches/@fortune-sheet__core@1.0.4.patch`](patches/@fortune-sheet__core@1.0.4.patch) - `@fortune-sheet/react@1.0.4` — [`patches/@fortune-sheet__react@1.0.4.patch`](patches/@fortune-sheet__react@1.0.4.patch) - `@yao-pkg/pkg@6.21.0` — [`patches/@yao-pkg__pkg@6.21.0.patch`](patches/@yao-pkg__pkg@6.21.0.patch) +- `app-builder-lib@26.17.0` — [`patches/app-builder-lib@26.17.0.patch`](patches/app-builder-lib@26.17.0.patch) - `exceljs@4.4.0` — [`patches/exceljs@4.4.0.patch`](patches/exceljs@4.4.0.patch) - `node-pty@1.2.0-beta.15` — [`patches/node-pty@1.2.0-beta.15.patch`](patches/node-pty@1.2.0-beta.15.patch) @@ -269,6 +271,7 @@ Direct dependencies of the `pyproject.toml` manifests, plus `uv` as the developm | Package | License | Role | | --- | --- | --- | | [`hatchling`](https://github.com/pypa/hatch) | MIT | build backend | +| [`pillow`](https://github.com/python-pillow/Pillow) | MIT-CMU | Python project dependency | | [`pydantic`](https://github.com/pydantic/pydantic) | MIT | runtime dependency of `deepseek-harness-sdk` | | [`pytest`](https://github.com/pytest-dev/pytest) | MIT | test-only | | [`uv`](https://github.com/astral-sh/uv) | MIT / Apache-2.0 | development workflow tool | diff --git a/apps/cli/composition.md b/apps/cli/composition.md index 9888602296..8b2cfb83ab 100644 --- a/apps/cli/composition.md +++ b/apps/cli/composition.md @@ -30,6 +30,8 @@ flowchart LR cfg --> plugin_dsh_base_typert_loader plugin_dsh_base_typert_gateway["typert-gateway
@deepseek-ai/dsh-api-gateway"] cfg --> plugin_dsh_base_typert_gateway + plugin_dsh_base_remote_access["remote-access
@deepseek-ai/dsh-api-remote-access"] + cfg --> plugin_dsh_base_remote_access plugin_dsh_base_session_title["session-title
@deepseek-ai/dsh-session-title"] cfg --> plugin_dsh_base_session_title plugin_dsh_base_session_title_llm["session-title-llm
@deepseek-ai/dsh-session-title-first-prompt-llm"] @@ -211,6 +213,7 @@ flowchart LR | `typert` | `@deepseek-ai/dsh-typert-registry` | | `typert-loader` | `@deepseek-ai/dsh-typert-loader` | | `typert-gateway` | `@deepseek-ai/dsh-api-gateway` | +| `remote-access` | `@deepseek-ai/dsh-api-remote-access` | | `session-title` | `@deepseek-ai/dsh-session-title` | | `session-title-llm` | `@deepseek-ai/dsh-session-title-first-prompt-llm` | | `user-questions` | `@deepseek-ai/dsh-user-questions` | diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index 69b8cfee07..7cc7f2a17a 100644 --- a/apps/cli/reference/README.i18n.yaml +++ b/apps/cli/reference/README.i18n.yaml @@ -21,8 +21,8 @@ en: 8d2cfff9bab7327f zh: 21515ab6e6798504 /dsh-cli-behavior-reference/web-profile: - en: ee6c00e55a2f0a10 - zh: 404942e397e213ab + en: d768b6213e75a890 + zh: 731791b496ce14ef /dsh-cli-behavior-reference/shared-deployment-behavior: en: 6a7abc9339e8f37f zh: 133adb834c8e195d diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index 3123b7898e..8195b3e5c5 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -126,11 +126,11 @@ The production Web runner needs built package and frontend artifacts (`pnpm run Process shutdown gives the plugin tree up to five seconds to dispose. The first `SIGINT`/`SIGTERM` starts that graceful drain — `SIGTERM` is a supervisor's ordinary stop request and exits 0 on every surface, `SIGINT` reports 130; a second signal forces immediate exit. If one-shot normal completion is already stuck in disposal, the first `Ctrl+C` is the escalation and exits immediately instead of being swallowed. -The base-backed modes treat the invoking directory as the default workspace root, load applicable `AGENTS.md` or `CLAUDE.md` instructions with a 65,536-byte render budget, and use an in-memory SQLite session content index. The standalone `sdk-minimal` profile uses the invoking directory as its sandbox-policy root but intentionally omits filesystem tools, instruction discovery, and SQLite. An enabled HMR plugin watches both `cordis.patch.yml` layers (profile and home) and reapplies valid edits transactionally; without HMR, the process applies them once. A one-shot surface exits through its bounded shutdown, which disposes any live watchers. +The base-backed modes treat the invoking directory as the default workspace root, load applicable `AGENTS.md` or `CLAUDE.md` instructions with a 65,536-byte render budget, and use an in-memory SQLite session content index. The standalone `sdk-minimal` profile uses the invoking directory as its sandbox-policy root and includes `read_image`, but omits text filesystem tools, instruction discovery, and SQLite. An enabled HMR plugin watches both `cordis.patch.yml` layers (profile and home) and reapplies valid edits transactionally; without HMR, the process applies them once. A one-shot surface exits through its bounded shutdown, which disposes any live watchers. New sessions in base-backed profiles default to the `workspace-write` permission preset. Bash and filesystem mutations are restricted to the session workspace and platform temporary roots; reads and network access are not confined, while process visibility depends on the selected sandbox backend — bwrap runs commands in a private PID namespace that hides host processes, and Landlock and Seatbelt leave host process visibility unchanged. `DSH_PERMISSION_MODE` changes the process fallback. Stored General-settings permissions affect later Web sessions, not an already-open one. The standalone `sdk-minimal` tree instead pins `danger-full-access` and mounts no approval or permission-settings service. -`DSH_TOOLS_MODE` selects `native` or `ptc` for the process; another value fails at boot. The shipped `minimal` agent preset keeps that deployment presentation, fixes the complete system prompt to `You are a helpful software engineer assistant.`, and composes only the platform-selected persistent shell. Select 极简模式 when creating a Web session; every other prompt section and model-facing plugin remains absent from that agent while the shared browser, workspace, persistence, sandbox, and permission host stays in place. +`DSH_TOOLS_MODE` selects `native` or `ptc` for the process; another value fails at boot. The shipped `minimal` agent preset keeps that deployment presentation, fixes the complete system prompt to `You are a helpful software engineer assistant.`, and provides the platform-selected persistent shell, `read_image`, and `working_directory`. It supports automatic context compaction and the human `/compact` command. Select 极简模式 when creating a Web session; the shared browser, workspace, persistence, sandbox, and permission host remains in place. ## Shared deployment behavior diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index 9a43ca2858..e1dbf1ffe8 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -128,11 +128,11 @@ dsh web --help 进程关闭时,插件树最多有 5 秒完成 dispose。首次收到 `SIGINT` 或 `SIGTERM` 时会开始优雅排空:`SIGTERM` 是监督进程发出的常规停止请求,在所有运行模式下都以 0 退出;`SIGINT` 则报告 130。第二次收到信号时会立即强制退出。如果一次性运行在正常结束时已经卡在 dispose 阶段,第一次按下 `Ctrl+C` 就会直接升级为强制退出,而不会被忽略。 -基于 base 的模式都将运行命令时所在的目录作为默认 workspace 根目录,以 65,536 字节渲染预算加载适用的 `AGENTS.md` 或 `CLAUDE.md` 指令,并使用内存 SQLite 会话内容索引。独立的 `sdk-minimal` profile 把运行命令时所在的目录作为沙箱策略根目录,但刻意省略文件系统工具、指令发现与 SQLite。启用的 HMR 插件会监视 profile 与 home 两个 `cordis.patch.yml` 配置层的有效变更,并以事务方式重新应用;未启用 HMR 时只应用一次。一次性运行模式通过有界关闭流程退出,该流程会 dispose 所有实时监视器。 +基于 base 的模式都将运行命令时所在的目录作为默认 workspace 根目录,以 65,536 字节渲染预算加载适用的 `AGENTS.md` 或 `CLAUDE.md` 指令,并使用内存 SQLite 会话内容索引。独立的 `sdk-minimal` profile 把运行命令时所在的目录作为沙箱策略根目录并包含 `read_image`,但省略文本文件系统工具、指令发现与 SQLite。启用的 HMR 插件会监视 profile 与 home 两个 `cordis.patch.yml` 配置层的有效变更,并以事务方式重新应用;未启用 HMR 时只应用一次。一次性运行模式通过有界关闭流程退出,该流程会 dispose 所有实时监视器。 基于 base 的 profile 中,新会话默认使用 `workspace-write` 权限预设。Bash 和文件系统修改仅限于会话 workspace 与平台临时根目录;读取和网络访问不受限制,进程可见性则取决于所选沙箱后端——bwrap 在私有 PID 命名空间中运行命令并隐藏宿主进程,Landlock 与 Seatbelt 保持宿主进程可见性不变。`DSH_PERMISSION_MODE` 更改进程后备值。General settings 中存储的权限影响后续 Web 会话,不改变已打开的会话。独立的 `sdk-minimal` 配置树则固定为 `danger-full-access`,且不挂载 approval 或权限 settings 服务。 -`DSH_TOOLS_MODE` 为进程选择 `native` 或 `ptc`;其他值会导致启动失败。随附的 `minimal` agent preset 会保留该部署的呈现方式,将完整系统提示词固定为 `You are a helpful software engineer assistant.`,并且仅组合按平台选择的持久 shell。创建 Web 会话时请选择极简模式;该 agent 不包含任何其他提示词段落或面向模型的插件,而共享的浏览器、workspace、持久化、沙箱与权限宿主保持不变。 +`DSH_TOOLS_MODE` 为进程选择 `native` 或 `ptc`;其他值会导致启动失败。随附的 `minimal` agent preset 会保留该部署的呈现方式,将完整系统提示词固定为 `You are a helpful software engineer assistant.`,并提供按平台选择的持久 shell、`read_image` 和 `working_directory`。它支持自动上下文压缩和用户 `/compact` 命令。创建 Web 会话时请选择极简模式;共享的浏览器、workspace、持久化、沙箱与权限宿主保持不变。 ## 共享部署行为 diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index 4ad8abce2d..0b92215c86 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -1249,9 +1249,14 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', ['llm-deepseek', '@deepseek-ai/dsh-llm-deepseek-api-key'], ['sandbox', '@deepseek-ai/dsh-sandbox-local'], ['filesystem', '@deepseek-ai/dsh-fs-local'], + ['attachment-local', '@deepseek-ai/dsh-attachment-local'], + ['tool-fs', '@deepseek-ai/dsh-tool-fs'], ['working-directory', '@deepseek-ai/dsh-working-directory'], ['tool-working-directory', '@deepseek-ai/dsh-tool-working-directory'], ['session-projection', '@deepseek-ai/dsh-session-projection'], + ['token-meter', '@deepseek-ai/dsh-token-meter'], + ['compaction-basic', '@deepseek-ai/dsh-compaction-basic'], + ['image-offload', '@deepseek-ai/dsh-compaction-image-offload'], ['sandbox-policy', '@deepseek-ai/dsh-sandbox-policy'], ['subprocess', '@deepseek-ai/dsh-subprocess-local'], ['pty', '@deepseek-ai/dsh-terminal'], diff --git a/apps/cli/tests/optional-bundle-transitions.e2e.ts b/apps/cli/tests/optional-bundle-transitions.e2e.ts index 623958ea9c..f084cb29cb 100644 --- a/apps/cli/tests/optional-bundle-transitions.e2e.ts +++ b/apps/cli/tests/optional-bundle-transitions.e2e.ts @@ -171,7 +171,7 @@ describe.skipIf(!existsSync(join(root, 'apps/cli/lib/bin.js')))('Official option const minimal = await agent('minimal') const standard = await agent() expect(names()).toEqual(['working_directory']) - expect(names(minimal.agent)).toEqual([process.platform === 'win32' ? 'pwsh' : 'bash', 'working_directory']) + expect(names(minimal.agent)).toEqual([process.platform === 'win32' ? 'pwsh' : 'bash', 'read_image', 'working_directory']) for (const name of ['str_replace_editor', 'session_search', 'terminal_open', 'ralph']) expect(names(standard.agent)).not.toContain(name) expect((await ctx.skills.list({ cwd: workspace, scope: standard.agent })).some(skill => skill.name === 'dsh-badge')).toBe(false) }) @@ -245,7 +245,7 @@ describe.skipIf(!existsSync(join(root, 'apps/cli/lib/bin.js')))('Official option expect(toolResult(owner.agent, 'badge-on')).toContain('dsh-badge.png') const minimal = await agent('minimal') expect((await ctx.skills.list({ cwd: workspace, scope: minimal.agent })).map(skill => skill.name)).toContain('dsh-badge') - expect(names(minimal.agent)).toEqual([process.platform === 'win32' ? 'pwsh' : 'bash', 'working_directory']) + expect(names(minimal.agent)).toEqual([process.platform === 'win32' ? 'pwsh' : 'bash', 'read_image', 'working_directory']) await select(bundles.badge, false) expect((await load('badge-after')).isError).toBe(true) }) diff --git a/apps/cli/tests/profiles/headless/tests/credentials.e2e.ts b/apps/cli/tests/profiles/headless/tests/credentials.e2e.ts new file mode 100644 index 0000000000..f1323add9e --- /dev/null +++ b/apps/cli/tests/profiles/headless/tests/credentials.e2e.ts @@ -0,0 +1,203 @@ +/** Credential selection through the shipped headless profile and real HTTP adapters. */ +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { createServer } from 'node:http' +import { once } from 'node:events' +import type { AddressInfo } from 'node:net' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { startMockLlmServer } from '@deepseek-ai/dsh-llm-mock-server' +import { runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' +import { describe, expect, it } from 'vitest' + +const binScript = fileURLToPath(new URL('../../../../../../apps/cli/src/bin.ts', import.meta.url)) +const tsconfigPath = fileURLToPath(new URL('../../../../../../tsconfig.json', import.meta.url)) +const savedKey = 'headless-saved-key' +const launchKey = 'headless-launch-key' +const projectKey = 'headless-project-key' +const homeKey = 'headless-home-key' +const processTimeoutMs = 60_000 + +/** Anthropic SDK events require the SSE event name beside the JSON payload. */ +async function startAnthropicServer() { + const keys: Array = [] + const server = createServer((request, response) => { + keys.push(request.headers['x-api-key']) + request.resume() + response.writeHead(200, { 'content-type': 'text/event-stream' }) + for (const event of [ + { type: 'message_start', message: { id: 'auth-test', type: 'message', role: 'assistant', model: 'headless-auth-model', content: [], usage: { input_tokens: 1, output_tokens: 0 } } }, + { type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } }, + { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'mock response recovered' } }, + { type: 'content_block_stop', index: 0 }, + { type: 'message_delta', delta: { stop_reason: 'end_turn', stop_sequence: null }, usage: { output_tokens: 1 } }, + { type: 'message_stop' }, + ]) response.write(`event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`) + response.end() + }) + server.listen(0, '127.0.0.1') + await once(server, 'listening') + return { + keys, + baseURL: `http://127.0.0.1:${(server.address() as AddressInfo).port}`, + async close() { + server.closeAllConnections() + await new Promise((resolve, reject) => server.close((error) => { + if (error === undefined) resolve() + else reject(error) + })) + }, + } +} + +const credentials = JSON.stringify({ + version: 1, + refs: { DEEPSEEK_API_KEY: savedKey, ANTHROPIC_API_KEY: savedKey }, + records: { + 'llm-pi-ai/anthropic': { kind: 'api-key', key: savedKey }, + 'llm-pi-ai/openai-codex': { + kind: 'grant', payload: { type: 'oauth', access: 'headless-saved-access', refresh: 'headless-saved-refresh', expires: 1 }, + }, + }, +}) + '\n' + +describe('headless credential policy', { retry: 0 }, () => { + it.each([ + { name: 'launch environment', launch: launchKey, project: projectKey, home: homeKey, expected: launchKey }, + { name: 'project .env', launch: undefined, project: projectKey, home: homeKey, expected: projectKey }, + { name: 'home .env', launch: undefined, project: undefined, home: homeKey, expected: homeKey }, + ])('uses $name instead of saved credentials', async (scenario) => { + const server = await startMockLlmServer({ sequence: ['success'], repeatLast: true, apiKey: scenario.expected }) + try { + const result = await runLoaderSmoke({ + label: `headless credentials: ${scenario.name}`, + tempDirPrefix: 'headless-credentials-', binScript, tsconfigPath, sourceImport: 'tsx/esm', + configPath: '', binArgs: ['headless', 'Reply briefly.'], processTimeoutMs, + env: { + DEEPSEEK_API_KEY: scenario.launch, DEEPSEEK_BASE_URL: server.baseURL, DSH_TELEMETRY_DISABLED: '1', + }, + prepare: async (cwd) => { + await mkdir(join(cwd, '.dsh'), { recursive: true }) + await writeFile(join(cwd, '.dsh', '.credentials.yaml'), credentials, { mode: 0o600 }) + if (scenario.project !== undefined) await writeFile(join(cwd, '.env'), `DEEPSEEK_API_KEY=${scenario.project}\n`) + if (scenario.home !== undefined) await writeFile(join(cwd, '.dsh', '.env'), `DEEPSEEK_API_KEY=${scenario.home}\n`) + }, + inspect: async (cwd) => { + expect(await readFile(join(cwd, '.dsh', '.credentials.yaml'), 'utf8')).toBe(credentials) + }, + }) + expect(result.stdout.trim()).toBe('mock response recovered') + expect(result.stderr).toBe('') + expect(server.requests.length).toBeGreaterThan(0) + expect(server.requests.every(request => request.headers['x-api-key'] === scenario.expected)).toBe(true) + } finally { + await server.close() + } + }, processTimeoutMs + 15_000) + + it.each(['named reference', 'native discovery'] as const)('uses launch credentials for pi-ai %s', async (mode) => { + const server = await startAnthropicServer() + try { + const result = await runLoaderSmoke({ + label: `headless pi-ai credentials: ${mode}`, + tempDirPrefix: 'headless-pi-credentials-', binScript, tsconfigPath, sourceImport: 'tsx/esm', + configPath: '', binArgs: ['headless', '--patch', './auth.patch.json', 'Reply briefly.'], processTimeoutMs, + env: { + ANTHROPIC_API_KEY: launchKey, ANTHROPIC_AUTH_TOKEN: undefined, ANTHROPIC_OAUTH_TOKEN: undefined, + ANTHROPIC_FEDERATION_RULE_ID: undefined, ANTHROPIC_ORGANIZATION_ID: undefined, + ANTHROPIC_IDENTITY_TOKEN_FILE: undefined, ANTHROPIC_SERVICE_ACCOUNT_ID: undefined, ANTHROPIC_WORKSPACE_ID: undefined, + DEEPSEEK_API_KEY: undefined, DSH_TELEMETRY_DISABLED: '1', + }, + prepare: async (cwd) => { + await mkdir(join(cwd, '.dsh'), { recursive: true }) + await writeFile(join(cwd, '.dsh', '.credentials.yaml'), credentials, { mode: 0o600 }) + await writeFile(join(cwd, 'auth.patch.json'), JSON.stringify([ + { id: 'agent-default-model', config: { provider: 'anthropic', model: 'headless-auth-model' } }, + { id: 'llm-pi-ai', config: { providers: { anthropic: { + ...mode === 'named reference' ? { apiKeyEnv: 'ANTHROPIC_API_KEY' } : {}, + baseURL: server.baseURL, + models: [{ id: 'headless-auth-model', contextWindow: 32_768, maxTokens: 1024 }], + } } } }, + ])) + }, + inspect: async (cwd) => { + expect(await readFile(join(cwd, '.dsh', '.credentials.yaml'), 'utf8')).toBe(credentials) + }, + }) + expect(result.stdout.trim()).toBe('mock response recovered') + expect(result.stderr).toBe('') + expect(server.keys.length).toBeGreaterThan(0) + expect(server.keys.every(key => key === launchKey)).toBe(true) + } finally { + await server.close() + } + }, processTimeoutMs + 15_000) + + it.each(['no environment key', 'empty environment key', 'invalid stored document', 'restore stored credentials'] as const)('%s', async (mode) => { + const restoring = mode === 'restore stored credentials' + const empty = mode === 'empty environment key' + const missing = mode === 'no environment key' || empty + const document = mode === 'invalid stored document' ? 'invalid: [\n' : credentials + const server = await startMockLlmServer({ sequence: ['success'], repeatLast: true, apiKey: restoring ? savedKey : launchKey }) + try { + const result = await runLoaderSmoke({ + label: `headless credential boundary: ${mode}`, + tempDirPrefix: 'headless-credential-boundary-', binScript, tsconfigPath, sourceImport: 'tsx/esm', + configPath: '', + binArgs: ['headless', ...restoring ? ['--patch', './auth.patch.json'] : [], 'Reply briefly.'], + processTimeoutMs, expectedExitCode: missing ? 1 : 0, + env: { DEEPSEEK_API_KEY: empty ? '' : missing ? undefined : launchKey, DEEPSEEK_BASE_URL: server.baseURL, DSH_TELEMETRY_DISABLED: '1' }, + prepare: async (cwd) => { + await mkdir(join(cwd, '.dsh'), { recursive: true }) + await writeFile(join(cwd, '.dsh', '.credentials.yaml'), document, { mode: 0o600 }) + if (empty) await writeFile(join(cwd, '.env'), `DEEPSEEK_API_KEY=${projectKey}\n`) + if (restoring) await writeFile(join(cwd, 'auth.patch.json'), JSON.stringify( + ['credentials', 'authorization', 'deepseek-account'].map(id => ({ id, disabled: false })), + )) + }, + inspect: async (cwd) => { + expect(await readFile(join(cwd, '.dsh', '.credentials.yaml'), 'utf8')).toBe(document) + }, + }) + if (missing) { + expect(result.stdout).toBe('\n') + expect(result.stderr).toContain('MISSING_CREDENTIAL') + expect(server.requests).toHaveLength(0) + } else { + expect(result.stdout.trim()).toBe('mock response recovered') + expect(result.stderr).toBe('') + expect(server.requests.length).toBeGreaterThan(0) + } + } finally { + await server.close() + } + }, processTimeoutMs + 15_000) + + it.each([ + { provider: 'deepseek-account', model: 'deepseek-v4-flash', code: 'ACCOUNT_SIGN_IN_REQUIRED' }, + { provider: 'openai-codex', model: 'gpt-5.4', code: 'PI_AI_ERROR: Provider is not configured: openai-codex' }, + ])('fails without saved sign-in for $provider', async ({ provider, model, code }) => { + const result = await runLoaderSmoke({ + label: `headless saved sign-in: ${provider}`, + tempDirPrefix: 'headless-saved-sign-in-', binScript, tsconfigPath, sourceImport: 'tsx/esm', + configPath: '', binArgs: ['headless', '--patch', './auth.patch.json', 'Reply briefly.'], + processTimeoutMs, expectedExitCode: 1, + env: { DEEPSEEK_API_KEY: undefined, OPENAI_API_KEY: undefined, DSH_TELEMETRY_DISABLED: '1' }, + prepare: async (cwd) => { + await mkdir(join(cwd, '.dsh'), { recursive: true }) + await writeFile(join(cwd, '.dsh', '.credentials.yaml'), credentials, { mode: 0o600 }) + await writeFile(join(cwd, 'auth.patch.json'), JSON.stringify([ + { id: 'agent-default-model', config: { provider, model } }, + ...provider === 'openai-codex' ? [{ id: 'llm-pi-ai', config: { providers: { + [provider]: { models: [{ id: model, contextWindow: 32_768, maxTokens: 1024 }] }, + } } }] : [], + ])) + }, + inspect: async (cwd) => { + expect(await readFile(join(cwd, '.dsh', '.credentials.yaml'), 'utf8')).toBe(credentials) + }, + }) + expect(result.stdout).toBe('\n') + expect(result.stderr).toContain(code) + expect(result.stderr).not.toContain('pending') + }, processTimeoutMs + 15_000) +}) diff --git a/apps/cli/tests/profiles/headless/tests/expected/headless-profile/session.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/headless-profile/session.expected.jsonl index 904ee65726..bfcfc3ca75 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/headless-profile/session.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/headless-profile/session.expected.jsonl @@ -15,6 +15,8 @@ {"type":"session/title-llm-request","data":{"titleProvider":"session-title-first-prompt-llm","messageSeqs":[8],"route":{"provider":"cli-mock","model":"cli-mock"},"system":"Create a concise title for an AI coding-assistant session from the supplied human messages.\nReturn only the title on one line, **in plain text of natural language**, with no quotes, prefix, explanation, Markdown, XML, or terminal control codes. No code is allowed.\nUse the language of the messages.\nAim for about 5 words in non-CJK languages or 10 CJK characters.\nIf the messages give little to name, still return a short best-effort title, such as Greeting, instead of explaining.","messages":[{"content":[{"type":"text","text":"Generate the session title from this JSON array of human messages:\n[{\"seq\":8,\"text\":\"Prove the product headless profile path with one real tool round trip.\"}]"}],"source":{"kind":"dsh-session-title-llm"},"role":"user","id":"{{sessionId}}"}],"maxTokens":4096,"reasoningEffort":"off"}} {"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"Inspecting the task before the tool call."},{"type":"tool-call","id":"cli-smoke-call","name":"bash","arguments":"{\"command\":\"printf CLI_TOOL_ROUND_TRIP\",\"description\":\"Prove the CLI tool round trip.\"}"}],"source":{"kind":"model","provider":"cli-mock","model":"cli-mock"},"id":"{{sessionId}}"},"usage":{"inputTokens":11,"outputTokens":3,"cacheReadTokens":2},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":0,"index":0,"dt":[],"texts":["Inspecting the task before the tool call."]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Inspecting the task before the tool call."}}},{"type":"chunk","time":0,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":1,"dt":[],"id":"cli-smoke-call","name":"bash","args":["{\"command\":\"printf CLI_TOOL_ROUND_TRIP\",\"description\":\"Prove the CLI tool round trip.\"}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"cli-smoke-call","name":"bash","arguments":"{\"command\":\"printf CLI_TOOL_ROUND_TRIP\",\"description\":\"Prove the CLI tool round trip.\"}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":11,"outputTokens":3,"cacheReadTokens":2}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"cli-smoke-call","name":"bash","arguments":"{\"command\":\"printf CLI_TOOL_ROUND_TRIP\",\"description\":\"Prove the CLI tool round trip.\"}"}} +{"type":"job/registered","data":{"jobId":"{{id:1}}","owner":"{{sessionId}}","kind":"bash","label":"printf CLI_TOOL_ROUND_TRIP","startedAt":0,"toolCall":{"callId":"cli-smoke-call","rootCallId":"cli-smoke-call"}}} +{"type":"job/settled","data":{"jobId":"{{id:1}}","owner":"{{sessionId}}","kind":"bash","label":"printf CLI_TOOL_ROUND_TRIP","startedAt":0,"toolCall":{"callId":"cli-smoke-call","rootCallId":"cli-smoke-call"},"finishedAt":0,"status":"completed","cause":"producer","producerReported":true,"detail":"exit code: 0"}} {"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"cli-smoke-call"},"toolCallId":"cli-smoke-call","content":[{"type":"text","text":"CLI_TOOL_ROUND_TRIP"}],"isError":false,"id":"{{sessionId}}"},"meta":{"cwd":"{{cwd}}"}},"sourceEventSeqs":[15],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} diff --git a/apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/child.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/child.expected.jsonl index 00868634d4..b0372d96c6 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/child.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/child.expected.jsonl @@ -8,7 +8,7 @@ {"type":"step/start","data":{"turn":1,"step":1}} {"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE."},{"type":"text","text":"Your parent agent id is \"{{sessionId}}\". Before you finish, send your result to that agent with send_message({ agent_id: \"{{sessionId}}\", message: \"\" }). The parent shares your workspace but does not automatically receive your transcript, tool output, or reasoning. Send earlier messages as well when a finding changes what the parent should do next; sending a message does not end your turn."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nDelegation depth for `subagent`: current 1, maximum 1, remaining levels 0. Do not call `subagent` at this depth; carry out the work directly.\n\nDelegation depth for `subagent_fork`: current 1, maximum 1, remaining levels 0. Do not call `subagent_fork` at this depth; carry out the work directly.\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"tool:subagent:depth","text":"Delegation depth for `subagent`: current 1, maximum 1, remaining levels 0. Do not call `subagent` at this depth; carry out the work directly."},{"name":"tool:subagent_fork:depth","text":"Delegation depth for `subagent_fork`: current 1, maximum 1, remaining levels 0. Do not call `subagent_fork` at this depth; carry out the work directly."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","reasoningEffort":"low"},"tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"session/title","data":{"title":"Use the write tool exactly","messageSeqs":[8],"source":{"kind":"fallback"}}} diff --git a/apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/child.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/child.expected.jsonl index 1b82a99cf0..c07532bd7d 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/child.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/child.expected.jsonl @@ -8,7 +8,7 @@ {"type":"step/start","data":{"turn":1,"step":1}} {"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly CHILD_RESULT and nothing else. Do not call send_message."},{"type":"text","text":"Your parent agent id is \"{{sessionId}}\". Before you finish, send your result to that agent with send_message({ agent_id: \"{{sessionId}}\", message: \"\" }). The parent shares your workspace but does not automatically receive your transcript, tool output, or reasoning. Send earlier messages as well when a finding changes what the parent should do next; sending a message does not end your turn."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nDelegation depth for `subagent`: current 1, maximum 1, remaining levels 0. Do not call `subagent` at this depth; carry out the work directly.\n\nDelegation depth for `subagent_fork`: current 1, maximum 1, remaining levels 0. Do not call `subagent_fork` at this depth; carry out the work directly.\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"tool:subagent:depth","text":"Delegation depth for `subagent`: current 1, maximum 1, remaining levels 0. Do not call `subagent` at this depth; carry out the work directly."},{"name":"tool:subagent_fork:depth","text":"Delegation depth for `subagent_fork`: current 1, maximum 1, remaining levels 0. Do not call `subagent_fork` at this depth; carry out the work directly."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"session/title","data":{"title":"Reply with exactly CHILD_RESULT and","messageSeqs":[8],"source":{"kind":"fallback"}}} diff --git a/apps/cli/tests/profiles/headless/tests/fixtures/team-llm.mjs b/apps/cli/tests/profiles/headless/tests/fixtures/team-llm.mjs index 119380327b..9c1bedd00e 100644 --- a/apps/cli/tests/profiles/headless/tests/fixtures/team-llm.mjs +++ b/apps/cli/tests/profiles/headless/tests/fixtures/team-llm.mjs @@ -183,17 +183,18 @@ class TeamFixtureAdapter extends LlmAdapter { throw new Error('Team profile exposes a direct subagent tool') } const initial = options.messages.findLast(message => message.role === 'user' && message.source.kind === 'user') - const identity = initial?.content[0]?.text?.trimEnd() - if (identity !== 'TEAM_WORKFLOW_CHILD' && (!tools.includes('spawn_teammate') || !tools.includes('workflow'))) { + const workflowChild = initial?.content[0]?.text === 'TEAM_WORKFLOW_CHILD' + const context = options.messages.findLast(message => message.source?.kind === 'runtime-context') + const identity = context?.source.sections?.find(section => section.name === 'team:identity')?.text + if (!workflowChild && (!tools.includes('spawn_teammate') || !tools.includes('workflow'))) { throw new Error('Team profile is missing teammate or workflow tools') } - const chunks = identity === 'TEAM_WORKFLOW_CHILD' - ? textChunks('Fresh workflow child complete.') - : identity === '\nYou are teammate "researcher".\nYour Team Lead is named "lead".\nUse list_agents({}) to find your teammates and their names.\nTo message your Team Lead, use send_message({ target: "lead", message: "..." }).\nTo message another teammate, use send_message({ target: "", message: "..." }).\n' - ? researcher(options.messages) - : identity === '\nYou are teammate "implementer".\nYour Team Lead is named "lead".\nUse list_agents({}) to find your teammates and their names.\nTo message your Team Lead, use send_message({ target: "lead", message: "..." }).\nTo message another teammate, use send_message({ target: "", message: "..." }).\n' - ? implementer(options.messages) - : lead(options.messages) + let chunks + if (workflowChild) chunks = textChunks('Fresh workflow child complete.') + else if (identity?.startsWith('You are teammate "researcher".')) chunks = researcher(options.messages) + else if (identity?.startsWith('You are teammate "implementer".')) chunks = implementer(options.messages) + else if (identity?.startsWith('You are the Team Lead named "lead".')) chunks = lead(options.messages) + else throw new Error('Team runtime context is missing a recognized identity') for (const chunk of chunks) { options.signal?.throwIfAborted() yield chunk diff --git a/apps/cli/tests/profiles/headless/tests/harness.ts b/apps/cli/tests/profiles/headless/tests/harness.ts index cf51dc23a6..43164c9d7b 100644 --- a/apps/cli/tests/profiles/headless/tests/harness.ts +++ b/apps/cli/tests/profiles/headless/tests/harness.ts @@ -11,7 +11,7 @@ import * as ToolTodo from '@deepseek-ai/dsh-tool-todo' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek-api-key' import TokenMeter from '@deepseek-ai/dsh-token-meter' import ToolResultPruner from '@deepseek-ai/dsh-compaction-tool-result-pruner' -import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' +import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import * as SessionCheckpointPolicy from '@deepseek-ai/dsh-session-checkpoint-policy' import { BasicCompactionEngine } from '@deepseek-ai/dsh-compaction-basic' import type { BasicCompactionConfig } from '@deepseek-ai/dsh-compaction-basic' diff --git a/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts b/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts index 6406d63753..2e45acbb6c 100644 --- a/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts @@ -985,22 +985,26 @@ describe('headless stream-json snapshots', () => { if (data.name !== 'team_task_update' || typeof data.arguments !== 'string') return false return (JSON.parse(data.arguments) as JsonObject).action === 'complete' }) - const identityReminders = logs.flatMap((log) => { + const identities = logs.flatMap((log) => { if (log === parent || log === workflowChild) return [] const initial = parseJsonl(log.content).find(row => row.type === 'user/message' && ((row.data as JsonObject).source as JsonObject).kind === 'user') if (initial === undefined) throw new Error('Teammate Session has no initial task') const content = (initial.data as JsonObject).content as JsonObject[] - expect(content).toHaveLength(2) - const identity = content[0]!.text - if (typeof identity !== 'string') throw new Error('Teammate initial task has no identity text') - return [identity.trimEnd()] + expect(content).toHaveLength(1) + const context = parseJsonl(log.content).find(row => row.type === 'user/message' + && ((row.data as JsonObject).source as JsonObject).kind === 'runtime-context') + const source = (context?.data as JsonObject)?.source as JsonObject + const sections = source?.sections as JsonObject[] | undefined + const identity = sections?.find(section => section.name === 'team:identity')?.text + if (typeof identity !== 'string') throw new Error('Teammate runtime context has no identity') + return [identity] }).sort() projection = { sessions: logs.length, workflowStopReason: (rows.find(row => row.type === 'tool-workflow/run-end')?.data as JsonObject)?.stopReason, memberEdges: members.length, - identityReminders, + identities, activeMembers: members.filter(member => member.phase === 'active').map(member => member.name).sort(), tasks: latestTasks.map(task => ({ subject: task.subject, @@ -1035,21 +1039,17 @@ describe('headless stream-json snapshots', () => { ], "checkedRoster": true, "deliveredMessages": 0, - "identityReminders": [ - " - You are teammate "implementer". + "identities": [ + "You are teammate "implementer". Your Team Lead is named "lead". - Use list_agents({}) to find your teammates and their names. To message your Team Lead, use send_message({ target: "lead", message: "..." }). - To message another teammate, use send_message({ target: "", message: "..." }). - ", - " - You are teammate "researcher". + Use list_agents({}) to find your teammates and their names. + To message another teammate, use send_message({ target: "", message: "..." }).", + "You are teammate "researcher". Your Team Lead is named "lead". - Use list_agents({}) to find your teammates and their names. To message your Team Lead, use send_message({ target: "lead", message: "..." }). - To message another teammate, use send_message({ target: "", message: "..." }). - ", + Use list_agents({}) to find your teammates and their names. + To message another teammate, use send_message({ target: "", message: "..." }).", ], "memberEdges": 4, "queuedMessages": 0, diff --git a/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts b/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts index 764f353345..cce567d62e 100644 --- a/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts @@ -212,7 +212,7 @@ describe('PTC mode typed values: keyless real-process contracts', () => { }); return started.jobId; `)) - expect(jobId).toBe('bash-1') + expect(jobId).toMatch(/^bash-[0-9a-f]{16}$/) const polled = completion(await runCode(ctx, ` return await tools.job_output({ job_id: ${JSON.stringify(jobId)}, wait: true, timeout_ms: 5000 }); @@ -257,7 +257,8 @@ describe('PTC mode typed values: keyless real-process contracts', () => { }), ]) const job = ctx.jobs.list()[0] - expect(job).toMatchObject({ id: 'bash-1', status: 'running' }) + expect(job).toMatchObject({ status: 'running' }) + expect(job?.id).toMatch(/^bash-[0-9a-f]{16}$/) afterPublication.abort('outer-call-cancelled') expect((await running).isError).toBe(true) expect(ctx.jobs.list()[0]).toMatchObject({ id: job!.id, status: 'running' }) diff --git a/apps/cli/tests/profiles/headless/tests/semantic-checkpoint.expected.e2e.ts b/apps/cli/tests/profiles/headless/tests/semantic-checkpoint.expected.e2e.ts index e2815248f0..6ef2b9b331 100644 --- a/apps/cli/tests/profiles/headless/tests/semantic-checkpoint.expected.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/semantic-checkpoint.expected.e2e.ts @@ -11,7 +11,7 @@ import { import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' import { createUserMessage, ToolCallId, createMessage } from '@deepseek-ai/dsh-llm' import { SessionSeq, SESSION_FORMAT_VERSION, SessionId, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session' -import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' +import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import { logPath } from '../../../../../../packages/session/session-persistence-jsonl/src/format.ts' import { describe, expect, it } from 'vitest' diff --git a/apps/cli/tests/profiles/headless/tests/session-format-guard.expected.e2e.ts b/apps/cli/tests/profiles/headless/tests/session-format-guard.expected.e2e.ts index 1574c2cab1..e5a66ed914 100644 --- a/apps/cli/tests/profiles/headless/tests/session-format-guard.expected.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/session-format-guard.expected.e2e.ts @@ -17,7 +17,7 @@ import { type SessionEvent, type SessionHeader, } from '@deepseek-ai/dsh-session' -import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' +import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import { generationLogFilename, generationLogPath, diff --git a/apps/cli/tests/profiles/headless/tests/subagent-diagnostic.expected.e2e.ts b/apps/cli/tests/profiles/headless/tests/subagent-diagnostic.expected.e2e.ts index cec8642309..941d95c31d 100644 --- a/apps/cli/tests/profiles/headless/tests/subagent-diagnostic.expected.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/subagent-diagnostic.expected.e2e.ts @@ -16,7 +16,7 @@ import { import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm' import { SessionSeq, SESSION_FORMAT_VERSION, SessionId, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session' -import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' +import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import { describe, expect, it } from 'vitest' const fixtureDir = fileURLToPath(new URL('./expected/subagent-diagnostic', import.meta.url)) diff --git a/apps/cli/tests/profiles/headless/tests/subagent-inheritance.expected.e2e.ts b/apps/cli/tests/profiles/headless/tests/subagent-inheritance.expected.e2e.ts index 52df6db0d9..adaf678278 100644 --- a/apps/cli/tests/profiles/headless/tests/subagent-inheritance.expected.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/subagent-inheritance.expected.e2e.ts @@ -16,7 +16,7 @@ import { import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' import { createMessage, createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import { SessionSeq, SESSION_FORMAT_VERSION, SessionId, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session' -import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' +import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import { describe, expect, it } from 'vitest' const fixtureDir = fileURLToPath(new URL('./expected/subagent-inheritance', import.meta.url)) diff --git a/apps/cli/tests/profiles/headless/tests/workspace-context-resume.expected.e2e.ts b/apps/cli/tests/profiles/headless/tests/workspace-context-resume.expected.e2e.ts index aa6da3ad23..6dcc842cf9 100644 --- a/apps/cli/tests/profiles/headless/tests/workspace-context-resume.expected.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/workspace-context-resume.expected.e2e.ts @@ -23,7 +23,7 @@ import { type SessionEvent, type SessionHeader, } from '@deepseek-ai/dsh-session' -import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' +import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import { logPath } from '../../../../../../packages/session/session-persistence-jsonl/src/format.ts' import { renderAgentInstructions } from '@deepseek-ai/dsh-agent-instructions' import { resolveConfig, workspaceBaselineIdentity } from '@deepseek-ai/dsh-agent-instructions/src/config.ts' diff --git a/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts b/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts index 64c1c20ab3..5c51bd36ae 100644 --- a/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts +++ b/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts @@ -202,10 +202,14 @@ describe('Python SDK dsh profile keyless smoke', () => { }, 40_000) it.each([ - { label: 'boots the standalone minimal profile through its generated manifest', editorEnabled: false }, - { label: 'executes the documented editor opt-in patch with sdk-minimal', editorEnabled: true }, - ])('$label', async ({ editorEnabled }) => { + { label: 'boots the standalone minimal profile through its generated manifest', editorEnabled: false, imageEnabled: false, optOut: false, offloadEnabled: false }, + { label: 'executes the documented editor opt-in patch with sdk-minimal', editorEnabled: true, imageEnabled: false, optOut: false, offloadEnabled: false }, + { label: 'sends a read_image result to the model with sdk-minimal', editorEnabled: false, imageEnabled: true, optOut: false, offloadEnabled: false }, + { label: 'offloads an over-budget image and continues with sdk-minimal', editorEnabled: false, imageEnabled: true, optOut: false, offloadEnabled: true }, + { label: 'retains the previous tools through the documented opt-out patch', editorEnabled: false, imageEnabled: false, optOut: true, offloadEnabled: false }, + ])('$label', async ({ editorEnabled, imageEnabled, optOut, offloadEnabled }) => { const root = await mkdtemp(join(tmpdir(), 'dsh-python-sdk-minimal-')) + onTestFinished(() => rm(root, { recursive: true, force: true })) const editorPatch = join(root, 'editor.patch.yml') if (editorEnabled) { const guide = await readFile(join(repoRoot, 'docs/user/guide/python-sdk.md'), 'utf8') @@ -214,7 +218,24 @@ describe('Python SDK dsh profile keyless smoke', () => { expect(yaml).toBeDefined() await writeFile(editorPatch, yaml!) } + if (optOut) { + const guide = await readFile(join(repoRoot, 'docs/upgrade-guide/v0.2.1-alpha.2/sdk-minimal-image-compaction/guide.md'), 'utf8') + const yaml = guide.match(/```yaml\n([\s\S]*?)```/)?.[1] + expect(yaml).toBeDefined() + await writeFile(editorPatch, yaml!) + } + if (offloadEnabled) await writeFile(editorPatch, [ + '- id: llm-deepseek', + ' config:', + ' apiKeyEnv: DEEPSEEK_API_KEY', + ' maxRequestFilesBytes: 1', + ' imageOffloadByteQuantum: 1', + '', + ].join('\n')) const editorFile = join(root, 'editor.txt') + if (imageEnabled) await writeFile(join(root, 'red.png'), Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC', 'base64', + )) const editorContent = 'sdk-minimal editor opt-in\n' const editorCalls = editorEnabled ? [ { command: 'create', path: editorFile, file_text: editorContent }, @@ -222,17 +243,29 @@ describe('Python SDK dsh profile keyless smoke', () => { ] : [] const modelRequests: Record[] = [] const modelServer = createServer((request, response) => { + if (imageEnabled && request.url?.endsWith('/files')) { + request.resume() + response.writeHead(200, { 'content-type': 'application/json' }) + response.end(JSON.stringify({ + id: 'file-minimal-image', type: 'file', size_bytes: 69, + created_at: new Date().toISOString(), + filename: 'red.png', mime_type: 'image/png', + })) + return + } let body = '' request.setEncoding('utf8') request.on('data', (chunk: string) => { body += chunk }) request.on('end', () => { modelRequests.push(JSON.parse(body) as Record) response.writeHead(200, { 'content-type': 'text/event-stream' }) - const toolCall = editorCalls[modelRequests.length - 1] + const toolCall = imageEnabled && modelRequests.length === 1 + ? { file_path: 'red.png' } + : editorCalls[modelRequests.length - 1] response.end(messagesResponse(toolCall ? { type: 'tool_use', - id: `editor-${toolCall.command}`, - name: 'str_replace_editor', + id: imageEnabled ? 'minimal-image-read' : `editor-${editorCalls[modelRequests.length - 1]?.command}`, + name: imageEnabled ? 'read_image' : 'str_replace_editor', input: toolCall, } : { type: 'text', text: 'done' }, toolCall ? 'tool_use' : 'end_turn')) }) @@ -244,7 +277,7 @@ describe('Python SDK dsh profile keyless smoke', () => { ...launch.args, '--profile', 'sdk-minimal', - ...(editorEnabled ? ['--patch', editorPatch] : []), + ...(editorEnabled || optOut || offloadEnabled ? ['--patch', editorPatch] : []), ], { cwd: repoRoot, env: { @@ -274,7 +307,7 @@ describe('Python SDK dsh profile keyless smoke', () => { jsonrpc: '2.0', id: 1, method: 'initialize', - params: { cwd: root, provider: 'deepseek-official', model: 'deepseek-v4-pro' }, + params: { cwd: root, provider: 'deepseek-official', model: imageEnabled ? 'deepseek-flash' : 'deepseek-v4-pro' }, })}\n`) await waitForLine(lines, value => value.id === 1, () => stderr) child.stdin.write(`${JSON.stringify({ @@ -283,9 +316,11 @@ describe('Python SDK dsh profile keyless smoke', () => { method: 'session/prompt', params: { sessionId: 'minimal', contentBlocks: [{ type: 'text', text: 'inspect tools' }] }, })}\n`) + const offloads: Record[] = [] const turnEnd = await waitForLine(lines, (value) => { const params = value.params as Record | undefined const event = params?.event as Record | undefined + if (params?.sessionId === 'minimal' && event?.type === 'image/offload') offloads.push(event) return params?.sessionId === 'minimal' && event?.type === 'turn/end' }, () => stderr) expect(turnEnd, `${JSON.stringify(turnEnd)}\n${stderr}`).toMatchObject({ @@ -302,10 +337,31 @@ describe('Python SDK dsh profile keyless smoke', () => { const tools = modelRequests[0]?.tools as { name?: string }[] expect(tools.map(tool => tool.name)).toEqual([ process.platform === 'win32' ? 'pwsh' : 'bash', + ...(optOut ? [] : ['read_image']), ...(editorEnabled ? ['str_replace_editor'] : []), 'working_directory', ]) - expect(modelRequests).toHaveLength(editorEnabled ? 3 : 1) + expect(modelRequests).toHaveLength(editorEnabled ? 3 : imageEnabled ? 2 : 1) + if (imageEnabled) { + const messages = modelRequests[1]?.messages as { + content: { type: string; tool_use_id?: string; content?: { type: string; source?: { type: string; file_id: string } }[] }[] + }[] + const imageResult = messages.flatMap(message => message.content) + .find(block => block.type === 'tool_result' && block.tool_use_id === 'minimal-image-read') + if (offloadEnabled) { + expect(offloads).toHaveLength(1) + expect(offloads[0]).toMatchObject({ data: { targets: [{ imageIndexes: [0] }] } }) + expect(imageResult?.content?.some(block => block.type === 'image')).toBe(false) + expect(JSON.stringify(imageResult?.content)).toContain('red.png') + } else { + expect(offloads).toEqual([]) + expect(imageResult?.content).toEqual(expect.arrayContaining([ + { type: 'image', source: { type: 'file', file_id: 'file-minimal-image' } }, + ])) + } + const stored = await readdir(join(root, '.dsh', 'attachments', 'v1', 'objects'), { recursive: true }) + expect(stored.some(file => /[a-f0-9]{64}$/u.test(file))).toBe(true) + } if (editorEnabled) { expect(await readFile(editorFile, 'utf8')).toBe(editorContent) expect(modelRequests[2]?.messages).toEqual(expect.arrayContaining([ @@ -334,7 +390,6 @@ describe('Python SDK dsh profile keyless smoke', () => { child.kill('SIGKILL') await child await new Promise(resolve => modelServer.close(() => { resolve() })) - await rm(root, { recursive: true, force: true }) } }, 40_000) @@ -494,8 +549,11 @@ it.each(['unset', 'empty', 'bundled', 'full', 'python-only', 'python-only-no-cli }, () => stderr) const names = (requests[0]!.tools as { name: string }[]).map(value => value.name) expect(names.includes('load_workspace_dependencies')).toBe(enabled) + expect(JSON.stringify(requests[0]!.messages).includes('`office`')).toBe(enabled && mode !== 'missing-assets') + expect(names).not.toContain('office_tools') + expect(names).not.toContain('render_document') for (const name of ['office-docx', 'office-pptx', 'office-xlsx']) { - expect(JSON.stringify(requests[0]!.messages).includes(name)).toBe(enabled && mode !== 'missing-assets' && mode !== 'python-only') + expect(JSON.stringify(requests[0]!.messages).includes(name)).toBe(enabled && mode !== 'missing-assets') } if (mode === 'wrong-type') { expect(requests).toHaveLength(2) @@ -524,8 +582,8 @@ it.each(['unset', 'empty', 'bundled', 'full', 'python-only', 'python-only-no-cli expect.objectContaining({ role: 'user', content }), ])) } - if (mode === 'python-only') expect(stderr).toContain('node') - if (mode === 'missing-assets') expect(stderr).toContain('check_office.py') + if (mode === 'python-only') expect(stderr).not.toContain('ENOENT') + if (mode === 'missing-assets') expect(stderr).toContain('SKILL.md') await send(3, 'shutdown') const exit = await child expect(exit.timedOut).toBe(false) diff --git a/apps/cli/tests/profiles/sdk/office-cli.e2e.ts b/apps/cli/tests/profiles/sdk/office-cli.e2e.ts index d94a88a304..2c90e5fc6e 100644 --- a/apps/cli/tests/profiles/sdk/office-cli.e2e.ts +++ b/apps/cli/tests/profiles/sdk/office-cli.e2e.ts @@ -2,7 +2,7 @@ * 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. + * NODE/CLI/ASSETS under the same prefix select Desktop command and skill 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' @@ -29,16 +29,13 @@ it.skipIf(!process.env.DEEPSEEK_API_KEY || process.platform === 'win32')(`agent 'word/document.xml': strToU8('Office CLI discovery'), })) const patch = join(root, 'office.patch.yml') + const node = process.env.DSH_OFFICE_TEST_NODE, cli = process.env.DSH_OFFICE_TEST_CLI + const assetRoot = process.env.DSH_OFFICE_TEST_ASSETS 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 } : {}), - }, - }] }], + { id: 'skill-office', disabled: false }, + { id: 'skill-office', config: { ...assetRoot === undefined ? {} : { assetRoot }, ...node === undefined ? {} : { node }, ...cli === undefined ? {} : { cli } } }, ])) // A private POSIX tool directory also excludes /usr/bin/node on Linux. const path = join(root, 'bin') @@ -54,7 +51,7 @@ it.skipIf(!process.env.DEEPSEEK_API_KEY || process.platform === 'win32')(`agent 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 }) + const lookup = await execa('/bin/sh', ['-c', 'command -v node || command -v dsoffice'], { 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, @@ -103,10 +100,12 @@ it.skipIf(!process.env.DEEPSEEK_API_KEY || process.platform === 'win32')(`agent 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) + const commandText = commands.map((call) => { + if (typeof call.arguments !== 'string') throw new Error('Shell tool call did not contain serialized arguments') + return (JSON.parse(call.arguments) as { command: string }).command + }).join('\n') + for (const operation of ['capabilities', 'convert']) expect(commandText, diagnostic).toMatch(new RegExp(`\\s${operation}\\b`, 'u')) 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.5') expect(capabilities.runtime.cliPath).toMatch(/lib[/\\]cli\.js$/u) diff --git a/apps/cli/tests/profiles/web/tests/office.expected.e2e.ts b/apps/cli/tests/profiles/web/tests/office.expected.e2e.ts new file mode 100644 index 0000000000..9c47584c49 --- /dev/null +++ b/apps/cli/tests/profiles/web/tests/office.expected.e2e.ts @@ -0,0 +1,12 @@ +/** Ordinary Web leaves Office authoring composition to its carrier. */ +import { expect, it } from 'vitest' +import { withDefaultWeb } from './default-web-process.ts' + +it('does not mount Office skills, commands, or authoring dependencies in ordinary Web', async (test) => { + await withDefaultWeb(test, async ({ request }) => { + const roster = await request('roster') + for (const name of ['skill-office', 'office-commands', 'tool-workspace-dependencies']) { + expect(roster.plugins.some(plugin => plugin.name === name)).toBe(false) + } + }) +}) diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index db0d8e26e3..4d1f9210a2 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -1,6 +1,6 @@ import { createRequire } from 'node:module' import { randomUUID } from 'node:crypto' -import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, readFile, realpath, rm, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { readFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' @@ -25,7 +25,8 @@ import { createPluginProfile } from '../../desktop/src/project-manager.ts' const SETTINGS_NAMESPACE = 'agent-preset-registry' const SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE = 'subagent-model-selection-settings' import { applyChildComposition, childSessionMeta } from '@deepseek-ai/dsh-subagent' -import { ToolCallId } from '@deepseek-ai/dsh-llm' +import { ToolCallId, LlmAdapter, createUserMessage } from '@deepseek-ai/dsh-llm' +import type { GenerateOptions, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-compaction-basic' import type {} from '@deepseek-ai/dsh-skill' import type {} from '@deepseek-ai/dsh-tools' @@ -45,6 +46,48 @@ const CLAUDE_CODE_PACKAGE_DIR = join(REPO_ROOT, 'packages/subagent/subagent-clau /** The installation anchor whose dependency surface the runtime resolution mirrors. */ const INSTALL_ANCHOR = join(REPO_ROOT, 'apps/cli/package.json') const MINIMAL_PROMPT = 'You are a helpful software engineer assistant.' +/** Deterministic model responses for the shipped minimal compaction path. */ +class MinimalCompactionAdapter extends LlmAdapter { + readonly requests: GenerateOptions[] = [] + + override resolveModel(provider: string, model: string): Promise { + return Promise.resolve({ provider, id: model, name: model, context: { contextWindow: 100_000 } }) + } + + override async * stream(options: GenerateOptions): AsyncIterable { + this.requests.push(options) + yield { type: 'block-start', index: 0, blockType: 'text' } + yield { type: 'block-end', index: 0, block: { + type: 'text', text: options.purpose === 'compaction' ? 'Preserve the requested project task.' : 'Ready.', + } } + yield { type: 'finish', reason: { kind: 'stop' } } + } +} +/** Emits one image read, then retains the request carrying its settled result. */ +class MinimalImageAdapter extends LlmAdapter { + readonly requests: GenerateOptions[] = [] + + override resolveModel(provider: string, model: string): Promise { + return Promise.resolve({ provider, id: model, name: model, context: { contextWindow: 1_000_000 }, + inputModalities: ['text', 'image'] }) + } + + override async * stream(options: GenerateOptions): AsyncIterable { + if (options.purpose !== undefined) { + yield { type: 'block-start', index: 0, blockType: 'text' } + yield { type: 'block-end', index: 0, block: { type: 'text', text: 'Image inspection' } } + yield { type: 'finish', reason: { kind: 'stop' } } + return + } + this.requests.push(options) + const first = this.requests.length === 1 + yield { type: 'block-start', index: 0, blockType: first ? 'tool-call' : 'text' } + yield { type: 'block-end', index: 0, block: first + ? { type: 'tool-call', id: ToolCallId('minimal-image-read'), name: 'read_image', arguments: '{"file_path":"red.png"}' } + : { type: 'text', text: 'Image inspected.' } } + yield { type: 'finish', reason: { kind: first ? 'tool-calls' : 'stop' } } + } +} const MINIMAL_BASH_DESCRIPTION = `Run commands in a bash shell * When invoking this tool, the contents of the "command" parameter does NOT need to be XML-escaped. * Network access depends on the task environment. Prefer configured mirrors/proxies when they are available. @@ -74,6 +117,7 @@ async function bootWeb( { id: 'storage-json', config: { root: storageRoot } }, // Fixed Session IDs must stay inside this boot's temporary profile root. { id: 'session-persistence-jsonl', config: { root: join(profileHome, 'sessions') } }, + { id: 'attachment-local', config: { dshHome: profileHome } }, // Host rows with side effects outside this process: a bound port, a served // asset tree, a telemetry exporter. `api-gateway` and `directory-picker` // stay ENABLED on purpose — the api-proxy is the host row that injects @@ -310,15 +354,112 @@ describe('the shipped Web composition', () => { expect(assembly.sections).toEqual([ { name: 'deployment:persona-prefix', text: MINIMAL_PROMPT }, ]) - expect(assembly.tools.map(tool => tool.name)).toEqual(['bash', 'working_directory']) + expect(assembly.tools.map(tool => tool.name)).toEqual(['bash', 'read_image', 'working_directory']) expect(assembly.tools.find(tool => tool.name === 'bash')?.description).toBe(MINIMAL_BASH_DESCRIPTION) expect(ctx.commands.find(handle.agent, 'goal')).toBeUndefined() // serviceFor reports preset-owned providers; unisolated consumers inherit the host fs. expect(ctx.agentPresets.serviceFor(handle.agent, 'fs')).toBeUndefined() expect(ctx.get('fs')?.sandboxMode).toBeDefined() expect(handle.agent.ctx.get('fs')?.sandboxMode).toBe(ctx.get('fs')?.sandboxMode) - expect(ctx.agentPresets.serviceFor(handle.agent, 'compaction')).toBeUndefined() + expect(ctx.agentPresets.serviceFor(handle.agent, 'compaction')).toBeDefined() expect(handle.agent.ctx.get('compaction')).toBeUndefined() + expect(ctx.commands.find(handle.agent, 'compact')).toBeDefined() + expect(await ctx.commands.execute(handle.agent, '/compact', [], new AbortController().signal)) + .toMatchObject({ result: { kind: 'success', text: 'No compactable history yet.' } }) + expect(handle.agent.session.snapshotEvents().some(event => event.type === 'compaction/start')).toBe(false) + } finally { + await handle.dispose() + } + }) + + it('reads an image in minimal mode and carries its durable reference into the next request', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'dsh-minimal-image-')) + onTestFinished(() => rm(cwd, { recursive: true, force: true })) + await writeFile(join(cwd, 'red.png'), Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC', 'base64')) + const adapter = new MinimalImageAdapter() + const unregister = ctx.llm.registerAdapter(['minimal-image-test'], adapter) + onTestFinished(unregister) + const handle = await ctx.agents.create({ + sessionId: SessionId('preset-minimal-image'), meta: { cwd }, + agentOptions: { provider: 'minimal-image-test', model: 'minimal-image-test' }, + setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined), + }) + try { + handle.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Inspect red.png.' }], source: { kind: 'user' } })) + await handle.agent.whenIdle() + expect(adapter.requests).toHaveLength(2) + const result = handle.agent.session.snapshotEvents().find(event => event.type === 'tool/result') + expect(result?.data.message.isError, JSON.stringify(result?.data.message.content)).toBe(false) + const image = handle.agent.session.deriveMessages().flatMap(message => message.content) + .find(block => block.type === 'image') + expect(image?.type).toBe('image') + if (image?.type !== 'image') throw new Error('minimal image read produced no durable image') + expect((await ctx.attachments.readImage(image.attachment)).data.byteLength).toBe(image.attachment.bytes) + expect(JSON.stringify(adapter.requests[1]?.messages)).toContain(image.attachment.attachmentId) + expect(adapter.requests[0]?.tools?.map(tool => tool.name)).toEqual(['bash', 'read_image', 'working_directory']) + expect(result?.data.meta).toMatchObject({ path: await realpath(join(cwd, 'red.png')) }) + } finally { + await handle.dispose() + } + }) + + it('compacts minimal history through /compact and continues from the checkpoint', async () => { + const adapter = new MinimalCompactionAdapter() + const unregister = ctx.llm.registerAdapter(['minimal-compaction-test'], adapter) + onTestFinished(unregister) + const handle = await ctx.agents.create({ + sessionId: SessionId('preset-minimal-compaction'), + agentOptions: { provider: 'minimal-compaction-test', model: 'minimal-compaction-test' }, + setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined), + }) + try { + handle.agent.followup(createUserMessage({ + content: [{ type: 'text', text: 'Remember the project requirements. '.repeat(200) }], source: { kind: 'user' }, + })) + await handle.agent.whenIdle() + const before = handle.agent.session.surface.replaceGeneration + const execution = await ctx.commands.execute(handle.agent, '/compact', [], new AbortController().signal) + expect(execution?.result.kind).toBe('success') + expect(handle.agent.session.surface.replaceGeneration).toBeGreaterThan(before) + const summary = handle.agent.session.snapshotEvents().find(event => event.type === 'compaction/summary') + expect(summary?.data.sourceCommandId).toBe(execution?.commandId) + expect(adapter.requests.filter(request => request.purpose === 'compaction')).toHaveLength(1) + handle.agent.followup(createUserMessage({ + content: [{ type: 'text', text: 'Continue the project task.' }], source: { kind: 'user' }, + })) + await handle.agent.whenIdle() + const last = adapter.requests.at(-1) + expect(last?.messages.flatMap(message => message.content) + .some(block => block.type === 'text' && block.text.includes('Preserve the requested project task.'))).toBe(true) + expect(toolNames(ctx, handle.agent)).toEqual(['bash', 'read_image', 'working_directory']) + } finally { + await handle.dispose() + } + }) + + it('automatically compacts minimal history under context pressure', async () => { + const adapter = new MinimalCompactionAdapter() + const unregister = ctx.llm.registerAdapter(['minimal-pressure-test'], adapter) + onTestFinished(unregister) + const handle = await ctx.agents.create({ + sessionId: SessionId('preset-minimal-pressure'), + agentOptions: { provider: 'minimal-pressure-test', model: 'minimal-pressure-test' }, + setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined), + }) + try { + for (let turn = 0; turn < 3; turn++) { + handle.agent.followup(createUserMessage({ + content: [{ type: 'text', text: 'Remember the project requirements. '.repeat(6000) }], + source: { kind: 'user' }, + })) + await handle.agent.whenIdle() + } + const summaries = handle.agent.session.snapshotEvents().filter(event => event.type === 'compaction/summary') + expect(summaries.length).toBeGreaterThan(0) + expect(summaries.every(event => event.data.sourceCommandId === undefined)).toBe(true) + expect(handle.agent.session.snapshotEvents().some(event => event.type === 'compaction/start' + && event.data.turn !== null)).toBe(true) + expect(adapter.requests.some(request => request.purpose === 'compaction')).toBe(true) } finally { await handle.dispose() } @@ -334,8 +475,10 @@ describe('the shipped Web composition', () => { setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined), }) try { - expect(toolNames(ctx, minimal.agent)).toEqual(['bash', 'working_directory']) + expect(toolNames(ctx, minimal.agent)).toEqual(['bash', 'read_image', 'working_directory']) expect(toolNames(ctx, full.agent).length).toBeGreaterThan(10) + expect(ctx.agentPresets.serviceFor(minimal.agent, 'compaction')) + .not.toBe(ctx.agentPresets.serviceFor(full.agent, 'compaction')) await minimal.dispose() @@ -500,7 +643,7 @@ describe('the shipped Web composition', () => { // stays the preset's choice — minimal mounts no `tool-skill`, so its // tool table has no loader even though the global layer is readable. expect((await ctx.skills.list({ scope: handle.agent })).map(skill => skill.name)).toContain('dsh-badge') - expect(toolNames(ctx, handle.agent)).toEqual(['bash', 'working_directory']) + expect(toolNames(ctx, handle.agent)).toEqual(['bash', 'read_image', 'working_directory']) } finally { await handle.dispose() } @@ -771,18 +914,19 @@ describe('a forked session', () => { }) describe('a delegated child', () => { - it('runs on the composition its parent runs on', async () => { + it.each([false, true])('inherits preset tools with seeded=%s and refuses child reminders', async (seeded) => { const parent = await ctx.agents.create({ - sessionId: SessionId('preset-child-parent'), + sessionId: SessionId(`preset-child-parent-${seeded}`), meta: { agentPreset: 'standard' }, setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined), }) // Exactly what an in-process subagent driver's creation window does. const child = await parent.agent.ctx.agents.create({ - sessionId: SessionId('preset-child'), - meta: childSessionMeta(parent.agent, 1, false), + sessionId: SessionId(`preset-child-${seeded}`), + meta: childSessionMeta(parent.agent, 1, seeded), + ...(seeded ? { seed: [], inheritedEventCount: SessionLogOffset(0) } : {}), setup: (agentCtx) => { - applyChildComposition(agentCtx, parent.agent, {}) + return applyChildComposition(agentCtx, parent.agent, {}) }, }) try { @@ -790,6 +934,21 @@ describe('a delegated child', () => { // The shipped `standard` preset is the whole coding agent; an empty // child here is the defect, and equality alone would not catch it. expect(toolNames(ctx, child.agent)).toContain('bash') + expect(ctx.tools.schemas(child.agent)).toEqual(ctx.tools.schemas(parent.agent)) + const calls = [ + ['schedule_create', { prompt: 'Check', after_seconds: 60, title: 'Check' }], + ['schedule_list', {}], + ['schedule_delete', { id: 'task-1' }], + ['schedule_update', { id: 'task-1', title: 'Check' }], + ] as const + for (const [name, args] of calls) { + expect(toolNames(ctx, child.agent)).toContain(name) + const result = await ctx.tools.execute({ + callId: ToolCallId(`child-${name}`), signal: new AbortController().signal, + name, arguments: args, agent: child.agent, + }) + expect(result.value).toEqual({ code: 'subagent_session', message: 'A delegated subagent cannot use reminders.' }) + } expect(child.agent.session.header.agentPreset).toBe('standard') } finally { await child.dispose() @@ -808,7 +967,7 @@ describe('a delegated child', () => { sessionId: SessionId('preset-child-switch'), meta: childSessionMeta(parent.agent, 1, false), setup: (agentCtx) => { - applyChildComposition(agentCtx, parent.agent, {}) + return applyChildComposition(agentCtx, parent.agent, {}) }, }) try { @@ -837,7 +996,7 @@ describe('the default preset as a user setting', () => { }) try { // An omitted preset id resolves the stored default instead of the composed default. - expect(toolNames(ctx, handle.agent)).toEqual(['bash', 'working_directory']) + expect(toolNames(ctx, handle.agent)).toEqual(['bash', 'read_image', 'working_directory']) } finally { await handle.dispose() } @@ -883,7 +1042,7 @@ describe('a profile patch stored before Developer tools owned preset selection', setup: agentCtx => legacy.agentPresets.mount(agentCtx).then(() => undefined), }) try { - expect(toolNames(legacy, handle.agent)).toEqual(['bash', 'working_directory']) + expect(toolNames(legacy, handle.agent)).toEqual(['bash', 'read_image', 'working_directory']) } finally { await handle.dispose() } diff --git a/apps/desktop-host/tests/office.spec.ts b/apps/desktop-host/tests/office.spec.ts index 862f832029..02e741d4ce 100644 --- a/apps/desktop-host/tests/office.spec.ts +++ b/apps/desktop-host/tests/office.spec.ts @@ -1,6 +1,6 @@ import { cp, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { join, sep } from 'node:path' import { pathToFileURL } from 'node:url' import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' @@ -9,12 +9,14 @@ import AgentRegistry from '@deepseek-ai/dsh-agent' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import SkillRegistry from '@deepseek-ai/dsh-skill' import ToolRuntime from '@deepseek-ai/dsh-tools' -import { expect, it } from 'vitest' +import { expect, it, vi } from 'vitest' import * as desktopOffice from '../src/office.ts' it('loads Desktop Office CLI paths and removes them on disposal', async () => { const root = await mkdtemp(join(tmpdir(), 'desktop-office-')) + vi.stubEnv('DSH_HOME', root) 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 }) @@ -47,10 +49,10 @@ it('loads Desktop Office CLI paths and removes them on disposal', async () => { 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((await ctx.skills.list()).map(skill => skill.name)).toEqual(['office', 'office-docx', 'office-export-pdf', 'office-pptx', 'office-recalculate', 'office-render', 'office-xlsx']) + expect((await ctx.skills.get('office-pptx'))?.resourceBase).toEqual({ kind: 'directory', path: join(assets, 'office-pptx').split(sep).join('/') }) + expect((await ctx.skills.get('office-pptx'))?.content).toContain('office-render') + expect((await ctx.skills.get('office-pptx'))?.content).not.toContain(nodeDirectory) 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() @@ -58,7 +60,10 @@ it('loads Desktop Office CLI paths and removes them on disposal', async () => { expect(await ctx.skills.list()).toEqual([]) expect(ctx.tools.schemas()).toEqual([]) } finally { - await ctx.fiber.dispose() - await rm(root, { recursive: true, force: true }) + try { await ctx.fiber.dispose() } + finally { + vi.unstubAllEnvs() + await rm(root, { recursive: true, force: true }) + } } }) diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 783cd52d85..6060f79da9 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -2,24 +2,36 @@ # section, a hash of its English and Chinese blocks outside code blocks and generated regions. # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/desktop/README.md +/: + en: 0d36989eb614345d + zh: a0a1a18c07621393 /deepseek-harness-desktop: - en: 5b8518fd5e0030ef - zh: 02efee59c39647dc + en: 69a6e0c6712e463b + zh: 84e1d0b09c803704 +/deepseek-harness-desktop/summary: + en: 7bc8bf30de693222 + zh: e85993d8852b6cab +/deepseek-harness-desktop/table-of-contents: + en: 05fd227f99ee535a + zh: 60d7d62bc0026973 +/deepseek-harness-desktop/application-overview: + en: e9b22f232837e131 + zh: fc5f3834b969d2d2 /deepseek-harness-desktop/terminal-command: - en: bef92953c237363f - zh: d21d6effd21b8c00 + en: 5f2d25a1d8472647 + zh: 2acb5c5d7d1917e2 /deepseek-harness-desktop/closing-the-window-and-quitting: - en: c323e02e3f0305fe - zh: 3c730db632136443 + en: 24387a07ae4eb8e7 + zh: 0119352685cf273c /deepseek-harness-desktop/key-technical-decisions: - en: 069fdfd72d2ea26a + en: 3724c53da4f7647b zh: 76ae549d43c99bb4 /deepseek-harness-desktop/key-technical-decisions/bundled-workspace-dependencies: - en: 1a3023b9ca699757 - zh: 45ebed9f64fa4e32 + en: 3368ff9b955ada69 + zh: e9209ef6ad4fd305 /deepseek-harness-desktop/bundled-command-runtime: - en: 09f0b75af9f0ed14 - zh: 60bbc2e67d5e87a8 + en: 372297005b57a08b + zh: 074a635ac17e4362 /deepseek-harness-desktop/installation-ownership: en: 22153a298a6662e1 zh: 3600803c8c9d0274 @@ -27,8 +39,8 @@ en: a1a47b09f4a72ba4 zh: 7b1cf27f0b72cf31 /deepseek-harness-desktop/installation-ownership/host-environment: - en: 84a9cc7ca9455aa8 - zh: 4091b8aa8231b74a + en: 98cf848762482d48 + zh: 74f9100a39ec8c2f /deepseek-harness-desktop/develop: en: fe6d23e6a3d3bc66 zh: d60c8d3a55747916 @@ -36,41 +48,17 @@ en: a51346b8d880ddba zh: 7913214c1c66063c /deepseek-harness-desktop/develop/welcome-window-appearance: - en: ae319a496639b38d - zh: ef044103240ac9da + en: 617a89aeb28131b9 + zh: 4d84bd5d86501c86 /deepseek-harness-desktop/package: - en: 4cd8a8da6977928d - zh: 9f624855a2edac37 -/deepseek-harness-desktop/package/release-versions: - en: d938302f92fc3a1a - zh: 29df50bc8791baa7 -/deepseek-harness-desktop/package/local-macos-dmg-without-release-signing: - en: 57869a5e3885f843 - zh: 7cfc01b2dcbebd78 -/deepseek-harness-desktop/package/local-official-bundles-from-a-loopback-registry: - en: 5f05072ec82cc62d - zh: a3d63b58d1ef2db7 -/deepseek-harness-desktop/package/runtime-file-selection: - en: 66898d7b99807291 - zh: dfbbbb607ad2709f -/deepseek-harness-desktop/package/upload-updates: - en: f48baa680e841b0d - zh: 25ded1aeaac6dad1 -/deepseek-harness-desktop/package/unsigned-windows-test-installer: - en: fb6de542360dbae7 - zh: 0e17d7765fb10c48 -/deepseek-harness-desktop/package/windows-installer-interface: - en: 1fc1db8f54475c8f - zh: dd834ed206070396 -/deepseek-harness-desktop/package/windows-ev-signing: - en: 64836513eb7eda6e - zh: 6ebb9184a7aa51de -/deepseek-harness-desktop/package/desktop-runtime-preparation: - en: 9a71990bee9867a7 - zh: 76c5c1fdff6e6908 + en: ac31ebdd9de54f34 + zh: 921e534d5c4b3a16 +/deepseek-harness-desktop/windows-installer-interface: + en: 8cb4b7babbd8714d + zh: b4aebc1d2d152e01 /deepseek-harness-desktop/updates: - en: 41d0f50eb7173342 - zh: ce940ef3cbcc6fa5 + en: 4788b6b60489ef92 + zh: 6312c5590efa27f1 /deepseek-harness-desktop/updates/local-desktop-settings: en: 2e57457c44c85b5f zh: 5b8510a3dc4c6c9d @@ -78,14 +66,14 @@ en: 54596bef0706d305 zh: 48737302dcf3c939 /deepseek-harness-desktop/updates/local-updater-qualification: - en: bc50fd8a4d96c227 - zh: fb0a6ce80c31cda6 + en: 68ec2f8bd5763ea6 + zh: 23b932dfe8503602 /deepseek-harness-desktop/low-level-development-overrides: - en: 0b225f914142ef0b - zh: 9ffd07768d37824e + en: 8264b135871e8978 + zh: 0939aafb1b2961fc /deepseek-harness-desktop/known-limitations: - en: 5b77213c86bbcfb6 - zh: b34bf21773bb852b + en: c63d4dc7a9104954 + zh: a4c676e5b46a6ac0 /deepseek-harness-desktop/dev-note: en: 8e26b460ba31f21b zh: fbcd0307b3867125 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index c43ff167a8..e0bb92c5b7 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -1,7 +1,35 @@ +--- +description: "Desktop runs the Harness workspace in a native application. This reference covers application behavior, development and integration; the packaging, Windows signing and publication guides own build and release operations." +--- + # DeepSeek Harness Desktop English | [中文](README.zh.md) +## Summary + +Desktop runs the Harness workspace in a native application. This reference covers application behavior, development and integration; the packaging, Windows signing and publication guides own build and release operations. + +## Table of Contents + +- [Application overview](#application-overview) +- [Terminal command](#terminal-command) +- [Closing the window and quitting](#closing-the-window-and-quitting) +- [Key technical decisions](#key-technical-decisions) +- [Bundled command runtime](#bundled-command-runtime) +- [Installation ownership](#installation-ownership) +- [Develop](#develop) +- [Package](#package) +- [Windows installer interface](#windows-installer-interface) +- [Updates](#updates) +- [Low-level development overrides](#low-level-development-overrides) +- [Known limitations](#known-limitations) +- [Dev Note](#dev-note) + + + +## Application overview + Desktop analytics follows the [product collection policy](../../packages/client/product-analytics/README.md), including its live application setting. Web usage is excluded. Update installation waits for its local analytics intake before locking API admission and stopping the Host. Intake has a one-second deadline; failure does not prevent installation, and collector delivery is not awaited. The desktop application is an Electron shell around the complete dsh Web application. An Electron RunAsNode child starts the shared profile runner, and Electron immediately loads the packaged Web entry at `dsh-app://app/`. Its shared loading page waits for Host boot injections, then starts the client without navigating to another document. Electron forwards application HTTP requests to the authenticated Web Host, dropping connection-level response headers (`transfer-encoding`, `connection`, `keep-alive`) that describe the Node fetch rather than the resource, and marking plugin bundle responses `no-store` because their per-launch revisions would only accumulate in Chromium's disk cache; WebSocket streams connect to that Host with credentials attached only for the owned application window. Node IPC carries boot injections, readiness, and shutdown. Desktop listens on an OS-assigned port by default, so it cannot collide with Web’s `3080` or a reserved port; a `webserver.config.port` patch can override it. @@ -24,6 +52,8 @@ Desktop microphone access is restricted to audio requests from the primary `dsh- Press F12 (Fn+F12 on media-key keyboards), Command+Option+I on macOS, or Ctrl+Shift+I on Windows to toggle DevTools for the focused application page, including in packaged builds. These native shortcuts use hidden application-menu items. Update overlays and packaged embedded browser guests disable DevTools. + + ## Terminal command The application menu's **Manage dsh Command…** entry, immediately below **Check for Updates…**, shows the current command and offers Install, Repair, and Remove. The command uses Desktop's installed runtime and the ordinary [dsh CLI](../cli/README.md), including when the Desktop application is closed. Open a new terminal after installation and run `dsh --version`. @@ -32,6 +62,8 @@ On macOS, installation creates `/usr/local/bin/dsh` and requests administrator a Command registration is optional after installing Desktop. Use **Manage dsh Command… → Remove** before uninstalling Desktop to remove its CLI registration; the application uninstaller does not remove it. Finish CLI commands before updating or uninstalling Desktop. The CLI runtime version follows the installed Desktop release. See [bundled command runtime](#bundled-command-runtime) for Desktop plugin commands and runtime limitations. + + ## Closing the window and quitting Closing the main window (macOS close button and ⌘W; Windows ×, Alt+F4, and the taskbar Close window command) hides it; Windows asks for acknowledgement before the first hide. The page and the Host keep running, tasks continue, and the next show presents the same document with its session, drafts, and scroll position; a fullscreen macOS window leaves fullscreen before hiding. The window returns through the Dock icon, a second launch, or `dsh://open` on macOS, and through the tray on Windows. Minimize is unchanged. Closing the welcome window before the workspace opens quits on Windows and, on macOS, keeps the application in the Dock without a window. @@ -46,6 +78,8 @@ The confirmation is skipped when the installer restart already confirmed task in The tray renderer enlarges the whale by 20% around the tile center while preserving the background and aspect ratio; application and installer icons retain their original proportions. + + ## Key technical decisions The original artwork lives in `resources/icon.png` and `resources/icon.svg`; platform adaptations retain the whale and gradients in `resources/icon-windows.*` and `resources/icon-macos.*`. Export each platform SVG as a transparent 1024×1024 PNG. Electron-builder generates the multi-size ICO for the Windows application, installer, and uninstaller ([Windows icon requirements](https://learn.microsoft.com/en-us/windows/apps/design/iconography/app-icon-construction)). The installation pages use matching artwork in both themes; the uninstaller's welcome and finish pages share `installer/assets/uninstaller-sidebar.png`, converted to a 164×314 BMP during preparation. @@ -56,15 +90,17 @@ The macOS File menu displays the accepted single-key binding, including arrow ke The macOS PNG uses an inset rounded background for legacy ICNS packaging, with representations up to 1024 pixels. It is a flattened icon, not an Icon Composer document. Apple's [app icon guidance](https://developer.apple.com/design/human-interface-guidelines/app-icons) describes unmasked layers for Icon Composer; those inputs require a separate macOS export and must not reuse the rounded ICNS artwork. Verify Finder and Dock appearance on supported macOS versions before release. + + ### Bundled workspace dependencies electron-builder copies only the manifest's `dependencies` into `app.asar/node_modules`, so the Electron main bundle `lib/main.js` inlines its workspace devDependencies and leaves `electron`, Node builtins, and those `dependencies` as its only bare imports; a sandboxed preload may leave only `electron`, `events`, `timers`, and `url`, the modules its `require` polyfill resolves. The main bundle resolves the inlined packages from their `lib/` output, so the root `build:lib:host` bundles `apps/desktop` after the concurrent workspace tsdown pass, and [`desktop-bundle-imports`](scripts/desktop-bundle-imports.mjs) fails any Desktop bundle whose static, dynamic, or `require()` import the packaged application cannot resolve. Without that check an import rolldown could not resolve ships as an external specifier and fails at launch with `ERR_MODULE_NOT_FOUND`. The [archived bundle-order decision](../../.agents/notes/archived/process/2026-09-22-desktop-main-bundle-after-workspace-tsdown.md) records the alternatives. -Signed Windows packaging scans the primary runtime and application production dependencies for PE content, including files without a conventional extension. The final scan covers the entire unpacked application. Directory links, malformed `MZ` files and non-PE `.exe`, `.dll` or `.pyd` files stop packaging; data files beginning with `MZ` are also rejected unless they contain a valid PE header. It preserves valid vendor signatures and signs unsigned code before sealing runtime hashes or executing smoke checks. Public-key inspection uses batches of 32 files with at most four processes; hardware-token signing remains serial, and each new signature must match the configured certificate and carry a timestamp. Hardware signing and verification failures stop the run; isolated timestamp requests follow the bounded policy below. Electron-builder preserves copied runtime executables only after signature and exact-byte verification. A final PE-signature audit and a fresh-cache ASAR payload and Host smoke must pass before the release completion record is written. Development, preparation-only and unsigned builds do not use the hardware token and can be blocked by Windows code-integrity policy; no build mode disables that policy. Passing smoke checks does not establish compatibility with every enterprise policy. +Signed Windows packaging scans the primary runtime and application production dependencies for PE content, including files without a conventional extension. The final scan covers the entire unpacked application. Directory links, malformed `MZ` files and non-PE `.exe`, `.dll` or `.pyd` files stop packaging; data files beginning with `MZ` are also rejected unless they contain a valid PE header. It preserves valid vendor signatures and signs unsigned code before sealing runtime hashes or executing smoke checks. Public-key inspection uses batches of 32 files with at most four processes; hardware-token signing remains serial, and each new signature must match the configured certificate and carry a timestamp. Hardware signing and verification failures stop the run; isolated timestamp requests follow the [bounded retry policy](windows-signing/README.md#windows-ev-signing). Electron-builder preserves copied runtime executables only after signature and exact-byte verification. A final PE-signature audit and a fresh-cache ASAR payload and Host smoke must pass before the release completion record is written. Development, preparation-only and unsigned builds do not use the hardware token and can be blocked by Windows code-integrity policy; no build mode disables that policy. Passing smoke checks does not establish compatibility with every enterprise policy. Desktop carries independent Python, Node.js and pnpm distributions. Python includes numpy, pandas, python-docx, python-pptx, openpyxl, Pillow, lxml and XlsxWriter with their complete dependencies. The `load_workspace_dependencies` tool installs this payload offline on first use under `$DSH_HOME/dsh-runtimes/dsh-primary-runtime` (normally `~/.dsh/dsh-runtimes/dsh-primary-runtime`) and returns absolute interpreter, pnpm script and library paths plus `pythonDistributions`, the bundled distribution names and versions. The version report excludes user-installed additions. Office tasks prefer these libraries unless user or workspace instructions select another environment. Execute the pnpm script with the returned Node executable. The returned Node library directory is reserved for bundled libraries, not pnpm's global installation directory. -Desktop registers `office-docx`, `office-pptx`, and `office-xlsx` by default. The skills use the bundled Python libraries for creation and focused edits, then reopen the files and run a shared structural checker before delivery. PowerPoint creation and editing use python-pptx. Skill resources are copied to `runtime/office-skills` outside ASAR so Python can read the checker. An available `render_document` tool can add visual inspection; its absence does not prevent authoring or delivery. See the [Office skill package](../../packages/skill/skill-office/README.md) for checks and limitations. +Desktop exposes the compact `office` router and named authoring/operation subskills. Authoring uses bundled Python libraries; operation skills invoke the configured Node and CLI paths. Skills live in `runtime/office-skills` outside ASAR. See the [Office skill package](../../packages/skill/skill-office/README.md) for progressive loading and limitations. The payload follows the Desktop release. `runtime.json` records the Desktop version, target, top-level interpreter/package-manager versions and the Python distribution map, and a digest of the selected target’s locked payload inputs and assembly format. Distribution names use PEP 503 normalization; duplicate normalized names reject the manifest. Legacy `components` manifests remain readable through normalization, with their existing library-version consistency checks. Matching installations are reused; a dependency or archive change replaces the directory after a complete staged copy even when the Desktop version stays unchanged. Older manifests without a digest are replaced on their next installation. User-added Python packages remain only while the payload identity matches. A failed directory replacement retains the previous installation; Windows may refuse replacement while an interpreter is still running. @@ -98,6 +134,8 @@ To manage Desktop's plugins, launch Desktop once to initialize its profile, full Finish CLI commands before updating or uninstalling Desktop; the installer does not coordinate with running commands. The CLI does not supply Office authoring-skill resource paths. Desktop's existing Office setup and Web attachment conversion retain their own resource configuration. `prepare:runtime` copies the launch scripts without compiling a native CLI launcher. + + ## Installation ownership Electron owns `$DSH_HOME/profiles/desktop`. Its `dependencies` contains packages installed by pnpm; `dsh.profile.bundles` contains the built-in bundles followed by enabled plugins. The signed application supplies dsh, the private Desktop Host, and their production packages from `resources/app.asar/dsh`. Packaged applications select runtime profile resolution without creating package links; development profiles use filesystem links. Both host and plugins execute in the same Electron Node-mode process; Desktop does not enable `--preserve-symlinks`. The CLI cannot boot this profile. The Desktop-installed command can manage its plugins while the application is quit; npm-installed dsh cannot mutate it. @@ -144,6 +182,8 @@ Recovery waits for Host shutdown before changing plugin activation. The native r macOS and Linux GUI launches inherit only the session manager's environment, without variables exported by shell startup files. Before the first Host starts, Desktop runs the account's login shell (from the user database, not `$SHELL`) once as ` -ilc` and reads its `env -0` output between delimiters, so `~/.zprofile` and `~/.zshrc` (or the shell's equivalents) take effect for the Host, agent shells, terminals, and profile configuration. Reading runs in parallel with profile preparation. The probe has no terminal input and sets `DISABLE_AUTO_UPDATE=true`, `ZSH_TMUX_AUTOSTARTED=true`, and `ZSH_TMUX_AUTOSTART=false` to keep oh-my-zsh and tmux plugins from blocking. Shell values replace inherited values except `PWD`, `OLDPWD`, `SHLVL`, `_`, those probe variables, and launcher-owned `DSH_*` and `ELECTRON_*` names; Desktop resolves paths such as `DSH_HOME` before the read, so the Host keeps the same values. The read completes at the closing delimiter, so background processes started by startup files may keep running with their output discarded. A candidate that fails to start, exits nonzero, prints no delimited output, or exceeds `DSH_DESKTOP_LOGIN_SHELL_TIMEOUT_MS` (integer milliseconds from 1000 through 2147483647, default `10000`; its process group is killed) is logged as a warning, and Desktop tries `/bin/zsh`, `/bin/bash`, and `/bin/sh` in turn; when all fail the Host uses the inherited environment. The dump command uses POSIX syntax, so an account shell such as csh, tcsh, or nushell fails and the Host receives the environment from the first system shell's startup files instead. Quitting Desktop during the read kills the running probe's process group. The result is read once per application process, so changes to shell startup files apply after quitting and reopening Desktop. Windows GUI launches already inherit the user and system environment variables from the registry, so Windows skips this step. + + ## Develop The development application menu offers Reload Page (Cmd+R on macOS, Ctrl+R elsewhere) and Restart App and Host. Restart waits for Host shutdown before relaunching Electron and starting a new Host; neither action rebuilds source files. @@ -184,274 +224,44 @@ The browser-login waiting page offers a copy-link action for the current pending The welcome window follows system appearance with the design’s Platform light/dark colors and shows the 600 × 700 entry layout and the API-key form, with native window controls, a draggable title area, a local brand SVG, system sans-serif fallbacks, and locally bundled Montserrat Light for non-button text. The window uses macOS menu vibrancy or Windows acrylic with the onboarding window tint: 40% white in light mode and 50% rgb(24 25 28) in dark mode. The local React welcome entry bundles React and the shared `StateDot` loading indicator with its CSS; it uses the isolated preload without loading the main Web application. The entry, sign-in status and API-key pages share a fixed bottom action row; the back-to-sign-in link sits below it. Buttons share the platform motion timings, and Reduce Motion disables their transitions. The OS owns blur strength and outer corners. macOS Reduce Transparency suppresses translucency, and Increase Contrast forces that setting on. Save and continue writes to the development credential store; Set up later opens the real workspace without saving a key or completion flag. The generated project links the declared workspace dependency closure as well as pnpm’s hoisted packages, so unhoisted configured plugins remain resolvable. [The window note](../../.agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.md) owns the material and onboarding decisions. + + ## Package - -### Release versions - -Before each Desktop packaging run, first confirm the complete version string with the current user. Check the selected deployment, dsh base version, retained release records, and published objects, then propose the exact version for approval. Do not start packaging until the user confirms that version; the deployment setting alone does not authorize a version choice. - -Record the current dsh version as the base. A production Desktop release uses that exact version, including any `alpha`, `beta`, or `rc` identifiers. A test release preserves the complete prerelease base and appends `.YYYYMMDD.index`; a stable base uses `-test.YYYYMMDD.index` instead. - -| dsh base | Production Desktop | Test Desktop example | -|---|---|---| -| `0.1.6-alpha.1` | `0.1.6-alpha.1` | `0.1.6-alpha.1.20260916.1` | -| `0.1.6-beta.2` | `0.1.6-beta.2` | `0.1.6-beta.2.20260916.1` | -| `0.1.6-rc.3` | `0.1.6-rc.3` | `0.1.6-rc.3.20260916.1` | -| `0.1.6` | `0.1.6` | `0.1.6-test.20260916.1` | - -Use the actual creation date in Asia/Shanghai. For each base and date, start the index at 1 and increment after checking retained release records and published objects; never reuse a published version. Test distribution does not publish the corresponding unsuffixed base. - -Manual Windows CI uses a separate unpublished build identifier: `auto` appends the UTC date, workflow run number and attempt (`.YYYYMMDD.run.attempt`, with `-test` for a stable base). For example, `0.2.1-alpha.1.20261008.42.2`. These numbers are not reserved release sequence numbers. - -Pass the confirmed version to the packaging command as `--build-version`, which reaches the artifact names, the update feed, and the upload validation as one value. The manifests keep the product version, so a test build no longer rewrites the release family and leaves nothing to revert: - -```sh -pnpm --dir apps/desktop run package:win:x64 --build-version 0.1.6-alpha.1.20260916.1 -``` - -`--build-version auto` proposes the next index for today, reading the published objects in the destination bucket and falling back to this target's local output directory when no bucket is configured or the listing cannot finish within its deadline. Confirm the proposal it prints before an upload; a run script forwards `--` on its own, and the packaging entry accepts it either way. - -A production release publishes the product version and takes no `--build-version`. Uploading one tags the packaged commit as `desktop-v` once the artifacts are public; a build from a modified checkout is not tagged, and a tagging failure prints the command to run by hand rather than failing a completed upload. Test and local builds are deliberately left untagged, and every artifact records `dshBuildCommit` and `dshBuildDirty` in its manifest so a build handed over directly remains traceable. - -Version derivation does not change the fixed update channel or `nightly.yml` / `nightly-mac.yml` filenames. SemVer orders `0.1.6-alpha.1 < 0.1.6-alpha.1.20260916.1 < 0.1.6-alpha.2`, and a stable base's test version precedes that stable release. Clients only accept a greater version: replacing a feed cannot move an installed higher version to a lower corrected version. Such clients need manual installation; keep automatic downgrade disabled. The [archived version decision](../../.agents/notes/archived/process/2026-09-16-desktop-release-version-derivation.md) explains why the channel does not supply the prerelease identifier. - -Except for local unsigned macOS builds, packaging, upload, and manual macOS signature verification read `apps/desktop/.env.windows` or `.env.macos`, selected by target platform. Copy the [Windows template](.env.windows.example) or [macOS template](.env.macos.example) and fill in the local settings; Git ignores both local files, and packaged artifacts exclude them. Release fields come only from the target file, without fallback to system or shell variables; `PATH`, proxies, and build-tool settings remain inherited. The published version is an argument rather than a release field, and upload reads it from the completion record the packaging run wrote. Files use UTF-8 with optional BOM; relative certificate, SignTool, Apple API key, and keychain paths resolve from `apps/desktop`, values are not shell-expanded, and passwords containing `#` or spaces need quotes. CI also creates the target file before invoking packaging. - -Every package command checks the application ID and its mode-specific update and signing configuration before building or downloading, then probes the external tools the run will use: the archive reader, and on a Windows target the installer compiler. macOS checks the identity, Team ID, one complete notarization strategy, readable local `CSC_LINK` p12 file, explicit `CSC_KEY_PASSWORD`, and referenced API key and keychain files; Windows checks the public code-signing certificate, SignTool file, container name, and PIN format. Windows preparation-only builds and explicit unsigned builds on either platform do not require signing credentials. Configuration checks do not authenticate the PIN, log in to the token, unlock a keychain, or contact Apple; actual signing and notarization perform those checks. `--build-version auto` does contact the destination bucket, including under `--check`. Run the same checks separately: - -```sh -pnpm --dir apps/desktop run check:package -``` - -`prepare:desktop` is not a prerequisite: - -```sh -pnpm run package:desktop -``` - -Release automation uses fixed target commands so runtime preparation, dsh preparation, and electron-builder receive the same platform and architecture: - -```sh -pnpm run package:desktop:mac:arm64 -pnpm run package:desktop:mac:x64 -pnpm run package:desktop:win:x64 -``` - -The macOS arm64 command requires Apple Silicon. The macOS x64 command runs on Intel macOS or Apple Silicon with Rosetta. The Windows x64 command requires Windows x64. Linux is not a supported Desktop release target. - -Each target owns its packed package inputs, prepared runtime, package set, dsh tree, pnpm preparation state, unpacked application, update metadata, and final artifacts under `apps/desktop/.desktop-build/targets//`. The Electron archive cache remains shared under `.desktop-build/downloads` because every archive name includes its version, platform, and architecture and is verified before extraction. A target build never consumes another target's mutable preparation state. - -### Local macOS DMG without release signing - -After `pnpm install`, run one command from the repository root. No `.env.macos`, Apple account, certificate, or update service configuration is required: - -```sh -pnpm run package:desktop:mac:arm64:unsigned -pnpm run package:desktop:mac:x64:unsigned -``` - -Add `--check` to validate local configuration and tools without building, or `--dir` to stop at the unpacked application. The DMG is `apps/desktop/.desktop-build/targets//unsigned-artifacts/deepseek-harness--mac--unsigned.dmg`. After all checks pass, the command prints `DMG:` followed by its absolute path. Open it and copy the application to a local test directory. - -These commands ignore `.env.macos` and inherited release settings, use `com.deepseek.harness` as the app ID, and omit automatic-update and mandatory-update configuration. Build tools, standard network proxies, and `DSH_DESKTOP_NPM_REGISTRY` remain available from the shell. They skip Developer ID signing, Apple notarization, temporary signing keychains, and notarization proxy changes. Electron receives a local ad-hoc signature without certificates so the modified executable can run; the DMG is unsigned. Both preparation and the assembled application still run runtime integrity and smoke checks. Local builds omit update feeds, ZIPs, and release completion records, and the upload commands cannot publish them. macOS may require approval in System Settings → Privacy & Security to open an unnotarized application. - -### Local Official bundles from a loopback registry - -The test DMG excludes the provider runtimes the Official on-demand catalog advertises, and a release publishes matching bundle versions to npm. To install locally built bundles before that release, start a loopback registry that packs and serves the catalog's complete local dependency closure: - -```sh -pnpm run dev:bundle-registry -``` - -The command validates the catalog and the workspace inventory, runs `pnpm run build:official`, packs the closure into a new `dist/test-bundles//` directory, writes `bundle-registry.json` last as the completion marker, and only then prints the artifact path, the registry URL, and the exact `name@version` specs. It serves `127.0.0.1` on an allocated port under a fresh namespace until Ctrl+C or SIGTERM, and stopping it leaves both the artifacts and the installed packages on disk. It needs no `.env.macos`, Apple credentials, or npm credentials, and it does not change `pnpm run package:desktop:unsigned`. - -In the packaged application, open Plugins → Add plugin, paste one printed spec, choose **Custom address**, paste the printed registry URL, and install. A custom registry is asked alone, so this process must answer every dependency: repository-owned packages come from the run's own archives, and third-party packages keep npm's real metadata and bytes through a fixed redirect to `registry.npmjs.org`, which still requires network access. The installed entry stays Official because its package name is in the catalog. The registry choice is remembered in this browser; choose an offered registry again to switch back. - -A run's artifacts are immutable, so a rebuild serves a new namespace and a new URL, and the Official **Update** control does not appear while the installed and running versions match. The profile records the previous run's tarball URL in `pnpm-lock.yaml`, and pnpm verifies recorded entries, so installing or reinstalling that bundle against a new URL is refused with `ERR_PNPM_TARBALL_URL_MISMATCH` and can leave the earlier files in place. To test changed code at the same version, clear the stale resolution first — `pnpm clean --lockfile` in `$DSH_HOME/profiles/`, or install into a fresh profile — then install again from the new URL. `bundle-registry.json` records each archive's version, filename, size, and SHA-512 integrity together with the packing checkout's commit and dirty flag. - -The served `time` is the real packing time, so pnpm 11's default 24-hour `minimumReleaseAge` records the exemption it granted in the profile's `pnpm-workspace.yaml` (`minimumReleaseAgeExclude`); an explicitly stricter policy there fails the installation with pnpm's own message instead of being overridden. A `@scope:registry` route in `~/.npmrc` or the profile's `.npmrc` overrides `--registry` for that scope, so it must not route the served scope. - -### Runtime file selection - -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 and Yarn development metadata, selected native compiler outputs, and node-pty prebuilds for other platforms. Non-Windows targets also omit `node-pty/third_party/conpty`; Windows targets retain that directory. It preserves runtime JavaScript, target 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 [Electron shell file list](scripts/electron-builder-config.mjs) excludes recognized JavaScript/CSS/TypeScript source maps and TypeScript build caches from the separate `resources/app.asar/node_modules` dependency collection. These exclusions preserve runtime code, native modules, WASM, data files (including ordinary `.map` assets), licenses, and notices; [shell archive tests](tests/shell-file-selection.spec.ts) check retained bytes and exclusions. - -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 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. - -```powershell -pwsh -NoProfile -File apps/desktop/scripts/smoke-windows.ps1 -Makensis $Makensis -SevenZip $SevenZip -PluginDir $PluginDir -FrameLibrary apps/desktop/.desktop-build/targets/win-x64/installer-ui/window-frame.dll -``` - -The Windows installer checks for a running application at startup and after destination selection, before extracting the new version beside the installation directory. It checks again before replacing directories through same-volume renames. A running application blocks installation; update launches allow up to ten seconds for it to exit. Same-path upgrades preserve the old directory until promotion succeeds; extraction failure leaves it intact, and promotion failure attempts to restore it. The installer removes the old backup before launch. Forced termination or power loss can leave `.new-*` or `.old-*` directories; installation-location and scope migrations retain electron-builder's old-uninstaller flow. - -When extraction fails, the installer writes a report with the 7-Zip result and its complete error output to the updater cache directory as `%LOCALAPPDATA%\-updater\installer-logs\extract-failure-.log` (currently `@deepseek-aidsh-desktop-updater`) and shows the first error line with a **Copy error details** button; silent installs write only the report. Unsigned Windows builds (`DSH_DESKTOP_UNSIGNED=1`) name their installer `deepseek-harness--win-x64-unsigned.exe` so they cannot be mistaken for release artifacts. - + + + + + + -### Upload updates +Use the dedicated guides for build and release operations. Shared Windows runners have accepted credential and device isolation limits; read them before signing or executing branch code. -Fixed installer downloads use `pnpm run upload:latest:mac:arm64`, `pnpm run upload:latest:mac:x64`, or `pnpm run upload:latest:win:x64` from the repository root. They upload the completed target's DMG or EXE to `desktop/dsh-latest-macos-arm64.dmg`, `desktop/dsh-latest-macos-x64.dmg`, or `desktop/dsh-latest-windows-x64.exe`. Production URLs start with `https://download.deepseek.com/`; test URLs use `DOWNLOAD_TEST_ORIGIN`. Both deployments reuse the target dotenv's deployment, bucket, and credentials, all normal release validation, and the upload audit below. Each command replaces one fixed object without publishing feeds, blockmaps, or release tags. Test fixed downloads are shared across release IDs in the test bucket; the packaged update URL still requires its original release ID. Publication is explicit, including prerelease versions. Deployment infrastructure must configure revalidation or purge cached fixed downloads after replacement. Local loopback tests cover the upload; release operators own real COS and CDN acceptance. - -Test and production uploads through `upload:*` retain a fresh `.desktop-build/upload-records/--*` directory after release preflight. `plan.json` records destination, version, every file's size/SHA-512, and published YAML bytes; flushed `events.jsonl` records PUT intent and available response status/request ID; `result.json` records completion or the last failed stage. A missing final result means interruption or unavailable storage, not success. No credential values, authorization headers, or raw SDK errors are recorded. Audit-write failures stop later PUTs. Every object is one streamed Tencent COS PUT with an explicit length and Content-MD5; the COS SDK repeats a request only when its body is not a stream, and this uploader does not retry either. Retain partial records and inspect remote state before another operation: a timeout or failed receipt write does not prove that the object was not stored. These records are local, not tamper-proof or automatically backed up; archive each release's records with its build evidence in controlled storage. Public CDN readback remains separate release qualification, explicitly marked `not-performed` in the upload result. - -Windows operators can keep a CLIXML object with DPAPI-encrypted `SecretId` and `SecretKey` SecureString fields outside the repository. The [credential launcher](scripts/upload-with-credentials.ps1) requires an explicit `-CredentialFile` and `-Environment production` or `test`; without `-Upload`, it only verifies decryption and injection into a local Node child, with no network request. It requires Node on `PATH` and the Windows user and machine that encrypted the file. Plaintext, empty, and whitespace-only fields fail. The parent environment is unchanged; the child receives only the selected COS pair after unrelated secrets and Node preload options are removed. Raw child stderr is suppressed and credential values in stdout are redacted. This check does not prove COS authorization. An explicit upload additionally requires `-Upload -Target -Bucket ` and the normal completed-release prerequisites below; actual cloud upload remains release-operator qualification. Add `-Latest` to that upload invocation to replace the fixed installer without publishing update feeds or a release tag. This launcher supports permanent keys, not STS credentials. An explicit upload requires the selected deployment and bucket to match the target dotenv and completed package record before network writes; it uses the DPAPI credential pair even if the dotenv contains other COS keys. - -`DSH_DESKTOP_AUTO_UPDATE_ENV` selects `test` or `production` for both the URL embedded during packaging and the later COS upload; an absent value selects `test`. Test packaging requires its HTTPS origin in `DOWNLOAD_TEST_ORIGIN`; production uses `https://download.deepseek.com`. Upload requires the selected bucket in `DOWNLOAD_TEST_COS_BUCKET` or `DOWNLOAD_PROD_COS_BUCKET`. Production feeds use `dsh-desk/feeds//` and binaries use `dsh-desk/bin//`. Test releases require `DOWNLOAD_TEST_RELEASE_ID`: 32 lowercase hexadecimal characters inserted as `dsh-desk//feeds//` and `dsh-desk//bin//`. YAML references, stable-channel aliases, and blockmaps stay inside that release directory. Targets are `mac-arm64`, `mac-x64`, and `win-x64`. - -The update destination and upload credentials follow the selected deployment: - -| Environment | Public origin | COS bucket | COS credentials | -|---|---|---|---| -| `test` or unset | `DOWNLOAD_TEST_ORIGIN` | `DOWNLOAD_TEST_COS_BUCKET` | `DOWNLOAD_TEST_COS_SECRET_ID`, `DOWNLOAD_TEST_COS_SECRET_KEY` | -| `production` | `https://download.deepseek.com` | `DOWNLOAD_PROD_COS_BUCKET` | `DOWNLOAD_PROD_COS_SECRET_ID`, `DOWNLOAD_PROD_COS_SECRET_KEY` | - -Generate a fresh ID for each test release batch with the command below and copy its output into `DOWNLOAD_TEST_RELEASE_ID` in `.env.macos` or `.env.windows`. These Git-ignored platform files own the value; shell variables do not override it, and dotenv values are not shell-expanded. Keep the ID through packaging, upload, and retries; upload rejects a completion record with a different update URL. To test an upgrade between versions, reuse the installed client’s ID for the later version. Production does not use this field. - -```sh -node --input-type=module -e "import { randomBytes } from 'node:crypto'; console.log(randomBytes(16).toString('hex'))" -``` - -Distribute each test batch through its complete download links. Installed test clients retain their batch feed and do not discover a new ID automatically. Format validation cannot establish randomness; use the generator output. Random paths reduce guessing, not access by link holders; withdrawing a batch requires deleting its COS objects and purging its CDN directory. - -Configure the update origin and selected COS bucket, SecretId, and SecretKey in the target dotenv file, then package and upload the same target: - -```sh -pnpm run package:desktop:mac:arm64 -pnpm run upload:mac:arm64 -``` - -The target dotenv file for internal-test packaging explicitly sets `DSH_DESKTOP_AUTO_UPDATE_ENV=test` and `DOWNLOAD_TEST_ORIGIN=https://download-test.deepseek.com`; uploads use `DOWNLOAD_TEST_COS_BUCKET=bj-toc-download-test-1320056602` and separate test credentials. Both test and production use the fixed Nightly channel; deployment selection does not enable channel switching. - -Early internal-test packages use the `test` deployment. Select `production` explicitly only for a production release; changing upload credentials does not retarget an existing package. Packaging requires no COS credentials, disables electron-builder publishing, strips COS credentials from child processes, and records completion only after signing and notarization succeed. Upload validates this record, deployment, target, shared version, filenames, sizes, and SHA-512 before reading credentials. Packages and blockmaps upload before YAML; historical objects are retained. Every release publishes `nightly.yml` or `nightly-mac.yml`; stable releases also publish `latest.yml` or `latest-mac.yml` pointing at the same artifacts. Published YAML uses absolute binary URLs. The uploader leaves Cache-Control unset, including the empty header the COS SDK would otherwise add: deployment infrastructure owns cache policy, with uncached feeds and separately configurable binary caching. Serialize publication per target and verify public artifacts and feed contents before release qualification. - -The signed macOS configuration uses the required release environment instead of accepting whichever certificate appears first in a keychain. It rejects empty values, a malformed Team ID, a signing identity that includes electron-builder's unsupported `Developer ID Application:` prefix, and incomplete notarization credentials. Signed macOS packaging requires the configured identity and its private key. Runtime preparation applies that identity, a secure timestamp, and hardened runtime to every embedded Mach-O file; after signing the application, a deep strict check rejects any other leaf authority or Team ID before artifact creation. The signed fixed-target macOS installer commands create separate copies of the signed application and run two artifact lanes concurrently. One lane notarizes and staples the App before generating the ZIP and its update metadata. The other encloses its signed App copy in a signed DMG, then notarizes, staples, and verifies the DMG; its inner App has no individually stapled ticket. Both lanes must finish successfully before their artifacts reach the final directory and the release completion record is written. Signed directory-only commands also require notarization credentials and wait for Apple notarization and App stapling. The [parallel notarization decision](../../.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.md) owns copy isolation and container ticket semantics. `CSC_LINK` must name a local p12 containing the Developer ID Application certificate and private key; URLs and Base64 inputs are not supported. `CSC_KEY_PASSWORD` is its export password, not an Apple account or login password; an explicitly empty value supports an unencrypted p12. Before building, packaging creates and unlocks a private temporary keychain, imports the p12, authorizes signing, and signs a small probe. Runtime and App signing use this keychain explicitly; existing login keychains require no setup or manual unlocking. Child processes receive its path without the p12 password. The keychain is deleted after success or ordinary failure; CI must clean temporary credentials after forced termination. CI writes the certificate and `.env.macos` from its secret store, restricts file access, and deletes both after the job. Ambient `CSC_NAME` and certificate discovery order do not select the release owner. Notary credentials may instead use electron-builder's complete Apple ID or keychain-profile strategy. The two macOS identity variables are also required when repeating the application check manually with `pnpm --dir apps/desktop run verify:mac-signature -- `. - -macOS signing visits real files without following Framework symlink aliases. PAK resources retain all shipped languages and are sealed by the enclosing Framework or application signature instead of receiving individual signatures. The [release policy](../../.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md) owns the dependency patch and verification requirements. - -macOS runtime preparation caches verified single-architecture Mach-O signatures in `.desktop-build/targets//signature-cache`. Keys include input bytes and permissions, the signing probe’s actual leaf certificate, identifier, entitlement bytes, macOS version, and signing tools and policy. Git commits do not determine reuse: uncommitted byte changes invalidate the affected file. Every hit verifies cached bytes, the strict signature, certificate, identifier, entitlements, secure timestamp and hardened runtime before replacing an unchanged input. Universal binaries are signed each time. Runtime integrity and smoke checks, App signing, and Apple notarization still run. The cache requires trusted local build storage. Cache corruption or unsafe links fail the build; stop packaging before deleting the affected cache directory and retrying. Successful signing stages prune complete entries to a one-GiB payload budget; interrupted temporary entries require manual removal. Concurrent eviction can fail a reader safely. Logs report hit, miss and uncached counts. - -Mac package commands print `DESKTOP_PACKAGING_RECORD` with a unique directory under `apps/desktop/.desktop-build/packaging-runs/`. After reading local configuration, each run retains `run.json` (published version, product version, Git commit, dirty-worktree flag, target and Node version), `events.jsonl` (timestamped phases, durations, parallel output attribution and proxy restoration), redacted child `stdout.log` / `stderr.log`, and `result.json` (overall outcome, stage results and artifact directory). Events also record pack concurrency and whether each proxy is configured. Logs survive failures and later builds; no automatic deletion runs. Missing `result.json` means completion is unconfirmed. Known credential values are redacted; environments and notarytool authentication arguments are not recorded. Runtime preparation records package staging, installation, materialization, signing, manifests, smoke checks, descriptor verification and cleanup separately. Nested phase durations overlap and must not be added together. - -App and DMG notarization each record a submission ID and separate `notarytool:upload:*` and `notarytool:wait:*` durations. Upload uses `submit --no-wait` and includes authentication, local validation, transfer and server acceptance; it is not pure transfer time. Waiting starts after that command returns and includes polling and remaining Apple processing; Apple can start processing before upload submission returns. Apple status and diagnostic logs are retained, including rejection; signature checks, acceptance checks and stapling remain owned by `@electron/notarize`. Directory-only package commands use this same measured App notarization path. Individual subprocess output remains live in the terminal, while phase failures and nested errors are retained in the journal. Both platforms record parent-process packaging failures and print redacted diagnostics, including proxy recovery instructions, to the terminal. The configuration-only `check:package` command creates no run log. - -Mac packaging reads three tuning fields from `.env.macos`: - -| Setting | Default | Scope | -|---|---|---| -| `DSH_DESKTOP_MACOS_PACK_CONCURRENCY` | `4` | Workers for first-party and vendor workspace tarballs; requires a positive integer. | -| `DSH_DESKTOP_MACOS_DOWNLOAD_PROXY` | Empty | HTTP/HTTPS proxy origin for Electron, runtime assets, pnpm installation and builder downloads. | -| `DSH_DESKTOP_MACOS_NOTARIZATION_PROXY` | Empty | HTTP proxy origin for Apple tools through temporary system proxy settings. | - -The proxy fields are independent and reject URL credentials, paths, queries and fragments. Empty values preserve inherited networking. An explicit download proxy replaces child-process proxy variables and bypasses local hosts only; it does not change system settings. Windows and standalone `release:pack` retain their existing concurrency defaults. Company proxy addresses belong in the ignored local file; see internal documentation for addresses. - -Apple tooling uses the active macOS network service's HTTP/HTTPS proxies. Configured notarization routing checks proxy reachability, saves that service's settings, enables the proxy around both artifact lanes, and restores the saved settings after both lanes settle. An originally disabled proxy with an empty server and port zero is restored by disabling it; the temporary server and port may remain stored but inactive. Directory-only builds apply it to App notarization after the signed directory build. This temporarily affects other applications and requires permission to change system proxies; PAC, auto-discovery, SOCKS and authenticated proxy configurations must be disabled first. Packaging and recovery acquire the same per-user POSIX file lock before reading recovery data or changing proxies; process exit releases ownership, while the lock file remains in place. The lock loads `@deepseek-ai/node-addon-system/flock` at first use rather than at script start, so `check:package` and the packaging entry load on a checkout that has not built `native/system`; when the host addon binary or the entry's JavaScript is missing at lock time, the loader runs `pnpm run build:native-system` and `pnpm --dir native/system run build:ts` before locking, so recovery works on such a checkout as well. This prevents overlapping proxy transactions across checkouts; other users and network-setting tools must not change these settings concurrently. SIGINT/SIGTERM wait for active work before restoration. After forced termination or a restoration error, stop any remaining notarization processes and run `pnpm --dir apps/desktop run restore:mac-proxy`; the saved record remains until restoration succeeds. Configuration checks validate URL syntax without changing system settings or contacting the proxy. - -### Unsigned Windows test installer - -On Windows x64, use the complete unsigned packaging command for local installation testing: - -```sh -pnpm run package:desktop:win:x64:unsigned -``` - -The command reads `.env.windows` and requires `DSH_DESKTOP_APP_ID` and the [mandatory-update policy settings](#mandatory-update-policy), including a real HTTPS service origin. The local run and CI use Node 24; the remaining tools are the root `packageManager` version of pnpm, Git, PowerShell, tar, Python, Visual C++ Build Tools and a Windows SDK. Set `PYTHON` when Python is absent from `PATH`. It writes the installer to `.desktop-build/targets/win-x64/unsigned-artifacts/`, omits ordinary automatic-update configuration, strips signing credentials, and creates no release completion record. EV and upload credentials are unnecessary. Signed packaging and upload retain their release requirements. - -Run `pnpm install --frozen-lockfile`, then `pnpm run package:desktop:win:x64:unsigned --check` to validate configuration and tooling before the full command above. Packaging performs its own complete build and isolated runtime smoke without an online model. Stage logs remain under `.desktop-build/packaging-runs/`; a successful build alone does not qualify the installer. Dependency installation and runtime preparation need access to npm, GitHub release assets and nodejs.org; use the existing registry and proxy configuration when the network requires it. Caches and tool locations belong to the build machine, not tracked personal paths. - -The [manual Windows workflow](../../.github/workflows/windows-package.yml) selects a self-hosted Windows x64 runner with the dedicated `dsh-win-package-trial` label and calls the same unsigned command. Provision PowerShell 7 and the tools listed above before starting the runner. It requires the policy deployment and HTTPS origin; test deployments also require comma-separated login origins. These public inputs generate a temporary `.env.windows` without signing or upload credentials. `build_version` defaults to `auto`, using the CI identifier described in [Release versions](#release-versions). An explicit version uses the existing product-version validator. The resolved version appears in the run summary before packaging; manifests remain unchanged. - -After this workflow reaches the default branch, select it in Actions, choose the source branch, fill the inputs and click Run workflow. It has no push or PR trigger and leaves existing automatic CI unchanged. Packaging logs and successful EXE artifacts are retained for 14 days; installation, signing and publication are separate operations. Run it once from the default branch and inspect the retained logs and installer before relying on Actions packaging. Checkout cleans the runner-owned source directory, including ignored files; never point it at a development checkout. Runs share one concurrency group without cancelling an active build; only one run can remain pending, and a newer dispatch replaces that pending run. Diagnostics are uploaded only after a successful clean checkout, and generated settings are removed even when preparation fails after writing them. +- [Desktop packaging](packaging/README.md): local and Actions builds, versions, deployment settings, artifacts, logs and verification. +- [Windows signing and runner operations](windows-signing/README.md): machine setup, accepted isolation risks, signing credentials, token failures and cache maintenance. +- [Desktop publication](publishing/README.md): upload credentials, release identity, fixed downloads, update feeds and upload recovery. -### Windows installer interface + + +## Windows installer interface The Windows installer uses native NSIS pages with light and dark palettes, system shadows, an editable installation directory, and a finish page whose launch checkbox is selected by default. Installation is restricted to the current user. Clicking Install or pressing Enter validates the current path; drive roots remain invalid after normalization, including repeated trailing separators. New destinations must be empty, and nonempty destinations must be registered installations. Running executables at the affected installation path produce a native prompt and remain running; same-named applications in other directories do not block installation. Silent updates wait up to ten seconds for the affected application to exit, then stop with exit code 2 if it is still running. -The theme follows Windows at startup; `/THEME=light`, `/THEME=dark`, and `/THEME=auto` select a palette explicitly. The window appears after its branded controls are ready. When the welcome page first appears, the installer moves above ordinary windows once and flashes its taskbar button if another window has focus; it does not remain always on top. Progress reads the pinned 7-Zip extractor’s percentage; directory promotion, registration, and cleanup retain bounded estimates. The weighted percentage does not predict remaining time. After NSIS reports success, the bar fills over 600 ms and displays 100% briefly before the finish page appears; the transition targets 750 ms. The finish page preserves the window position. Finish dismisses the installer before launching the installed executable; a launch failure restores the page for retry. Directory replacement and failure recovery follow the installation flow described above. First-launch profile preparation remains a separate Desktop operation. +The theme follows Windows at startup; `/THEME=light`, `/THEME=dark`, and `/THEME=auto` select a palette explicitly. The window appears after its branded controls are ready. When the welcome page first appears, the installer moves above ordinary windows once and flashes its taskbar button if another window has focus; it does not remain always on top. Progress reads the pinned 7-Zip extractor’s percentage; directory promotion, registration, and cleanup retain bounded estimates. The weighted percentage does not predict remaining time. After NSIS reports success, the bar fills over 600 ms and displays 100% briefly before the finish page appears; the transition targets 750 ms. The finish page preserves the window position. Finish dismisses the installer before launching the installed executable; a launch failure restores the page for retry. Directory replacement and failure recovery follow the [installation checks](packaging/README.md#runtime-file-selection). First-launch profile preparation remains a separate Desktop operation. Windows packaging compiles an x86 Win32/GDI+ helper with Visual C++ Build Tools and a Windows SDK; signed builds sign this helper through the configured Windows signer. The preparation hook leaves production dependency collection to electron-builder on every platform. -Run `pnpm --dir apps/desktop run test:installer` from the repository root on an interactive Windows x64 desktop to build and exercise a small native test payload through the production installer configuration. Each run uses a unique product identity and sequentially exercises English-only and Chinese-only installer variants, selecting test labels from the displayed welcome button. Both variants install into private directories and uninstall after testing; screenshots and results remain under `.desktop-build/installer-tests/`. The checks include upgrades to registered paths with trailing separators and rejection of drive roots. The optional `--signed` flag uses the Windows EV configuration below to sign test executables and the helper before embedding them; it does not enable an update feed. +Run `pnpm --dir apps/desktop run test:installer` from the repository root on an interactive Windows x64 desktop to build and exercise a small native test payload through the production installer configuration. Each run uses a unique product identity and sequentially exercises English-only and Chinese-only installer variants, selecting test labels from the displayed welcome button. Both variants install into private directories and uninstall after testing; screenshots and results remain under `.desktop-build/installer-tests/`. The checks include upgrades to registered paths with trailing separators and rejection of drive roots. The optional `--signed` flag uses the [Windows EV configuration](windows-signing/README.md#signing-configuration) to sign test executables and the helper before embedding them; it does not enable an update feed. The Windows uninstaller removes the Electron user-data directory (browser storage and caches under `%APPDATA%`, nested under the package scope), the `%APPDATA%` product directory, and the updater download cache under `%LOCALAPPDATA%` together with the application, then removes ordinary empty scope directories below `%APPDATA%`. The Harness home (`~/.dsh` or `DSH_HOME`: sessions, settings, credentials, plugins) is never touched, and a `DSH_HOME` published as a Windows environment variable additionally protects any target overlapping it. Silent uninstalls remove the same data; an uninstaller started with `--updated` or `/KEEP_APP_DATA`, as electron-builder does for in-place updates and for replacing an older installation from another directory, retains it. Removal runs through the native helper, which refuses protected Windows folders and paths overlapping the installation or the home, requires a fixed local drive, leaves a linked root or ancestor in place, unlinks reparse points without entering their targets, clears read-only attributes, and keeps deleting siblings after a locked file; residue never stops uninstallation and is not reported. Installation records `InstallLocation` on the Windows uninstall entry as standard inventory metadata; on Windows 11, Uninstall in the Start menu context menu opens the installed-apps list for every Win32 application, and only MSIX packages uninstall directly from there. The uninstaller declares DPI awareness and uses Microsoft YaHei UI for Chinese. This behavior is Windows-only. Use `node apps/desktop/scripts/test-windows-installer.mjs --uninstall-only --compile-only` to compile isolated English and Chinese fixtures with a per-run scoped package name. Omit `--compile-only` to run the native remover regression and the interactive, silent, `--updated`, `/KEEP_APP_DATA` and `DSH_HOME`-inside-Electron-data checks against seeded data. Compilation does not establish installed-uninstall behavior. -### Windows EV signing - -Runtime signing shares complete signed files across the current Windows account's worktrees. `DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR` in `.env.windows` selects an absolute fixed-drive directory; the default is `%USERPROFILE%\.dsh-desktop-signing\signature-cache\v1`. Cache directories must belong to the current account and exclude other ordinary accounts from their access permissions; linked paths are rejected. Entries identify the original bytes, public certificate and signing toolchain. Every restoration checks the digest, Windows trust, timestamp and certificate before replacing the unsigned file; invalid entries stop packaging without hardware fallback. Cache age alone does not trigger signing. The cache trusts programs running as the same account and does not defend against administrators. The [runtime signature cache decision](../../.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.md) owns qualification and design limits. - -Preflight, primary-runtime signing, application-runtime signing and artifact creation each hold the account's signing-stage lock until their supervised child processes settle. Other builds wait before entering those stages; compilation and preparation do not hold the lock. Runtime signing workers own their locks, so terminating only the outer packaging process does not unlock a surviving cache user. Migration and maintenance acquire the same lock; older or external signing commands do not participate and must be scheduled separately. Stage waiting is outside the preflight deadline. Closing the stage handle releases ordinary contention without removing the separate hardware-attempt interlock; a retained hardware failure still requires operator recovery. - -Cache-hit copying, hashing and per-file trust checks run with `DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY` workers from `.env.windows` (default `4`, integer `1`–`8`). All restores and their post-verification finish before cache misses enter serial hardware signing. A restore or verification failure stops new dispatch and drains active work before releasing the stage lock; hardware signing, runtime smoke checks and final integrity validation remain required. - -Each runtime stage writes `SIGNATURE_CACHE_SUMMARY` to stdout and its packaging journal, including the actual directory, policy identity, hit/miss counts, newly published and retained entries, signing requests, avoided signing requests and validation failures. Timings distinguish signing, trust verification of restored and newly signed files, and restoration; restoration includes its trust check and staging cleanup. These counters exclude preflight, final artifact signing and the outer runtime signature checks. Stage lock events record waiting separately. Concurrent timings sum per-file work rather than stage wall time. - -Use `pnpm --dir apps/desktop run cache:windows-signatures --usage` to inspect structurally complete-entry bytes and counts, or `--from ` to import an explicitly selected cache owned by the same account. Migration leaves the source unchanged, skips staging names, rejects corrupt entries and retains valid existing entries even when their timestamp bytes differ. `--clear` explicitly removes complete entries under the stage lock; no automatic capacity eviction runs. `--directory ` selects a maintenance destination without loading release credentials. Incomplete staging entries remain untouched and are counted separately; inspect them only after all builds have stopped. These commands never clear hardware failure evidence. Default storage is outside AppData so MSIX launcher virtualization does not split the account's cache; redirected overrides fail explicitly. - -Signed Windows builds run a supervised signing preflight before compilation or dependency preparation. Static configuration, certificate validity, audit storage, compiler availability and any retained signing interlock are checked without token access. The local .NET Framework C# compiler creates a small private probe; the production signer signs it once, and verification requires the configured certificate and a timestamp before building continues. The probe is never executed. The overall 60-second preflight deadline includes timestamp attempts; deadline expiry, hardware signing errors and verification failures stop the run without another hardware attempt. Success proves the current signing path works, not that the PIN was independently authenticated: SafeNet may reuse login state. Do not log out or repeat authentication to test the PIN. `--check`, preparation-only and `--unsigned` modes do not run this hardware preflight; unsigned artifacts remain ineligible for release upload. Automated regression tests use a fake signer; release operators qualify real hardware separately. - -Windows NSIS uploads require the generated, nonempty `.exe.blockmap` beside the installer. The blockmap is uploaded before channel YAML; NSIS installer metadata does not require the embedded `blockMapSize` used by the separate web-installer format. File-plan tests use the pinned builder's blockmap generator, not a hand-authored embedded-map field. - -Signed Windows configuration derives the updater's `publisherName` from the same public certificate's `CN`, `O`, and `C` attributes. Each must be present, nonempty, and single-valued. These identity attributes allow certificate renewal without pinning a leaf thumbprint. The installed application's `app-update.yml` carries the expected publisher; the downloaded feed does not choose it. Unsigned test builds omit updater configuration. See the [signature qualification record](tests/README.md) for real-file verification and its limits. - -For this project's SafeNet token, `SignTool Error: No private key is available.` indicates an incorrect PIN. Stop all signing attempts immediately and wait for the user to correct the PIN before continuing. Five incorrect PIN attempts lock the token. Do not retry packaging or signing probes after this error. The signer serializes token operations and rejects all queued tasks after the first failure. - -Windows package commands print `DESKTOP_PACKAGING_RECORD` with a unique directory under `.desktop-build/packaging-runs/`. Each run retains `run.json`, timestamped `events.jsonl`, redacted `stdout.log` and `stderr.log`, and `result.json`. A signing failure also writes `fatal.json` and notifies the parent through stderr; the supervisor immediately requests termination of its stage process tree and waits for exit. Failed stages cannot start subsequent stages or create a release completion record. Journal failures also stop the run. Termination errors remain failures and require operator inspection; a missing final record means completion was not established. - -Hardware signing requires a supervised run. Before invoking the command interpreter, the signer atomically acquires `%USERPROFILE%/.dsh-desktop-signing/attempt.json` and records the attempt. Only successful signing followed by verification of the configured primary signature releases that file; timestamps are completed afterward without hardware access. Failure, interruption, an existing interlock, or unavailable audit storage prevents further hardware access, including from another signer instance, process, or checkout under the same Windows account. There is no timed recovery or automatic retry. An administrator must inspect the retained evidence and token state before explicitly authorizing interlock recovery; logging into the token or replacing a PIN file does not clear it. The records distinguish signing intent, command-interpreter PID, and completion; they do not measure internal CSP/token authentication attempts. Command arguments, PINs, and credential environments are not recorded. Independent Windows accounts and unrelated signing programs are outside this interlock. - -Windows packaging fixes the 7-Zip filter to `BCJ` for compatibility with the bundled NSIS decoder. This preserves ARM64 binaries carried by dependencies in x64 installers; automatic ARM64 filtering produces entries that this decoder cannot extract. - -NSIS removes its temporary extraction tree during installation, before the completion page or an automatic launch. The installed production packages remain ordinary files; startup does not extract them again. Installation still writes the complete application tree. - -Fill in `.env.windows` with `DSH_DESKTOP_WINDOWS_CER_FILE` (public EV leaf certificate), `DSH_DESKTOP_WINDOWS_SIGNTOOL` (SafeNet-compatible SignTool), `DSH_DESKTOP_WINDOWS_KEY_CONTAINER` (matching private-key container), and `DSH_DESKTOP_WINDOWS_TOKEN_PIN` (Token Password). The private key stays on the USB token; keep the certificate and local credential file out of Git. - -```sh -pnpm run package:desktop:win:x64 -``` - -Insert and unlock the token before packaging. The electron-builder hook passes each artifact to the CRLF `scripts/windows-sign.cmd`, which invokes the configured SignTool once with `/f`, SafeNet `/kc "[{{PIN}}]=container"`, `/csp "eToken Base Cryptographic Provider"`, a SHA-256 file digest, without a timestamp. The hook then completes a DigiCert SHA-256 RFC 3161 timestamp on isolated copies without signing credentials. The hook never substitutes electron-builder's bundled SignTool and never retries a failed signing request. Windows release packaging fails instead of emitting unsigned artifacts when the SignTool, certificate, container, PIN, token, or signature is unavailable. - -Timestamp completion retries only a normally exited timestamp command returning failure or warning, at most three times with one- and two-second delays. Each attempt starts from the same verified primary signature. Launch errors, uncertain termination and verification failures stop immediately. SignTool operates on short private paths; publication copies verified bytes to the target volume before atomic replacement. Final Windows trust, certificate, timestamp and normalized full-file equality are required. Exhaustion stops packaging and preserves evidence without another hardware call. See the [signature completion decision](../../.agents/notes/implemented/process/2026-09-17-windows-signature-completion.md). - -The PIN cannot contain `]`, a quote, or a line break because those characters delimit the SafeNet `/kc` value or its CMD argument. The CMD disables delayed expansion so a PIN containing `!` reaches SafeNet unchanged. Packaging withholds every `DSH_DESKTOP_WINDOWS_*` field from build and runtime-preparation subprocesses, gives signing preflight, the dedicated runtime signing stages and electron-builder only the four configured inputs, gives the signing CMD only the validated signing fields in an otherwise scrubbed environment, clears those fields before SignTool starts, and redacts SignTool diagnostics. SafeNet still requires the PIN in the SignTool process command line. The local `.env.windows` stores the PIN in plaintext and needs restricted file access; CI uses a temporary file and deletes it after the job. Do not commit or share its contents or print credentials in logs. Configuration checks consume no token PIN attempts; signing still stops the batch on its first failure. - -Create a runnable application directory instead of an installer by using the matching `:dir` command, such as: - -```sh -pnpm run package:desktop:dir -pnpm run package:desktop:mac:arm64:dir -``` - -To inspect or troubleshoot the prepared host-target resources without invoking electron-builder, stop the same pipeline after preparation: - -```sh -pnpm run prepare:desktop -``` - -This diagnostic command is an alternative stopping point, not the first half of a two-command build. A later `package:desktop*` command repeats the official build and preparation so it cannot consume stale dsh packages, runtime files, or dsh content. - - - -### Desktop runtime preparation - -Desktop [patch policy](scripts/runtime-patch-policy.ts) explicitly classifies every workspace patch as shared, workspace-only, or runtime-only. Shared entries reuse the root patch bytes and verify their hash against the root lockfile; runtime-only entries use separate repository files without changing workspace installation. Workspace-only patches belong to build tools or dependencies already embedded in client bundles. Unclassified or stale entries, missing files, changed hashes, incompatible resolved versions, and workspace-only packages entering the runtime stop packaging. The [preparation script](scripts/prepare-runtime-patches.ts) writes only to the temporary project; pnpm applies the selected patches and rejects application failures. - -Desktop dependency overrides are declared separately in the patch-policy module, not inferred from patch versions. The pi-ai constraint keeps its runtime version at the version qualified by the shared patch. Desktop still resolves a fresh runtime lockfile for each build, then installs with `--frozen-lockfile`; dependency resolution across separate builds is not yet reproducible. CLI packed-installation tests and other distribution flows retain their own configuration. - -Every package command builds the repository, packs the first-party production closures rooted at dsh and the private Desktop Host, and prepares the target Electron distribution plus primary-runtime with its shared pnpm CLI. `prepare:dsh` installs the production graph once at build time, prepares materialized packages for electron-builder to archive under `app.asar/dsh`, removes package-manager metadata, and writes `desktop-runtime.json` with shared package versions and final file hashes. Signed macOS builds sign and verify native files before inventory generation; electron-builder excludes this already-signed tree from nested re-signing. Resource mappings explicitly include `dsh/node_modules`, which the default root-directory filter omits; the prepared runtime inventory is checked after native signing. Native executables and libraries are unpacked beside ASAR; Python, standalone Node and pnpm remain in external runtime resources. Windows packaging checks every prepared PE against its ASAR unpacked entry and byte-identical disk copy, including unsigned builds. Builder glob rules match braces in PE filenames as single-character wildcards, so a matching neighboring file may also be unpacked. Prepared runtime smoke uses the verified target descriptor rather than the build host architecture. Signed installer, notarization, installed upgrade, and target-specific native-module qualification require the release environment. - -Signed macOS packaging writes `Contents/Resources/app-update.yml` while assembling the App and before code signing, including the directory build that feeds the parallel ZIP and DMG lanes. The signing hook verifies the exact feed and updater cache directory. Both lane copies and the promoted App are checked again before the release completion record is written; a missing or mismatched configuration prevents artifact promotion and therefore prevents upload. - -An unpacked artifact contains Electron, the materialized dsh production tree, pnpm, and the shell application. Installer size and filesystem size differ; release qualification measures both, plus the profile’s plugin storage and first-launch latency. The runtime trades more application files for eliminating core package installation on the user’s machine. + ## Updates @@ -459,7 +269,7 @@ On Windows, the downloaded-update confirmation explains that the application clo Native update overlays wait for a ready document and a visible parent, and reappear when that parent is shown again. Closing an overlay releases its input interception and parent listeners. The [local window qualification](tests/README.md#verification-overlay) exercises these transitions without starting a workspace. -Packaged applications check fixed Nightly asynchronously at startup. Ordinary polling uses a ten-minute base interval with independently sampled ±20% jitter. Each check failure doubles the base delay up to one hour; success resets it. The randomized delay is bounded by that cap and starts after all joined callers settle. Foreground and system-resume checks respect the same monotonic deadline; the localized Check for Updates menu item, including in the Windows caption's Application menu, runs immediately and joins an in-flight check. A newly received mandatory policy also requests an immediate feed check. Automatic checks never open dialogs or download packages. Manual checks display checking, failure, or no-update feedback with the installed version. Ordinary update dialogs fade in and out in place; consecutive prompts replace the card content and reset its scroll position while retaining the dark translucent backdrop without blurring the parent page. +Packaged applications check fixed Nightly asynchronously at startup. Ordinary polling uses a ten-minute base interval with independently sampled ±20% jitter. Each check failure doubles the base delay up to one hour; success resets it. The randomized delay is bounded by that cap and starts after all joined callers settle. Foreground and system-resume checks respect the same monotonic deadline; the localized Check for Updates menu item, including in the Windows caption's Application menu, runs immediately and joins an in-flight check. A newly received mandatory policy also requests an immediate feed check. Automatic checks never open dialogs or download packages. Manual checks display checking, failure, or no-update feedback with the installed version. Ordinary update dialogs fade in and out in place; consecutive prompts replace the card content and reset its scroll position while retaining the dark translucent backdrop without blurring the parent page. On Windows, dismissal hides the modal before destroying it so the parent is enabled before native window activation changes. `DSH_DESKTOP_UPDATE_CHECK_INTERVAL_MS` configures the ordinary base interval, and `DSH_DESKTOP_UPDATE_CHECK_MAX_BACKOFF_MS` configures the cap; both accept integer milliseconds from 1000 through 2147483647, with the cap at least the interval. An omitted cap defaults to the larger of one hour and the interval. `DSH_DESKTOP_UPDATE_CHECK_JITTER` sets the fractional jitter from 0 through 1, defaulting to `0.2`; the final delay is at least one second and never exceeds the cap. These settings do not change mandatory-policy polling or authorize download retries. @@ -536,10 +346,14 @@ The command builds the Desktop shell and runs its coordinator with real Electron The downloaded bytes are inert, and the installation call is recorded rather than executed. The test substitutes browser opening and clipboard writing to avoid external navigation and clipboard changes. It does not boot the full product workspace, qualify a real installer or restart, verify publisher signatures, or exercise differential updates or macOS. Stalled policy requests, feed requests, and payload transfers exercise real deadlines and recovery. The actual ordinary dialog verifies isolated preload loading, card geometry, an unfiltered parent page, cancellation, task-warning choices, and explicit installation approval; account-row component tests provide separate evidence. The [local qualification decision](../../.agents/notes/implemented/testing/2026-09-10-desktop-local-updater-qualification.md) and [verification record](tests/README.md) preserve these limits; production release requirements remain unchanged. + + ## Low-level development overrides An unpackaged Electron process uses `.desktop-build/development/project` under its application directory as its development project. `DSH_DESKTOP_PNPM_ENTRY` and `DSH_DESKTOP_DSH_DIR` are optional overrides with application-path defaults. `DSH_DESKTOP_PRIMARY_RUNTIME_DIR` is required for every unpackaged launch: the development launchers (`dev:desktop`, `start:desktop`, and the workspace-update qualification runner) set it to the primary-runtime directory of the target they prepared, and a launch without it fails with the fatal startup dialog. The launcher must set it because the shell cannot derive that directory from `process.arch`: the build target fixes Windows to x64 while the host may be arm64. Packaged applications ignore these variables, resolve signed resources from `process.resourcesPath`, and use the managed Desktop profile. + + ## Known limitations - Account sign-in is not connected; the Sign in button is disabled. Windows material rendering still requires platform QA. @@ -563,6 +377,8 @@ Private Platform deployment headers are injected by the embedded browser session The account provider’s `embeddedPageDist` configuration adds a `dist` query parameter to embedded Usage and Top-up URLs. Its default is empty; private frontend branch selectors belong in the local profile patch. It does not change API URLs or credential delivery. + + ## Dev Note Pre-launch CDN and capacity decisions are tracked in the [Desktop update proposal](../../.agents/notes/proposed/feature/2026-09-08-desktop-update-policy-and-installation.md#cdn-and-capacity-qualification). diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index c09fd062ae..430ac4ad3d 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -1,7 +1,35 @@ +--- +description: "Desktop 在原生应用中运行 Harness 工作区。本参考文档说明应用行为、开发与集成;打包、Windows 签名和发布指南分别维护构建与发布操作。" +--- + # DeepSeek Harness 桌面端 [English](README.md) | 中文 +## 概述 + +Desktop 在原生应用中运行 Harness 工作区。本参考文档说明应用行为、开发与集成;打包、Windows 签名和发布指南分别维护构建与发布操作。 + +## 目录 + +- [应用概览](#application-overview) +- [终端命令](#terminal-command) +- [关闭窗口与退出](#closing-the-window-and-quitting) +- [关键技术决策](#key-technical-decisions) +- [内置命令运行时](#bundled-command-runtime) +- [安装归属](#installation-ownership) +- [开发](#develop) +- [打包](#package) +- [Windows 安装界面](#windows-installer-interface) +- [更新](#updates) +- [底层开发覆盖项](#low-level-development-overrides) +- [已知限制](#known-limitations) +- [开发备注](#dev-note) + + + +## 应用概览 + 桌面埋点遵循[产品采集策略](../../packages/client/product-analytics/README.zh.md)及其动态应用配置,不包含 Web 使用情况。安装更新会等待该操作的本地埋点接收请求结束,再锁定 API 准入并停止 Host。接收请求的时限为一秒,失败不会阻止安装,也不等待收集端完成发送。 桌面应用是完整 dsh Web 应用外的一层 Electron 壳。Electron RunAsNode 子进程启动共享 profile runner,Electron 立即从 `dsh-app://app/` 加载打包内的 Web 入口。共享加载页等待 Host 启动注入,然后在同一文档中启动客户端。Electron 将应用 HTTP 请求转发给已认证的 Web Host,转发时丢弃描述 Node fetch 连接而非资源本身的响应头(`transfer-encoding`、`connection`、`keep-alive`),并把插件 bundle 响应标记为 `no-store`,因为其每次启动都变化的 revision 只会在 Chromium 磁盘缓存中累积;WebSocket 流连接到该 Host,仅为归属的应用窗口附加凭据。Node IPC 承载启动注入、就绪与关闭。Desktop 默认监听系统分配的端口,因此不会与 Web 的 `3080` 或系统保留端口冲突;可通过 `webserver.config.port` patch 覆盖。 @@ -24,6 +52,8 @@ Desktop Host 的 Platform API 请求与更新策略请求用相同的 Platform 按 F12(多媒体功能键键盘上为 Fn+F12)、macOS 的 Command+Option+I 或 Windows 的 Ctrl+Shift+I,可切换当前获得焦点的应用页面的 DevTools,打包版本同样支持。这些原生快捷键通过隐藏的应用菜单项注册。更新遮罩和打包版本的内嵌浏览器禁用 DevTools。 + + ## 终端命令 应用菜单中的**管理 dsh 命令…**位于**检查更新…**下方,显示当前命令,并提供安装、修复和移除操作。命令复用 Desktop 已安装的运行时和普通 [dsh CLI](../cli/README.zh.md),Desktop 应用关闭后也可以使用。安装后打开新终端,运行 `dsh --version`。 @@ -32,6 +62,8 @@ macOS 安装会创建 `/usr/local/bin/dsh`;目录权限需要时,系统会 命令注册是安装 Desktop 后的可选操作。卸载 Desktop 前,请通过**管理 dsh 命令… → 移除**删除其 CLI 注册;应用卸载程序不会移除该注册。更新或卸载 Desktop 前请结束 CLI 命令。CLI 运行时版本随已安装的 Desktop 版本变化。Desktop 插件命令与运行时限制见[内置命令运行时](#bundled-command-runtime)。 + + ## 关闭窗口与退出 关闭主窗口(macOS 的关闭按钮和 ⌘W;Windows 的 ×、Alt+F4 和任务栏"关闭窗口")会隐藏窗口;Windows 首次隐藏前需要确认。页面和 Host 继续运行,任务不受影响,下次显示时仍是原来的文档,会话、草稿和滚动位置都保留;macOS 全屏窗口先退出全屏再隐藏。macOS 通过 Dock 图标、再次启动或 `dsh://open` 找回窗口,Windows 通过托盘找回。最小化行为不变。进入工作区前关闭欢迎窗口,Windows 上走退出流程,macOS 上应用留在 Dock 中且没有窗口。 @@ -46,6 +78,8 @@ Windows 在整个运行期间常驻托盘图标。悬停提示为产品名,单 托盘渲染器以底板中心为基准将鲸鱼放大 20%,保留背景和宽高比;应用和安装器图标保持原有比例。 + + ## 关键技术决策 设计师原稿位于 `resources/icon.png` 和 `resources/icon.svg`;平台适配保留鲸鱼与渐变,分别位于 `resources/icon-windows.*` 和 `resources/icon-macos.*`。将各平台 SVG 导出为透明的 1024×1024 PNG。electron-builder 为 Windows 应用、安装程序和卸载程序生成多尺寸 ICO([Windows 图标要求](https://learn.microsoft.com/en-us/windows/apps/design/iconography/app-icon-construction))。安装页面在两种主题下使用匹配的图案;卸载程序的欢迎和完成页共用 `installer/assets/uninstaller-sidebar.png`,准备阶段将其转换为 164×314 BMP。 @@ -62,11 +96,11 @@ macOS PNG 使用带留白的圆角底板,供传统 ICNS 打包使用,包含 electron-builder 只把清单中的 `dependencies` 复制进 `app.asar/node_modules`,因此 Electron 主进程 bundle `lib/main.js` 内联其工作区 devDependencies,裸导入只剩 `electron`、Node 内置模块与这些 `dependencies`;沙箱 preload 只能留下 `electron`、`events`、`timers` 与 `url`,即其 `require` polyfill 能解析的模块。主进程 bundle 从被内联包的 `lib/` 产物解析它们,所以根 `build:lib:host` 在并发的工作区 tsdown 阶段之后才为 `apps/desktop` 打 bundle,并由 [`desktop-bundle-imports`](scripts/desktop-bundle-imports.mjs) 让任何静态、动态或 `require()` 导入无法在打包应用内解析的 Desktop bundle 直接失败。没有这项检查时,rolldown 无法解析的导入会作为外部说明符进入产物,并在启动时以 `ERR_MODULE_NOT_FOUND` 失败。[已归档的bundle 顺序决策](../../.agents/notes/archived/process/2026-09-22-desktop-main-bundle-after-workspace-tsdown.md)记录了备选方案。 -Windows 签名打包按 PE 文件内容扫描第一方运行时和应用生产依赖,包括没有常规扩展名的文件。最终扫描覆盖整个解包应用。目录链接、格式错误的 `MZ` 文件以及非 PE 的 `.exe`、`.dll` 或 `.pyd` 文件会使打包停止;以 `MZ` 开头的数据文件也会被拒绝,除非包含有效 PE 头。它保留有效的上游签名,并在记录运行时哈希或执行冒烟检查前为未签名代码补签。公钥验签每个进程处理最多 32 个文件,同时最多运行四个进程;硬件令牌签名仍串行执行,每个新签名必须匹配配置的证书且带时间戳。硬件签名或验签失败会停止本轮执行;独立的时间戳请求遵循下文的有界重试规则。electron-builder 只有在验签和逐字节比对通过后,才保留复制后运行时可执行文件的签名。写入发布完成记录前,必须通过最终 PE 签名检查,以及使用全新缓存的 ASAR 载荷和 Host 冒烟检查。开发、仅准备和未签名构建不使用硬件令牌,可能被 Windows 代码完整性策略阻止;任何构建模式都不会关闭该策略。冒烟检查通过不代表兼容所有企业策略。 +Windows 签名打包按 PE 文件内容扫描第一方运行时和应用生产依赖,包括没有常规扩展名的文件。最终扫描覆盖整个解包应用。目录链接、格式错误的 `MZ` 文件以及非 PE 的 `.exe`、`.dll` 或 `.pyd` 文件会使打包停止;以 `MZ` 开头的数据文件也会被拒绝,除非包含有效 PE 头。它保留有效的上游签名,并在记录运行时哈希或执行冒烟检查前为未签名代码补签。公钥验签每个进程处理最多 32 个文件,同时最多运行四个进程;硬件令牌签名仍串行执行,每个新签名必须匹配配置的证书且带时间戳。硬件签名或验签失败会停止本轮执行;独立的时间戳请求遵循[有界重试规则](windows-signing/README.zh.md#windows-ev-signing)。electron-builder 只有在验签和逐字节比对通过后,才保留复制后运行时可执行文件的签名。写入发布完成记录前,必须通过最终 PE 签名检查,以及使用全新缓存的 ASAR 载荷和 Host 冒烟检查。开发、仅准备和未签名构建不使用硬件令牌,可能被 Windows 代码完整性策略阻止;任何构建模式都不会关闭该策略。冒烟检查通过不代表兼容所有企业策略。 Desktop 携带独立的 Python、Node.js 和 pnpm 分发包。Python 包含 numpy、pandas、python-docx、python-pptx、openpyxl、Pillow、lxml、XlsxWriter 及其完整依赖。`load_workspace_dependencies` 工具首次使用时,将该产物离线安装到 `$DSH_HOME/dsh-runtimes/dsh-primary-runtime`(通常为 `~/.dsh/dsh-runtimes/dsh-primary-runtime`),并返回解释器、pnpm 脚本和库目录的绝对路径,以及记录内置分发包名称与版本的 `pythonDistributions`。版本报告不包含用户自行安装的包。Office 任务默认使用这些库,用户或工作区指令指定其他环境时遵循其要求。pnpm 脚本通过返回的 Node 可执行文件运行。返回的 Node 库目录为随包交付的库预留,不是 pnpm 的全局安装目录。 -Desktop 默认注册 `office-docx`、`office-pptx` 和 `office-xlsx`。这些技能使用内置 Python 库创建文件和进行定点编辑,随后重新打开文件,并在交付前运行共享结构检查器。PowerPoint 的创建和编辑使用 python-pptx。技能资源复制到 ASAR 外的 `runtime/office-skills`,让 Python 可以读取检查器。可用的 `render_document` 工具可以补充视觉检查;缺少该工具不妨碍创作或交付。检查范围与限制见 [Office 技能包](../../packages/skill/skill-office/README.zh.md)。 +Desktop 展示精简的 `office` 路由及可具名调用的创作/操作子技能。创作使用内置 Python 库;操作技能调用配置的 Node 和 CLI 路径。技能位于 ASAR 外的 `runtime/office-skills`。渐进加载与限制见 [Office 技能包](../../packages/skill/skill-office/README.zh.md)。 该产物随 Desktop 版本发布。`runtime.json` 记录 Desktop 版本、目标平台、顶层解释器和包管理器版本及 Python 分发包版本表,以及所选目标的锁定产物输入与组装格式的摘要。分发包名称按 PEP 503 归一化;名称归一化后重复时,清单会被拒绝。旧 `components` 清单通过归一化继续可读,并保留其原有库版本一致性校验。匹配的安装会被复用;依赖或压缩包变化后,即使 Desktop 版本不变,也会在完整暂存副本完成后替换目录。不含摘要的旧清单会在下次安装时被替换。用户自行添加的 Python 包仅在产物身份一致时保留。目录替换失败时保留之前的安装;解释器仍在运行时,Windows 可能拒绝替换。 @@ -100,6 +134,8 @@ Welcome 加载共享 Toast 的配色和阴影变量,挂载在 body 下的通 更新或卸载 Desktop 前请结束 CLI 命令;安装程序不与运行中的命令协调。CLI 不提供 Office 创作 skill 的资源路径。Desktop 现有 Office 设置与 Web 附件转换保留各自的资源配置。`prepare:runtime` 复制启动脚本,无需编译原生 CLI 启动器。 + + ## 安装归属 Electron 拥有 `$DSH_HOME/profiles/desktop`。其 `dependencies` 包含 pnpm 安装的包;`dsh.profile.bundles` 包含内置 bundle,后接已启用插件。签名应用从 `resources/app.asar/dsh` 提供 dsh、私有 Desktop Host 及其生产依赖。打包应用选择 runtime profile 解析,不创建包链接;开发 profile 使用文件系统链接。宿主与插件在同一个 Electron Node 模式进程中执行;Desktop 不启用 `--preserve-symlinks`。CLI 不能启动此 profile。Desktop 内置命令可在应用退出后管理其插件;npm 安装的 dsh 不能修改它。 @@ -146,6 +182,8 @@ macOS 上自定义菜单保留 Electron 的标准 Window 菜单及应用隐藏 macOS 和 Linux 从图形界面启动的程序只继承会话管理器提供的环境,不包含 shell 启动文件导出的变量。第一个 Host 启动之前,Desktop 以 ` -ilc` 运行一次账户的登录 shell(取自用户数据库,不看 `$SHELL`),读取定界符之间的 `env -0` 输出,使 `~/.zprofile` 和 `~/.zshrc`(或该 shell 的对应文件)对 Host、agent shell、终端和 profile 配置生效。读取与 profile 准备并行进行。读取进程没有终端输入,并设置 `DISABLE_AUTO_UPDATE=true`、`ZSH_TMUX_AUTOSTARTED=true` 和 `ZSH_TMUX_AUTOSTART=false`,避免 oh-my-zsh 和 tmux 插件阻塞。shell 的值覆盖继承的值,但 `PWD`、`OLDPWD`、`SHLVL`、`_`、上述读取变量以及启动方自有的 `DSH_*` 和 `ELECTRON_*` 除外;Desktop 在读取之前已按 `DSH_HOME` 等变量解析路径,因此 Host 保持相同的值。读取在结束定界符出现时完成,因此启动文件启动的后台进程可以继续运行,其输出被丢弃。候选 shell 无法启动、以非零状态退出、没有输出定界内容或超过 `DSH_DESKTOP_LOGIN_SHELL_TIMEOUT_MS`(1000 到 2147483647 的整数毫秒,默认 `10000`;超时会结束其进程组)时,Desktop 记录一条警告,并依次尝试 `/bin/zsh`、`/bin/bash` 和 `/bin/sh`;全部失败时 Host 使用继承的环境。读取命令使用 POSIX 语法,因此 csh、tcsh 或 nushell 等账户 shell 会失败,Host 改为获得第一个系统 shell 的启动文件所设置的环境。读取期间退出 Desktop 会结束正在运行的读取进程组。每个应用进程只读取一次,因此修改 shell 启动文件后需要退出并重新打开 Desktop。Windows 从图形界面启动的程序已经从注册表继承用户和系统环境变量,因此 Windows 跳过这一步。 + + ## 开发 开发环境应用菜单提供“刷新页面”(macOS 为 Cmd+R,其他平台为 Ctrl+R)和“重启应用与 Host”。重启会等待 Host 关闭,再重新启动 Electron 和新的 Host;这两项操作都不会重新构建源码。 @@ -186,274 +224,44 @@ Desktop 在 Host 启动后、打开工作区前检查模型 API Key 引用是否 欢迎窗口使用设计稿的 Platform light/dark 颜色跟随系统外观,展示 600 × 700 的入口布局和 API Key 表单,包含原生窗口控件、可拖动标题区域、本地品牌 SVG、系统无衬线字体回退,以及非按钮文字使用的本地 Montserrat Light 字体。窗口使用 macOS menu vibrancy 或 Windows acrylic,叠加 onboarding 的窗口背景色:浅色模式为 40% 白色,深色模式为 50% rgb(24 25 28)。本地 React 欢迎入口将 React、公共 `StateDot` 加载指示器及其 CSS 一起打包;它通过隔离 preload 工作,不加载主 Web 应用。入口、登录状态和 API Key 页面共用固定的底部操作行;“返回登录”链接位于操作行下方。按钮共用平台的过渡时序,开启“减少动态效果”会禁用过渡。操作系统控制模糊强度和外部圆角。macOS 的“降低透明度”会抑制半透明效果,“增强对比度”会强制开启该设置。“保存并继续”写入开发环境的凭证存储;“稍后配置”打开真实工作区,不保存密钥或完成标记。生成的开发项目同时链接已声明的 workspace 依赖闭包和 pnpm 提升的包,因此未提升的配置插件仍能解析。[窗口记录](../../.agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.zh.md)负责材质与引导决策。 + + ## 打包 - -### 发布版本 - -每次 Desktop 打包前,第一步都要与当前用户确认完整版本号。检查所选部署环境、dsh 基础版本、保留的发布记录和已发布对象,再提出准确版本供用户确认。用户确认前不得启动打包;仅选择部署环境不代表用户已认可版本号。 - -记录当前 dsh 版本作为基础版本。production Desktop 使用完全相同的版本,包括其中的 `alpha`、`beta` 或 `rc` 标识。test 发布保留完整的预发布基础版本并追加 `.YYYYMMDD.index`;稳定基础版本则追加 `-test.YYYYMMDD.index`。 - -| dsh 基础版本 | production Desktop | test Desktop 示例 | -|---|---|---| -| `0.1.6-alpha.1` | `0.1.6-alpha.1` | `0.1.6-alpha.1.20260916.1` | -| `0.1.6-beta.2` | `0.1.6-beta.2` | `0.1.6-beta.2.20260916.1` | -| `0.1.6-rc.3` | `0.1.6-rc.3` | `0.1.6-rc.3.20260916.1` | -| `0.1.6` | `0.1.6` | `0.1.6-test.20260916.1` | - -日期使用实际创建时的 Asia/Shanghai 日期。每个基础版本、每天的序号从 1 开始,检查保留的发布记录与已发布对象后递增;绝不复用已发布版本。test 分发不发布对应的无后缀基础版本。 - -Windows 手动 CI 使用独立的未发布构建标识:`auto` 追加 UTC 日期、工作流运行编号和尝试次数(`.YYYYMMDD.run.attempt`,稳定基础版本使用 `-test`)。例如 `0.2.1-alpha.1.20261008.42.2`。这些编号不是预留的发布序号。 - -把确认后的版本通过 `--build-version` 传给打包命令,该值同时决定产物文件名、更新 feed 与上传校验。清单保留产品版本,因此 test 打包不再改写发布家族,也不留下需要还原的改动: - -```sh -pnpm --dir apps/desktop run package:win:x64 --build-version 0.1.6-alpha.1.20260916.1 -``` - -`--build-version auto` 会给出当天的下一个序号:读取目标 bucket 中已发布的对象,未配置 bucket 或列举未能在期限内完成时回退到本目标的本地输出目录。上传前请确认它打印的版本号;run script 会自行透传 `--`,打包入口两种写法都接受。 - -production 发布使用产品版本本身,不传 `--build-version`。其上传成功后会把打包所用 commit 打成 `desktop-v<版本>` 标签;来自有改动工作区的构建不打标签,打标签失败也只打印手工命令,不会让已完成的上传变成失败。test 与本地构建有意不留标签,而所有产物的清单都记录 `dshBuildCommit` 与 `dshBuildDirty`,直接分发的构建同样可溯源。 - -版本派生不改变固定更新通道,也不改变 `nightly.yml` / `nightly-mac.yml` 文件名。SemVer 排序为 `0.1.6-alpha.1 < 0.1.6-alpha.1.20260916.1 < 0.1.6-alpha.2`,稳定基础版本的测试版低于该稳定版。客户端只接受更高版本:替换 feed 无法让已安装的较高版本更新到较低的纠正版。这类客户端需要手动安装;保持自动降级关闭。[已归档的版本决策](../../.agents/notes/archived/process/2026-09-16-desktop-release-version-derivation.md)解释为什么不能用通道名替换预发布标识。 - -除本地 macOS 未签名构建外,打包、上传以及手动 macOS 签名检查使用 `apps/desktop/.env.windows` 或 `.env.macos`,由目标平台选择。复制对应的 [Windows 模板](.env.windows.example) 或 [macOS 模板](.env.macos.example),填写本机配置;Git 忽略这两个本地文件,安装产物也不包含它们。发布字段只从目标文件读取,不回退到系统或 shell 中的同名变量;`PATH`、代理和构建工具环境仍保留。发布版本是命令参数而非发布字段,上传从打包写下的完成记录中读取它。文件使用 UTF-8,支持 BOM;相对证书、SignTool、Apple API Key 和钥匙串路径以 `apps/desktop` 为基准,变量值不做 shell 展开,包含 `#` 或空格的密码需要引号。CI 同样在运行前生成目标文件。 - -每条打包命令在构建与下载前检查应用 ID 以及该模式需要的更新与签名配置,随后探测本次运行要用的外部工具:归档读取工具,以及 Windows 目标的安装器编译器。macOS 检查身份、Team ID、一套完整公证凭据、`CSC_LINK` 指定的可读本地 p12 文件、显式配置的 `CSC_KEY_PASSWORD`,以及引用的 API Key 和钥匙串文件;Windows 检查公开代码签名证书、SignTool 文件、容器名称和 PIN 格式。仅准备 Windows 资源以及两个平台上的显式未签名打包均不要求签名凭据。配置检查不验证 PIN 是否正确、Token 是否登录、钥匙串是否解锁或 Apple 是否接受凭据;实际签名与公证负责这些检查。`--build-version auto` 会访问目标 bucket,`--check` 下同样如此。单独运行相同检查: - -```sh -pnpm --dir apps/desktop run check:package -``` - -无需提前执行 `prepare:desktop`: - -```sh -pnpm run package:desktop -``` - -发布自动化使用固定目标命令,确保运行时准备、dsh 准备与 electron-builder 接收相同的平台和架构: - -```sh -pnpm run package:desktop:mac:arm64 -pnpm run package:desktop:mac:x64 -pnpm run package:desktop:win:x64 -``` - -macOS arm64 命令要求 Apple Silicon。macOS x64 命令可以在 Intel macOS 或带 Rosetta 的 Apple Silicon 上运行。Windows x64 命令要求 Windows x64。Linux 不是受支持的 Desktop 发布目标。 - -每个目标都在 `apps/desktop/.desktop-build/targets//` 下持有自己的打包输入、已准备运行时、包集合、dsh 依赖树、pnpm 准备状态、未打包应用、更新元数据和最终产物。Electron 归档缓存继续由 `.desktop-build/downloads` 共享,因为每个归档文件名都包含版本、平台和架构,并且在解包前经过验证。目标构建绝不读取其他目标的可变准备状态。 - -### 无发布签名的本地 macOS DMG - -执行 `pnpm install` 后,在仓库根目录运行一条命令即可,无需 `.env.macos`、Apple 账号、证书或更新服务配置: - -```sh -pnpm run package:desktop:mac:arm64:unsigned -pnpm run package:desktop:mac:x64:unsigned -``` - -添加 `--check` 仅验证本地配置和工具而不构建,或添加 `--dir` 只生成解包应用。DMG 路径为 `apps/desktop/.desktop-build/targets//unsigned-artifacts/deepseek-harness--mac--unsigned.dmg`。所有检查通过后,命令打印 `DMG:` 及其绝对路径。打开后将应用复制到本地测试目录。 - -这些命令忽略 `.env.macos` 和继承的发布配置,使用 `com.deepseek.harness` 作为应用 ID,并省略自动更新和强制更新配置。构建工具、标准网络代理与 `DSH_DESKTOP_NPM_REGISTRY` 仍可从 shell 提供。它们跳过 Developer ID 签名、Apple 公证、临时签名钥匙串和公证代理修改。Electron 使用无需证书的本地 ad-hoc 签名,以便修改后的可执行文件能够运行;DMG 不签名。准备阶段和组装后的应用仍执行运行时完整性与冒烟检查。本地构建不生成更新源、ZIP 或发布完成记录,上传命令无法发布这些产物。macOS 可能要求在“系统设置 → 隐私与安全性”中批准打开未经公证的应用。 - -### 用回环 registry 安装本地 Official 包 - -测试 DMG 不包含 Official 按需目录所宣传的 provider 运行时,而正式发布才会把对应版本的 bundle 发到 npm。要在发布前安装本地构建的 bundle,请启动一个回环 registry,它会打包并提供该目录的完整本地依赖闭包: - -```sh -pnpm run dev:bundle-registry -``` - -该命令先校验目录与 workspace 清单,运行 `pnpm run build:official`,把闭包打包进新的 `dist/test-bundles//` 目录,最后写入 `bundle-registry.json` 作为完成标记,然后才打印产物路径、registry URL 和精确的 `name@version` 规格。它在分配到的端口上只监听 `127.0.0.1`,使用一次性的命名空间提供服务,直到 Ctrl+C 或 SIGTERM;停止后产物和已安装的包都保留在磁盘上。它不需要 `.env.macos`、Apple 凭据或 npm 凭据,也不改变 `pnpm run package:desktop:unsigned`。 - -在打包应用中打开“插件 → 添加插件”,粘贴一条打印出的规格,选择**自定义地址**,粘贴打印出的 registry URL 后安装。自定义 registry 会被单独询问,因此必须由本进程回答每一个依赖:仓库自有的包取自本次运行的产物,第三方包则通过固定重定向到 `registry.npmjs.org` 保留 npm 的真实元数据与字节,因此仍需要网络访问。安装后的条目仍归类为 Official,因为它的包名在目录中。registry 选择会在此浏览器中记住;再次选择某个提供的 registry 即可切回。 - -一次运行的产物是不可变的,所以重新构建会提供新的命名空间和新 URL;当已安装版本与运行版本一致时,Official 的**更新**控件不会出现。profile 会在 `pnpm-lock.yaml` 中记录上一次运行的 tarball URL,而 pnpm 会校验已记录的条目,因此用新 URL 再次安装或重装该 bundle 会被 `ERR_PNPM_TARBALL_URL_MISMATCH` 拒绝,并且可能继续保留旧文件。要在同一版本上验证改动后的代码,请先清除过期的解析结果——在 `$DSH_HOME/profiles/` 中运行 `pnpm clean --lockfile`,或改用全新的 profile——然后再从新 URL 安装。`bundle-registry.json` 记录每个归档的版本、文件名、大小和 SHA-512 完整性,以及打包所用 checkout 的 commit 和 dirty 标记。 - -所提供的 `time` 是真实的打包时间,因此 pnpm 11 默认的 24 小时 `minimumReleaseAge` 会在 profile 的 `pnpm-workspace.yaml` 中记录它授予的豁免(`minimumReleaseAgeExclude`);如果那里显式配置了更严格的策略,安装会以 pnpm 自己的报错失败,而不会被覆盖。`~/.npmrc` 或 profile 的 `.npmrc` 中的 `@scope:registry` 路由会覆盖该 scope 的 `--registry`,因此不能让它们指向正在提供服务的 scope。 - -### 运行时文件筛选 - -Desktop 在本地打包工作区包,并通过目标捆绑的 Node 和 pnpm 安装外部依赖。[Desktop 文件策略](scripts/runtime-file-policy.ts)随后在签名和完整性封装前过滤不可变的 `resources/app.asar/dsh/node_modules` 副本。它排除 TypeScript 声明、已识别的 JavaScript/CSS/TypeScript source map、TypeScript 构建缓存、Domino 测试目录及 Yarn 开发元数据、选定的原生编译器输出和其他平台的 node-pty 预构建文件。非 Windows 目标还会排除 `node-pty/third_party/conpty`;Windows 目标保留该目录。它保留运行时 JavaScript、目标平台的原生模块及其 DLL/EXE 辅助文件、WASM、未知资源、许可证和 notices。依赖清单在完整性封装前经过 electron-builder 的元数据清理,确保归档保持已记录的字节。该策略不修改 npm tarball、捆绑的包管理器或用户安装的插件文件。 - -[Electron 外壳文件清单](scripts/electron-builder-config.mjs)从单独收集的 `resources/app.asar/node_modules` 依赖中排除已识别的 JavaScript/CSS/TypeScript source map 和 TypeScript 构建缓存。这些排除规则保留运行时代码、原生模块、WASM、数据文件(包括普通 `.map` 资源)、许可证和 notices;[外壳归档测试](tests/shell-file-selection.spec.ts)检查保留文件的字节和排除项。 - -[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 输出。每个组装后的应用(包括目录包和未签名构建)都会针对 ASAR 重复产物和 Host 检查。归档完整性检查将归档内完整描述符与准备结果比对,并核对归档和解包目录中的文件内容与清单、归档内文件记录的执行标志,以及解包文件的物理权限。转换失败会在写入发布记录前终止打包;macOS DMG/ZIP 构建在公证前执行这些检查。 - -Windows 发布验收还需在 Desktop 构建后手动运行[目录和替换检查](scripts/smoke-windows.ps1)。将 `$Makensis`、`$SevenZip` 和 `$PluginDir` 分别设为锁定版本构建器的 NSIS 编译器、7-Zip 可执行文件和 x86-unicode NSIS 插件目录;通过 `-FrameLibrary` 传入已准备好的 `window-frame.dll`,即可同时覆盖原生解压路径及其失败报告。从仓库根目录运行以下命令。它验证 目录替换与回滚和两种文件占用替换方式;不属于单元测试通道。 - -```powershell -pwsh -NoProfile -File apps/desktop/scripts/smoke-windows.ps1 -Makensis $Makensis -SevenZip $SevenZip -PluginDir $PluginDir -FrameLibrary apps/desktop/.desktop-build/targets/win-x64/installer-ui/window-frame.dll -``` - -Windows 安装器在启动时和选定目标目录后检查应用是否正在运行,通过检查后才将新版本解压到安装目录旁边。通过同卷目录改名替换前,安装器会再次检查。运行中的应用会阻止安装;更新启动允许等待应用退出,最长十秒。同路径升级在替换成功前保留旧目录;解压失败时旧版不变,替换失败时尝试恢复旧目录。安装器在启动前清理旧版备份。强制结束安装器或断电可能留下 `.new-*` 或 `.old-*` 目录;不同安装位置或安装范围迁移仍使用 electron-builder 的旧卸载器流程。 - -解压失败时,安装器会把 7-Zip 的结果和完整错误输出写入更新缓存目录 `%LOCALAPPDATA%\<按包名派生>-updater\installer-logs\extract-failure-<时间戳>.log`(当前为 `@deepseek-aidsh-desktop-updater`),并在弹窗中显示首条错误行和 **复制错误信息** 按钮;静默安装只写入报告。未签名的 Windows 构建(`DSH_DESKTOP_UNSIGNED=1`)会将安装包命名为 `deepseek-harness-<版本>-win-x64-unsigned.exe`,以免被误当作发布产物。 - + + + + + + -### 上传更新 +构建与发布操作请使用独立指南。共享 Windows runner 存在已接受的凭据和设备隔离限制;签名或执行分支代码前必须阅读。 -固定安装包下载在仓库根目录使用 `pnpm run upload:latest:mac:arm64`、`pnpm run upload:latest:mac:x64` 或 `pnpm run upload:latest:win:x64`。它们把已完成目标的 DMG 或 EXE 上传到 `desktop/dsh-latest-macos-arm64.dmg`、`desktop/dsh-latest-macos-x64.dmg` 或 `desktop/dsh-latest-windows-x64.exe`。生产 URL 以 `https://download.deepseek.com/` 开头;测试 URL 使用 `DOWNLOAD_TEST_ORIGIN`。两个部署环境均复用目标 dotenv 中的部署、bucket 和凭据、全部常规发布校验以及下述上传审计。每个命令覆盖一个固定对象,不发布更新清单、blockmap 或发布标签。测试固定下载在测试 bucket 中跨发布 ID 共享;包内更新 URL 仍要求原有发布 ID。发布由操作人员显式执行,也允许预发布版本。部署基础设施必须配置重新验证,或在覆盖后清除固定下载的缓存。本地回环测试覆盖上传行为;真实 COS 和 CDN 验收由发布操作人员负责。 - -test 与 production 的 `upload:*` 上传在发布前置检查通过后,分别保留新的 `.desktop-build/upload-records/--*` 目录。`plan.json` 记录目标、版本、每个文件的大小/SHA-512 和发布的 YAML 字节;刷盘的 `events.jsonl` 记录 PUT 意图及可用的响应状态/请求 ID;`result.json` 记录完成结果或最后失败阶段。缺少最终结果表示中断或存储不可用,不表示成功。不记录凭据值、认证头或原始 SDK 错误。审计写入失败即停止后续 PUT。每个对象都以一次流式腾讯 COS PUT 上传,并携带显式长度与 Content-MD5;COS SDK 仅在请求体不是流时才会重发请求,上传器自身也不重试。保留部分记录,检查远端状态后再执行下一次操作:超时或回执写入失败不能证明对象未存储。这些记录仅在本地,不防篡改,也不会自动备份;每次发布应将它们与构建证据一同归档到受控存储。公网 CDN 回读仍是单独的发布验收,上传结果明确标记为 `not-performed`。 - -Windows 操作人员可以在仓库外保存 CLIXML 对象,其中 `SecretId` 和 `SecretKey` 是经 DPAPI 加密的 SecureString 字段。[凭据启动器](scripts/upload-with-credentials.ps1)要求显式提供 `-CredentialFile` 和 `-Environment production` 或 `test`;不指定 `-Upload` 时,只验证解密以及向本地 Node 子进程注入凭据,不发起网络请求。它要求 `PATH` 中有 Node,并使用加密该文件时的 Windows 用户和机器。明文、空字段及纯空白字段都会失败。父进程环境保持不变;子进程先清除无关密钥与 Node 预加载选项,再仅接收所选 COS 凭据对。原始子进程 stderr 不会显示,stdout 中的凭据值会被遮盖。此检查不能证明 COS 授权有效。显式上传还要求 `-Upload -Target -Bucket ` 及下述常规发布完成前提;真实云端上传仍需发布操作人员验收。在该上传调用中添加 `-Latest`,即可替换固定安装包,而不发布更新清单或发布标签。此启动器支持长期密钥,不支持 STS 凭据。显式上传要求所选部署环境和 bucket 与目标 dotenv 文件及已完成的打包记录一致,才会发起网络写入;即使 dotenv 文件含有其他 COS 密钥,也使用 DPAPI 凭据对。 - -`DSH_DESKTOP_AUTO_UPDATE_ENV` 同时选择打包写入的 URL 与后续 COS 上传环境,可取 `test` 或 `production`;缺省为 `test`。测试打包通过 `DOWNLOAD_TEST_ORIGIN` 提供 HTTPS origin;生产使用 `https://download.deepseek.com`。上传通过 `DOWNLOAD_TEST_COS_BUCKET` 或 `DOWNLOAD_PROD_COS_BUCKET` 提供所选 bucket。生产清单位于 `dsh-desk/feeds//`,安装包位于 `dsh-desk/bin//`。测试发布必须配置 `DOWNLOAD_TEST_RELEASE_ID`:32 位小写十六进制字符,分别插入路径 `dsh-desk//feeds//` 和 `dsh-desk//bin//`。YAML 引用、稳定通道别名和 blockmap 都位于该发布目录内。目标为 `mac-arm64`、`mac-x64` 和 `win-x64`。 - -更新目标与上传凭据都与所选环境对应: - -| 环境 | 公开 origin | COS bucket | COS 凭据 | -|---|---|---|---| -| `test` 或未设置 | `DOWNLOAD_TEST_ORIGIN` | `DOWNLOAD_TEST_COS_BUCKET` | `DOWNLOAD_TEST_COS_SECRET_ID`、`DOWNLOAD_TEST_COS_SECRET_KEY` | -| `production` | `https://download.deepseek.com` | `DOWNLOAD_PROD_COS_BUCKET` | `DOWNLOAD_PROD_COS_SECRET_ID`、`DOWNLOAD_PROD_COS_SECRET_KEY` | - -每个测试发布批次用下方命令生成新 ID,将输出填入 `.env.macos` 或 `.env.windows` 的 `DOWNLOAD_TEST_RELEASE_ID`。这两个被 Git 忽略的平台文件管理该值,shell 变量不能覆盖它,dotenv 值也不会进行 shell 展开。打包、上传和重试必须沿用同一个 ID;上传会拒绝更新 URL 不一致的完成记录。需要验证跨版本升级时,后续版本沿用已安装客户端的 ID。生产环境不使用该字段。 - -```sh -node --input-type=module -e "import { randomBytes } from 'node:crypto'; console.log(randomBytes(16).toString('hex'))" -``` - -通过完整下载链接分发每个测试批次。已安装的测试客户端保留当前批次的清单地址,不会自动发现新 ID。格式校验无法判断随机性,请使用生成器的输出。随机路径降低被猜中的概率,不限制持有链接者访问;撤下批次需要删除其 COS 对象并清除对应 CDN 目录缓存。 - -在目标 `.env` 中配置更新地址与所选 COS bucket、SecretId、SecretKey,再打包并上传同一个目标: - -```sh -pnpm run package:desktop:mac:arm64 -pnpm run upload:mac:arm64 -``` - -内测打包在目标 `.env` 中显式设置 `DSH_DESKTOP_AUTO_UPDATE_ENV=test` 和 `DOWNLOAD_TEST_ORIGIN=https://download-test.deepseek.com`;上传使用 `DOWNLOAD_TEST_COS_BUCKET=bj-toc-download-test-1320056602` 及独立测试凭据。test 和 production 都使用固定 Nightly 通道,部署选择不提供通道切换。 - -前期内测包使用 `test` 部署。只有正式发布才显式选择 `production`;更换上传凭据不会改变已有安装包的更新目标。打包不需要 COS 凭据,会禁用 electron-builder 发布、移除子进程的 COS 凭据,并且仅在签名与公证成功后记录完成状态。上传在读取凭据前验证该记录、部署、目标、共同版本号、文件名、大小与 SHA-512。安装包和 blockmap 先于 YAML 上传;历史对象继续保留。每个版本发布 `nightly.yml` 或 `nightly-mac.yml`;稳定版本还发布指向相同产物的 `latest.yml` 或 `latest-mac.yml`。发布的 YAML 使用安装包绝对 URL。上传器不设置 Cache-Control,包括 COS SDK 否则会添加的空头部:缓存策略由部署基础设施负责,清单不缓存,安装包缓存单独配置。同一目标应串行发布,并在发布验收前验证公网产物与清单内容。 - -macOS 签名配置使用必填发布环境,不会接受钥匙串中最先发现的证书。空值、格式错误的 Team ID、包含 electron-builder 不支持的 `Developer ID Application:` 前缀的签名身份,以及不完整的公证凭据都会被拒绝。macOS 签名打包要求已配置的身份及其私钥可用。运行时准备会把该身份、安全时间戳与 hardened runtime 应用到每个内嵌 Mach-O 文件;应用签名完成后,深度严格检查会拒绝其他叶证书 Authority 或 Team ID,验证通过才生成发布产物。macOS 签名固定目标安装包命令为已签名应用创建独立副本,并发执行两条产物流。一路先公证 App 并钉票,再生成 ZIP 及其更新元数据。另一路把已签名 App 副本封装进签名 DMG,再公证 DMG、钉票并验证;其中的 App 不单独附加票据。只有两路均成功结束,产物才会移入最终目录并写入发布完成记录。签名的仅目录命令同样需要公证凭据,并等待 Apple 公证和 App 钉票完成。[并行公证决策](../../.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.zh.md)负责副本隔离与容器票据语义。`CSC_LINK` 必须指向包含 Developer ID Application 证书及私钥的本地 p12,不支持 URL 或 Base64 输入。`CSC_KEY_PASSWORD` 是其导出密码,不是 Apple 账号或登录密码;未加密的 p12 可显式填写空值。构建前,打包流程自动创建并解锁私有临时钥匙串、导入 p12、授权签名并签署小型探针。运行时与 App 签名显式使用该钥匙串,无需预先配置或手动解锁登录钥匙串。子进程只接收钥匙串路径,不接收 p12 密码。成功或普通失败后删除临时钥匙串;强制终止后由 CI 清理临时凭据。CI 从密钥存储生成证书文件和 `.env.macos`,限制文件访问权限,并在作业结束后删除二者。环境中的 `CSC_NAME` 与证书发现顺序都不能选择发布所有者。公证凭据也可以使用 electron-builder 支持的完整 Apple ID 或钥匙串 profile 方式。手动执行 `pnpm --dir apps/desktop run verify:mac-signature -- ` 重复应用检查时,也必须提供两个 macOS 身份变量。 - -macOS 签名遍历真实文件,不跟随 Framework 的软链接别名。PAK 资源保留全部随附语言,由外层 Framework 或应用签名记录完整性,不逐个签名。[发布策略](../../.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md)负责依赖补丁和验证要求。 - -macOS 运行时准备将已验证的单架构 Mach-O 签名缓存在 `.desktop-build/targets//signature-cache`。缓存键包含输入字节与权限、签名探针实际使用的叶证书、签名标识、entitlement 字节、macOS 版本,以及签名工具与策略。复用不取决于 Git 提交:未提交的字节变更会使对应文件失效。每次命中都验证缓存字节、严格签名、证书、标识、entitlements、安全时间戳和 hardened runtime,再替换未被并发修改的输入。通用二进制每次重新签名。运行时完整性与 smoke 检查、App 签名和 Apple 公证仍会执行。缓存要求受信任的本地构建存储。缓存损坏或不安全链接会令构建失败;停止打包后删除对应缓存目录再重试。成功的签名阶段会将完整缓存条目的内容总量裁剪至一 GiB;中断留下的临时条目需手动清理。并发淘汰可能使读取方安全失败。日志记录命中、未命中及未缓存数量。 - -Mac 打包命令通过 `DESKTOP_PACKAGING_RECORD` 输出 `apps/desktop/.desktop-build/packaging-runs/` 下的唯一目录。读取本地配置后,每次运行保留 `run.json`(发布版本、产品版本、Git 提交、工作区是否有改动、目标及 Node 版本)、`events.jsonl`(带时间戳的阶段、耗时、并行输出归属及代理恢复状态)、脱敏后的子进程 `stdout.log` / `stderr.log`,以及 `result.json`(整体结果、阶段结果和产物目录)。事件还记录打包并发数及各代理是否配置。失败和后续打包不会清除日志;没有自动删除流程。缺少 `result.json` 表示完成状态未经确认。已知凭据值会被遮盖;不记录环境变量全集或 notarytool 认证参数。运行时准备分别记录包暂存、安装、依赖树复制、签名、清单生成、smoke 检查、描述文件校验和清理。嵌套阶段的耗时存在重叠,不能直接相加。 - -App 和 DMG 公证分别记录 submission ID,以及独立的 `notarytool:upload:*` 和 `notarytool:wait:*` 耗时。上传使用 `submit --no-wait`,包含认证、本地校验、传输和服务端受理,并非纯传输时间。等待从该命令返回后开始,包含轮询及剩余的 Apple 处理时间;Apple 可能在上传提交命令返回前已开始处理。日志保留 Apple 状态与诊断信息,包括拒绝结果;签名检查、接受状态校验和 stapling 仍由 `@electron/notarize` 负责。仅生成目录的打包命令也使用同一条可计时的 App 公证路径。各子进程输出仍实时显示在终端,阶段失败及嵌套错误保留在事件日志中。两个平台均记录父进程打包失败,并在终端显示脱敏诊断,包括代理恢复操作指引。仅检查配置的 `check:package` 命令不创建运行日志。 - -Mac 打包从 `.env.macos` 读取三个调优字段: - -| 设置 | 默认值 | 作用范围 | -|---|---|---| -| `DSH_DESKTOP_MACOS_PACK_CONCURRENCY` | `4` | 第一方与 vendor workspace tarball 的打包 worker 数;必须是正整数。 | -| `DSH_DESKTOP_MACOS_DOWNLOAD_PROXY` | 空 | Electron、运行时资源、pnpm 安装及 builder 下载使用的 HTTP/HTTPS 代理 origin。 | -| `DSH_DESKTOP_MACOS_NOTARIZATION_PROXY` | 空 | 通过临时系统代理设置供 Apple 工具使用的 HTTP 代理 origin。 | - -两个代理字段互相独立,拒绝 URL 中的凭证、路径、查询参数和片段。空值沿用继承的网络设置。显式下载代理会替换子进程的代理变量,仅绕过本地主机;它不修改系统设置。Windows 与独立 `release:pack` 保持现有并发默认值。公司代理地址仅写入 Git 忽略的本地文件;具体地址参见内部文档。 - -Apple 工具使用 macOS 当前活动网络服务的 HTTP/HTTPS 代理。配置公证代理后,打包会检查代理可达性、保存该服务的设置,在两条产物任务期间启用代理,并在两条任务均结束后恢复原设置。对于原本关闭、服务器为空且端口为零的代理,恢复时仅关闭代理;临时服务器和端口可能保留,但不生效。仅生成目录的打包会在签名目录构建完成后的 App 公证期间启用代理。这会临时影响其他应用,并要求修改系统代理的权限;必须先禁用 PAC、自动发现、SOCKS 及需要认证的代理配置。打包和恢复在读取恢复记录或修改代理前获取同一个用户级 POSIX 文件锁;进程退出会释放锁的持有权,锁文件保留。该锁在首次使用时才加载 `@deepseek-ai/node-addon-system/flock`,而不是在脚本启动时加载,因此 `check:package` 和打包入口在未构建 `native/system` 的 checkout 上也能加载;加锁时若宿主 addon 二进制或入口的 JavaScript 缺失,加载器会先运行 `pnpm run build:native-system` 和 `pnpm --dir native/system run build:ts` 再加锁,因此恢复命令在这样的 checkout 上同样可用。这会阻止不同 checkout 的代理事务重叠;其他用户及网络设置工具不得同时修改这些设置。SIGINT/SIGTERM 会等待活动任务结束后恢复。强制终止或恢复失败后,先停止残留公证进程,再运行 `pnpm --dir apps/desktop run restore:mac-proxy`;保存的记录会保留到恢复成功。配置检查仅验证 URL 语法,不修改系统设置或连接代理。 - -### 未签名 Windows 测试安装包 - -在 Windows x64 上,使用完整的未签名打包命令进行本地安装测试: - -```sh -pnpm run package:desktop:win:x64:unsigned -``` - -该命令读取 `.env.windows`,要求设置 `DSH_DESKTOP_APP_ID` 和[强制更新策略配置](#mandatory-update-policy),包括真实的 HTTPS 服务 origin。本机验收和 CI 使用 Node 24;其他工具为根 `packageManager` 固定版本的 pnpm、Git、PowerShell、tar、Python、Visual C++ Build Tools 和 Windows SDK。Python 不在 `PATH` 中时设置 `PYTHON`。命令将安装包写入 `.desktop-build/targets/win-x64/unsigned-artifacts/`,省略普通自动更新配置,清除签名凭据,且不生成发布完成记录。无需 EV 或上传凭据。签名打包和上传仍遵循正式发布要求。 - -先运行 `pnpm install --frozen-lockfile`,再运行 `pnpm run package:desktop:win:x64:unsigned --check` 校验配置和工具,最后执行上面的完整命令。打包自行执行完整构建和隔离的运行时 smoke,不调用在线模型。阶段日志保留在 `.desktop-build/packaging-runs/`;仅构建成功不代表安装包验证通过。依赖安装和运行时准备需要访问 npm、GitHub release 资源和 nodejs.org;网络需要时使用现有 registry 和代理配置。缓存与工具位置属于构建机器配置,不在跟踪文件中固定个人路径。 - -[Windows 手动工作流](../../.github/workflows/windows-package.yml) 选择带有专用标签 `dsh-win-package-trial` 的自托管 Windows x64 runner,并调用同一个未签名命令。启动 runner 前需准备 PowerShell 7 和上述工具。必须填写策略部署环境和 HTTPS origin;测试部署还需填写逗号分隔的登录 origin。这些公开输入生成临时 `.env.windows`,不包含签名或上传凭据。`build_version` 默认为 `auto`,使用[发布版本](#release-versions)中说明的 CI 标识。显式版本复用现有产品版本校验器。解析后的版本在打包前显示于运行摘要,manifest 保持不变。 - -工作流进入默认分支后,在 Actions 中选择它,选择源码分支、填写输入并点击 Run workflow。它不监听 push 或 PR,不改变现有自动 CI。打包日志和成功生成的 EXE 产物保留 14 天;安装、签名和发布是独立操作。依赖 Actions 打包前,先从默认分支运行一次,并检查保留的日志和安装包。Checkout 会清理 runner 专用源码目录,包括被忽略的文件;不得指向日常开发目录。运行共享一个并发组,不取消正在进行的构建;最多保留一个待运行任务,新的手动触发会替换该待运行任务。仅在成功完成干净检出后上传诊断日志;即使配置准备在写入后失败,也会清理生成的配置。 +- [Desktop 打包](packaging/README.zh.md):本地与 Actions 构建、版本、部署配置、产物、日志和验收。 +- [Windows 签名与 runner 运维](windows-signing/README.zh.md):机器部署、已接受的隔离风险、签名凭据、Token 失败与缓存维护。 +- [Desktop 发布](publishing/README.zh.md):上传凭据、发布标识、固定下载、更新清单和上传恢复。 -### Windows 安装界面 + + +## Windows 安装界面 Windows 安装程序使用原生 NSIS 页面,提供亮暗配色、系统阴影、可编辑的安装目录,以及默认勾选立即启动的完成页。安装仅面向当前用户。点击安装或按 Enter 均校验当前路径;路径规范化后仍拒绝磁盘根目录,包括带重复末尾分隔符的写法。新安装位置必须为空,非空位置必须是已登记的安装目录。受影响安装路径中的程序运行时显示系统提示,并保持应用运行;其他目录中的同名应用不阻止安装。静默更新最多等待受影响应用退出十秒,若仍在运行则以退出码 2 结束。 -主题在启动时跟随 Windows;可用 `/THEME=light`、`/THEME=dark` 和 `/THEME=auto` 显式选择配色。窗口在品牌控件准备完成后显示。欢迎页首次出现时,安装窗口会一次性移到普通窗口前方;若焦点在其他窗口,任务栏按钮会闪烁提示,但安装窗口不会始终置顶。进度读取锁定版本的 7-Zip 解压器百分比;目录替换、注册和清理仍使用有界估算。加权百分比不代表剩余时间。NSIS 报告成功后,进度条用 600 毫秒补满并短暂显示 100%,再显示完成页;切换目标时长为 750 毫秒。完成页保留窗口位置。点击完成后,安装程序先隐藏窗口,再启动已安装的可执行文件;启动失败会恢复页面以供重试。目录替换和失败恢复遵循上文描述的安装流程。首次启动的配置档案准备仍属于独立的 Desktop 操作。 +主题在启动时跟随 Windows;可用 `/THEME=light`、`/THEME=dark` 和 `/THEME=auto` 显式选择配色。窗口在品牌控件准备完成后显示。欢迎页首次出现时,安装窗口会一次性移到普通窗口前方;若焦点在其他窗口,任务栏按钮会闪烁提示,但安装窗口不会始终置顶。进度读取锁定版本的 7-Zip 解压器百分比;目录替换、注册和清理仍使用有界估算。加权百分比不代表剩余时间。NSIS 报告成功后,进度条用 600 毫秒补满并短暂显示 100%,再显示完成页;切换目标时长为 750 毫秒。完成页保留窗口位置。点击完成后,安装程序先隐藏窗口,再启动已安装的可执行文件;启动失败会恢复页面以供重试。目录替换和失败恢复遵循[安装检查](packaging/README.zh.md#runtime-file-selection)。首次启动的配置档案准备仍属于独立的 Desktop 操作。 Windows 打包使用 Visual C++ Build Tools 和 Windows SDK 编译 x86 Win32/GDI+ 辅助库;签名构建通过已配置的 Windows 签名器对该库签名。准备钩子在所有平台上均由 electron-builder 继续负责收集生产依赖。 -在有交互式桌面的 Windows x64 上,从仓库根目录运行 `pnpm --dir apps/desktop run test:installer`,可将小型原生测试载荷接入正式安装配置并执行验证。每次运行使用独立产品身份,依次验证仅英文和仅中文的安装器变体,并根据实际显示的欢迎页按钮选择测试文案。两个变体均安装到私有目录并在测试后卸载;截图和结果保留在 `.desktop-build/installer-tests/` 下。检查包含末尾带分隔符的已登记路径升级,以及磁盘根目录拒绝。可选的 `--signed` 标志使用下文的 Windows EV 配置,在嵌入前对测试程序和辅助库签名;它不会启用更新源。 +在有交互式桌面的 Windows x64 上,从仓库根目录运行 `pnpm --dir apps/desktop run test:installer`,可将小型原生测试载荷接入正式安装配置并执行验证。每次运行使用独立产品身份,依次验证仅英文和仅中文的安装器变体,并根据实际显示的欢迎页按钮选择测试文案。两个变体均安装到私有目录并在测试后卸载;截图和结果保留在 `.desktop-build/installer-tests/` 下。检查包含末尾带分隔符的已登记路径升级,以及磁盘根目录拒绝。可选的 `--signed` 标志使用[Windows EV 配置](windows-signing/README.zh.md#signing-configuration),在嵌入前对测试程序和辅助库签名;它不会启用更新源。 Windows 卸载程序会随应用一起删除 Electron 用户数据目录(`%APPDATA%` 下按包作用域嵌套的浏览器存储与缓存)、`%APPDATA%` 下的产品目录,以及 `%LOCALAPPDATA%` 下的更新下载缓存,随后移除 `%APPDATA%` 之下普通且已空的作用域目录。Harness 主目录(`~/.dsh` 或 `DSH_HOME`:会话、设置、凭据、插件)不会被触碰;以 Windows 环境变量发布的 `DSH_HOME` 还会保护所有与其重叠的目标。静默卸载删除相同的数据;以 `--updated` 或 `/KEEP_APP_DATA` 启动的卸载程序保留数据,electron-builder 在原地更新和从其他目录替换旧安装时正是这样启动它。删除通过原生辅助程序执行:它拒绝受保护的 Windows 目录以及与安装目录或主目录重叠的路径,要求固定的本地驱动器,链接的根目录或祖先目录原样保留,遇到重解析点只解除链接而不进入目标,清除只读属性,并在遇到被占用文件后继续删除其余兄弟项;残余不会中止卸载,也不会提示。安装时在 Windows 卸载注册项上记录 `InstallLocation` 作为标准的清单元数据;在 Windows 11 上,开始菜单右键菜单中的“卸载”对所有 Win32 应用都会打开已安装应用列表,只有 MSIX 包能从那里直接卸载。卸载程序声明 DPI 感知,中文使用微软雅黑 UI。此行为仅适用于 Windows。 使用 `node apps/desktop/scripts/test-windows-installer.mjs --uninstall-only --compile-only` 以每次运行唯一的带作用域包名编译独立的中英文夹具。省略 `--compile-only` 可对预置数据运行原生删除器回归,以及交互、静默、`--updated`、`/KEEP_APP_DATA` 和 `DSH_HOME` 位于 Electron 数据内的检查。编译本身不能证明已安装卸载行为。 -### Windows EV 签名 - -运行时签名在当前 Windows 账户的各 worktree 间共享完整的已签名文件。`.env.windows` 中的 `DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR` 指定固定本地磁盘上的绝对目录;默认值为 `%USERPROFILE%\.dsh-desktop-signing\signature-cache\v1`。缓存目录必须属于当前账户,访问权限不得向其他普通账户开放;带链接的路径会被拒绝。缓存项标识原始字节、公钥证书和签名工具链。每次恢复都检查摘要、Windows 信任状态、时间戳和证书,再替换未签名文件;缓存项无效会停止打包,不回退到硬件签名。不会仅因缓存较旧而重新签名。缓存信任同账户运行的程序,不防御管理员。[运行时签名缓存决策](../../.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.zh.md)定义验收要求和设计限制。 - -预检、主要运行时签名、应用运行时签名和产物生成分别持有账户级签名阶段锁,直到受监督的子进程结束。其他构建进入这些阶段前等待;编译和准备步骤不持锁。运行时签名进程自行持锁,因此仅终止外层打包进程不会释放仍在访问缓存的阶段锁。迁移及维护获取同一把锁;旧版或外部签名命令不参与排队,应另行避免并发。阶段等待不计入预检期限。关闭阶段句柄会释放普通竞争锁,不删除独立的硬件尝试互锁;遗留硬件失败仍需操作人员恢复。 - -缓存命中的复制、摘要和逐文件信任检查使用 `.env.windows` 中的 `DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY` 个工作任务(默认 `4`,整数 `1`–`8`)。所有恢复及后置验签完成后,未命中项才进入串行硬件签名。恢复或验签失败会停止派发新任务,并在释放阶段锁前等待在途工作结束;硬件签名、运行时 smoke 检查和最终完整性校验仍须执行。 - -每个运行时阶段向标准输出及打包日志写入 `SIGNATURE_CACHE_SUMMARY`,包含实际目录、策略标识、命中及未命中数、新发布及保留项数、签名请求数、省去的签名请求数和验证失败数。计时区分签名、恢复文件和新签名文件的信任验证及恢复;恢复耗时包含其信任检查和暂存清理。这些统计不包含预检、最终产物签名及外层运行时验签。阶段锁事件单独记录等待时间。 并发计时累加逐文件工作耗时,不代表阶段墙钟时间。 - -使用 `pnpm --dir apps/desktop run cache:windows-signatures --usage` 查看结构完整的缓存项字节数和数量,或用 `--from ` 导入显式指定、属于同账户的旧缓存。迁移不修改源目录,跳过暂存名称,拒绝损坏项,并保留已有有效项,即使它们的时间戳字节不同。`--clear` 在阶段锁保护下显式删除完整缓存项;不执行自动容量淘汰。`--directory ` 指定维护目标目录,无需加载发布凭据。不完整暂存项保持原状并单独计数;仅在所有构建停止后检查它们。这些命令绝不清除硬件失败证据。默认存储位于 AppData 之外,避免 MSIX 启动器虚拟化将账户缓存拆开;被重定向的覆盖目录会明确失败。 - -Windows 签名构建在编译或准备依赖前执行受监督的签名预检。静态配置、证书有效期、审计存储、编译器可用性及遗留签名锁的检查不访问 Token。本地 .NET Framework C# 编译器生成一个专用小探针,由正式签名器仅签名一次,随后必须验出配置的证书和时间戳才能继续构建。探针绝不执行。预检的整体 60 秒期限包含时间戳尝试;超时、硬件签名报错或验签失败都会停止本轮流程,不再次调用硬件。成功只证明当前签名路径可用,不证明 PIN 已独立认证:SafeNet 可能复用登录状态。不要为了验证 PIN 而注销或重复认证。`--check`、仅准备和 `--unsigned` 模式不执行此硬件预检;未签名产物仍不能发布上传。自动回归测试使用假签名器,真实硬件由发布操作人员单独验收。 - -Windows NSIS 上传要求安装包旁存在生成的非空 `.exe.blockmap`。blockmap 先于通道 YAML 上传;NSIS 安装包元数据不要求另一种 web-installer 格式使用的内嵌 `blockMapSize`。文件清单测试使用固定版本构建器的 blockmap 生成器,而不是手工编造内嵌映射字段。 - -签名 Windows 配置从同一份公开证书的 `CN`、`O` 和 `C` 属性生成 updater 的 `publisherName`。每个属性都必须存在、非空且只有一个值。这些身份属性允许证书续期,无需固定叶证书指纹。已安装应用的 `app-update.yml` 保存预期发布者,下载的清单不能选择该身份。未签名测试构建省略 updater 配置。真实文件验证及其限制见[签名验收记录](tests/README.zh.md)。 - -本项目使用的 SafeNet Token 出现 `SignTool Error: No private key is available.` 时,说明 PIN(密码)错误。立即停止所有签名尝试,等待用户处理 PIN 后再继续。PIN 输错达到五次会锁定 Token。遇到该错误后,不得重试打包或签名探针。签名器串行执行 Token 操作,首次失败后拒绝所有排队任务。 - -Windows 打包命令通过 `DESKTOP_PACKAGING_RECORD` 输出 `.desktop-build/packaging-runs/` 下的唯一目录。每次运行保留 `run.json`、带时间戳的 `events.jsonl`、脱敏后的 `stdout.log` 和 `stderr.log`,以及 `result.json`。签名失败还会写入 `fatal.json` 并通过 stderr 通知父进程;监督程序立即请求终止当前阶段的进程树并等待退出。失败阶段不能启动后续阶段或生成发布完成记录。日志写入失败也会停止运行。终止错误仍按失败处理,需要操作者检查;缺少最终记录表示尚未确认完成。 - -硬件签名必须属于受监督的打包运行。调用命令解释器前,签名器原子获取 `%USERPROFILE%/.dsh-desktop-signing/attempt.json` 并记录本次尝试。只有签名成功且配置证书的主签名通过验证后才释放该文件;随后完成时间戳,不再访问硬件。失败、中断、已有锁定文件或审计存储不可用都会阻止再次访问硬件,包括同一 Windows 账户下的另一个签名器实例、进程或代码检出目录。没有定时恢复或自动重试。管理员必须检查保留的证据及令牌状态,再明确授权恢复锁定状态;登录令牌或替换 PIN 文件不会清除它。记录区分签名意图、命令解释器 PID 和完成结果,不计量 CSP/令牌内部的认证次数。不记录命令参数、PIN 或凭据环境。其他 Windows 账户及无关签名程序不在此锁定机制的保护范围内。 - -Windows 打包将 7-Zip 过滤器固定为 `BCJ`,以兼容内置的 NSIS 解码器。这样可以保留 x64 安装包中由依赖携带的 ARM64 二进制文件;自动 ARM64 过滤会生成该解码器无法解压的条目。 - -NSIS 在安装阶段清理临时解压目录,完成后才显示完成页或自动启动应用。已安装的生产依赖保持为普通文件;启动时不会再次解压。安装仍会写入完整的应用目录树。 - -在 `.env.windows` 中填写 `DSH_DESKTOP_WINDOWS_CER_FILE`(公开 EV 叶证书)、`DSH_DESKTOP_WINDOWS_SIGNTOOL`(SafeNet 兼容的 SignTool)、`DSH_DESKTOP_WINDOWS_KEY_CONTAINER`(匹配的私钥容器)和 `DSH_DESKTOP_WINDOWS_TOKEN_PIN`(Token Password)。私钥仍保留在 USB Token;不要把证书或本地凭据文件提交到 Git。 - -```sh -pnpm run package:desktop:win:x64 -``` - -打包前插入并解锁 Token。electron-builder 钩子把每个产物交给采用 CRLF 的 `scripts/windows-sign.cmd`;该 CMD 只调用一次已配置的 SignTool,并指定 `/f`、SafeNet `/kc "[{{PIN}}]=容器"`、`/csp "eToken Base Cryptographic Provider"`和 SHA-256 文件摘要,不请求时间戳。随后钩子在隔离副本上完成 DigiCert SHA-256 RFC 3161 时间戳,不传递签名凭据。钩子不会改用 electron-builder 内置的 SignTool,也不会重试失败的签名请求。SignTool、证书、容器、PIN、Token 或签名不可用时,Windows 发布打包会失败,不会生成未签名产物。 - -时间戳处理仅对正常退出但返回失败或警告的时间戳命令重试,最多尝试三次,间隔为一秒和两秒。每次均从同一份已验证的主签名开始。启动错误、终止状态不确定或验签失败会立即停止。SignTool 使用短的私有路径;发布时先把已验证字节复制到目标卷,再原子替换。最终必须通过 Windows 信任、证书、时间戳和规范化全文件相等检查。尝试耗尽后停止打包并保留证据,不再次调用硬件。参见[签名完成决策](../../.agents/notes/implemented/process/2026-09-17-windows-signature-completion.zh.md)。 - -PIN 不能包含 `]`、引号或换行,因为这些字符用于分隔 SafeNet `/kc` 值或对应的 CMD 参数。CMD 会禁用延迟展开,因此包含 `!` 的 PIN 可以原样到达 SafeNet。打包流程不会把任何 `DSH_DESKTOP_WINDOWS_*` 字段传给构建与 运行时准备子进程;它只向签名预检、独立的第一方运行时签名阶段与 electron-builder 提供四个配置输入,在其他字段已经清理的环境中只向签名 CMD 提供经过校验的签名字段,在 SignTool 启动前清除这些字段,并遮盖 SignTool 诊断。SafeNet 仍要求 PIN 出现在 SignTool 进程命令行中。本地 `.env.windows` 明文保存 PIN,应限制文件访问权限;CI 使用临时文件并在任务结束后删除。不要提交或分享文件内容,也不要把凭据写入日志。配置检查不会消耗 Token 的 PIN 尝试次数;签名仍在首次失败后停止整批任务。 - -使用对应的 `:dir` 命令可以生成可直接运行的应用目录,而不是安装包,例如: - -```sh -pnpm run package:desktop:dir -pnpm run package:desktop:mac:arm64:dir -``` - -需要检查或诊断为宿主目标准备的资源而不调用 electron-builder 时,可以让同一流水线在准备完成后停止: - -```sh -pnpm run prepare:desktop -``` - -这条诊断命令是另一种停止位置,并非两条命令构建流程的前半段。之后执行 `package:desktop*` 时仍会重新完成正式构建与准备,避免使用陈旧的 dsh 包、运行时文件或 dsh 内容。 - - - -### Desktop 运行时准备 - -Desktop [补丁策略](scripts/runtime-patch-policy.ts) 将每份工作区补丁明确归为共享、仅工作区或仅运行时。共享项复用根目录补丁字节,并对照根锁文件校验 hash;仅运行时项使用仓库内的独立文件,不改变工作区安装。仅工作区补丁用于构建工具或已嵌入客户端 bundle 的依赖。未分类或过期的条目、文件缺失、hash 改变、解析版本不兼容,以及仅工作区包进入运行时,都会阻止打包。[准备脚本](scripts/prepare-runtime-patches.ts) 只写入临时项目;pnpm 负责应用选中的补丁,应用失败时会报错。 - -Desktop 依赖 overrides 在补丁策略模块中单独声明,不从补丁版本推导。pi-ai 约束使运行时版本保持在共享补丁已验证的版本。Desktop 仍会在每次构建时重新解析运行时锁文件,再通过 `--frozen-lockfile` 安装;不同构建之间的依赖解析尚不保证可复现。CLI 打包安装测试及其他交付流程保留各自的配置。 - -每条打包命令都会构建仓库,打包以 dsh 和私有 Desktop Host 为根的第一方生产依赖闭包,并准备目标专用的 Electron 分发包和包含共享 pnpm CLI 的 primary-runtime。`prepare:dsh` 在构建时安装一次生产依赖图,准备物化包供 electron-builder 归档到 `app.asar/dsh`,移除包管理器元数据,并生成包含共享包版本和最终文件哈希的 `desktop-runtime.json`。macOS 签名构建先签名并验证原生文件,再生成清单;electron-builder 不对已签名的此目录重复进行嵌套签名。资源映射明确包含默认根目录过滤器会忽略的 `dsh/node_modules`;准备完成的运行时清单在原生签名后检查。原生可执行文件及库解包到 ASAR 旁;Python、独立 Node 和 pnpm 保留在外部 runtime 资源中。Windows 打包逐项检查准备好的 PE,确认其 ASAR 条目已标记为解包,且磁盘副本字节一致;未签名构建也执行此检查。Builder glob 规则用单字符通配符匹配 PE 文件名中的花括号,因此同目录中名称匹配的文件也可能被解包。准备好的运行时 smoke 沿用已验证的目标描述符,不使用构建宿主的架构。签名安装包、公证、已安装应用升级和各目标原生模块的验收需要发布环境。 - -macOS 签名打包在组装 App 时、代码签名前写入 `Contents/Resources/app-update.yml`,供并行 ZIP 与 DMG 路线使用的目录构建也执行此操作。签名钩子验证准确的更新源和 updater 缓存目录。写入发布完成记录前,流程会再次检查两条路线的副本和最终移入的 App;配置缺失或不匹配会阻止移入产物,因而也会阻止上传。 - -未压缩产物包含 Electron、物化后的 dsh 生产依赖树、pnpm,以及壳应用。安装包大小与文件系统占用不同;发布验收需要测量两者,以及 profile 插件存储和首次启动耗时。此布局用更多应用内文件换取消除用户机器上的核心包安装过程。 + ## 更新 @@ -461,7 +269,7 @@ Windows 下载完成后的更新确认说明应用会在安装期间关闭、完 原生更新浮层在文档就绪且父窗口可见时显示,并在父窗口再次显示时恢复。关闭浮层会释放输入拦截和父窗口监听。[本地窗口验证](tests/README.zh.md#verification-overlay)无需启动工作区即可检查这些切换。 -打包应用在启动后异步检查固定 Nightly。常规轮询以十分钟为基础间隔,每次独立采样 ±20% 的随机抖动。每次检查失败将基础延迟翻倍,上限为一小时;成功后重置。随机延迟不超过该上限,并从全部复用调用结算后开始计时。本地化的“检查更新…”菜单项(Windows 可从顶栏的“应用”菜单进入)立即执行,并复用正在进行的检查。回到前台和系统恢复时遵守相同的单调时钟截止时间。新收到的强更策略也会立即请求检查更新清单。自动检查从不弹窗或下载安装包。手动检查显示正在检查、失败或包含已安装版本号的无更新反馈。常规更新弹窗原位渐入渐出;连续弹窗替换卡片内容并重置其滚动位置,保留黑色半透明蒙层,不模糊父页面。 +打包应用在启动后异步检查固定 Nightly。常规轮询以十分钟为基础间隔,每次独立采样 ±20% 的随机抖动。每次检查失败将基础延迟翻倍,上限为一小时;成功后重置。随机延迟不超过该上限,并从全部复用调用结算后开始计时。本地化的“检查更新…”菜单项(Windows 可从顶栏的“应用”菜单进入)立即执行,并复用正在进行的检查。回到前台和系统恢复时遵守相同的单调时钟截止时间。新收到的强更策略也会立即请求检查更新清单。自动检查从不弹窗或下载安装包。手动检查显示正在检查、失败或包含已安装版本号的无更新反馈。常规更新弹窗原位渐入渐出;连续弹窗替换卡片内容并重置其滚动位置,保留黑色半透明蒙层,不模糊父页面。Windows 在销毁模态窗口前先隐藏它,确保原生窗口激活切换前父窗口已恢复启用。 `DSH_DESKTOP_UPDATE_CHECK_INTERVAL_MS` 配置常规基础间隔,`DSH_DESKTOP_UPDATE_CHECK_MAX_BACKOFF_MS` 配置上限;两者均接受 1000 至 2147483647 的整数毫秒数,且上限不能小于间隔。省略上限时取一小时与间隔中的较大值。`DSH_DESKTOP_UPDATE_CHECK_JITTER` 配置 0 至 1 的抖动比例,默认 `0.2`;最终延迟至少一秒,且不超过上限。这些配置不改变强更策略轮询,也不授权下载重试。 @@ -538,10 +346,14 @@ node apps/desktop/node_modules/pnpm/bin/pnpm.mjs --dir apps/desktop run test:upd 下载内容是不可执行的测试字节,安装调用仅记录而不执行。测试替换浏览器打开与剪贴板写入,避免外部导航和剪贴板修改。它不启动完整产品工作区,不验证真实安装器或重启,不验证发布者签名,也不覆盖差分更新或 macOS。停滞的策略请求、清单请求和负载传输会执行真实截止时间及恢复。真实常规弹窗验证隔离预加载、卡片尺寸、未施加模糊的父页面、取消、任务警告选项与显式安装批准;账户行组件测试另行提供证据。[本地验证决策](../../.agents/notes/implemented/testing/2026-09-10-desktop-local-updater-qualification.zh.md)和[验证记录](tests/README.zh.md)保留这些限制;生产发布要求保持不变。 + + ## 底层开发覆盖项 未打包的 Electron 进程使用应用目录下的 `.desktop-build/development/project` 作为开发项目。`DSH_DESKTOP_PNPM_ENTRY` 和 `DSH_DESKTOP_DSH_DIR` 是带应用路径默认值的可选覆盖项。每次未打包启动都必须设置 `DSH_DESKTOP_PRIMARY_RUNTIME_DIR`:开发启动器(`dev:desktop`、`start:desktop` 及工作区更新验证运行器)会把它设置为自己已准备目标的 primary-runtime 目录;缺少该变量的启动会以致命启动对话框失败。启动器必须设置它,因为壳无法从 `process.arch` 推导该目录:构建目标将 Windows 固定为 x64,而宿主可能是 arm64。打包应用会忽略这些变量,从 `process.resourcesPath` 解析签名资源,并使用受管 Desktop profile。 + + ## 已知限制 - 账号登录尚未接入;登录按钮禁用。Windows 材质效果仍需平台验证。 @@ -565,6 +377,8 @@ node apps/desktop/node_modules/pnpm/bin/pnpm.mjs --dir apps/desktop run test:upd 账号提供者的 `embeddedPageDist` 配置为内嵌用量和充值页面 URL 添加 `dist` 查询参数。默认值为空;私有前端分支选择值应写在本地 profile patch 中。此配置不改变 API 地址或凭证传递方式。 + + ## 开发备注 上线前 CDN 与容量决策见[桌面更新提案](../../.agents/notes/proposed/feature/2026-09-08-desktop-update-policy-and-installation.zh.md#cdn-and-capacity-qualification)。 diff --git a/apps/desktop/config/macos-packaging/macos-signing.env.example b/apps/desktop/config/macos-packaging/macos-signing.env.example new file mode 100644 index 0000000000..b4ec0dbe3f --- /dev/null +++ b/apps/desktop/config/macos-packaging/macos-signing.env.example @@ -0,0 +1,9 @@ +# Copy to macos-signing.env with mode 0600; keep this directory outside the runner checkout. +DSH_DESKTOP_MACOS_SIGNING_IDENTITY= +DSH_DESKTOP_MACOS_TEAM_ID= +CSC_LINK=../certificates/developer-id.p12 +CSC_KEY_PASSWORD= +# Configure exactly one notarization strategy; this template uses an App Store Connect API key. +APPLE_API_KEY=../certificates/notarization.p8 +APPLE_API_KEY_ID= +APPLE_API_ISSUER= diff --git a/apps/desktop/config/macos-packaging/production.env.example b/apps/desktop/config/macos-packaging/production.env.example new file mode 100644 index 0000000000..18360eadef --- /dev/null +++ b/apps/desktop/config/macos-packaging/production.env.example @@ -0,0 +1,9 @@ +# Copy to production.env and configure the production service before selecting this preset. +DSH_DESKTOP_APP_ID=com.deepseek.harness +DSH_DESKTOP_AUTO_UPDATE_ENV=production +DSH_DESKTOP_MANDATORY_UPDATE_PROD_ORIGIN= +DSH_DESKTOP_MACOS_PACK_CONCURRENCY=4 +DSH_DESKTOP_MACOS_DOWNLOAD_PROXY= +DSH_DESKTOP_MACOS_NOTARIZATION_PROXY= +# Optional absolute private directory; setup fills this in for machine presets. Omit to use checkout-local storage. +# DSH_DESKTOP_MACOS_SIGNATURE_CACHE_DIR= diff --git a/apps/desktop/config/macos-packaging/test.env.example b/apps/desktop/config/macos-packaging/test.env.example new file mode 100644 index 0000000000..aa40a119ce --- /dev/null +++ b/apps/desktop/config/macos-packaging/test.env.example @@ -0,0 +1,14 @@ +# Copy to test.env; paths in secret configuration resolve beside macos-signing.env. +DSH_DESKTOP_APP_ID=com.deepseek.harness +DSH_DESKTOP_AUTO_UPDATE_ENV=test +DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN= +DSH_DESKTOP_MANDATORY_UPDATE_CONFIG='{"allowedAuthOrigins":["https://login.example.com"]}' +DOWNLOAD_TEST_ORIGIN= +# Generate a 32-character lowercase hexadecimal release ID; retain it for upgrade testing. +DOWNLOAD_TEST_RELEASE_ID= +DSH_DESKTOP_MACOS_PACK_CONCURRENCY=4 +DSH_DESKTOP_MACOS_DOWNLOAD_PROXY= +# Temporarily changes the active network service's system HTTP/HTTPS proxies. +DSH_DESKTOP_MACOS_NOTARIZATION_PROXY= +# Optional absolute private directory; setup fills this in for machine presets. Omit to use checkout-local storage. +# DSH_DESKTOP_MACOS_SIGNATURE_CACHE_DIR= diff --git a/apps/desktop/config/windows-packaging/production.env.example b/apps/desktop/config/windows-packaging/production.env.example new file mode 100644 index 0000000000..2e05825590 --- /dev/null +++ b/apps/desktop/config/windows-packaging/production.env.example @@ -0,0 +1,7 @@ +# Copy to production.env in the same machine configuration directory and fill in the values. +DSH_DESKTOP_APP_ID=com.deepseek.harness +DSH_DESKTOP_AUTO_UPDATE_ENV=production +DSH_DESKTOP_MANDATORY_UPDATE_PROD_ORIGIN= +DSH_DESKTOP_MANDATORY_UPDATE_CONFIG='{}' +# DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR= +DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY=4 diff --git a/apps/desktop/config/windows-packaging/test.env.example b/apps/desktop/config/windows-packaging/test.env.example new file mode 100644 index 0000000000..718481f13a --- /dev/null +++ b/apps/desktop/config/windows-packaging/test.env.example @@ -0,0 +1,13 @@ +# Copy to test.env in the same machine configuration directory and fill in the values. +# Public test configuration. No PIN or upload credentials belong here. +DSH_DESKTOP_APP_ID=com.deepseek.harness +DSH_DESKTOP_AUTO_UPDATE_ENV=test +DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN= +# Replace login.example.com with the real login origin before packaging. +DSH_DESKTOP_MANDATORY_UPDATE_CONFIG='{"allowedAuthOrigins":["https://login.example.com"]}' +# Required for signed test packages; retain the release ID for an upgrade test batch. +DOWNLOAD_TEST_ORIGIN= +DOWNLOAD_TEST_RELEASE_ID= +# Optional absolute private cache directory. Omit to retain the account default cache. +# DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR= +DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY=4 diff --git a/apps/desktop/config/windows-packaging/windows-signing.env.example b/apps/desktop/config/windows-packaging/windows-signing.env.example new file mode 100644 index 0000000000..d086a84ae7 --- /dev/null +++ b/apps/desktop/config/windows-packaging/windows-signing.env.example @@ -0,0 +1,7 @@ +# Copy to windows-signing.env in the same machine configuration directory and fill in the values. +# Restrict this file to the runner account, SYSTEM and administrators. +# File paths resolve relative to this file; use the same account as local signing. +DSH_DESKTOP_WINDOWS_CER_FILE=../certificates/windows.cer +DSH_DESKTOP_WINDOWS_SIGNTOOL= +DSH_DESKTOP_WINDOWS_KEY_CONTAINER= +DSH_DESKTOP_WINDOWS_TOKEN_PIN= diff --git a/apps/desktop/electron-builder.config.d.mts b/apps/desktop/electron-builder.config.d.mts index a4d0739791..0682e6e9ad 100644 --- a/apps/desktop/electron-builder.config.d.mts +++ b/apps/desktop/electron-builder.config.d.mts @@ -30,6 +30,7 @@ export interface DesktopElectronBuilderConfig { readonly signIgnore: readonly string[] } readonly dmg: { + readonly format: 'ULMO' readonly sign: boolean readonly writeUpdateInfo: boolean } diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 563f60a858..34e859cd20 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -75,8 +75,6 @@ "@deepseek-ai/dsh-client-ui-primitives": "workspace:*", "react": "^18.2.0", "react-dom": "^18.2.0", - "@types/react": "~18.3.1", - "@types/react-dom": "~18.3.0", "vite": "^6.4.3", "@deepseek-ai/dsh-client-ui-theme": "workspace:*", "@deepseek-ai/dsh-client-shortcuts": "workspace:*", diff --git a/apps/desktop/packaging/README.i18n.yaml b/apps/desktop/packaging/README.i18n.yaml new file mode 100644 index 0000000000..40103064d6 --- /dev/null +++ b/apps/desktop/packaging/README.i18n.yaml @@ -0,0 +1,58 @@ +# Bilingual-pair consistency record for README.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write apps/desktop/packaging/README.md +/: + en: 9bc7767fc1bced33 + zh: 4f6198337a573cf4 +/desktop-packaging: + en: b35e10c88ade04fe + zh: a2d09add03407b91 +/desktop-packaging/summary: + en: e98e516b9e1bd0f4 + zh: 1a2abd7d89ef78a1 +/desktop-packaging/table-of-contents: + en: a6203dc2e690c7e1 + zh: 07b516549b8e3328 +/desktop-packaging/choose-a-build-path: + en: 17c6bcdefa05a8f8 + zh: 4ee18f832276144d +/desktop-packaging/release-versions: + en: 3f38ab26ee195b39 + zh: f00eddafa8f3b9ca +/desktop-packaging/configuration-and-local-builds: + en: f5dc36baf414ecc5 + zh: 8984741b356ac1d3 +/desktop-packaging/windows-actions-packaging: + en: e3f51e7130a0fe05 + zh: 70fd6a40d5899871 +/desktop-packaging/macos-self-hosted-packaging: + en: a3af9523fdc60036 + zh: 5e18d4740e9b7f57 +/desktop-packaging/unsigned-windows-test-installer: + en: 6843a0fdc03ead58 + zh: 957f92fc8126e23e +/desktop-packaging/local-macos-dmg-without-release-signing: + en: 2feaa0412247d81f + zh: 461dde8e13fd4734 +/desktop-packaging/macos-release-signing-and-diagnostics: + en: 62c125856aa03c26 + zh: 9fc00a43c8b1e8e2 +/desktop-packaging/directory-builds-and-preparation: + en: 30d58517c9c2044f + zh: 16d26bdc923fc4ba +/desktop-packaging/local-official-bundles-from-a-loopback-registry: + en: b42f103358d0dfd7 + zh: a534164ad9eb00da +/desktop-packaging/runtime-file-selection-and-installation-checks: + en: 06d5c666a3e7f2e4 + zh: c1d4a55cc29e11e3 +/desktop-packaging/desktop-runtime-preparation: + en: a7aa3066e649ffe8 + zh: 4334637910058c09 +/desktop-packaging/installation-and-release-verification: + en: 0006b6b1ae4998f4 + zh: 2bab6918b0c37b25 +/desktop-packaging/dev-note: + en: b75c8458928b72b9 + zh: 003ba4e17b4b5269 diff --git a/apps/desktop/packaging/README.md b/apps/desktop/packaging/README.md new file mode 100644 index 0000000000..9cf46b68e1 --- /dev/null +++ b/apps/desktop/packaging/README.md @@ -0,0 +1,275 @@ +--- +description: "Build Desktop installers locally or through Windows or macOS Actions, choose deployment and signing mode, and retrieve artifacts and diagnostics. This guide keeps build operations separate from publishing and installed-application qualification. Platform signing details and shared-runner risks must be checked before using hardware credentials." +--- + +# Desktop packaging + +English | [中文](README.zh.md) + +## Summary + +Build Desktop installers locally or through Windows or macOS Actions, choose deployment and signing mode, and retrieve artifacts and diagnostics. This guide keeps build operations separate from publishing and installed-application qualification. Platform signing details and shared-runner risks must be checked before using hardware credentials. + +## Table of Contents + +- [Choose a build path](#choose-a-build) +- [Release versions](#release-versions) +- [Configuration and local builds](#configuration-and-local-builds) +- [Windows Actions packaging](#windows-actions) +- [macOS self-hosted packaging](#macos-actions) +- [Unsigned Windows test installer](#unsigned-windows-test-installer) +- [Local macOS DMG without release signing](#local-macos-dmg-without-release-signing) +- [macOS release signing and diagnostics](#macos-release-signing) +- [Directory builds and preparation](#directory-and-preparation) +- [Local Official bundles from a loopback registry](#local-official-bundles-from-a-loopback-registry) +- [Runtime file selection and installation checks](#runtime-file-selection) +- [Desktop runtime preparation](#desktop-runtime-preparation) +- [Installation and release verification](#verification) + + + +## Choose a build path + +Run local commands from the repository root after installing locked dependencies. Use platform-specific commands for a local build, or platform Actions for the registered self-hosted runner. Choose test or production deployment independently from signed or unsigned mode. Read [Windows signing and runner operations](../windows-signing/README.md#trust-and-isolation) before any Windows signed build or shared-runner use. Installation is separate from packaging; [publication](../publishing/README.md) is a further explicit operation. + + + +## Release versions + +Before each Desktop packaging run, first confirm the complete version string with the current user. Check the selected deployment, dsh base version, retained release records, and published objects, then propose the exact version for approval. Do not start packaging until the user confirms that version; the deployment setting alone does not authorize a version choice. + +Record the current dsh version as the base. A production Desktop release uses that exact version, including any `alpha`, `beta`, or `rc` identifiers. A test release preserves the complete prerelease base and appends `.YYYYMMDD.index`; a stable base uses `-test.YYYYMMDD.index` instead. + +| dsh base | Production Desktop | Test Desktop example | +|---|---|---| +| `0.1.6-alpha.1` | `0.1.6-alpha.1` | `0.1.6-alpha.1.20260916.1` | +| `0.1.6-beta.2` | `0.1.6-beta.2` | `0.1.6-beta.2.20260916.1` | +| `0.1.6-rc.3` | `0.1.6-rc.3` | `0.1.6-rc.3.20260916.1` | +| `0.1.6` | `0.1.6` | `0.1.6-test.20260916.1` | + +Use the actual creation date in Asia/Shanghai. For each base and date, start the index at 1 and increment after checking retained release records and published objects; never reuse a published version. Test distribution does not publish the corresponding unsuffixed base. + +Manual Desktop CI uses a separate unpublished build identifier: `auto` appends the UTC date, workflow run number and attempt (`.YYYYMMDD.run.attempt`, with `-test` for a stable base). For example, `0.2.1-alpha.1.20261008.42.2`. These numbers are not reserved release sequence numbers. + +Pass the confirmed version to the packaging command as `--build-version`, which reaches the artifact names, the update feed, and the upload validation as one value. The manifests keep the product version, so a test build no longer rewrites the release family and leaves nothing to revert: + +```sh +pnpm --dir apps/desktop run package:win:x64 --build-version 0.1.6-alpha.1.20260916.1 +``` + +`--build-version auto` proposes the next index for today, reading the published objects in the destination bucket and falling back to this target's local output directory when no bucket is configured or the listing cannot finish within its deadline. Confirm the proposal it prints before an upload; a run script forwards `--` on its own, and the packaging entry accepts it either way. + +A production release publishes the product version and takes no `--build-version`. Uploading one tags the packaged commit as `desktop-v` once the artifacts are public; a build from a modified checkout is not tagged, and a tagging failure prints the command to run by hand rather than failing a completed upload. Test and local builds are deliberately left untagged, and every artifact records `dshBuildCommit` and `dshBuildDirty` in its manifest so a build handed over directly remains traceable. + +The installer embeds `DSH_DESKTOP_UPDATE_CHANNEL` (`nightly` by default, or `latest`) independently of version and deployment. Automatic updates remain on that channel even for a stable version; manually installing the other channel switches it. Latest packages reject prerelease versions. A stable product can produce a nightly `-test..` build, but that version ranks below the stable base and cannot upgrade an already installed higher version. Keep automatic downgrade disabled; stable corrective releases require a product version bump. CI version selection and publication are documented in [CI publication](../publishing/ci/README.md#package-and-publish). + + + +## Configuration and local builds + +Set `DSH_PACKAGING_CONFIG_DIR` to the absolute machine configuration directory and `DSH_PACKAGING_PRESET` to `test` or `production` for local Windows commands. The loader reads `presets/.env`; signed commands additionally read `secrets/windows-signing.env`. Relative certificate and SignTool paths resolve beside the file declaring them. Public presets reject credentials, and the signing file cannot override public settings. Missing configuration fails without falling back to `.env.windows`; commands without a preset retain the existing dotenv behavior. Unsigned and preparation-only presets never read the signing file. Cache maintenance also reads only public preset settings. Test signed packages also require the existing download origin and release ID, but no upload credentials. Local preset commands with `--build-version auto` number from local artifacts only; Actions uses its run number and attempt. + +Both manual packaging workflows accept `test_release_id`. Blank uses the machine preset; a nonblank value must contain 32 lowercase hexadecimal characters and requires the test preset. The loader applies it through `PACKAGE_TEST_RELEASE_ID` without changing the preset file. Keep the same value for successive upgrade tests on that platform. Rotating the default affects future builds only: installed clients retain their original update address, and old objects remain accessible until separately removed. A random prefix reduces discoverability but does not authenticate downloads. + +Outside machine-preset mode, packaging, upload, and manual macOS signature verification (except local unsigned macOS builds) read `apps/desktop/.env.windows` or `.env.macos`, selected by target platform. Copy the [Windows template](../.env.windows.example) or [macOS template](../.env.macos.example) and fill in the local settings; Git ignores both local files, and packaged artifacts exclude them. Release fields come only from the selected configuration files, without fallback to system or shell variables; `PATH`, proxies, and build-tool settings remain inherited. The published version is an argument rather than a release field, and upload reads it from the completion record the packaging run wrote. Files use UTF-8 with optional BOM; relative certificate, SignTool, Apple API key, and keychain paths resolve from `apps/desktop`, values are not shell-expanded, and passwords containing `#` or spaces need quotes. Windows CI reads the same machine presets. + +Every package command checks the application ID and its mode-specific update and signing configuration before building or downloading, then probes the external tools the run will use: the archive reader, and on a Windows target the installer compiler. macOS checks the identity, Team ID, one complete notarization strategy, readable local `CSC_LINK` p12 file, explicit `CSC_KEY_PASSWORD`, and referenced API key and keychain files; Windows checks the public code-signing certificate, SignTool file, container name, and PIN format. Windows preparation-only builds and explicit unsigned builds on either platform do not require signing credentials. Configuration checks do not authenticate the PIN, log in to the token, unlock a keychain, or contact Apple; actual signing and notarization perform those checks. Without a machine preset, `--build-version auto` contacts the destination bucket, including under `--check`. Run the same checks separately: + +```sh +pnpm --dir apps/desktop run check:package +``` + +`prepare:desktop` is not a prerequisite: + +```sh +pnpm run package:desktop +``` + +Release automation uses fixed target commands so runtime preparation, dsh preparation, and electron-builder receive the same platform and architecture: + +```sh +pnpm run package:desktop:mac:arm64 +pnpm run package:desktop:mac:x64 +pnpm run package:desktop:win:x64 +``` + +The macOS arm64 command requires Apple Silicon. The macOS x64 command runs on Intel macOS or Apple Silicon with Rosetta. The Windows x64 command requires Windows x64. Linux is not a supported Desktop release target. + +Both release and local unsigned macOS DMGs use LZMA compression (`dmg.format: 'ULMO'`) in [the electron-builder configuration](../scripts/electron-builder-config.mjs). A [build-time patch](../../../patches/app-builder-lib@26.17.0.patch) adds this format to the pinned builder's validation and TypeScript declarations. + +Each target owns its packed package inputs, prepared runtime, package set, dsh tree, pnpm preparation state, unpacked application, update metadata, and final artifacts under `apps/desktop/.desktop-build/targets//`. The Electron archive cache remains shared under `.desktop-build/downloads` because every archive name includes its version, platform, and architecture and is verified before extraction. A target build never consumes another target's mutable preparation state. + + + +## Windows Actions packaging + +The `Package Desktop · Windows` [manual workflow](../../../.github/workflows/windows-package.yml) selects the dedicated Windows x64 runner label `dsh-win-package-trial` and the machine preset `test` or `production`. Choose `unsigned` (default) or `signed`; signed mode uses the existing EV token preflight, signature cache and failure interlock. It does not publish or upload a release. `build_version` defaults to `auto`, using the CI identifier in [Release versions](#release-versions); an explicit value may be the checkout's product version or an extended build version such as `0.2.1-alpha.1.20261009.1` for product `0.2.1-alpha.1`. The version prefix must match the selected checkout; validation errors show its current example. Manifests remain unchanged. + +Select the workflow in Actions, choose the source branch, preset, signing mode and version, then click Run workflow. Before merging input changes, test the pushed branch with `gh workflow run windows-package.yml --ref -f preset=test -f signing=unsigned -f build_version=auto`; verify the web form after merging. There is no push or PR trigger. Logs and successful EXEs are retained for 14 days. Inspect the retained logs and installer before relying on packaging; installation is separate. Before checkout, Node removes the runner-owned source directory without following dependency junctions; the workflow validates its location and rejects linked ancestors. Never point it at a development checkout. Runs share one concurrency group without cancelling an active build; only one run remains pending, and a newer dispatch replaces it. Diagnostics upload requires a successful clean checkout. Machine settings stay outside the checkout and are never uploaded as artifacts. + +Open the completed run in Actions and download its Artifacts. `windows-x64-signed---` and `windows-x64-unsigned---` contain the corresponding EXE; `windows-package-logs--` contains packaging diagnostics. The upload steps are `Retain verified signed installer`, `Retain verified unsigned installer`, and `Retain packaging diagnostics`; their step durations and timestamped logs show upload time. These artifacts are not a Release or download-site publication. + + + +## macOS self-hosted packaging + +The `Package Desktop · macOS` [manual workflow](../../../.github/workflows/macos-package.yml) targets an Apple Silicon runner labeled `dsh-mac-package-trial`. It selects `test` or `production`, resolves `build_version=auto` from the UTC date, run number and attempt, and uses the existing signing, notarization and packaged-runtime checks. Successful DMG/ZIP files and diagnostic logs are retained for 14 days; this workflow does not publish a release. The operator must register the runner and verify a complete Actions run before relying on it. + +Run `node apps/desktop/scripts/setup-macos-packaging.mjs "$HOME/dsh-ci"` to prepare private `runner/`, `packaging/{presets,secrets,certificates}` and `cache/macos-signatures` directories without overwriting existing files; append `--check` to inspect them. Fill the [preset](../config/macos-packaging/test.env.example) and [signing](../config/macos-packaging/macos-signing.env.example) templates outside the checkout. Set secret and certificate files to mode `0600`. `DSH_PACKAGING_CONFIG_DIR` selects the absolute `packaging` directory and `DSH_PACKAGING_PRESET` selects its preset; missing files fail without falling back to `.env.macos`. Signed and preparation-only Mac builds read `secrets/macos-signing.env`; relative credential paths resolve beside that file. Public presets reject signing credentials, and signing files cannot override public settings. Local unsigned Mac builds retain their configuration-free behavior. + +Run `bash apps/desktop/scripts/start-macos-packaging-runner.sh "$HOME/dsh-ci/runner" "$HOME/dsh-ci/packaging" --check` to check the host tools and configuration files. The script selects the active Xcode and its macOS SDK together and compiles a small linker probe. After registration, omit `--check` to run in the foreground with a cleared inherited environment and prevent idle sleep while the runner is alive. Stop it with Ctrl-C before installing the service below. The runner owns its `_work` checkout, separate from daily development. The account can read its signing files, so run only trusted code. The existing notarization proxy restoration rules apply; after forced termination, inspect retained recovery records before another build. New setup templates set `DSH_DESKTOP_MACOS_SIGNATURE_CACHE_DIR` to the absolute `cache/macos-signatures` directory outside checkout. For existing installations, rerun setup and add that field to each preset; existing files are preserved. This retains signatures across clean checkouts; cache verification and limits are described under [macOS signing](#macos-release-signing). + + +The `skip_notarization` workflow option defaults to `false`; the equivalent CLI flag is `--skip-notarization`. It retains Developer ID signing, DMG signature verification and packaged-runtime verification, but omits Apple notarization and stapling and does not require Apple notarization credentials. DMG/ZIP filenames carry `-unnotarized`, are stored under `.desktop-build/targets//unnotarized-artifacts/`, and are uploaded with an `unnotarized-debug` label. These artifacts are for internal debugging only: Gatekeeper may block downloaded copies, and they are not qualified for distribution or update publishing. No release completion record is written. The flag rejects unsigned, Windows and preparation-only builds. + +Install the user service from the checkout as the same account that registered the runner, without `sudo`: + +```sh +bash apps/desktop/scripts/macos-runner-service.sh install "$HOME/dsh-ci/runner" "$HOME/dsh-ci/packaging" /opt/homebrew/opt/node@24/bin +bash apps/desktop/scripts/macos-runner-service.sh start "$HOME/dsh-ci/runner" +bash apps/desktop/scripts/macos-runner-service.sh status "$HOME/dsh-ci/runner" +``` + +The installer copies its startup script into `runner/dsh-service`, sets the service PATH to Node 24 plus system tools, and uses GitHub's `svc.sh` and `runsvc.sh`. The LaunchAgent survives SSH disconnection, but requires that account's desktop login after reboot and stops on desktop logout. FileVault disk unlock remains an operator step; this does not provide unattended boot. Logs are `runner/dsh-service/stdout.log` and `stderr.log`; verify `Connected to GitHub` and `Listening for Jobs`. To update installed startup code or tool paths, wait until the runner is idle, run `stop`, then `uninstall`, and repeat installation. Neither action unregisters the runner or removes packaging credentials. Uninstall retains `runner/dsh-service` (including logs) and the explicit `.path`; reinstall replaces the startup snapshot and PATH. + + + +## Unsigned Windows test installer + +On Windows x64, use the complete unsigned packaging command for local installation testing: + +```sh +pnpm run package:desktop:win:x64:unsigned +``` + +The command reads `.env.windows` and requires `DSH_DESKTOP_APP_ID` and the [mandatory-update policy settings](../README.md#mandatory-update-policy), including a real HTTPS service origin. The local run and CI use Node 24; the remaining tools are the root `packageManager` version of pnpm, Git, PowerShell, tar, Python, Visual C++ Build Tools and a Windows SDK. Set `PYTHON` when Python is absent from `PATH`. It writes the installer to `.desktop-build/targets/win-x64/unsigned-artifacts/`, omits ordinary automatic-update configuration, strips signing credentials, and creates no release completion record. EV and upload credentials are unnecessary. Signed packaging and upload retain their release requirements. + +Run `pnpm install --frozen-lockfile`, then `pnpm run package:desktop:win:x64:unsigned --check` to validate configuration and tooling before the full command above. Packaging performs its own complete build and isolated runtime smoke without an online model. Stage logs remain under `.desktop-build/packaging-runs/`; a successful build alone does not qualify the installer. Dependency installation and runtime preparation need access to npm, GitHub release assets and nodejs.org; use the existing registry and proxy configuration when the network requires it. Caches and tool locations belong to the build machine, not tracked personal paths. + + + +## Local macOS DMG without release signing + +After `pnpm install`, run one command from the repository root. No `.env.macos`, Apple account, certificate, or update service configuration is required: + +```sh +pnpm run package:desktop:mac:arm64:unsigned +pnpm run package:desktop:mac:x64:unsigned +``` + +Add `--check` to validate local configuration and tools without building, or `--dir` to stop at the unpacked application. The DMG is `apps/desktop/.desktop-build/targets//unsigned-artifacts/deepseek-harness--mac--unsigned.dmg`. After all checks pass, the command prints `DMG:` followed by its absolute path. Open it and copy the application to a local test directory. + +These commands ignore `.env.macos` and inherited release settings, use `com.deepseek.harness` as the app ID, and omit automatic-update and mandatory-update configuration. Build tools, standard network proxies, and `DSH_DESKTOP_NPM_REGISTRY` remain available from the shell. They skip Developer ID signing, Apple notarization, temporary signing keychains, and notarization proxy changes. Electron receives a local ad-hoc signature without certificates so the modified executable can run; the DMG is unsigned. Both preparation and the assembled application still run runtime integrity and smoke checks. Local builds omit update feeds, ZIPs, and release completion records, and the upload commands cannot publish them. macOS may require approval in System Settings → Privacy & Security to open an unnotarized application. + + + +## macOS release signing and diagnostics + +The signed macOS configuration uses the required release environment instead of accepting whichever certificate appears first in a keychain. It rejects empty values, a malformed Team ID, a signing identity that includes electron-builder's unsupported `Developer ID Application:` prefix, and incomplete notarization credentials. Signed macOS packaging requires the configured identity and its private key. Runtime preparation applies that identity, a secure timestamp, and hardened runtime to every embedded Mach-O file; after signing the application, a deep strict check rejects any other leaf authority or Team ID before artifact creation. The signed fixed-target macOS installer commands create separate copies of the signed application and run two artifact lanes concurrently. One lane notarizes and staples the App before generating the ZIP and its update metadata. The other encloses its signed App copy in a signed DMG, then notarizes, staples, and verifies the DMG; its inner App has no individually stapled ticket. Both lanes must finish successfully before their artifacts reach the final directory and the release completion record is written. Signed directory-only commands also require notarization credentials and wait for Apple notarization and App stapling. The [parallel notarization decision](../../../.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.md) owns copy isolation and container ticket semantics. `CSC_LINK` must name a local p12 containing the Developer ID Application certificate and private key; URLs and Base64 inputs are not supported. `CSC_KEY_PASSWORD` is its export password, not an Apple account or login password; an explicitly empty value supports an unencrypted p12. Before building, packaging creates and unlocks a private temporary keychain, imports the p12, authorizes signing, and signs a small probe. Runtime and App signing use this keychain explicitly; existing login keychains require no setup or manual unlocking. Child processes receive its path without the p12 password. The keychain is deleted after success or ordinary failure; CI must clean temporary credentials after forced termination. Ephemeral CI writes the certificate and `.env.macos` from its secret store, restricts file access, and deletes both after the job; self-hosted machine presets retain private files outside the checkout. Ambient `CSC_NAME` and certificate discovery order do not select the release owner. Notary credentials may instead use electron-builder's complete Apple ID or keychain-profile strategy. The two macOS identity variables are also required when repeating the application check manually with `pnpm --dir apps/desktop run verify:mac-signature -- `. + +macOS signing visits real files without following Framework symlink aliases. PAK resources retain all shipped languages and are sealed by the enclosing Framework or application signature instead of receiving individual signatures. The [release policy](../../../.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md) owns the dependency patch and verification requirements. + +macOS runtime preparation caches verified single-architecture Mach-O signatures in `.desktop-build/targets//signature-cache` unless `DSH_DESKTOP_MACOS_SIGNATURE_CACHE_DIR` selects external storage. An explicit path must be absolute and already exist, contain no linked ancestors, and be owned by the current account with mode `0700` and read/write/search access; invalid or empty settings fail preflight without fallback. Omit the field to retain target-local storage. Keys include input bytes and permissions, the signing probe’s actual leaf certificate, identifier, entitlement bytes, macOS version, and signing tools and policy. Git commits do not determine reuse: uncommitted byte changes invalidate the affected file. Every hit verifies cached bytes, the strict signature, certificate, identifier, entitlements, secure timestamp and hardened runtime before replacing an unchanged input. Universal binaries are signed each time. Runtime integrity and smoke checks, App signing, and Apple notarization still run. The cache requires trusted local build storage. Cache corruption or unsafe links fail the build; stop packaging before deleting the affected cache directory and retrying. Successful signing stages prune complete entries to a one-GiB payload budget; interrupted temporary entries require manual removal. Concurrent eviction can fail a reader safely. Logs report hit, miss and uncached counts. + +Mac package commands print `DESKTOP_PACKAGING_RECORD` with a unique directory under `apps/desktop/.desktop-build/packaging-runs/`. After reading local configuration, each run retains `run.json` (published version, product version, Git commit, dirty-worktree flag, target and Node version), `events.jsonl` (timestamped phases, durations, parallel output attribution and proxy restoration), redacted child `stdout.log` / `stderr.log`, and `result.json` (overall outcome, stage results and artifact directory). Events also record pack concurrency and whether each proxy is configured. Logs survive failures and later builds; no automatic deletion runs. Missing `result.json` means completion is unconfirmed. Known credential values are redacted; environments and notarytool authentication arguments are not recorded. Runtime preparation records package staging, installation, materialization, signing, manifests, smoke checks, descriptor verification and cleanup separately. Nested phase durations overlap and must not be added together. + +App and DMG notarization each record a submission ID and separate `notarytool:upload:*` and `notarytool:wait:*` durations. Upload uses `submit --no-wait` and includes authentication, local validation, transfer and server acceptance; it is not pure transfer time. Waiting starts after that command returns and includes polling and remaining Apple processing; Apple can start processing before upload submission returns. Apple status and diagnostic logs are retained, including rejection; signature checks, acceptance checks and stapling remain owned by `@electron/notarize`. Directory-only package commands use this same measured App notarization path. Individual subprocess output remains live in the terminal, while phase failures and nested errors are retained in the journal. Both platforms record parent-process packaging failures and print redacted diagnostics, including proxy recovery instructions, to the terminal. The configuration-only `check:package` command creates no run log. + +Mac packaging reads three tuning fields from `.env.macos`: + +| Setting | Default | Scope | +|---|---|---| +| `DSH_DESKTOP_MACOS_PACK_CONCURRENCY` | `4` | Workers for first-party and vendor workspace tarballs; requires a positive integer. | +| `DSH_DESKTOP_MACOS_DOWNLOAD_PROXY` | Empty | HTTP/HTTPS proxy origin for Electron, runtime assets, pnpm installation and builder downloads. | +| `DSH_DESKTOP_MACOS_NOTARIZATION_PROXY` | Empty | HTTP proxy origin for Apple tools through temporary system proxy settings. | + +The proxy fields are independent and reject URL credentials, paths, queries and fragments. Empty values preserve inherited networking. An explicit download proxy replaces child-process proxy variables and bypasses local hosts only; it does not change system settings. Windows and standalone `release:pack` retain their existing concurrency defaults. Company proxy addresses belong in the ignored local file; see internal documentation for addresses. + +Apple tooling uses the active macOS network service's HTTP/HTTPS proxies. Configured notarization routing checks proxy reachability, saves that service's settings, enables the proxy around both artifact lanes, and restores the saved settings after both lanes settle. An originally disabled proxy with an empty server and port zero is restored by disabling it; the temporary server and port may remain stored but inactive. Directory-only builds apply it to App notarization after the signed directory build. This temporarily affects other applications and requires permission to change system proxies; PAC, auto-discovery, SOCKS and authenticated proxy configurations must be disabled first. Packaging and recovery acquire the same per-user POSIX file lock before reading recovery data or changing proxies; process exit releases ownership, while the lock file remains in place. The lock loads `@deepseek-ai/node-addon-system/flock` at first use rather than at script start, so `check:package` and the packaging entry load on a checkout that has not built `native/system`; when the host addon binary or the entry's JavaScript is missing at lock time, the loader runs `pnpm run build:native-system` and `pnpm --dir native/system run build:ts` before locking, so recovery works on such a checkout as well. This prevents overlapping proxy transactions across checkouts; other users and network-setting tools must not change these settings concurrently. SIGINT/SIGTERM wait for active work before restoration. After forced termination or a restoration error, stop any remaining notarization processes and run `pnpm --dir apps/desktop run restore:mac-proxy`; the saved record remains until restoration succeeds. Configuration checks validate URL syntax without changing system settings or contacting the proxy. + + + +## Directory builds and preparation + +Create a runnable application directory instead of an installer by using the matching `:dir` command, such as: + +```sh +pnpm run package:desktop:dir +pnpm run package:desktop:mac:arm64:dir +``` + +To inspect or troubleshoot the prepared host-target resources without invoking electron-builder, stop the same pipeline after preparation: + +```sh +pnpm run prepare:desktop +``` + +This diagnostic command is an alternative stopping point, not the first half of a two-command build. A later `package:desktop*` command repeats the official build and preparation so it cannot consume stale dsh packages, runtime files, or dsh content. + + + +## Local Official bundles from a loopback registry + +The test DMG excludes the provider runtimes the Official on-demand catalog advertises, and a release publishes matching bundle versions to npm. To install locally built bundles before that release, start a loopback registry that packs and serves the catalog's complete local dependency closure: + +```sh +pnpm run dev:bundle-registry +``` + +The command validates the catalog and the workspace inventory, runs `pnpm run build:official`, packs the closure into a new `dist/test-bundles//` directory, writes `bundle-registry.json` last as the completion marker, and only then prints the artifact path, the registry URL, and the exact `name@version` specs. It serves `127.0.0.1` on an allocated port under a fresh namespace until Ctrl+C or SIGTERM, and stopping it leaves both the artifacts and the installed packages on disk. It needs no `.env.macos`, Apple credentials, or npm credentials, and it does not change `pnpm run package:desktop:unsigned`. + +In the packaged application, open Plugins → Add plugin, paste one printed spec, choose **Custom address**, paste the printed registry URL, and install. A custom registry is asked alone, so this process must answer every dependency: repository-owned packages come from the run's own archives, and third-party packages keep npm's real metadata and bytes through a fixed redirect to `registry.npmjs.org`, which still requires network access. The installed entry stays Official because its package name is in the catalog. The registry choice is remembered in this browser; choose an offered registry again to switch back. + +A run's artifacts are immutable, so a rebuild serves a new namespace and a new URL, and the Official **Update** control does not appear while the installed and running versions match. The profile records the previous run's tarball URL in `pnpm-lock.yaml`, and pnpm verifies recorded entries, so installing or reinstalling that bundle against a new URL is refused with `ERR_PNPM_TARBALL_URL_MISMATCH` and can leave the earlier files in place. To test changed code at the same version, clear the stale resolution first — `pnpm clean --lockfile` in `$DSH_HOME/profiles/`, or install into a fresh profile — then install again from the new URL. `bundle-registry.json` records each archive's version, filename, size, and SHA-512 integrity together with the packing checkout's commit and dirty flag. + +The served `time` is the real packing time, so pnpm 11's default 24-hour `minimumReleaseAge` records the exemption it granted in the profile's `pnpm-workspace.yaml` (`minimumReleaseAgeExclude`); an explicitly stricter policy there fails the installation with pnpm's own message instead of being overridden. A `@scope:registry` route in `~/.npmrc` or the profile's `.npmrc` overrides `--registry` for that scope, so it must not route the served scope. + + + +## Runtime file selection and installation checks + +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 and Yarn development metadata, selected native compiler outputs, and node-pty prebuilds for other platforms. Non-Windows targets also omit `node-pty/third_party/conpty`; Windows targets retain that directory. It preserves runtime JavaScript, target 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 [Electron shell file list](../scripts/electron-builder-config.mjs) excludes recognized JavaScript/CSS/TypeScript source maps and TypeScript build caches from the separate `resources/app.asar/node_modules` dependency collection. These exclusions preserve runtime code, native modules, WASM, data files (including ordinary `.map` assets), licenses, and notices; [shell archive tests](../tests/shell-file-selection.spec.ts) check retained bytes and exclusions. + +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 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. + +```powershell +pwsh -NoProfile -File apps/desktop/scripts/smoke-windows.ps1 -Makensis $Makensis -SevenZip $SevenZip -PluginDir $PluginDir -FrameLibrary apps/desktop/.desktop-build/targets/win-x64/installer-ui/window-frame.dll +``` + +The Windows installer checks for a running application at startup and after destination selection, before extracting the new version beside the installation directory. It checks again before replacing directories through same-volume renames. A running application blocks installation; update launches allow up to ten seconds for it to exit. Same-path upgrades preserve the old directory until promotion succeeds; extraction failure leaves it intact, and promotion failure attempts to restore it. The installer removes the old backup before launch. Forced termination or power loss can leave `.new-*` or `.old-*` directories; installation-location and scope migrations retain electron-builder's old-uninstaller flow. + +When extraction fails, the installer writes a report with the 7-Zip result and its complete error output to the updater cache directory as `%LOCALAPPDATA%\-updater\installer-logs\extract-failure-.log` (currently `@deepseek-aidsh-desktop-updater`) and shows the first error line with a **Copy error details** button; silent installs write only the report. Unsigned Windows builds (`DSH_DESKTOP_UNSIGNED=1`) name their installer `deepseek-harness--win-x64-unsigned.exe` so they cannot be mistaken for release artifacts. + +Windows packaging fixes the 7-Zip filter to `BCJ` for compatibility with the bundled NSIS decoder. This preserves ARM64 binaries carried by dependencies in x64 installers; automatic ARM64 filtering produces entries that this decoder cannot extract. + +NSIS removes its temporary extraction tree during installation, before the completion page or an automatic launch. The installed production packages remain ordinary files; startup does not extract them again. Installation still writes the complete application tree. + + + +## Desktop runtime preparation + +Desktop [patch policy](../scripts/runtime-patch-policy.ts) explicitly classifies every workspace patch as shared, workspace-only, or runtime-only. Shared entries reuse the root patch bytes and verify their hash against the root lockfile; runtime-only entries use separate repository files without changing workspace installation. Workspace-only patches belong to build tools or dependencies already embedded in client bundles. Unclassified or stale entries, missing files, changed hashes, incompatible resolved versions, and workspace-only packages entering the runtime stop packaging. The [preparation script](../scripts/prepare-runtime-patches.ts) writes only to the temporary project; pnpm applies the selected patches and rejects application failures. + +Desktop dependency overrides are declared separately in the patch-policy module, not inferred from patch versions. The pi-ai constraint keeps its runtime version at the version qualified by the shared patch. Desktop still resolves a fresh runtime lockfile for each build, then installs with `--frozen-lockfile`; dependency resolution across separate builds is not yet reproducible. CLI packed-installation tests and other distribution flows retain their own configuration. + +Every package command builds the repository, packs the first-party production closures rooted at dsh and the private Desktop Host, and prepares the target Electron distribution plus primary-runtime with its shared pnpm CLI. `prepare:dsh` installs the production graph once at build time, prepares materialized packages for electron-builder to archive under `app.asar/dsh`, removes package-manager metadata, and writes `desktop-runtime.json` with shared package versions and final file hashes. Signed macOS builds sign and verify native files before inventory generation; electron-builder excludes this already-signed tree from nested re-signing. Resource mappings explicitly include `dsh/node_modules`, which the default root-directory filter omits; the prepared runtime inventory is checked after native signing. Native executables and libraries are unpacked beside ASAR; Python, standalone Node and pnpm remain in external runtime resources. Windows packaging checks every prepared PE against its ASAR unpacked entry and byte-identical disk copy, including unsigned builds. Builder glob rules match braces in PE filenames as single-character wildcards, so a matching neighboring file may also be unpacked. Prepared runtime smoke uses the verified target descriptor rather than the build host architecture. Signed installer, notarization, installed upgrade, and target-specific native-module qualification require the release environment. + +Signed macOS packaging writes `Contents/Resources/app-update.yml` while assembling the App and before code signing, including the directory build that feeds the parallel ZIP and DMG lanes. The signing hook verifies the exact feed and updater cache directory. Both lane copies and the promoted App are checked again before the release completion record is written; a missing or mismatched configuration prevents artifact promotion and therefore prevents upload. + +An unpacked artifact contains Electron, the materialized dsh production tree, pnpm, and the shell application. Installer size and filesystem size differ; release qualification measures both, plus the profile’s plugin storage and first-launch latency. The runtime trades more application files for eliminating core package installation on the user’s machine. + + + +## Installation and release verification + +Keep the generated installer, its version and packaging records together. Review signatures and runtime checks before installation, then follow the [installed-update qualification procedure](../tests/installed-update/README.md) for installed launch and upgrade evidence. The [Desktop test record](../tests/README.md) owns verification limits; a successful workflow alone does not establish production preset, new-workstation or installed-upgrade acceptance. + +## Dev Note + +None. diff --git a/apps/desktop/packaging/README.zh.md b/apps/desktop/packaging/README.zh.md new file mode 100644 index 0000000000..125b480ce5 --- /dev/null +++ b/apps/desktop/packaging/README.zh.md @@ -0,0 +1,275 @@ +--- +description: "在本地或 Windows/macOS Actions 中生成 Desktop 安装包,选择部署与签名模式,并获取产物和诊断。本指南区分构建操作、发布与已安装应用验收。使用硬件凭据前,必须检查平台签名要求与共享 runner 风险。" +--- + +# Desktop 打包 + +[English](README.md) | 中文 + +## 概述 + +在本地或 Windows/macOS Actions 中生成 Desktop 安装包,选择部署与签名模式,并获取产物和诊断。本指南区分构建操作、发布与已安装应用验收。使用硬件凭据前,必须检查平台签名要求与共享 runner 风险。 + +## 目录 + +- [选择打包方式](#choose-a-build) +- [发布版本](#release-versions) +- [配置与本地构建](#configuration-and-local-builds) +- [Windows Actions 打包](#windows-actions) +- [macOS 自托管打包](#macos-actions) +- [未签名 Windows 测试安装包](#unsigned-windows-test-installer) +- [无发布签名的本地 macOS DMG](#local-macos-dmg-without-release-signing) +- [macOS 发布签名与诊断](#macos-release-signing) +- [目录构建与准备](#directory-and-preparation) +- [用回环 registry 安装本地 Official 包](#local-official-bundles-from-a-loopback-registry) +- [运行时文件筛选与安装检查](#runtime-file-selection) +- [Desktop 运行时准备](#desktop-runtime-preparation) +- [安装与发布验收](#verification) + + + +## 选择打包方式 + +安装锁定依赖后,从仓库根目录运行本地命令。本地构建使用平台命令,已注册的自托管 runner 使用对应平台的 Actions。test/production 部署与 signed/unsigned 模式分别选择。任何 Windows 签名构建或共享 runner 使用前,先阅读 [Windows 签名与 runner 运维](../windows-signing/README.zh.md#trust-and-isolation)。安装与打包分开进行;[发布](../publishing/README.zh.md)还需要单独的显式操作。 + + + +## 发布版本 + +每次 Desktop 打包前,第一步都要与当前用户确认完整版本号。检查所选部署环境、dsh 基础版本、保留的发布记录和已发布对象,再提出准确版本供用户确认。用户确认前不得启动打包;仅选择部署环境不代表用户已认可版本号。 + +记录当前 dsh 版本作为基础版本。production Desktop 使用完全相同的版本,包括其中的 `alpha`、`beta` 或 `rc` 标识。test 发布保留完整的预发布基础版本并追加 `.YYYYMMDD.index`;稳定基础版本则追加 `-test.YYYYMMDD.index`。 + +| dsh 基础版本 | production Desktop | test Desktop 示例 | +|---|---|---| +| `0.1.6-alpha.1` | `0.1.6-alpha.1` | `0.1.6-alpha.1.20260916.1` | +| `0.1.6-beta.2` | `0.1.6-beta.2` | `0.1.6-beta.2.20260916.1` | +| `0.1.6-rc.3` | `0.1.6-rc.3` | `0.1.6-rc.3.20260916.1` | +| `0.1.6` | `0.1.6` | `0.1.6-test.20260916.1` | + +日期使用实际创建时的 Asia/Shanghai 日期。每个基础版本、每天的序号从 1 开始,检查保留的发布记录与已发布对象后递增;绝不复用已发布版本。test 分发不发布对应的无后缀基础版本。 + +Desktop 手动 CI 使用独立的未发布构建标识:`auto` 追加 UTC 日期、工作流运行编号和尝试次数(`.YYYYMMDD.run.attempt`,稳定基础版本使用 `-test`)。例如 `0.2.1-alpha.1.20261008.42.2`。这些编号不是预留的发布序号。 + +把确认后的版本通过 `--build-version` 传给打包命令,该值同时决定产物文件名、更新 feed 与上传校验。清单保留产品版本,因此 test 打包不再改写发布家族,也不留下需要还原的改动: + +```sh +pnpm --dir apps/desktop run package:win:x64 --build-version 0.1.6-alpha.1.20260916.1 +``` + +`--build-version auto` 会给出当天的下一个序号:读取目标 bucket 中已发布的对象,未配置 bucket 或列举未能在期限内完成时回退到本目标的本地输出目录。上传前请确认它打印的版本号;run script 会自行透传 `--`,打包入口两种写法都接受。 + +production 发布使用产品版本本身,不传 `--build-version`。其上传成功后会把打包所用 commit 打成 `desktop-v<版本>` 标签;来自有改动工作区的构建不打标签,打标签失败也只打印手工命令,不会让已完成的上传变成失败。test 与本地构建有意不留标签,而所有产物的清单都记录 `dshBuildCommit` 与 `dshBuildDirty`,直接分发的构建同样可溯源。 + +安装包独立于版本和发布环境内置 `DSH_DESKTOP_UPDATE_CHANNEL`(默认 `nightly`,也可选择 `latest`)。自动更新始终保留该通道,即使安装的是稳定版本;手动安装另一通道的包才会切换。latest 包拒绝预发布版本。稳定产品可生成 nightly 的 `-test..` 构建,但该版本低于稳定基准,不能升级已安装的更高版本。保持自动降级关闭;稳定修复版需要提升产品版本。CI 版本选择与发布见 [CI 发布](../publishing/ci/README.zh.md#package-and-publish)。 + + + +## 配置与本地构建 + +本地 Windows 命令通过 `DSH_PACKAGING_CONFIG_DIR` 指定机器配置绝对目录,通过 `DSH_PACKAGING_PRESET` 选择 `test` 或 `production`。加载器读取 `presets/.env`;签名命令另读 `secrets/windows-signing.env`。相对证书和 SignTool 路径以声明它们的文件所在目录为基准。公开 preset 拒绝凭据,签名文件不能覆盖公开配置。配置缺失立即失败,不回退到 `.env.windows`;不选择 preset 的命令保留原 dotenv 行为。未签名和仅准备 preset 不读取签名文件。缓存维护也只读取公开 preset 配置。签名测试包还需现有下载 origin 和 release ID,但无需上传凭据。本地 preset 命令使用 `--build-version auto` 时仅按本地产物编号;Actions 使用运行编号和重试次数。 + +两个手动打包工作流都接受 `test_release_id`。留空沿用机器 preset;非空值必须是 32 位小写十六进制字符串,且仅允许 test preset。加载器通过 `PACKAGE_TEST_RELEASE_ID` 应用覆盖值,不修改 preset 文件。同一平台连续升级测试需使用相同值。轮换默认值只影响后续构建:已安装客户端保留原更新地址,旧对象仍可访问,除非另行删除。随机前缀降低地址的可发现性,但不提供下载身份认证。 + +不选择机器 preset 时,打包、上传以及手动 macOS 签名检查(本地 macOS 未签名构建除外)使用 `apps/desktop/.env.windows` 或 `.env.macos`,由目标平台选择。复制对应的 [Windows 模板](../.env.windows.example) 或 [macOS 模板](../.env.macos.example),填写本机配置;Git 忽略这两个本地文件,安装产物也不包含它们。发布字段只从选定的配置文件读取,不回退到系统或 shell 中的同名变量;`PATH`、代理和构建工具环境仍保留。发布版本是命令参数而非发布字段,上传从打包写下的完成记录中读取它。文件使用 UTF-8,支持 BOM;相对证书、SignTool、Apple API Key 和钥匙串路径以 `apps/desktop` 为基准,变量值不做 shell 展开,包含 `#` 或空格的密码需要引号。Windows CI 读取相同的机器 preset。 + +每条打包命令在构建与下载前检查应用 ID 以及该模式需要的更新与签名配置,随后探测本次运行要用的外部工具:归档读取工具,以及 Windows 目标的安装器编译器。macOS 检查身份、Team ID、一套完整公证凭据、`CSC_LINK` 指定的可读本地 p12 文件、显式配置的 `CSC_KEY_PASSWORD`,以及引用的 API Key 和钥匙串文件;Windows 检查公开代码签名证书、SignTool 文件、容器名称和 PIN 格式。仅准备 Windows 资源以及两个平台上的显式未签名打包均不要求签名凭据。配置检查不验证 PIN 是否正确、Token 是否登录、钥匙串是否解锁或 Apple 是否接受凭据;实际签名与公证负责这些检查。未选择机器 preset 时,`--build-version auto` 会访问目标 bucket,`--check` 下同样如此。单独运行相同检查: + +```sh +pnpm --dir apps/desktop run check:package +``` + +无需提前执行 `prepare:desktop`: + +```sh +pnpm run package:desktop +``` + +发布自动化使用固定目标命令,确保运行时准备、dsh 准备与 electron-builder 接收相同的平台和架构: + +```sh +pnpm run package:desktop:mac:arm64 +pnpm run package:desktop:mac:x64 +pnpm run package:desktop:win:x64 +``` + +macOS arm64 命令要求 Apple Silicon。macOS x64 命令可以在 Intel macOS 或带 Rosetta 的 Apple Silicon 上运行。Windows x64 命令要求 Windows x64。Linux 不是受支持的 Desktop 发布目标。 + +macOS 正式发布和本地未签名 DMG 都通过 [electron-builder 配置](../scripts/electron-builder-config.mjs)中的 `dmg.format: 'ULMO'` 使用 LZMA 压缩。[构建时补丁](../../../patches/app-builder-lib@26.17.0.patch)为固定版本 builder 的校验规则和 TypeScript 声明添加此格式。 + +每个目标都在 `apps/desktop/.desktop-build/targets//` 下持有自己的打包输入、已准备运行时、包集合、dsh 依赖树、pnpm 准备状态、未打包应用、更新元数据和最终产物。Electron 归档缓存继续由 `.desktop-build/downloads` 共享,因为每个归档文件名都包含版本、平台和架构,并且在解包前经过验证。目标构建绝不读取其他目标的可变准备状态。 + + + +## Windows Actions 打包 + +`Package Desktop · Windows` [手动工作流](../../../.github/workflows/windows-package.yml) 选择专用 Windows x64 runner 标签 `dsh-win-package-trial`,并使用机器 preset `test` 或 `production`。选择 `unsigned`(默认)或 `signed`;签名模式复用现有 EV token 预检、签名缓存与失败 interlock,不发布或上传 release。`build_version` 默认为 `auto`,使用[发布版本](#release-versions)中的 CI 标识;显式值可以是源码产品版本,或扩展构建版本,例如产品 `0.2.1-alpha.1` 对应 `0.2.1-alpha.1.20261009.1`。版本前缀必须匹配所选源码;校验错误显示其当前示例。manifest 保持不变。 + +在 Actions 选择工作流、源码分支、preset、签名模式和版本,再点击 Run workflow。输入变更合并前,用 `gh workflow run windows-package.yml --ref -f preset=test -f signing=unsigned -f build_version=auto` 测试已推送分支;合并后验证网页表单。它不监听 push 或 PR。日志和成功生成的 EXE 保留 14 天。依赖打包流程前检查日志和安装包;安装是独立操作。检出前,Node 删除 runner 专用源码目录,不跟随依赖 junction;工作流校验目标位置并拒绝链接祖先目录。不得指向日常开发目录。运行共享一个并发组,不取消正在进行的构建;最多保留一个待运行任务,新触发会替换它。仅在成功完成干净检出后上传诊断。机器配置位于 checkout 之外,不作为产物上传。 + +在 Actions 打开完成的运行并下载 Artifacts。`windows-x64-signed---` 与 `windows-x64-unsigned---` 包含对应 EXE;`windows-package-logs--` 包含打包诊断。上传步骤名为 `Retain verified signed installer`、`Retain verified unsigned installer` 和 `Retain packaging diagnostics`;步骤耗时与带时间戳的日志显示上传用时。这些产物不等于发布 Release 或下载站内容。 + + + +## macOS 自托管打包 + +`Package Desktop · macOS` [手动工作流](../../../.github/workflows/macos-package.yml) 使用带有 `dsh-mac-package-trial` 标签的 Apple Silicon runner。它选择 `test` 或 `production`,以 UTC 日期、运行编号和重试次数解析 `build_version=auto`,并复用现有签名、公证和打包运行时检查。成功的 DMG/ZIP 与诊断日志保留 14 天;工作流不发布 release。操作人员须注册 runner,并验收完整 Actions 运行后再依赖该流程。 + +运行 `node apps/desktop/scripts/setup-macos-packaging.mjs "$HOME/dsh-ci"` 创建私有 `runner/`、`packaging/{presets,secrets,certificates}` 和 `cache/macos-signatures` 目录,不覆盖已有文件;追加 `--check` 仅检查。在 checkout 外填写 [preset](../config/macos-packaging/test.env.example) 和[签名](../config/macos-packaging/macos-signing.env.example)模板,将秘密和证书文件权限设为 `0600`。`DSH_PACKAGING_CONFIG_DIR` 指定 `packaging` 的绝对目录,`DSH_PACKAGING_PRESET` 选择其中的 preset;缺少文件立即失败,不回退到 `.env.macos`。Mac 签名和仅准备构建读取 `secrets/macos-signing.env`,相对凭据路径以该文件所在目录为基准。公开 preset 拒绝签名凭据,签名文件不能覆盖公开配置。本地未签名 Mac 构建保留无需配置的行为。 + +运行 `bash apps/desktop/scripts/start-macos-packaging-runner.sh "$HOME/dsh-ci/runner" "$HOME/dsh-ci/packaging" --check` 检查宿主工具和配置文件。脚本同时选择当前 Xcode 及其 macOS SDK,并编译小型链接探针。注册后省略 `--check`,清理继承环境并在前台启动 runner,存活期间阻止空闲睡眠。安装下方服务前,先用 Ctrl-C 停止。runner 拥有 `_work` 下的源码目录,与日常开发分开。该账户可读取签名文件,因此只执行可信代码。沿用现有公证代理恢复规则;强制终止后,先检查保留的恢复记录再构建。新建模板将 `DSH_DESKTOP_MACOS_SIGNATURE_CACHE_DIR` 设为 checkout 外 `cache/macos-signatures` 的绝对路径。已有部署重新运行 setup 后,在各 preset 中补上该字段;已有文件保持不变。这样可跨干净检出保留签名;缓存验证和容量限制见 [macOS 签名](#macos-release-signing)。 + + +工作流选项 `skip_notarization` 默认为 `false`;对应 CLI 参数是 `--skip-notarization`。该模式保留 Developer ID 签名、DMG 签名验证和打包运行时验证,跳过 Apple 公证和 stapling,不要求 Apple 公证凭据。DMG/ZIP 文件名带 `-unnotarized`,存放于 `.desktop-build/targets//unnotarized-artifacts/`,上传产物标为 `unnotarized-debug`。这些产物仅供内部调试:Gatekeeper 可能阻止下载副本运行,不能视为可分发包或用于更新发布。该模式不写入发布完成记录,不能与未签名、Windows 或仅准备构建组合。 + +在 checkout 中,以注册 runner 的同一账户安装用户服务,不使用 `sudo`: + +```sh +bash apps/desktop/scripts/macos-runner-service.sh install "$HOME/dsh-ci/runner" "$HOME/dsh-ci/packaging" /opt/homebrew/opt/node@24/bin +bash apps/desktop/scripts/macos-runner-service.sh start "$HOME/dsh-ci/runner" +bash apps/desktop/scripts/macos-runner-service.sh status "$HOME/dsh-ci/runner" +``` + +安装脚本将启动脚本复制到 `runner/dsh-service`,将服务 PATH 设为 Node 24 和系统工具,并使用 GitHub 的 `svc.sh` 与 `runsvc.sh`。LaunchAgent 在 SSH 断开后继续运行,但重启后需要该账户登录桌面,注销桌面时停止。FileVault 磁盘解锁仍由操作人员完成,这不提供无人值守开机。日志位于 `runner/dsh-service/stdout.log` 和 `stderr.log`;验收时确认出现 `Connected to GitHub` 与 `Listening for Jobs`。更新已安装的启动脚本或工具路径时,等待 runner 空闲,依次执行 `stop`、`uninstall`,再重新安装。这两个操作都不会注销 runner 或删除打包凭据。卸载保留 `runner/dsh-service`(包括日志)和显式 `.path`;重新安装会替换启动快照和 PATH。 + + + +## 未签名 Windows 测试安装包 + +在 Windows x64 上,使用完整的未签名打包命令进行本地安装测试: + +```sh +pnpm run package:desktop:win:x64:unsigned +``` + +该命令读取 `.env.windows`,要求设置 `DSH_DESKTOP_APP_ID` 和[强制更新策略配置](../README.zh.md#mandatory-update-policy),包括真实的 HTTPS 服务 origin。本机验收和 CI 使用 Node 24;其他工具为根 `packageManager` 固定版本的 pnpm、Git、PowerShell、tar、Python、Visual C++ Build Tools 和 Windows SDK。Python 不在 `PATH` 中时设置 `PYTHON`。命令将安装包写入 `.desktop-build/targets/win-x64/unsigned-artifacts/`,省略普通自动更新配置,清除签名凭据,且不生成发布完成记录。无需 EV 或上传凭据。签名打包和上传仍遵循正式发布要求。 + +先运行 `pnpm install --frozen-lockfile`,再运行 `pnpm run package:desktop:win:x64:unsigned --check` 校验配置和工具,最后执行上面的完整命令。打包自行执行完整构建和隔离的运行时 smoke,不调用在线模型。阶段日志保留在 `.desktop-build/packaging-runs/`;仅构建成功不代表安装包验证通过。依赖安装和运行时准备需要访问 npm、GitHub release 资源和 nodejs.org;网络需要时使用现有 registry 和代理配置。缓存与工具位置属于构建机器配置,不在跟踪文件中固定个人路径。 + + + +## 无发布签名的本地 macOS DMG + +执行 `pnpm install` 后,在仓库根目录运行一条命令即可,无需 `.env.macos`、Apple 账号、证书或更新服务配置: + +```sh +pnpm run package:desktop:mac:arm64:unsigned +pnpm run package:desktop:mac:x64:unsigned +``` + +添加 `--check` 仅验证本地配置和工具而不构建,或添加 `--dir` 只生成解包应用。DMG 路径为 `apps/desktop/.desktop-build/targets//unsigned-artifacts/deepseek-harness--mac--unsigned.dmg`。所有检查通过后,命令打印 `DMG:` 及其绝对路径。打开后将应用复制到本地测试目录。 + +这些命令忽略 `.env.macos` 和继承的发布配置,使用 `com.deepseek.harness` 作为应用 ID,并省略自动更新和强制更新配置。构建工具、标准网络代理与 `DSH_DESKTOP_NPM_REGISTRY` 仍可从 shell 提供。它们跳过 Developer ID 签名、Apple 公证、临时签名钥匙串和公证代理修改。Electron 使用无需证书的本地 ad-hoc 签名,以便修改后的可执行文件能够运行;DMG 不签名。准备阶段和组装后的应用仍执行运行时完整性与冒烟检查。本地构建不生成更新源、ZIP 或发布完成记录,上传命令无法发布这些产物。macOS 可能要求在“系统设置 → 隐私与安全性”中批准打开未经公证的应用。 + + + +## macOS 发布签名与诊断 + +macOS 签名配置使用必填发布环境,不会接受钥匙串中最先发现的证书。空值、格式错误的 Team ID、包含 electron-builder 不支持的 `Developer ID Application:` 前缀的签名身份,以及不完整的公证凭据都会被拒绝。macOS 签名打包要求已配置的身份及其私钥可用。运行时准备会把该身份、安全时间戳与 hardened runtime 应用到每个内嵌 Mach-O 文件;应用签名完成后,深度严格检查会拒绝其他叶证书 Authority 或 Team ID,验证通过才生成发布产物。macOS 签名固定目标安装包命令为已签名应用创建独立副本,并发执行两条产物流。一路先公证 App 并钉票,再生成 ZIP 及其更新元数据。另一路把已签名 App 副本封装进签名 DMG,再公证 DMG、钉票并验证;其中的 App 不单独附加票据。只有两路均成功结束,产物才会移入最终目录并写入发布完成记录。签名的仅目录命令同样需要公证凭据,并等待 Apple 公证和 App 钉票完成。[并行公证决策](../../../.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.zh.md)负责副本隔离与容器票据语义。`CSC_LINK` 必须指向包含 Developer ID Application 证书及私钥的本地 p12,不支持 URL 或 Base64 输入。`CSC_KEY_PASSWORD` 是其导出密码,不是 Apple 账号或登录密码;未加密的 p12 可显式填写空值。构建前,打包流程自动创建并解锁私有临时钥匙串、导入 p12、授权签名并签署小型探针。运行时与 App 签名显式使用该钥匙串,无需预先配置或手动解锁登录钥匙串。子进程只接收钥匙串路径,不接收 p12 密码。成功或普通失败后删除临时钥匙串;强制终止后由 CI 清理临时凭据。临时 CI 从密钥存储生成证书文件和 `.env.macos`,限制文件访问权限,并在作业结束后删除二者;自托管机器 preset 在 checkout 外保留私有文件。环境中的 `CSC_NAME` 与证书发现顺序都不能选择发布所有者。公证凭据也可以使用 electron-builder 支持的完整 Apple ID 或钥匙串 profile 方式。手动执行 `pnpm --dir apps/desktop run verify:mac-signature -- ` 重复应用检查时,也必须提供两个 macOS 身份变量。 + +macOS 签名遍历真实文件,不跟随 Framework 的软链接别名。PAK 资源保留全部随附语言,由外层 Framework 或应用签名记录完整性,不逐个签名。[发布策略](../../../.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md)负责依赖补丁和验证要求。 + +macOS 运行时准备将已验证的单架构 Mach-O 签名缓存在 `.desktop-build/targets//signature-cache`,除非通过 `DSH_DESKTOP_MACOS_SIGNATURE_CACHE_DIR` 选择外置存储。显式路径必须是已存在的绝对目录,祖先路径不能包含链接,目录须归当前账户所有、权限为 `0700` 且可读写及遍历;非法或空配置在预检时失败,不回退。省略该字段保留目标本地缓存。缓存键包含输入字节与权限、签名探针实际使用的叶证书、签名标识、entitlement 字节、macOS 版本,以及签名工具与策略。复用不取决于 Git 提交:未提交的字节变更会使对应文件失效。每次命中都验证缓存字节、严格签名、证书、标识、entitlements、安全时间戳和 hardened runtime,再替换未被并发修改的输入。通用二进制每次重新签名。运行时完整性与 smoke 检查、App 签名和 Apple 公证仍会执行。缓存要求受信任的本地构建存储。缓存损坏或不安全链接会令构建失败;停止打包后删除对应缓存目录再重试。成功的签名阶段会将完整缓存条目的内容总量裁剪至一 GiB;中断留下的临时条目需手动清理。并发淘汰可能使读取方安全失败。日志记录命中、未命中及未缓存数量。 + +Mac 打包命令通过 `DESKTOP_PACKAGING_RECORD` 输出 `apps/desktop/.desktop-build/packaging-runs/` 下的唯一目录。读取本地配置后,每次运行保留 `run.json`(发布版本、产品版本、Git 提交、工作区是否有改动、目标及 Node 版本)、`events.jsonl`(带时间戳的阶段、耗时、并行输出归属及代理恢复状态)、脱敏后的子进程 `stdout.log` / `stderr.log`,以及 `result.json`(整体结果、阶段结果和产物目录)。事件还记录打包并发数及各代理是否配置。失败和后续打包不会清除日志;没有自动删除流程。缺少 `result.json` 表示完成状态未经确认。已知凭据值会被遮盖;不记录环境变量全集或 notarytool 认证参数。运行时准备分别记录包暂存、安装、依赖树复制、签名、清单生成、smoke 检查、描述文件校验和清理。嵌套阶段的耗时存在重叠,不能直接相加。 + +App 和 DMG 公证分别记录 submission ID,以及独立的 `notarytool:upload:*` 和 `notarytool:wait:*` 耗时。上传使用 `submit --no-wait`,包含认证、本地校验、传输和服务端受理,并非纯传输时间。等待从该命令返回后开始,包含轮询及剩余的 Apple 处理时间;Apple 可能在上传提交命令返回前已开始处理。日志保留 Apple 状态与诊断信息,包括拒绝结果;签名检查、接受状态校验和 stapling 仍由 `@electron/notarize` 负责。仅生成目录的打包命令也使用同一条可计时的 App 公证路径。各子进程输出仍实时显示在终端,阶段失败及嵌套错误保留在事件日志中。两个平台均记录父进程打包失败,并在终端显示脱敏诊断,包括代理恢复操作指引。仅检查配置的 `check:package` 命令不创建运行日志。 + +Mac 打包从 `.env.macos` 读取三个调优字段: + +| 设置 | 默认值 | 作用范围 | +|---|---|---| +| `DSH_DESKTOP_MACOS_PACK_CONCURRENCY` | `4` | 第一方与 vendor workspace tarball 的打包 worker 数;必须是正整数。 | +| `DSH_DESKTOP_MACOS_DOWNLOAD_PROXY` | 空 | Electron、运行时资源、pnpm 安装及 builder 下载使用的 HTTP/HTTPS 代理 origin。 | +| `DSH_DESKTOP_MACOS_NOTARIZATION_PROXY` | 空 | 通过临时系统代理设置供 Apple 工具使用的 HTTP 代理 origin。 | + +两个代理字段互相独立,拒绝 URL 中的凭证、路径、查询参数和片段。空值沿用继承的网络设置。显式下载代理会替换子进程的代理变量,仅绕过本地主机;它不修改系统设置。Windows 与独立 `release:pack` 保持现有并发默认值。公司代理地址仅写入 Git 忽略的本地文件;具体地址参见内部文档。 + +Apple 工具使用 macOS 当前活动网络服务的 HTTP/HTTPS 代理。配置公证代理后,打包会检查代理可达性、保存该服务的设置,在两条产物任务期间启用代理,并在两条任务均结束后恢复原设置。对于原本关闭、服务器为空且端口为零的代理,恢复时仅关闭代理;临时服务器和端口可能保留,但不生效。仅生成目录的打包会在签名目录构建完成后的 App 公证期间启用代理。这会临时影响其他应用,并要求修改系统代理的权限;必须先禁用 PAC、自动发现、SOCKS 及需要认证的代理配置。打包和恢复在读取恢复记录或修改代理前获取同一个用户级 POSIX 文件锁;进程退出会释放锁的持有权,锁文件保留。该锁在首次使用时才加载 `@deepseek-ai/node-addon-system/flock`,而不是在脚本启动时加载,因此 `check:package` 和打包入口在未构建 `native/system` 的 checkout 上也能加载;加锁时若宿主 addon 二进制或入口的 JavaScript 缺失,加载器会先运行 `pnpm run build:native-system` 和 `pnpm --dir native/system run build:ts` 再加锁,因此恢复命令在这样的 checkout 上同样可用。这会阻止不同 checkout 的代理事务重叠;其他用户及网络设置工具不得同时修改这些设置。SIGINT/SIGTERM 会等待活动任务结束后恢复。强制终止或恢复失败后,先停止残留公证进程,再运行 `pnpm --dir apps/desktop run restore:mac-proxy`;保存的记录会保留到恢复成功。配置检查仅验证 URL 语法,不修改系统设置或连接代理。 + + + +## 目录构建与准备 + +使用对应的 `:dir` 命令可以生成可直接运行的应用目录,而不是安装包,例如: + +```sh +pnpm run package:desktop:dir +pnpm run package:desktop:mac:arm64:dir +``` + +需要检查或诊断为宿主目标准备的资源而不调用 electron-builder 时,可以让同一流水线在准备完成后停止: + +```sh +pnpm run prepare:desktop +``` + +这条诊断命令是另一种停止位置,并非两条命令构建流程的前半段。之后执行 `package:desktop*` 时仍会重新完成正式构建与准备,避免使用陈旧的 dsh 包、运行时文件或 dsh 内容。 + + + +## 用回环 registry 安装本地 Official 包 + +测试 DMG 不包含 Official 按需目录所宣传的 provider 运行时,而正式发布才会把对应版本的 bundle 发到 npm。要在发布前安装本地构建的 bundle,请启动一个回环 registry,它会打包并提供该目录的完整本地依赖闭包: + +```sh +pnpm run dev:bundle-registry +``` + +该命令先校验目录与 workspace 清单,运行 `pnpm run build:official`,把闭包打包进新的 `dist/test-bundles//` 目录,最后写入 `bundle-registry.json` 作为完成标记,然后才打印产物路径、registry URL 和精确的 `name@version` 规格。它在分配到的端口上只监听 `127.0.0.1`,使用一次性的命名空间提供服务,直到 Ctrl+C 或 SIGTERM;停止后产物和已安装的包都保留在磁盘上。它不需要 `.env.macos`、Apple 凭据或 npm 凭据,也不改变 `pnpm run package:desktop:unsigned`。 + +在打包应用中打开“插件 → 添加插件”,粘贴一条打印出的规格,选择**自定义地址**,粘贴打印出的 registry URL 后安装。自定义 registry 会被单独询问,因此必须由本进程回答每一个依赖:仓库自有的包取自本次运行的产物,第三方包则通过固定重定向到 `registry.npmjs.org` 保留 npm 的真实元数据与字节,因此仍需要网络访问。安装后的条目仍归类为 Official,因为它的包名在目录中。registry 选择会在此浏览器中记住;再次选择某个提供的 registry 即可切回。 + +一次运行的产物是不可变的,所以重新构建会提供新的命名空间和新 URL;当已安装版本与运行版本一致时,Official 的**更新**控件不会出现。profile 会在 `pnpm-lock.yaml` 中记录上一次运行的 tarball URL,而 pnpm 会校验已记录的条目,因此用新 URL 再次安装或重装该 bundle 会被 `ERR_PNPM_TARBALL_URL_MISMATCH` 拒绝,并且可能继续保留旧文件。要在同一版本上验证改动后的代码,请先清除过期的解析结果——在 `$DSH_HOME/profiles/` 中运行 `pnpm clean --lockfile`,或改用全新的 profile——然后再从新 URL 安装。`bundle-registry.json` 记录每个归档的版本、文件名、大小和 SHA-512 完整性,以及打包所用 checkout 的 commit 和 dirty 标记。 + +所提供的 `time` 是真实的打包时间,因此 pnpm 11 默认的 24 小时 `minimumReleaseAge` 会在 profile 的 `pnpm-workspace.yaml` 中记录它授予的豁免(`minimumReleaseAgeExclude`);如果那里显式配置了更严格的策略,安装会以 pnpm 自己的报错失败,而不会被覆盖。`~/.npmrc` 或 profile 的 `.npmrc` 中的 `@scope:registry` 路由会覆盖该 scope 的 `--registry`,因此不能让它们指向正在提供服务的 scope。 + + + +## 运行时文件筛选与安装检查 + +Desktop 在本地打包工作区包,并通过目标捆绑的 Node 和 pnpm 安装外部依赖。[Desktop 文件策略](../scripts/runtime-file-policy.ts)随后在签名和完整性封装前过滤不可变的 `resources/app.asar/dsh/node_modules` 副本。它排除 TypeScript 声明、已识别的 JavaScript/CSS/TypeScript source map、TypeScript 构建缓存、Domino 测试目录及 Yarn 开发元数据、选定的原生编译器输出和其他平台的 node-pty 预构建文件。非 Windows 目标还会排除 `node-pty/third_party/conpty`;Windows 目标保留该目录。它保留运行时 JavaScript、目标平台的原生模块及其 DLL/EXE 辅助文件、WASM、未知资源、许可证和 notices。依赖清单在完整性封装前经过 electron-builder 的元数据清理,确保归档保持已记录的字节。该策略不修改 npm tarball、捆绑的包管理器或用户安装的插件文件。 + +[Electron 外壳文件清单](../scripts/electron-builder-config.mjs)从单独收集的 `resources/app.asar/node_modules` 依赖中排除已识别的 JavaScript/CSS/TypeScript source map 和 TypeScript 构建缓存。这些排除规则保留运行时代码、原生模块、WASM、数据文件(包括普通 `.map` 资源)、许可证和 notices;[外壳归档测试](../tests/shell-file-selection.spec.ts)检查保留文件的字节和排除项。 + +[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 输出。每个组装后的应用(包括目录包和未签名构建)都会针对 ASAR 重复产物和 Host 检查。归档完整性检查将归档内完整描述符与准备结果比对,并核对归档和解包目录中的文件内容与清单、归档内文件记录的执行标志,以及解包文件的物理权限。转换失败会在写入发布记录前终止打包;macOS DMG/ZIP 构建在公证前执行这些检查。 + +Windows 发布验收还需在 Desktop 构建后手动运行[目录和替换检查](../scripts/smoke-windows.ps1)。将 `$Makensis`、`$SevenZip` 和 `$PluginDir` 分别设为锁定版本构建器的 NSIS 编译器、7-Zip 可执行文件和 x86-unicode NSIS 插件目录;通过 `-FrameLibrary` 传入已准备好的 `window-frame.dll`,即可同时覆盖原生解压路径及其失败报告。从仓库根目录运行以下命令。它验证 目录替换与回滚和两种文件占用替换方式;不属于单元测试通道。 + +```powershell +pwsh -NoProfile -File apps/desktop/scripts/smoke-windows.ps1 -Makensis $Makensis -SevenZip $SevenZip -PluginDir $PluginDir -FrameLibrary apps/desktop/.desktop-build/targets/win-x64/installer-ui/window-frame.dll +``` + +Windows 安装器在启动时和选定目标目录后检查应用是否正在运行,通过检查后才将新版本解压到安装目录旁边。通过同卷目录改名替换前,安装器会再次检查。运行中的应用会阻止安装;更新启动允许等待应用退出,最长十秒。同路径升级在替换成功前保留旧目录;解压失败时旧版不变,替换失败时尝试恢复旧目录。安装器在启动前清理旧版备份。强制结束安装器或断电可能留下 `.new-*` 或 `.old-*` 目录;不同安装位置或安装范围迁移仍使用 electron-builder 的旧卸载器流程。 + +解压失败时,安装器会把 7-Zip 的结果和完整错误输出写入更新缓存目录 `%LOCALAPPDATA%\<按包名派生>-updater\installer-logs\extract-failure-<时间戳>.log`(当前为 `@deepseek-aidsh-desktop-updater`),并在弹窗中显示首条错误行和 **复制错误信息** 按钮;静默安装只写入报告。未签名的 Windows 构建(`DSH_DESKTOP_UNSIGNED=1`)会将安装包命名为 `deepseek-harness-<版本>-win-x64-unsigned.exe`,以免被误当作发布产物。 + +Windows 打包将 7-Zip 过滤器固定为 `BCJ`,以兼容内置的 NSIS 解码器。这样可以保留 x64 安装包中由依赖携带的 ARM64 二进制文件;自动 ARM64 过滤会生成该解码器无法解压的条目。 + +NSIS 在安装阶段清理临时解压目录,完成后才显示完成页或自动启动应用。已安装的生产依赖保持为普通文件;启动时不会再次解压。安装仍会写入完整的应用目录树。 + + + +## Desktop 运行时准备 + +Desktop [补丁策略](../scripts/runtime-patch-policy.ts) 将每份工作区补丁明确归为共享、仅工作区或仅运行时。共享项复用根目录补丁字节,并对照根锁文件校验 hash;仅运行时项使用仓库内的独立文件,不改变工作区安装。仅工作区补丁用于构建工具或已嵌入客户端 bundle 的依赖。未分类或过期的条目、文件缺失、hash 改变、解析版本不兼容,以及仅工作区包进入运行时,都会阻止打包。[准备脚本](../scripts/prepare-runtime-patches.ts) 只写入临时项目;pnpm 负责应用选中的补丁,应用失败时会报错。 + +Desktop 依赖 overrides 在补丁策略模块中单独声明,不从补丁版本推导。pi-ai 约束使运行时版本保持在共享补丁已验证的版本。Desktop 仍会在每次构建时重新解析运行时锁文件,再通过 `--frozen-lockfile` 安装;不同构建之间的依赖解析尚不保证可复现。CLI 打包安装测试及其他交付流程保留各自的配置。 + +每条打包命令都会构建仓库,打包以 dsh 和私有 Desktop Host 为根的第一方生产依赖闭包,并准备目标专用的 Electron 分发包和包含共享 pnpm CLI 的 primary-runtime。`prepare:dsh` 在构建时安装一次生产依赖图,准备物化包供 electron-builder 归档到 `app.asar/dsh`,移除包管理器元数据,并生成包含共享包版本和最终文件哈希的 `desktop-runtime.json`。macOS 签名构建先签名并验证原生文件,再生成清单;electron-builder 不对已签名的此目录重复进行嵌套签名。资源映射明确包含默认根目录过滤器会忽略的 `dsh/node_modules`;准备完成的运行时清单在原生签名后检查。原生可执行文件及库解包到 ASAR 旁;Python、独立 Node 和 pnpm 保留在外部 runtime 资源中。Windows 打包逐项检查准备好的 PE,确认其 ASAR 条目已标记为解包,且磁盘副本字节一致;未签名构建也执行此检查。Builder glob 规则用单字符通配符匹配 PE 文件名中的花括号,因此同目录中名称匹配的文件也可能被解包。准备好的运行时 smoke 沿用已验证的目标描述符,不使用构建宿主的架构。签名安装包、公证、已安装应用升级和各目标原生模块的验收需要发布环境。 + +macOS 签名打包在组装 App 时、代码签名前写入 `Contents/Resources/app-update.yml`,供并行 ZIP 与 DMG 路线使用的目录构建也执行此操作。签名钩子验证准确的更新源和 updater 缓存目录。写入发布完成记录前,流程会再次检查两条路线的副本和最终移入的 App;配置缺失或不匹配会阻止移入产物,因而也会阻止上传。 + +未压缩产物包含 Electron、物化后的 dsh 生产依赖树、pnpm,以及壳应用。安装包大小与文件系统占用不同;发布验收需要测量两者,以及 profile 插件存储和首次启动耗时。此布局用更多应用内文件换取消除用户机器上的核心包安装过程。 + + + +## 安装与发布验收 + +一起保留生成的安装包、版本和打包记录。安装前检查签名与运行时验证结果,再按[已安装更新验收流程](../tests/installed-update/README.zh.md)记录安装后启动及升级证据。[Desktop 测试记录](../tests/README.zh.md)维护验证限制;工作流成功本身不代表 production preset、新工作站或已安装升级验收通过。 + +## 开发备注 + +无。 diff --git a/apps/desktop/publishing/README.i18n.yaml b/apps/desktop/publishing/README.i18n.yaml new file mode 100644 index 0000000000..9483eaff54 --- /dev/null +++ b/apps/desktop/publishing/README.i18n.yaml @@ -0,0 +1,37 @@ +# Bilingual-pair consistency record for README.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write apps/desktop/publishing/README.md +/: + en: 32f414f3e27d5afd + zh: e16c49177f708c47 +/desktop-publication: + en: 03551b589405750e + zh: ccbf7e38dcf5d452 +/desktop-publication/summary: + en: 722633597f74ace6 + zh: 5303bcf5567a8511 +/desktop-publication/table-of-contents: + en: 6217df1d38ff46aa + zh: 22375410c0816130 +/desktop-publication/publication-prerequisites: + en: b407de63118c9bf9 + zh: de19f1ba9feacbbf +/desktop-publication/deployment-and-test-release-identity: + en: 405588e021bcfb60 + zh: d9f9700b0b6db6f8 +/desktop-publication/publish-installers-and-update-feeds: + en: 3c3e3a09573c4386 + zh: c851ae6a94f2284a +/desktop-publication/fixed-installer-downloads: + en: 38054a5f89590cfa + zh: 9e115f9bdeeab213 +/desktop-publication/upload-credentials: + en: 1c5689525753b08d + zh: 93089de765806733 +/desktop-publication/upload-audit-and-recovery: + en: ec85f43ece09a5e3 + zh: 2de5bf801c9c9e39 +/desktop-publication/dev-note: + en: b75c8458928b72b9 + zh: 003ba4e17b4b5269 diff --git a/apps/desktop/publishing/README.md b/apps/desktop/publishing/README.md new file mode 100644 index 0000000000..71c52c1d75 --- /dev/null +++ b/apps/desktop/publishing/README.md @@ -0,0 +1,88 @@ +--- +description: "Publish completed Desktop installers and update feeds to the selected download service. This reference covers deployment identity, upload credentials, fixed downloads and audit records. Build configuration and signing operations have separate owners." +--- + +# Desktop publication + +English | [中文](README.zh.md) + +## Summary + +Publish completed Desktop installers and update feeds to the selected download service. This reference covers deployment identity, upload credentials, fixed downloads and audit records. Build configuration and signing operations have separate owners. + +## Table of Contents + +- [Publication prerequisites](#prerequisites) +- [Deployment and test release identity](#upload-updates) +- [Publish installers and update feeds](#publish-feeds) +- [Fixed installer downloads](#fixed-downloads) +- [Upload credentials](#credentials) +- [Upload audit and recovery](#audit-and-recovery) + +For retained self-hosted builds, use the [CI publication workflow](ci/README.md), which keeps installers on their original machine and supports resumable publication. The commands below remain the existing manually authorized local uploader: they retain checkout-local audit records and use the operator's Git identity. They do not share the CI publisher's locks, approval or tag-only recovery. Do not run both writers against one channel concurrently. Both use the same upload-plan validation; separate orchestration preserves existing manual commands while adding immutable-attempt recovery for CI. Consolidating or retiring the manual commands is separate work. + + + +## Publication prerequisites + +Publication requires completed release artifacts and explicit operator authorization. Building an installer or downloading an Actions artifact does not publish it. Read the [release version rules](../packaging/README.md#release-versions) and retain the packaging evidence. Machine presets are for packaging, not upload/COS commands: unset `DSH_PACKAGING_PRESET` and provide the existing target dotenv upload configuration. The configuration and actual COS/CDN acceptance must be verified by the release operator. + + + +## Deployment and test release identity + +`DSH_DESKTOP_AUTO_UPDATE_ENV` selects `test` or `production` for both the URL embedded during packaging and the later COS upload; an absent value selects `test`. Test packaging requires its HTTPS origin in `DOWNLOAD_TEST_ORIGIN`; production uses `https://download.deepseek.com`. Upload requires the selected bucket in `DOWNLOAD_TEST_COS_BUCKET` or `DOWNLOAD_PROD_COS_BUCKET`. Production feeds use `dsh-desk/feeds//` and binaries use `dsh-desk/bin//`. Test releases require `DOWNLOAD_TEST_RELEASE_ID`: 32 lowercase hexadecimal characters inserted as `dsh-desk//feeds//` and `dsh-desk//bin//`. YAML references, stable-channel aliases, and blockmaps stay inside that release directory. Targets are `mac-arm64`, `mac-x64`, and `win-x64`. + +The update destination and upload credentials follow the selected deployment: + +| Environment | Public origin | COS bucket | COS credentials | +|---|---|---|---| +| `test` or unset | `DOWNLOAD_TEST_ORIGIN` | `DOWNLOAD_TEST_COS_BUCKET` | `DOWNLOAD_TEST_COS_SECRET_ID`, `DOWNLOAD_TEST_COS_SECRET_KEY` | +| `production` | `https://download.deepseek.com` | `DOWNLOAD_PROD_COS_BUCKET` | `DOWNLOAD_PROD_COS_SECRET_ID`, `DOWNLOAD_PROD_COS_SECRET_KEY` | + +Generate a fresh ID for each test release batch with the command below and copy its output into `DOWNLOAD_TEST_RELEASE_ID` in `.env.macos` or `.env.windows`. These Git-ignored platform files own the value; shell variables do not override it, and dotenv values are not shell-expanded. Keep the ID through packaging, upload, and retries; upload rejects a completion record with a different update URL. To test an upgrade between versions, reuse the installed client’s ID for the later version. Production does not use this field. + +```sh +node --input-type=module -e "import { randomBytes } from 'node:crypto'; console.log(randomBytes(16).toString('hex'))" +``` + +Distribute each test batch through its complete download links. Installed test clients retain their batch feed and do not discover a new ID automatically. Format validation cannot establish randomness; use the generator output. Random paths reduce guessing, not access by link holders; withdrawing a batch requires deleting its COS objects and purging its CDN directory. + + + +## Publish installers and update feeds + +Configure the update origin and selected COS bucket, SecretId, and SecretKey in the target dotenv file, then package and upload the same target: + +```sh +pnpm run package:desktop:mac:arm64 +pnpm run upload:mac:arm64 +``` + +The target dotenv file for internal-test packaging explicitly sets `DSH_DESKTOP_AUTO_UPDATE_ENV=test` and `DOWNLOAD_TEST_ORIGIN=https://download-test.deepseek.com`; uploads use `DOWNLOAD_TEST_COS_BUCKET=bj-toc-download-test-1320056602` and separate test credentials. Both test and production use the fixed Nightly channel; deployment selection does not enable channel switching. + +Early internal-test packages use the `test` deployment. Select `production` explicitly only for a production release; changing upload credentials does not retarget an existing package. Packaging requires no COS credentials, disables electron-builder publishing, strips COS credentials from child processes, and records completion only after signing and notarization succeed. Upload validates this record, deployment, target, shared version, filenames, sizes, and SHA-512 before reading credentials. Packages and blockmaps upload before YAML; historical objects are retained. Every release publishes `nightly.yml` or `nightly-mac.yml`; stable releases also publish `latest.yml` or `latest-mac.yml` pointing at the same artifacts. Published YAML uses absolute binary URLs. The uploader leaves Cache-Control unset, including the empty header the COS SDK would otherwise add: deployment infrastructure owns cache policy, with uncached feeds and separately configurable binary caching. Serialize publication per target and verify public artifacts and feed contents before release qualification. + +Windows NSIS uploads require the generated, nonempty `.exe.blockmap` beside the installer. The blockmap is uploaded before channel YAML; NSIS installer metadata does not require the embedded `blockMapSize` used by the separate web-installer format. File-plan tests use the pinned builder's blockmap generator, not a hand-authored embedded-map field. + + + +## Fixed installer downloads + +Fixed installer downloads use `pnpm run upload:latest:mac:arm64`, `pnpm run upload:latest:mac:x64`, or `pnpm run upload:latest:win:x64` from the repository root. They upload the completed target's DMG or EXE to `desktop/dsh-latest-macos-arm64.dmg`, `desktop/dsh-latest-macos-x64.dmg`, or `desktop/dsh-latest-windows-x64.exe`. Production URLs start with `https://download.deepseek.com/`; test URLs use `DOWNLOAD_TEST_ORIGIN`. Both deployments reuse the target dotenv's deployment, bucket, and credentials, all normal release validation, and the upload audit below. Each command replaces one fixed object without publishing feeds, blockmaps, or release tags. Test fixed downloads are shared across release IDs in the test bucket; the packaged update URL still requires its original release ID. Publication is explicit, including prerelease versions. Deployment infrastructure must configure revalidation or purge cached fixed downloads after replacement. Local loopback tests cover the upload; release operators own real COS and CDN acceptance. + + + +## Upload credentials + +Windows operators can keep a CLIXML object with DPAPI-encrypted `SecretId` and `SecretKey` SecureString fields outside the repository. The [credential launcher](../scripts/upload-with-credentials.ps1) requires an explicit `-CredentialFile` and `-Environment production` or `test`; without `-Upload`, it only verifies decryption and injection into a local Node child, with no network request. It requires Node on `PATH` and the Windows user and machine that encrypted the file. Plaintext, empty, and whitespace-only fields fail. The parent environment is unchanged; the child receives only the selected COS pair after unrelated secrets and Node preload options are removed. Raw child stderr is suppressed and credential values in stdout are redacted. This check does not prove COS authorization. An explicit upload additionally requires `-Upload -Target -Bucket ` and the normal completed-release prerequisites below; actual cloud upload remains release-operator qualification. Add `-Latest` to that upload invocation to replace the fixed installer without publishing update feeds or a release tag. This launcher supports permanent keys, not STS credentials. An explicit upload requires the selected deployment and bucket to match the target dotenv and completed package record before network writes; it uses the DPAPI credential pair even if the dotenv contains other COS keys. + + + +## Upload audit and recovery + +Test and production uploads through `upload:*` retain a fresh `.desktop-build/upload-records/--*` directory after release preflight. `plan.json` records destination, version, every file's size/SHA-512, and published YAML bytes; flushed `events.jsonl` records PUT intent and available response status/request ID; `result.json` records completion or the last failed stage. A missing final result means interruption or unavailable storage, not success. No credential values, authorization headers, or raw SDK errors are recorded. Audit-write failures stop later PUTs. Every object is one streamed Tencent COS PUT with an explicit length and Content-MD5; the COS SDK repeats a request only when its body is not a stream, and this uploader does not retry either. Retain partial records and inspect remote state before another operation: a timeout or failed receipt write does not prove that the object was not stored. These records are local, not tamper-proof or automatically backed up; archive each release's records with its build evidence in controlled storage. Public CDN readback remains separate release qualification, explicitly marked `not-performed` in the upload result. + +## Dev Note + +None. diff --git a/apps/desktop/publishing/README.zh.md b/apps/desktop/publishing/README.zh.md new file mode 100644 index 0000000000..22952efae4 --- /dev/null +++ b/apps/desktop/publishing/README.zh.md @@ -0,0 +1,88 @@ +--- +description: "将完整 Desktop 安装包与更新清单发布到选定下载服务。本参考文档说明部署标识、上传凭据、固定下载和审计记录。打包配置与签名操作由独立文档维护。" +--- + +# Desktop 发布 + +[English](README.md) | 中文 + +## 概述 + +将完整 Desktop 安装包与更新清单发布到选定下载服务。本参考文档说明部署标识、上传凭据、固定下载和审计记录。打包配置与签名操作由独立文档维护。 + +## 目录 + +- [发布前提](#prerequisites) +- [部署环境与测试发布标识](#upload-updates) +- [发布安装包与更新清单](#publish-feeds) +- [固定安装包下载](#fixed-downloads) +- [上传凭据](#credentials) +- [上传审计与恢复](#audit-and-recovery) + +自托管留存构建使用 [CI 发布工作流](ci/README.zh.md),在原机器保留安装包并支持恢复发布。 以下命令继续作为现有人工授权的本地上传器使用:它们保留 checkout 内的审计记录,并使用操作员的 Git 身份,不共享 CI 发布器的锁、审批或仅补 tag 恢复机制。不要让两种上传器并发写入同一频道。两者复用相同的上传计划校验;分开编排可保留现有手动命令,同时为 CI 增加不可变 attempt 恢复能力。统一或退役手动命令属于独立工作。 + + + +## 发布前提 + +发布需要完整的 release 产物和操作者明确授权。生成安装包或下载 Actions 产物不会发布它。先阅读[发布版本规则](../packaging/README.zh.md#release-versions)并保留打包证据。机器 preset 用于打包,不支持 upload/COS 命令:取消设置 `DSH_PACKAGING_PRESET`,并提供既有目标 dotenv 上传配置。配置以及真实 COS/CDN 验收由发布操作者验证。 + + + +## 部署环境与测试发布标识 + +`DSH_DESKTOP_AUTO_UPDATE_ENV` 同时选择打包写入的 URL 与后续 COS 上传环境,可取 `test` 或 `production`;缺省为 `test`。测试打包通过 `DOWNLOAD_TEST_ORIGIN` 提供 HTTPS origin;生产使用 `https://download.deepseek.com`。上传通过 `DOWNLOAD_TEST_COS_BUCKET` 或 `DOWNLOAD_PROD_COS_BUCKET` 提供所选 bucket。生产清单位于 `dsh-desk/feeds//`,安装包位于 `dsh-desk/bin//`。测试发布必须配置 `DOWNLOAD_TEST_RELEASE_ID`:32 位小写十六进制字符,分别插入路径 `dsh-desk//feeds//` 和 `dsh-desk//bin//`。YAML 引用、稳定通道别名和 blockmap 都位于该发布目录内。目标为 `mac-arm64`、`mac-x64` 和 `win-x64`。 + +更新目标与上传凭据都与所选环境对应: + +| 环境 | 公开 origin | COS bucket | COS 凭据 | +|---|---|---|---| +| `test` 或未设置 | `DOWNLOAD_TEST_ORIGIN` | `DOWNLOAD_TEST_COS_BUCKET` | `DOWNLOAD_TEST_COS_SECRET_ID`、`DOWNLOAD_TEST_COS_SECRET_KEY` | +| `production` | `https://download.deepseek.com` | `DOWNLOAD_PROD_COS_BUCKET` | `DOWNLOAD_PROD_COS_SECRET_ID`、`DOWNLOAD_PROD_COS_SECRET_KEY` | + +每个测试发布批次用下方命令生成新 ID,将输出填入 `.env.macos` 或 `.env.windows` 的 `DOWNLOAD_TEST_RELEASE_ID`。这两个被 Git 忽略的平台文件管理该值,shell 变量不能覆盖它,dotenv 值也不会进行 shell 展开。打包、上传和重试必须沿用同一个 ID;上传会拒绝更新 URL 不一致的完成记录。需要验证跨版本升级时,后续版本沿用已安装客户端的 ID。生产环境不使用该字段。 + +```sh +node --input-type=module -e "import { randomBytes } from 'node:crypto'; console.log(randomBytes(16).toString('hex'))" +``` + +通过完整下载链接分发每个测试批次。已安装的测试客户端保留当前批次的清单地址,不会自动发现新 ID。格式校验无法判断随机性,请使用生成器的输出。随机路径降低被猜中的概率,不限制持有链接者访问;撤下批次需要删除其 COS 对象并清除对应 CDN 目录缓存。 + + + +## 发布安装包与更新清单 + +在目标 `.env` 中配置更新地址与所选 COS bucket、SecretId、SecretKey,再打包并上传同一个目标: + +```sh +pnpm run package:desktop:mac:arm64 +pnpm run upload:mac:arm64 +``` + +内测打包在目标 `.env` 中显式设置 `DSH_DESKTOP_AUTO_UPDATE_ENV=test` 和 `DOWNLOAD_TEST_ORIGIN=https://download-test.deepseek.com`;上传使用 `DOWNLOAD_TEST_COS_BUCKET=bj-toc-download-test-1320056602` 及独立测试凭据。test 和 production 都使用固定 Nightly 通道,部署选择不提供通道切换。 + +前期内测包使用 `test` 部署。只有正式发布才显式选择 `production`;更换上传凭据不会改变已有安装包的更新目标。打包不需要 COS 凭据,会禁用 electron-builder 发布、移除子进程的 COS 凭据,并且仅在签名与公证成功后记录完成状态。上传在读取凭据前验证该记录、部署、目标、共同版本号、文件名、大小与 SHA-512。安装包和 blockmap 先于 YAML 上传;历史对象继续保留。每个版本发布 `nightly.yml` 或 `nightly-mac.yml`;稳定版本还发布指向相同产物的 `latest.yml` 或 `latest-mac.yml`。发布的 YAML 使用安装包绝对 URL。上传器不设置 Cache-Control,包括 COS SDK 否则会添加的空头部:缓存策略由部署基础设施负责,清单不缓存,安装包缓存单独配置。同一目标应串行发布,并在发布验收前验证公网产物与清单内容。 + +Windows NSIS 上传要求安装包旁存在生成的非空 `.exe.blockmap`。blockmap 先于通道 YAML 上传;NSIS 安装包元数据不要求另一种 web-installer 格式使用的内嵌 `blockMapSize`。文件清单测试使用固定版本构建器的 blockmap 生成器,而不是手工编造内嵌映射字段。 + + + +## 固定安装包下载 + +固定安装包下载在仓库根目录使用 `pnpm run upload:latest:mac:arm64`、`pnpm run upload:latest:mac:x64` 或 `pnpm run upload:latest:win:x64`。它们把已完成目标的 DMG 或 EXE 上传到 `desktop/dsh-latest-macos-arm64.dmg`、`desktop/dsh-latest-macos-x64.dmg` 或 `desktop/dsh-latest-windows-x64.exe`。生产 URL 以 `https://download.deepseek.com/` 开头;测试 URL 使用 `DOWNLOAD_TEST_ORIGIN`。两个部署环境均复用目标 dotenv 中的部署、bucket 和凭据、全部常规发布校验以及下述上传审计。每个命令覆盖一个固定对象,不发布更新清单、blockmap 或发布标签。测试固定下载在测试 bucket 中跨发布 ID 共享;包内更新 URL 仍要求原有发布 ID。发布由操作人员显式执行,也允许预发布版本。部署基础设施必须配置重新验证,或在覆盖后清除固定下载的缓存。本地回环测试覆盖上传行为;真实 COS 和 CDN 验收由发布操作人员负责。 + + + +## 上传凭据 + +Windows 操作人员可以在仓库外保存 CLIXML 对象,其中 `SecretId` 和 `SecretKey` 是经 DPAPI 加密的 SecureString 字段。[凭据启动器](../scripts/upload-with-credentials.ps1)要求显式提供 `-CredentialFile` 和 `-Environment production` 或 `test`;不指定 `-Upload` 时,只验证解密以及向本地 Node 子进程注入凭据,不发起网络请求。它要求 `PATH` 中有 Node,并使用加密该文件时的 Windows 用户和机器。明文、空字段及纯空白字段都会失败。父进程环境保持不变;子进程先清除无关密钥与 Node 预加载选项,再仅接收所选 COS 凭据对。原始子进程 stderr 不会显示,stdout 中的凭据值会被遮盖。此检查不能证明 COS 授权有效。显式上传还要求 `-Upload -Target -Bucket ` 及下述常规发布完成前提;真实云端上传仍需发布操作人员验收。在该上传调用中添加 `-Latest`,即可替换固定安装包,而不发布更新清单或发布标签。此启动器支持长期密钥,不支持 STS 凭据。显式上传要求所选部署环境和 bucket 与目标 dotenv 文件及已完成的打包记录一致,才会发起网络写入;即使 dotenv 文件含有其他 COS 密钥,也使用 DPAPI 凭据对。 + + + +## 上传审计与恢复 + +test 与 production 的 `upload:*` 上传在发布前置检查通过后,分别保留新的 `.desktop-build/upload-records/--*` 目录。`plan.json` 记录目标、版本、每个文件的大小/SHA-512 和发布的 YAML 字节;刷盘的 `events.jsonl` 记录 PUT 意图及可用的响应状态/请求 ID;`result.json` 记录完成结果或最后失败阶段。缺少最终结果表示中断或存储不可用,不表示成功。不记录凭据值、认证头或原始 SDK 错误。审计写入失败即停止后续 PUT。每个对象都以一次流式腾讯 COS PUT 上传,并携带显式长度与 Content-MD5;COS SDK 仅在请求体不是流时才会重发请求,上传器自身也不重试。保留部分记录,检查远端状态后再执行下一次操作:超时或回执写入失败不能证明对象未存储。这些记录仅在本地,不防篡改,也不会自动备份;每次发布应将它们与构建证据一同归档到受控存储。公网 CDN 回读仍是单独的发布验收,上传结果明确标记为 `not-performed`。 + +## 开发备注 + +无。 diff --git a/apps/desktop/publishing/ci/README.i18n.yaml b/apps/desktop/publishing/ci/README.i18n.yaml new file mode 100644 index 0000000000..b5f7e587e8 --- /dev/null +++ b/apps/desktop/publishing/ci/README.i18n.yaml @@ -0,0 +1,31 @@ +# Bilingual-pair consistency record for README.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write apps/desktop/publishing/ci/README.md +/: + en: 5bbdeb4ea94502b4 + zh: 107894e10c74d22d +/desktop-ci-publication: + en: a610b66106849734 + zh: bc1760791f56fa28 +/desktop-ci-publication/summary: + en: 470bdf1487a739f9 + zh: cd80330eaa8f730c +/desktop-ci-publication/table-of-contents: + en: 1b6a4d7146879eec + zh: f2520fc412cd2d2a +/desktop-ci-publication/machine-setup: + en: 990ff7e56dd6005a + zh: 18f9dc23c520dc8c +/desktop-ci-publication/package-and-publish: + en: 6e29eb0dff23e2c8 + zh: 65d5d57b63b86904 +/desktop-ci-publication/recovery-and-records: + en: 370f21dc25fc1098 + zh: cf7f433b8eaa3dcd +/desktop-ci-publication/restore-a-channel: + en: d82e2ccf70cce397 + zh: d93dcb77cc4b463e +/desktop-ci-publication/dev-note: + en: b75c8458928b72b9 + zh: 003ba4e17b4b5269 diff --git a/apps/desktop/publishing/ci/README.md b/apps/desktop/publishing/ci/README.md new file mode 100644 index 0000000000..98bbb3cb3c --- /dev/null +++ b/apps/desktop/publishing/ci/README.md @@ -0,0 +1,68 @@ +--- +description: "Operate retained Desktop package publication from dedicated macOS and Windows runners." +--- + +# Desktop CI publication + +English | [中文](README.zh.md) + +## Summary + +Publish a completed package from its original machine without downloading its installer again. Both test and production publish binaries followed by update YAML. Both deployments require GitHub Environment approval; only production creates a GitHub App tag. Real COS, CDN and administrator-permission acceptance remain deployment checks; local fixtures do not establish those permissions. + +## Table of Contents + +- [Machine setup](#machine-setup) +- [Package and publish](#package-and-publish) +- [Recovery and records](#recovery-and-records) +- [Restore a channel](#restore-a-channel) + + + +## Machine setup + +Keep `DSH_PACKAGING_CONFIG_DIR` pointed at the existing machine packaging directory. Completed attempts are copied to its sibling `releases///`; `DSH_DESKTOP_RELEASES_DIR` overrides that root with an absolute path. The inventory includes installers, update archives, blockmaps, completion metadata and a credential-free manifest. Debug packages are retained but cannot publish. Copies are checked before the attempt directory becomes visible, and an existing attempt cannot be replaced. GitHub retains the same file inventory for 14 days. Local files remain until an operator removes them; do not delete an attempt while it is being verified or published. + +Create `publishing/test.json` and, when authorized, `publishing/production.json` below the configuration directory. Each declares `bucket`, HTTPS `origin` without a path, and an absolute `credentialsFile`. The credential file contains only `secretId` and `secretKey`. macOS also requires `macOS.signingIdentity` and `macOS.teamId`; Windows requires `windows.certificateThumbprint`. Production requires the exact origin `https://download.deepseek.com` and `githubApp.clientId` plus an absolute `githubApp.privateKeyFile`. These files stay outside Git. The uploader supports permanent COS credentials, not STS session credentials. Bucket versioning must be disabled for immutable binary writes. + +Set the repository variable `DESKTOP_PUBLISH_RUNNERS` to a JSON mapping of registered runner names to unique labels and targets, for example `{"mini":{"label":"dsh-mini-publish","target":"mac-arm64"}}`. Assign that label to exactly that runner. Windows uses target `win-x64`. The routing job checks the original successful job, and the publisher checks its own runner name against the recorded name. Machines need no shared filesystem or LAN connection to each other. + +An administrator must create `desktop-test` and `desktop-production`, each with required reviewers and an allowed `master` deployment branch. The workflow checks reviewers for either deployment. Test can allow self-approval. The GitHub App needs installation access to this repository and Contents write permission, plus permission under the `desktop-v*` tag ruleset to create release tags. Reviewer approval does not transfer the reviewer's tag permissions to the App. Missing configuration stops publication; there is no personal-token fallback. The App action creates and later revokes a short-lived token. Protect the local private key and COS credential files with the publishing account's filesystem permissions. The token action accepts key contents, so the masked PEM also passes through the runner's temporary `GITHUB_OUTPUT` file during the job; masking hides logs, not local file access. + +Environment approval gates this workflow, not arbitrary code running under the same machine account. Account isolation and forced source-branch restrictions are separate operational work. Keep existing release Environments unchanged. The test job uses its own reviewers and never creates a real release tag; local simulated transports exercise tag creation and recovery without verifying App permissions. + + + +## Package and publish + +Select the platform's `Package Desktop` workflow, preset, source, channel and version mode. `source_ref` accepts `master` or `dsh-v*` matching the selected product version; the selected source must contain these CI adapters, so older tags fail before dependency installation. The publisher runs from the default branch while recording the actual source commit separately. `version_mode=default` selects product for production and auto for test; product preserves the selected checkout version, auto appends date/run/attempt, and manual requires `build_version`. A latest package requires a stable version, so test/latest needs product or a stable manual version. The optional `test_release_id` is the test URL prefix: blank retains the machine default, successive upgrade tests on that platform need the same value, and defaults should be rotated periodically. It is not a GitHub run ID. See [packaging configuration](../../packaging/README.md#configuration-and-local-builds) for its validation and rotation limits. + +Select `publish` to request publication after packaging succeeds. Windows must be signed; macOS must finish notarization and stapling. The separate `Publish Desktop · COS` workflow reads the small request manifest from GitHub, binds it to the source run and attempt, and routes to the original machine. It uses the locally retained installers. A failed build or one without a publication request produces no upload. + +Before uploading, the publisher hashes all retained files, checks version and destination metadata, and rechecks the platform signatures. macOS verifies the DMG and extracts the ZIP to verify the app's ticket; Windows checks Authenticode, the configured signer thumbprint and the timestamp. Preparation writes an approval summary with version, channel, platform, deployment, source ref/SHA, package run/attempt, object keys and proposed tag. Both publishing jobs wait for **Review deployments → Approve and deploy**, then recheck the approved manifest and upload plan digest. Changed bytes or destinations reject. Approval cannot edit a version; reject and rebuild instead. Both deployments upload missing binaries, verify remote bytes, then update YAML. There is no files-only mode in this workflow. Production finally creates `desktop-v` at the recorded packaging commit; an existing tag must name the same commit. + + + +## Recovery and records + +Re-run the publishing workflow after resolving a failure; do not rebuild solely to retry an upload. Its source package run and attempt remain fixed. Missing objects are uploaded, identical objects are skipped, and conflicting same-name bytes stop the operation. Unknown write outcomes are reconciled by reading remote bytes. A newer channel version prevents a stale upload from replacing it. Each package updates only its embedded channel: nightly writes `nightly.yml` / `nightly-mac.yml`, latest writes `latest.yml` / `latest-mac.yml`. Latest binaries use a `latest/` subdirectory under the target binary prefix, separate from existing nightly objects. Publishing the same source/version to both channels requires two packages; an existing release tag is reused only for the same source commit. + +The sibling `upload-records/` directory retains an operation identity, newline-delimited events and terminal result. Feed-write intents retain both previous and requested YAML. Failures record their stage, safe error classification and diagnostic; raw provider responses are excluded. Audit initialization becomes visible only after its files are complete; abandoned `.initializing-*` directories are not scanned for completed publication. Writes and readback are recorded before proceeding; missing terminal results mean interruption, not success. Actions also keeps its operation record as a diagnostic artifact for 30 days. These local records are not tamper-proof. COS readback verifies storage bytes, not public CDN behavior; public download acceptance is separate. + +If the feed completed but tagging failed, the result is `published-tag-pending` and the job fails visibly. On retry, durable feed-completion evidence permits only tag recovery, even if a later release has advanced the channel. It never re-publishes old YAML. An existing conflicting tag requires administrator investigation and is never moved automatically. + +Normal publication and manual restoration acquire the same local channel lock. A crash can leave a lock directory under the sibling `publish-locks/`; verify no writer is running before manually removing it. A runner label alone does not coordinate writers on other machines. Keep one publishing machine per channel and preserve its records when migrating. + + + +## Restore a channel + +Run the repository script `apps/desktop/scripts/rollback-desktop-channel.ts` through `node --import tsx` on the publishing machine. Keep `GITHUB_REPOSITORY`, `RUNNER_NAME`, `DSH_PACKAGING_CONFIG_DIR` and `DESKTOP_PUBLISH_ROUTE` set to the original package context. The route JSON contains `runId`, `attempt`, `commit`, `runnerName`, `target`, `preset`, `sourceRef` and `channel`, available in the retained manifest's source fields. Pass `--record` with that successful publication's audit directory, `--expected-version` with the version currently online, and `--reason` with the operational reason. + +Without `--execute`, the script reads remote objects and prints the exact YAML change without writing. Preview requires COS read credentials. With `--execute`, it verifies historical files, preserves before/after YAML in a new audit record, checks the current channel again, and restores only YAML. It neither uploads binaries nor changes tags. A channel already matching the target bytes is skipped; other channels must match the expected current version. This permits recovery after one feed was restored before an interruption. A different current version aborts the operation. Restoration is not a multi-object transaction; inspect the audit after any partial failure before continuing. + +The Desktop client disables automatic downgrade. Restoring an old feed can stop additional clients receiving a bad release; clients already on a newer version do not downgrade. A higher-version corrective release is a separate recovery path. + +## Dev Note + +None. diff --git a/apps/desktop/publishing/ci/README.zh.md b/apps/desktop/publishing/ci/README.zh.md new file mode 100644 index 0000000000..d689d2a85e --- /dev/null +++ b/apps/desktop/publishing/ci/README.zh.md @@ -0,0 +1,68 @@ +--- +description: "在专用 macOS 和 Windows runner 上发布留存的 Desktop 安装包。" +--- + +# Desktop CI 发布 + +[English](README.md) | 中文 + +## 概述 + +在原打包机器上发布完整安装包,无需再次下载安装包。test 和 production 都先发布二进制文件,再更新 YAML。两种环境都需要 GitHub Environment 审批;只有 production 通过 GitHub App 创建 tag。真实 COS、CDN 和管理员权限仍需部署验收;本地 fixture 不证明这些权限可用。 + +## 目录 + +- [机器配置](#machine-setup) +- [打包与发布](#package-and-publish) +- [恢复与记录](#recovery-and-records) +- [恢复频道](#restore-a-channel) + + + +## 机器配置 + +保持 `DSH_PACKAGING_CONFIG_DIR` 指向现有机器打包目录。完成的构建复制到其同级 `releases///`;`DSH_DESKTOP_RELEASES_DIR` 可用绝对路径覆盖该根目录。文件清单包含安装包、更新归档、blockmap、完成记录和不含凭据的 manifest。调试包会留存,但不能发布。复制内容通过校验后才开放 attempt 目录,已有 attempt 不能被替换。GitHub 保留相同文件清单 14 天。本地文件保留到操作员删除;校验或发布期间不要删除对应 attempt。 + +在配置目录下创建 `publishing/test.json`,获得授权后再创建 `publishing/production.json`。每份配置声明 `bucket`、不带路径的 HTTPS `origin` 和绝对路径 `credentialsFile`。凭据文件只包含 `secretId` 和 `secretKey`。macOS 还需 `macOS.signingIdentity` 和 `macOS.teamId`;Windows 需要 `windows.certificateThumbprint`。production 必须使用 origin `https://download.deepseek.com`,并提供 `githubApp.clientId` 和绝对路径 `githubApp.privateKeyFile`。这些文件不进入 Git。上传器支持长期 COS 凭据,不支持 STS 会话凭据。不可覆盖的二进制写入要求 bucket 版本控制关闭。 + +将仓库变量 `DESKTOP_PUBLISH_RUNNERS` 配置为已注册 runner 名称到唯一标签和目标的 JSON 映射,例如 `{"mini":{"label":"dsh-mini-publish","target":"mac-arm64"}}`。该标签只能分配给这台 runner。Windows 使用目标 `win-x64`。路由 job 检查原构建成功的 job,发布器再检查自身 runner 名称与记录一致。机器之间不需要共享文件系统或局域网连接。 + +管理员必须创建 `desktop-test` 和 `desktop-production`,分别设置必需审批人并允许 `master` 部署分支。工作流会检查两种环境的审批人配置。test 可允许发起者自行审批。GitHub App 需要安装到本仓库、具有 Contents write 权限,并获准在 `desktop-v*` tag ruleset 下创建发布 tag。审批不会把审批人的 tag 权限传给 App。缺少配置会停止发布,不回退到个人 token。App action 创建短期 token,并在结束后撤销。使用发布账户的文件系统权限保护本机私钥及 COS 凭据文件。token action 需要密钥内容,因此已屏蔽日志的 PEM 在 job 期间也会经过 runner 临时 `GITHUB_OUTPUT` 文件;日志屏蔽不限制本地文件访问。 + +Environment 审批限制此工作流,不限制同一机器账户运行的任意代码。账户隔离和强制源分支限制属于独立运维工作。保持现有发布 Environment 不变。test job 使用自己的审批人,不创建真实发布 tag;本地模拟接口覆盖 tag 创建与恢复,但不验证 App 权限。 + + + +## 打包与发布 + +选择对应平台的 `Package Desktop` 工作流、preset、源码、通道和版本模式。`source_ref` 接受 `master` 或与所选产品版本一致的 `dsh-v*`;源码必须包含这些 CI 适配脚本,因此旧 tag 会在安装依赖前失败。发布器从默认分支运行,单独记录实际源码提交。`version_mode=default` 对 production 选择 product,对 test 选择 auto;product 保留所选源码版本,auto 追加日期/run/attempt,manual 要求填写 `build_version`。latest 包要求稳定版本,因此 test/latest 需选择 product 或稳定的手填版本。可选 `test_release_id` 是测试 URL 前缀:留空沿用机器默认值,同一平台连续升级测试需保持相同值,并应定期轮换默认值。它不是 GitHub run ID。格式校验和轮换限制见[打包配置](../../packaging/README.zh.md#configuration-and-local-builds)。 + +选中 `publish`,请求在打包成功后发布。Windows 必须签名;macOS 必须完成公证和 stapling。独立的 `Publish Desktop · COS` 工作流从 GitHub 读取小型请求 manifest,将其与原 run 和 attempt 核对,再路由到原机器。安装包使用本机留存文件。构建失败或未请求发布时不会上传。 + +上传前,发布器校验全部留存文件的哈希、版本及目标信息,再复核平台签名。macOS 校验 DMG,并解压 ZIP 校验 app 票据;Windows 校验 Authenticode、配置的签名证书指纹及时间戳。准备阶段生成审批摘要,列出版本、通道、平台、环境、源码 ref/SHA、package run/attempt、对象路径及拟创建的 tag。两种发布 job 均等待 **Review deployments → Approve and deploy**,随后重新核对已批准的 manifest 与上传计划摘要。文件或目标变化会被拒绝。审批不能修改版本;应拒绝并重新打包。两种环境都上传缺失文件、校验远端字节,再更新 YAML。此工作流没有只上传文件的模式。production 最后为记录的打包提交创建 `desktop-v`;已有 tag 必须指向同一提交。 + + + +## 恢复与记录 + +解决故障后重跑发布工作流,不要仅为重试上传而重新打包。原 package run 和 attempt 保持不变。缺失对象会上传,相同对象跳过,同名但字节不同的对象会停止操作。写入结果不明时通过读取远端字节核对。频道已有更高版本时,旧上传不能替换它。每个包只更新自身内置通道:nightly 写入 `nightly.yml` / `nightly-mac.yml`,latest 写入 `latest.yml` / `latest-mac.yml`。latest 二进制位于目标二进制前缀下的 `latest/` 子目录,与已有 nightly 对象分离。同一源码和版本发布到两个通道需要分别打包;已有发布 tag 仅在源码提交相同时复用。 + +同级 `upload-records/` 目录保留操作标识、逐行事件和最终结果。频道写入意图同时记录先前和目标 YAML。失败记录阶段、安全错误分类与诊断,不保存原始服务响应。审计初始化在文件完整后才可见;遗留的 `.initializing-*` 目录不参与已完成发布的扫描。写入和回读在继续操作前落盘;没有最终结果代表中断,不代表成功。Actions 还将操作记录保存为诊断 artifact,保留 30 天。这些本地记录不防篡改。COS 回读验证存储字节,不代表公开 CDN 行为;公开下载需单独验收。 + +频道更新完成但 tag 失败时,结果为 `published-tag-pending`,job 明确失败。重试时,已落盘的频道完成证据只允许恢复 tag,即使后来发布已推进频道,也不会重新发布旧 YAML。已有冲突 tag 需要管理员调查,不会自动移动。 + +正常发布和手动恢复获取同一把本地频道锁。崩溃可能在同级 `publish-locks/` 下留下锁目录;手动删除前确认没有写入任务运行。runner 标签本身不能协调其他机器的写入。每个频道保持单一发布机器,迁移时保留记录。 + + + +## 恢复频道 + +在发布机器上通过 `node --import tsx` 运行仓库脚本 `apps/desktop/scripts/rollback-desktop-channel.ts`。保持 `GITHUB_REPOSITORY`、`RUNNER_NAME`、`DSH_PACKAGING_CONFIG_DIR` 和 `DESKTOP_PUBLISH_ROUTE` 对应原打包上下文。route JSON 包含 `runId`、`attempt`、`commit`、`runnerName`、`target`、`preset`、`sourceRef` 和 `channel`,可从留存 manifest 的 source 字段取得。`--record` 指定该次成功发布的审计目录,`--expected-version` 指定当前线上版本,`--reason` 填写操作原因。 + +不带 `--execute` 时,脚本读取远端对象并打印确切 YAML 变更,不执行写入。预览需要 COS 读取凭据。带 `--execute` 时,脚本校验历史文件,将前后 YAML 保存在新的审计记录中,再次检查当前频道,然后仅恢复 YAML。它不上传二进制文件,也不改变 tag。已经与目标字节完全相同的频道会跳过,其余频道必须匹配预期当前版本,因此可以恢复写完一个 feed 后中断的操作。其他当前版本会中止操作。恢复不是多对象事务;部分失败后需先检查审计记录再继续。 + +Desktop 客户端禁用自动降级。恢复旧 feed 可以阻止更多客户端获得问题版本,已经安装更高版本的客户端不会降级。发布更高版本号的修正版是独立恢复方式。 + +## 开发备注 + +无。 diff --git a/apps/desktop/scripts/desktop-auto-update-environment.d.mts b/apps/desktop/scripts/desktop-auto-update-environment.d.mts index 00ebf8c5c0..f656f9baaf 100644 --- a/apps/desktop/scripts/desktop-auto-update-environment.d.mts +++ b/apps/desktop/scripts/desktop-auto-update-environment.d.mts @@ -10,6 +10,7 @@ export type DesktopAutoUpdateTarget = 'mac-arm64' | 'mac-x64' | 'win-x64' /** Public updater URL and object prefixes for one release target. */ export interface DesktopAutoUpdateConfig { readonly environment: DesktopAutoUpdateEnvironment + readonly channel: 'nightly' | 'latest' readonly target: DesktopAutoUpdateTarget readonly origin: string readonly publicUrl: string @@ -60,6 +61,7 @@ export function desktopBuildRecordFilename(target: DesktopAutoUpdateTarget): str export function desktopUpdateMetadataFilename( version: string, platform: NodeJS.Platform, + channel?: 'nightly' | 'latest', ): string /** @@ -89,3 +91,10 @@ export function resolveDesktopUploadConfig( platform: NodeJS.Platform, arch: string, ): DesktopUploadConfig + +/** + * Resolve the packaged update channel, defaulting omitted local settings to nightly. + * @param env Packaging settings. + * @returns Validated channel. + */ +export function resolveDesktopUpdateChannel(env: NodeJS.ProcessEnv): 'nightly' | 'latest' diff --git a/apps/desktop/scripts/desktop-auto-update-environment.mjs b/apps/desktop/scripts/desktop-auto-update-environment.mjs index 464d079b4d..a1973a9c64 100644 --- a/apps/desktop/scripts/desktop-auto-update-environment.mjs +++ b/apps/desktop/scripts/desktop-auto-update-environment.mjs @@ -5,6 +5,17 @@ import { valid } from 'semver' /** Environment variable that selects the Desktop update deployment. */ export const DESKTOP_AUTO_UPDATE_ENV = 'DSH_DESKTOP_AUTO_UPDATE_ENV' +/** + * Resolve the channel embedded in the installer; omitted local settings retain nightly. + * @param {NodeJS.ProcessEnv} env Packaging or upload settings. + * @returns {'nightly' | 'latest'} Validated channel. + */ +export function resolveDesktopUpdateChannel(env) { + const channel = env.DSH_DESKTOP_UPDATE_CHANNEL || 'nightly' + if (channel !== 'nightly' && channel !== 'latest') throw new Error('desktop auto-update: invalid update channel') + return channel +} + const UPDATE_ENVIRONMENTS = { test: { originEnvName: 'DOWNLOAD_TEST_ORIGIN', @@ -68,16 +79,18 @@ export function desktopBuildRecordFilename(target) { * Return the electron-builder channel metadata filename for an application version. * @param {string} version - Desktop semantic version. * @param {NodeJS.Platform} platform - Target platform. + * @param {'nightly' | 'latest'} channel - Selected channel; omitted means nightly. * @returns {string} Channel metadata filename emitted for the target. */ -export function desktopUpdateMetadataFilename(version, platform) { +export function desktopUpdateMetadataFilename(version, platform, channel = 'nightly') { if (valid(version) === null) { throw new Error(`desktop auto-update: invalid Desktop version ${JSON.stringify(version)}`) } if (platform !== 'darwin' && platform !== 'win32') { throw new Error(`desktop auto-update: unsupported metadata platform ${platform}`) } - return `nightly${platform === 'darwin' ? '-mac' : ''}.yml` + resolveDesktopUpdateChannel({ DSH_DESKTOP_UPDATE_CHANNEL: channel }) + return `${channel}${platform === 'darwin' ? '-mac' : ''}.yml` } /** @@ -124,10 +137,11 @@ function httpsOrigin(value, name) { * @param {NodeJS.ProcessEnv} env - Packaging or upload environment. * @param {NodeJS.Platform} platform - Target Node.js platform. * @param {string} arch - Target Node.js architecture. - * @returns {{ environment: 'test' | 'production', target: 'mac-arm64' | 'mac-x64' | 'win-x64', origin: string, publicUrl: string, keyPrefix: string, binaryKeyPrefix: string }} Resolved updater configuration. + * @returns {{ environment: 'test' | 'production', channel: 'nightly' | 'latest', target: 'mac-arm64' | 'mac-x64' | 'win-x64', origin: string, publicUrl: string, keyPrefix: string, binaryKeyPrefix: string }} Resolved updater configuration. * @throws {Error} When the test deployment lacks a valid HTTPS origin or a 32-character lowercase hexadecimal release ID. */ export function resolveDesktopAutoUpdateConfig(env, platform, arch) { + const channel = resolveDesktopUpdateChannel(env) const environment = resolveDesktopAutoUpdateEnvironment(env) const target = resolveDesktopAutoUpdateTarget(platform, arch) const deployment = UPDATE_ENVIRONMENTS[environment] @@ -148,10 +162,11 @@ export function resolveDesktopAutoUpdateConfig(env, platform, arch) { const keyPrefix = `${releasePrefix}/feeds/${target}` return { environment, + channel, target, origin, keyPrefix, - binaryKeyPrefix: `${releasePrefix}/bin/${target}`, + binaryKeyPrefix: `${releasePrefix}/bin/${target}${channel === 'latest' ? '/latest' : ''}`, publicUrl: `${origin}/${keyPrefix}/`, } } @@ -161,7 +176,7 @@ export function resolveDesktopAutoUpdateConfig(env, platform, arch) { * @param {NodeJS.ProcessEnv} env - Upload environment. * @param {NodeJS.Platform} platform - Target Node.js platform. * @param {string} arch - Target Node.js architecture. - * @returns {{ environment: 'test' | 'production', target: 'mac-arm64' | 'mac-x64' | 'win-x64', origin: string, publicUrl: string, keyPrefix: string, binaryKeyPrefix: string, bucket: string, secretIdEnvName: string, secretKeyEnvName: string }} Resolved upload configuration. + * @returns {{ environment: 'test' | 'production', channel: 'nightly' | 'latest', target: 'mac-arm64' | 'mac-x64' | 'win-x64', origin: string, publicUrl: string, keyPrefix: string, binaryKeyPrefix: string, bucket: string, secretIdEnvName: string, secretKeyEnvName: string }} Resolved upload configuration. * @throws {Error} When the selected deployment lacks a bucket or valid updater configuration. */ export function resolveDesktopUploadConfig(env, platform, arch) { diff --git a/apps/desktop/scripts/desktop-build-paths.d.mts b/apps/desktop/scripts/desktop-build-paths.d.mts index 9a8e408ed1..c7b8173ca1 100644 --- a/apps/desktop/scripts/desktop-build-paths.d.mts +++ b/apps/desktop/scripts/desktop-build-paths.d.mts @@ -5,6 +5,7 @@ export interface DesktopTargetBuildPaths { readonly root: string readonly artifacts: string readonly unsignedArtifacts: string + readonly unnotarizedArtifacts: string readonly runtime: string readonly packageSet: string readonly dsh: string @@ -72,3 +73,14 @@ export function developmentRuntimeDirectory( hostPlatform?: NodeJS.Platform, hostArch?: string, ): string + +/** + * Select the output directory and filename suffix for one packaging mode. + * @param paths Target artifact directories. + * @param mode Validated packaging mode. + * @returns Matching artifact destination and filename marker. + */ +export function desktopArtifactOutput( + paths: Pick, + mode: { unsigned: boolean, skipNotarization: boolean }, +): { directory: string, suffix: string } diff --git a/apps/desktop/scripts/desktop-build-paths.mjs b/apps/desktop/scripts/desktop-build-paths.mjs index 4f33e804c3..e577b1d3fe 100644 --- a/apps/desktop/scripts/desktop-build-paths.mjs +++ b/apps/desktop/scripts/desktop-build-paths.mjs @@ -38,7 +38,7 @@ function assertSupportedTarget(target) { /** * Return the mutable preparation and artifact directories owned by one release target. * @param {'mac-arm64' | 'mac-x64' | 'win-x64'} target - Supported Desktop target name. - * @returns {{ root: string, artifacts: string, unsignedArtifacts: string, runtime: string, packageSet: string, dsh: string, dshPnpm: string, electron: string, packedDsh: string, packedVendor: string, packedLandlock: string, downloads: string }} Target paths plus the shared immutable download cache. + * @returns {{ root: string, artifacts: string, unsignedArtifacts: string, unnotarizedArtifacts: string, runtime: string, packageSet: string, dsh: string, dshPnpm: string, electron: string, packedDsh: string, packedVendor: string, packedLandlock: string, downloads: string }} Target paths plus the shared immutable download cache. */ export function desktopTargetBuildPaths(target) { assertSupportedTarget(target) @@ -48,6 +48,7 @@ export function desktopTargetBuildPaths(target) { root, artifacts: join(root, 'artifacts'), unsignedArtifacts: join(root, 'unsigned-artifacts'), + unnotarizedArtifacts: join(root, 'unnotarized-artifacts'), runtime: join(root, 'runtime'), packageSet: join(root, 'package-set'), dsh: join(root, 'dsh'), @@ -105,3 +106,15 @@ export function developmentRuntimeDirectory( ) { return join(resolveDesktopTargetBuildPaths(env, hostPlatform, hostArch).runtime, 'primary-runtime') } + +/** + * Select the output directory and filename suffix for one packaging mode. + * @param {{ artifacts: string, unsignedArtifacts: string, unnotarizedArtifacts: string }} paths Target artifact directories. + * @param {{ unsigned: boolean, skipNotarization: boolean }} mode Validated packaging mode. + * @returns {{ directory: string, suffix: string }} Matching artifact destination and filename marker. + */ +export function desktopArtifactOutput(paths, mode) { + if (mode.unsigned) return { directory: paths.unsignedArtifacts, suffix: '-unsigned' } + if (mode.skipNotarization) return { directory: paths.unnotarizedArtifacts, suffix: '-unnotarized' } + return { directory: paths.artifacts, suffix: '' } +} diff --git a/apps/desktop/scripts/desktop-build-version-discovery.ts b/apps/desktop/scripts/desktop-build-version-discovery.ts index fd5a2c9e10..68291ca3b8 100644 --- a/apps/desktop/scripts/desktop-build-version-discovery.ts +++ b/apps/desktop/scripts/desktop-build-version-discovery.ts @@ -2,7 +2,7 @@ * Suggest the next build version for a product version, so the sequence number * is derived rather than remembered. * - * `apps/desktop/README.md` gives the form as `..` + * `apps/desktop/packaging/README.md#release-versions` gives the form as `..` * for a prerelease base and `-test..` for a * stable one. What makes an index correct is which ones are taken, and that is * recorded where the builds went: the update bucket for an uploaded build, the @@ -30,8 +30,8 @@ const LISTING_DEADLINE_MS = 8_000 /** Objects one listing page may return. */ const LISTING_PAGE_SIZE = 1000 -/** Artifact name electron-builder writes for one build, on either platform; unsigned builds add a suffix. */ -const ARTIFACT = /(?:^|\/)deepseek-harness-(?.+)-(?:mac|win)-(?:arm64|x64)(?:-unsigned)?\.(?:exe|dmg|zip)$/u +/** Artifact name electron-builder writes for one build, on either platform; unsigned and unnotarized builds add a suffix. */ +const ARTIFACT = /(?:^|\/)deepseek-harness-(?.+)-(?:mac|win)-(?:arm64|x64)(?:-unsigned|-unnotarized)?\.(?:exe|dmg|zip)$/u /** Inputs that decide which versions are already taken. */ export interface DesktopBuildVersionSuggestionOptions { diff --git a/apps/desktop/scripts/desktop-build-version.mjs b/apps/desktop/scripts/desktop-build-version.mjs index 411b5238fb..bddf4ca492 100644 --- a/apps/desktop/scripts/desktop-build-version.mjs +++ b/apps/desktop/scripts/desktop-build-version.mjs @@ -2,7 +2,7 @@ * Resolve the version one build publishes, which is not always the version the * repository declares. * - * Manual Windows CI uses UTC date, workflow run number and attempt as + * Manual Desktop CI uses UTC date, workflow run number and attempt as * `.YYYYMMDD.run.attempt`; these identify unpublished builds, not release sequences. * * A production release publishes the version in the manifests, aligned with the @@ -10,7 +10,7 @@ * sequence number, so one test feed can carry several builds of a single * product version: `0.1.6-alpha.1.20260916.1` from a prerelease base and * `0.1.6-test.20260916.1` from a stable one, as the release versions table in - * `apps/desktop/README.md` gives them. Passing that version here keeps it out + * `apps/desktop/packaging/README.md#release-versions` gives them. Passing that version here keeps it out * of the manifests, so the tracked version stays the product's while the build * version reaches electron-builder, the update feed, and the upload validation * as one input. diff --git a/apps/desktop/scripts/desktop-ci-version.d.mts b/apps/desktop/scripts/desktop-ci-version.d.mts new file mode 100644 index 0000000000..213e96fd1a --- /dev/null +++ b/apps/desktop/scripts/desktop-ci-version.d.mts @@ -0,0 +1,10 @@ +/** Resolve the version used by the manual Desktop packaging workflow. */ + +/** + * Resolve an explicit version or a UTC date/run/attempt identifier without release storage access. + * @param environment Manual version and GitHub run identity. + * @param productVersion Version declared by the selected checkout. + * @param now Time used for the UTC date segment. + * @returns Validated build version. + */ +export function desktopCiVersion(environment: NodeJS.ProcessEnv, productVersion: string, now?: Date): string diff --git a/apps/desktop/scripts/desktop-ci-version.mjs b/apps/desktop/scripts/desktop-ci-version.mjs new file mode 100644 index 0000000000..8fef629287 --- /dev/null +++ b/apps/desktop/scripts/desktop-ci-version.mjs @@ -0,0 +1,34 @@ +/** Resolve manual Desktop CI build versions without release storage access. */ +import { desktopBuildVersionPrefix, validateDesktopBuildVersion } from './desktop-build-version.mjs' + +/** + * Resolve an explicit version or a UTC date/run/attempt identifier without release storage access. + * @param {NodeJS.ProcessEnv} environment Manual version and GitHub run identity. + * @param {string} productVersion Version declared by the selected checkout. + * @param {Date} now Time used for the UTC date segment. + * @returns {string} Validated build version. + */ +export function desktopCiVersion(environment, productVersion, now = new Date()) { + const mode = environment.PACKAGE_VERSION_MODE + const selected = mode === 'default' ? (environment.DSH_PACKAGING_PRESET === 'production' ? 'product' : 'auto') : mode + if (selected !== undefined && !['product', 'auto', 'manual'].includes(selected)) throw new Error('Invalid version mode') + const requested = selected === 'product' ? productVersion : selected === 'auto' ? 'auto' : environment.PACKAGE_BUILD_VERSION?.trim() + if (selected === 'manual' && (!requested || requested === 'auto')) throw new Error('Manual version mode requires an explicit version') + const date = now.toISOString().slice(0, 10).replaceAll('-', '') + const example = `${desktopBuildVersionPrefix(productVersion)}${date}.1` + if (!requested) throw new Error(`Build version is required: use auto, ${productVersion}, or e.g. ${example}`) + let candidate = requested + if (requested === 'auto') { + for (const name of ['GITHUB_RUN_NUMBER', 'GITHUB_RUN_ATTEMPT']) { + if (!/^[1-9]\d*$/u.test(environment[name] ?? '')) throw new Error(`${name} must be a positive integer`) + } + candidate = `${desktopBuildVersionPrefix(productVersion)}${date}.${environment.GITHUB_RUN_NUMBER}.${environment.GITHUB_RUN_ATTEMPT}` + } + if (environment.DSH_DESKTOP_UPDATE_CHANNEL === 'latest' && candidate.includes('-')) throw new Error('Latest channel requires a stable version; select product or manual stable version') + try { + const resolved = validateDesktopBuildVersion(candidate, productVersion) + return resolved + } catch { + throw new Error(`Invalid build version for this checkout: use auto, ${productVersion}, or e.g. ${example}`) + } +} diff --git a/apps/desktop/scripts/desktop-package-environment.d.mts b/apps/desktop/scripts/desktop-package-environment.d.mts index a49ca46eb9..995c117d07 100644 --- a/apps/desktop/scripts/desktop-package-environment.d.mts +++ b/apps/desktop/scripts/desktop-package-environment.d.mts @@ -8,16 +8,19 @@ export function localMacOSPackageEnvironment(environment?: NodeJS.ProcessEnv): NodeJS.ProcessEnv /** - * Read the target's required UTF-8 dotenv file; release settings never fall back to ambient values. + * Read a machine preset when selected, otherwise the target's required dotenv; no ambient release fallback. + * PACKAGE_TEST_RELEASE_ID overrides the test preset only; blank retains its configured ID. * @param platform Target platform. * @param environment Parent environment, retained only for unrelated build tools. * @param appRoot Desktop application directory; relative credential paths resolve here. + * @param options Unsigned presets and Windows preparation-only presets never read signing credentials. * @returns Isolated environment with file-owned release settings. */ export function loadDesktopPackageEnvironment( platform: 'win32' | 'darwin', environment?: NodeJS.ProcessEnv, appRoot?: string, + options?: { unsigned?: boolean, prepareOnly?: boolean }, ): NodeJS.ProcessEnv /** @@ -30,5 +33,5 @@ export function loadDesktopPackageEnvironment( export function validateDesktopPackageEnvironment( environment: NodeJS.ProcessEnv, target: { platform: 'win32' | 'darwin', arch: string }, - options?: { unsigned?: boolean, prepareOnly?: boolean }, + options?: { unsigned?: boolean, prepareOnly?: boolean, skipNotarization?: boolean }, ): void diff --git a/apps/desktop/scripts/desktop-package-environment.mjs b/apps/desktop/scripts/desktop-package-environment.mjs index 51b878100a..8116dea245 100644 --- a/apps/desktop/scripts/desktop-package-environment.mjs +++ b/apps/desktop/scripts/desktop-package-environment.mjs @@ -1,13 +1,14 @@ /** Select local test defaults or platform release settings without changing the caller's environment. */ import { accessSync, constants, readFileSync, statSync } from 'node:fs' -import { dirname, join, resolve } from 'node:path' +import { dirname, isAbsolute, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { parseEnv } from 'node:util' import { resolveDesktopAppId, resolveMacOSNotarizationEnvironment, resolveMacOSSigningEnvironment, resolveNpmRegistry } from './desktop-release-environment.mjs' import { resolveDesktopAutoUpdateConfig } from './desktop-auto-update-environment.mjs' import { createWindowsTokenSigner } from './windows-sign.mjs' import { resolveDesktopPolicyEnvironment } from './desktop-policy-environment.mjs' +import { validateMacOSSignatureCacheDirectory } from './macos-signature-cache-directory.mjs' import { resolveMacOSPackageSettings } from './macos-package-settings.mjs' import { resolveWindowsSignatureCacheDirectory } from './windows-signature-cache-directory.mjs' import { resolveWindowsPackageSettings } from './windows-package-settings.mjs' @@ -15,7 +16,7 @@ import { resolveWindowsPackageSettings } from './windows-package-settings.mjs' const APP_ROOT = fileURLToPath(new URL('..', import.meta.url)) const SHARED_SETTING = /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|NPM_REGISTRY|MANDATORY_UPDATE_(?:CONFIG|(?:TEST|PROD)_ORIGIN))|DOWNLOAD_TEST_RELEASE_ID|DOWNLOAD_(?:TEST|PROD)_(?:ORIGIN|COS_BUCKET|COS_SECRET_ID|COS_SECRET_KEY))$/u const WINDOWS_SETTING = /^DSH_DESKTOP_WINDOWS_(?:CER_FILE|SIGNTOOL|KEY_CONTAINER|TOKEN_PIN|SIGNATURE_CACHE_DIR|SIGNATURE_CACHE_CONCURRENCY)$/u -const MACOS_SETTING = /^(?:DSH_DESKTOP_MACOS_(?:SIGNING_IDENTITY|TEAM_ID|PACK_CONCURRENCY|DOWNLOAD_PROXY|NOTARIZATION_PROXY)|APPLE_(?:API_KEY|API_KEY_ID|API_ISSUER|ID|APP_SPECIFIC_PASSWORD|TEAM_ID|KEYCHAIN|KEYCHAIN_PROFILE)|CSC_(?:LINK|KEY_PASSWORD))$/u +const MACOS_SETTING = /^(?:DSH_DESKTOP_MACOS_(?:SIGNING_IDENTITY|TEAM_ID|SIGNATURE_CACHE_DIR|PACK_CONCURRENCY|DOWNLOAD_PROXY|NOTARIZATION_PROXY)|APPLE_(?:API_KEY|API_KEY_ID|API_ISSUER|ID|APP_SPECIFIC_PASSWORD|TEAM_ID|KEYCHAIN|KEYCHAIN_PROFILE)|CSC_(?:LINK|KEY_PASSWORD))$/u const AMBIENT_RELEASE_SETTING = /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|MANDATORY_UPDATE_.*|WINDOWS_.*|MACOS_.*)|APPLE_.*|(?:WIN_)?CSC_.*|DOWNLOAD_(?:TEST|PROD)_.*)$/iu const FILE_SETTINGS = ['DSH_DESKTOP_WINDOWS_CER_FILE', 'DSH_DESKTOP_WINDOWS_SIGNTOOL', 'APPLE_API_KEY', 'APPLE_KEYCHAIN', 'CSC_LINK'] @@ -33,14 +34,61 @@ export function localMacOSPackageEnvironment(environment = process.env) { } /** - * Read the target's required UTF-8 dotenv file; release settings never fall back to ambient values. + * Read a machine preset when selected, otherwise the target's required dotenv; no ambient release fallback. + * PACKAGE_TEST_RELEASE_ID overrides the test preset only; blank retains its configured ID. * @param {'win32' | 'darwin'} platform Target platform. * @param {NodeJS.ProcessEnv} environment Parent environment, retained only for unrelated build tools. * @param {string} appRoot Desktop application directory; relative credential paths resolve here. + * @param {{ unsigned?: boolean, prepareOnly?: boolean }} options Unsigned presets and Windows preparation-only presets never read signing credentials. * @returns {NodeJS.ProcessEnv} Isolated environment with file-owned release settings. */ -export function loadDesktopPackageEnvironment(platform, environment = process.env, appRoot = APP_ROOT) { - const path = join(appRoot, platform === 'win32' ? '.env.windows' : '.env.macos') +export function loadDesktopPackageEnvironment(platform, environment = process.env, appRoot = APP_ROOT, options = {}) { + const preset = environment.DSH_PACKAGING_PRESET + let path = join(appRoot, platform === 'win32' ? '.env.windows' : '.env.macos') + if (preset !== undefined) { + if (preset !== 'test' && preset !== 'production') throw new Error('desktop package: preset must be test or production') + const root = environment.DSH_PACKAGING_CONFIG_DIR + if (!root || !isAbsolute(root)) throw new Error('desktop package: DSH_PACKAGING_CONFIG_DIR must be an absolute directory') + path = join(root, 'presets', `${preset}.env`) + } + const platformSetting = platform === 'win32' ? WINDOWS_SETTING : MACOS_SETTING + const publicPlatformSetting = platform === 'win32' + ? /^DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_(?:DIR|CONCURRENCY)$/u + : /^DSH_DESKTOP_MACOS_(?:SIGNATURE_CACHE_DIR|PACK_CONCURRENCY|DOWNLOAD_PROXY|NOTARIZATION_PROXY)$/u + const settings = readSettings(path, name => preset === undefined + ? SHARED_SETTING.test(name) || platformSetting.test(name) + : /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|NPM_REGISTRY|MANDATORY_UPDATE_(?:CONFIG|(?:TEST|PROD)_ORIGIN))|DOWNLOAD_TEST_(?:ORIGIN|RELEASE_ID))$/u.test(name) || publicPlatformSetting.test(name)) + if (preset !== undefined) { + if (settings.DSH_DESKTOP_AUTO_UPDATE_ENV !== preset) throw new Error('desktop package: preset deployment must match its name') + if (!options.unsigned && (platform === 'darwin' || !options.prepareOnly)) { + const filename = platform === 'win32' ? 'windows-signing.env' : 'macos-signing.env' + Object.assign(settings, readSettings(join(dirname(dirname(path)), 'secrets', filename), + name => platform === 'win32' + ? /^DSH_DESKTOP_WINDOWS_(?:CER_FILE|SIGNTOOL|KEY_CONTAINER|TOKEN_PIN)$/u.test(name) + : MACOS_SETTING.test(name) && !publicPlatformSetting.test(name))) + } + } + const releaseId = environment.PACKAGE_TEST_RELEASE_ID?.trim() + if (releaseId) { + if (preset !== 'test') throw new Error('desktop package: test release ID override requires the test preset') + if (!/^[a-f0-9]{32}$/u.test(releaseId)) throw new Error('desktop package: test release ID must contain 32 lowercase hexadecimal characters') + settings.DOWNLOAD_TEST_RELEASE_ID = releaseId + } + return { + ...Object.fromEntries(Object.entries(environment).filter(([name]) => !AMBIENT_RELEASE_SETTING.test(name))), + ...settings, + ...(environment.DSH_DESKTOP_UPDATE_CHANNEL === undefined ? {} : { DSH_DESKTOP_UPDATE_CHANNEL: environment.DSH_DESKTOP_UPDATE_CHANNEL }), + ...(preset === undefined ? {} : { DSH_PACKAGING_PRESET: preset }), + } +} + +/** + * Read an explicitly selected dotenv without exposing credential-bearing parser errors. + * @param {string} path Selected file; relative file settings resolve beside it. + * @param {(name: string) => boolean} accepts Whether this file may declare a setting. + * @returns {NodeJS.ProcessEnv} Validated file settings. + */ +function readSettings(path, accepts) { let contents try { contents = readFileSync(path, 'utf8') @@ -56,9 +104,8 @@ export function loadDesktopPackageEnvironment(platform, environment = process.en // Parser diagnostics can contain credential-bearing input. throw new Error(`desktop package: invalid dotenv syntax in ${path}`) } - const platformSetting = platform === 'win32' ? WINDOWS_SETTING : MACOS_SETTING for (const name of Object.keys(settings)) { - if (!SHARED_SETTING.test(name) && !platformSetting.test(name)) { + if (!accepts(name)) { throw new Error(`desktop package: unsupported setting ${name} in ${path}; use the platform template`) } if (settings[name].includes('\0')) throw new Error(`desktop package: ${name} cannot contain a NUL character`) @@ -66,10 +113,7 @@ export function loadDesktopPackageEnvironment(platform, environment = process.en for (const name of FILE_SETTINGS) { if (settings[name]?.trim()) settings[name] = resolve(dirname(path), settings[name].trim()) } - return { - ...Object.fromEntries(Object.entries(environment).filter(([name]) => !AMBIENT_RELEASE_SETTING.test(name))), - ...settings, - } + return settings } function requireReadableFile(environment, name) { @@ -86,7 +130,7 @@ function requireReadableFile(environment, name) { * Validate the packaging mode before preparation without invoking a token or Apple's services. * @param {NodeJS.ProcessEnv} environment Selected local defaults or file-owned release settings. * @param {{ platform: 'win32' | 'darwin', arch: string }} target Selected release target. - * @param {{ unsigned?: boolean, prepareOnly?: boolean }} options Explicit packaging mode. + * @param {{ unsigned?: boolean, prepareOnly?: boolean, skipNotarization?: boolean }} options Explicit packaging mode. * @returns {void} */ export function validateDesktopPackageEnvironment(environment, target, options = {}) { @@ -106,18 +150,21 @@ export function validateDesktopPackageEnvironment(environment, target, options = }) if (!options.prepareOnly) resolveWindowsSignatureCacheDirectory(environment) } else { + if (environment.DSH_DESKTOP_MACOS_SIGNATURE_CACHE_DIR !== undefined) validateMacOSSignatureCacheDirectory(environment.DSH_DESKTOP_MACOS_SIGNATURE_CACHE_DIR) resolveMacOSSigningEnvironment(environment) - const strategies = [ - ['APPLE_ID', 'APPLE_APP_SPECIFIC_PASSWORD', 'APPLE_TEAM_ID'], - ['APPLE_API_KEY', 'APPLE_API_KEY_ID', 'APPLE_API_ISSUER'], - ['APPLE_KEYCHAIN_PROFILE', 'APPLE_KEYCHAIN'], - ] - if (strategies.filter(names => names.some(name => environment[name] !== undefined)).length > 1) { - throw new Error('desktop package: configure exactly one macOS notarization strategy; comment out the other strategies') + if (!options.skipNotarization) { + const strategies = [ + ['APPLE_ID', 'APPLE_APP_SPECIFIC_PASSWORD', 'APPLE_TEAM_ID'], + ['APPLE_API_KEY', 'APPLE_API_KEY_ID', 'APPLE_API_ISSUER'], + ['APPLE_KEYCHAIN_PROFILE', 'APPLE_KEYCHAIN'], + ] + if (strategies.filter(names => names.some(name => environment[name] !== undefined)).length > 1) { + throw new Error('desktop package: configure exactly one macOS notarization strategy; comment out the other strategies') + } + const credentials = resolveMacOSNotarizationEnvironment(environment) + if ('appleApiKey' in credentials) requireReadableFile(environment, 'APPLE_API_KEY') + if ('keychain' in credentials) requireReadableFile(environment, 'APPLE_KEYCHAIN') } - const credentials = resolveMacOSNotarizationEnvironment(environment) - if ('appleApiKey' in credentials) requireReadableFile(environment, 'APPLE_API_KEY') - if ('keychain' in credentials) requireReadableFile(environment, 'APPLE_KEYCHAIN') requireReadableFile(environment, 'CSC_LINK') if (environment.CSC_KEY_PASSWORD === undefined) { throw new Error('desktop package: CSC_KEY_PASSWORD must be set to the p12 export password (use an explicit empty value for an unencrypted p12)') diff --git a/apps/desktop/scripts/desktop-publication-failure.ts b/apps/desktop/scripts/desktop-publication-failure.ts new file mode 100644 index 0000000000..8f3e6beb55 --- /dev/null +++ b/apps/desktop/scripts/desktop-publication-failure.ts @@ -0,0 +1,30 @@ +/** Safe failure details shared by publication diagnostics and durable journals. */ +import { ZodError } from 'zod' +import { DesktopPublishError } from './desktop-publish.ts' + +const publicCodes = new Set(['AccessDenied', 'InternalError', 'NoSuchBucket', 'BadDigest', 'SignatureDoesNotMatch', + 'RequestTimeout', 'TimeoutError', 'AbortError', 'ECONNRESET', 'ETIMEDOUT', 'ENOTFOUND', 'ENOSPC', 'EACCES', 'EPERM', 'ENOENT']) + +/** + * Keep actionable local validation details without recording SDK response bodies or credentials. + * @param error Failure from validation, filesystem, subprocess or remote transport. + * @param phase Operation stage known by the entrypoint. + * @returns Credential-free diagnostic suitable for stderr and an audit event. + */ +export function desktopPublicationFailure(error: unknown, phase: string): { phase: string; code: string; message: string } { + if (error instanceof DesktopPublishError) return { phase, code: error.code, message: error.message } + if (error instanceof ZodError) return { phase, code: 'invalid-data', + message: `Invalid fields: ${error.issues.map(issue => `${issue.path.join('.') || '(root)'} (${issue.code})`).join(', ')}` } + if (error instanceof SyntaxError) return { phase, code: 'invalid-json', message: 'Invalid JSON; check the configuration or retained record for this stage.' } + const ownedMessage = /^(desktop (?:publish|release store|retention|rollback|tag|upload|auto-update|macOS signing|build)[^\n]*:|Usage:)/u + if (error instanceof Error && ownedMessage.test(error.message)) { + return { phase, code: 'validation', message: error.message } + } + const record = typeof error === 'object' && error !== null ? error : {} + const candidate = 'code' in record ? record.code : 'name' in record ? record.name : undefined + const code = typeof candidate === 'string' && publicCodes.has(candidate) ? candidate : 'operation-failed' + const status = 'statusCode' in record && typeof record.statusCode === 'number' ? ` (HTTP ${record.statusCode})` : '' + const path = ['ENOENT', 'EACCES', 'EPERM', 'ENOSPC'].includes(code) && 'path' in record && typeof record.path === 'string' + ? `: ${record.path}` : '' + return { phase, code, message: `${code}${status}${path}. Check this stage's local files, signature verification or service permissions; raw provider output is omitted.` } +} diff --git a/apps/desktop/scripts/desktop-publish-config.ts b/apps/desktop/scripts/desktop-publish-config.ts new file mode 100644 index 0000000000..f5d9a314da --- /dev/null +++ b/apps/desktop/scripts/desktop-publish-config.ts @@ -0,0 +1,46 @@ +/** Read public deployment settings independently from upload and GitHub App secrets. */ +import { readFile } from 'node:fs/promises' +import { isAbsolute, join } from 'node:path' +import { z } from 'zod' + +const singleLine = z.string().min(1).regex(/^[^\r\n\0]+$/u, 'expected a single-line value') +const absolutePath = singleLine.refine(isAbsolute, 'expected an absolute path') +const schema = z.strictObject({ + bucket: z.string().regex(/^[a-z0-9-]+-\d+$/u), + origin: z.url().refine((value) => { const url = new URL(value); return url.protocol === 'https:' && !url.username && !url.password && url.pathname === '/' && !url.search && !url.hash }, 'expected an HTTPS origin'), + credentialsFile: absolutePath, + macOS: z.strictObject({ signingIdentity: z.string().min(1), teamId: z.string().regex(/^[A-Z0-9]{10}$/u) }).optional(), + windows: z.strictObject({ certificateThumbprint: z.string().regex(/^[A-Fa-f0-9]{40}$/u) }).optional(), + githubApp: z.strictObject({ clientId: singleLine, privateKeyFile: absolutePath }).optional(), +}) +/** Public machine configuration; all secret fields are file references. */ +export type DesktopPublishConfig = z.infer + +/** + * Read one named deployment without opening referenced credential files. + * @param configRoot Absolute machine packaging configuration directory. + * @param preset Selected deployment from the retained package. + * @returns Validated public configuration; production requires App configuration. + */ +export async function readDesktopPublishConfig(configRoot: string, preset: 'test' | 'production'): Promise { + if (!isAbsolute(configRoot)) throw new Error('desktop publish: configuration root must be absolute') + const config = schema.parse(JSON.parse(await readFile(join(configRoot, 'publishing', `${preset}.json`), 'utf8'))) + if (preset === 'production' && (config.origin !== 'https://download.deepseek.com' || !config.githubApp)) { + throw new Error('desktop publish: production requires its fixed origin and GitHub App configuration') + } + return config +} + +/** + * Read upload credentials only after authorization and local validation have passed. + * @param config Previously validated public deployment configuration. + * @returns Explicit COS credential pair; contents never enter audit records. + */ +export async function readDesktopUploadCredentials(config: DesktopPublishConfig): Promise<{ secretId: string; secretKey: string }> { + try { + return z.strictObject({ secretId: z.string().min(1), secretKey: z.string().min(1) }) + .parse(JSON.parse(await readFile(config.credentialsFile, 'utf8'))) + } catch { + throw new Error('desktop publish: cannot read valid upload credentials from the configured file') + } +} diff --git a/apps/desktop/scripts/desktop-publish-cos.ts b/apps/desktop/scripts/desktop-publish-cos.ts new file mode 100644 index 0000000000..5b89a8ef40 --- /dev/null +++ b/apps/desktop/scripts/desktop-publish-cos.ts @@ -0,0 +1,84 @@ +/** COS transport for resumable Desktop publication; callers authorize writes and own the channel lock. */ +import { createHash } from 'node:crypto' +import { createReadStream } from 'node:fs' +import { Readable, Writable } from 'node:stream' +import type COS from 'cos-nodejs-sdk-v5' +import { createDesktopCos, DESKTOP_COS_REGION, type DesktopCosCredentials } from './desktop-cos.ts' +import { cosOperation } from './cos-operation.ts' +import type { DesktopUploadPlan } from './desktop-upload-plan.ts' +import { desktopArtifactDigest, type DesktopPublishTransport } from './desktop-publish.ts' + +function missing(error: unknown): boolean { + return typeof error === 'object' && error !== null && 'code' in error && error.code === 'NoSuchKey' +} + +/** + * Create a bounded COS transport restricted to the validated plan's object keys. + * @param plan Destination and exact object inventory; arbitrary keys are rejected. + * @param credentials Credentials for this deployment only. + * @param createClient Client factory; absent uses the shared production client, tests supply a loopback client. + * @returns Streaming reads and single-attempt writes; transport construction performs no requests. + */ +export function desktopPublishCos( + plan: DesktopUploadPlan, credentials: DesktopCosCredentials, + createClient: (credentials: DesktopCosCredentials) => COS = createDesktopCos, +): DesktopPublishTransport { + const keys = new Map(plan.artifacts.map(artifact => [artifact.key, artifact.channelMetadata])) + const client = () => createClient(credentials) + const location = (key: string) => { + if (!keys.has(key)) throw new Error('desktop publish: object is outside the selected plan') + return { Bucket: plan.bucket, Region: DESKTOP_COS_REGION, Key: key } + } + return { + async read(key, channel) { + const address = location(key) + if (keys.get(key) !== channel) throw new Error('desktop publish: object role mismatch') + const sha512 = createHash('sha512') + const chunks: Buffer[] = [] + let size = 0 + const output = new Writable({ + write(bytes: Buffer, _encoding, done) { + size += bytes.length + if (channel && size > 1024 * 1024) { done(new Error('desktop publish: channel metadata exceeds 1 MiB')); return } + sha512.update(bytes) + if (channel) chunks.push(Buffer.from(bytes)) + done() + }, + }) + try { + const cos = client() + await cosOperation(cos, 900_000, () => cos.getObject({ ...address, Output: output })) + return { size, sha512: sha512.digest('base64'), ...(channel ? { contents: Buffer.concat(chunks).toString('utf8') } : {}) } + } catch (error) { + if (missing(error)) return null + throw error + } finally { output.destroy() } + }, + async put(artifact, digest, forbidOverwrite) { + const address = location(artifact.key) + if (keys.get(artifact.key) !== artifact.channelMetadata || forbidOverwrite === artifact.channelMetadata) { + throw new Error('desktop publish: object write policy mismatch') + } + const actual = await desktopArtifactDigest(artifact) + if (actual.size !== digest.size || actual.sha512 !== digest.sha512) throw new Error('desktop publish: local input changed') + const cos = client() + // COS overwrite prevention requires versioning to be absent, not enabled or suspended. + if (forbidOverwrite) { + const state = await cosOperation(cos, 30_000, () => cos.getBucketVersioning({ Bucket: plan.bucket, Region: DESKTOP_COS_REGION })) + if (state.statusCode !== 200 || state.VersioningConfiguration.Status !== undefined) { + throw new Error('desktop publish: immutable objects require bucket versioning to be disabled') + } + } + const md5 = createHash('md5') + const bytes = artifact.contents === undefined ? createReadStream(artifact.path) : [Buffer.from(artifact.contents)] + for await (const chunk of bytes) md5.update(chunk) + const body = artifact.contents === undefined ? createReadStream(artifact.path) : Readable.from([Buffer.from(artifact.contents)]) + try { + await cosOperation(cos, 900_000, () => cos.putObject({ + ...address, Body: body, ContentLength: digest.size, ContentType: artifact.contentType, + Headers: { 'Content-MD5': md5.digest('base64'), ...(forbidOverwrite ? { 'x-cos-forbid-overwrite': 'true' } : {}) }, + })) + } finally { body.destroy() } + }, + } +} diff --git a/apps/desktop/scripts/desktop-publish-record.ts b/apps/desktop/scripts/desktop-publish-record.ts new file mode 100644 index 0000000000..77ac3ca698 --- /dev/null +++ b/apps/desktop/scripts/desktop-publish-record.ts @@ -0,0 +1,110 @@ +/** Machine-local channel exclusion and durable, credential-free publication records. */ +import { createHash } from 'node:crypto' +import { appendFile, mkdir, mkdtemp, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises' +import { basename, join } from 'node:path' +import { z } from 'zod' +import type { DesktopPublishEvent } from './desktop-publish.ts' + +/** + * Execute one publication or rollback while excluding other writers of its exact destination. + * @param root Machine-owned lock directory shared by automatic and manual entrypoints. + * @param destination Bucket and channel prefix identifying the serialized destination. + * @param operation Work to finish before releasing the lock. + * @returns Operation result. A held or abandoned lock rejects; operators inspect stale locks explicitly. + */ +export async function withDesktopChannelLock(root: string, destination: string, operation: () => Promise): Promise { + await mkdir(root, { recursive: true, mode: 0o700 }) + const path = join(root, `${createHash('sha256').update(destination).digest('hex')}.lock`) + try { await mkdir(path, { mode: 0o700 }) } catch (cause) { + throw new Error(`desktop publish: channel is locked or inaccessible; inspect ${path}`, { cause }) + } + try { + await writeFile(join(path, 'owner.json'), JSON.stringify({ pid: process.pid, + startedAt: new Date().toISOString(), destination }), { flag: 'wx', mode: 0o600, flush: true }) + return await operation() + } finally { await rm(path, { recursive: true }) } +} + +/** An operation journal retained independently of disposable checkout and artifact files. */ +export interface DesktopPublicationRecord { + readonly directory: string + record(event: DesktopPublishEvent): Promise + finish(status: 'complete' | 'preview' | 'failed' | 'published-tag-pending'): Promise +} + +/** + * Allocate a private journal before performing any remote writes. + * @param root Persistent audit root, outside the build checkout. + * @param identity Public source, actor and destination metadata; never pass credentials. + * @returns Durable event writer and explicit terminal-status writer. + */ +export async function createDesktopPublicationRecord( + root: string, + identity: { repository: string; runId: string; attempt: string; actor: string; destination: string; operation: 'publish' | 'rollback'; reason: string }, +): Promise { + await mkdir(root, { recursive: true, mode: 0o700 }) + const staging = await mkdtemp(join(root, '.initializing-')) + const directory = join(root, `${identity.operation}-${basename(staging).slice('.initializing-'.length)}`) + const startedAt = new Date().toISOString() + try { + await writeFile(join(staging, 'operation.json'), `${JSON.stringify({ ...identity, + startedAt }, null, 2)}\n`, { flag: 'wx', mode: 0o600, flush: true }) + await writeFile(join(staging, 'events.jsonl'), '', { flag: 'wx', mode: 0o600, flush: true }) + await rename(staging, directory) + } catch (error) { + await rm(staging, { recursive: true, force: true }) + throw error + } + return { + directory, + async record(event) { + await appendFile(join(directory, 'events.jsonl'), `${JSON.stringify({ ...event, at: new Date().toISOString() })}\n`, { flush: true }) + }, + async finish(status) { + await writeFile(join(directory, 'result.json'), `${JSON.stringify({ status, + startedAt, finishedAt: new Date().toISOString() })}\n`, { flag: 'wx', mode: 0o600, flush: true }) + }, + } +} + +/** + * Find durable evidence that this immutable attempt finished updating its feeds. + * @param root Persistent publication journal root. + * @param expected Exact source and destination for the current retained package. + * @returns True permits tag-only recovery; false requires normal remote reconciliation. + */ +export async function wasDesktopAttemptPublished(root: string, expected: { + repository: string + runId: string + attempt: string + destination: string +}): Promise { + let entries + try { entries = await readdir(root, { withFileTypes: true }) } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') return false + throw error + } + for (const entry of entries) { + if (!entry.isDirectory() || !entry.name.startsWith('publish-')) continue + const directory = join(root, entry.name) + let operation + try { operation = JSON.parse(await readFile(join(directory, 'operation.json'), 'utf8')) } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') continue + throw error + } + const parsed = z.object({ operation: z.string(), repository: z.string(), + runId: z.string(), attempt: z.string(), destination: z.string() }).parse(operation) + if (parsed.operation !== 'publish' || parsed.repository !== expected.repository || parsed.runId !== expected.runId + || parsed.attempt !== expected.attempt || parsed.destination !== expected.destination) continue + let events + try { events = await readFile(join(directory, 'events.jsonl'), 'utf8') } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') continue + throw error + } + // Only newline-terminated entries were completely appended before an interruption. + for (const line of events.split('\n').slice(0, -1)) { + if (z.object({ type: z.string() }).parse(JSON.parse(line)).type === 'feeds-complete') return true + } + } + return false +} diff --git a/apps/desktop/scripts/desktop-publish-route.ts b/apps/desktop/scripts/desktop-publish-route.ts new file mode 100644 index 0000000000..46c869c4be --- /dev/null +++ b/apps/desktop/scripts/desktop-publish-route.ts @@ -0,0 +1,66 @@ +/** Validate a completed packaging attempt and route publication back to its original runner. */ +import { z } from 'zod' + +const positive = z.number().int().positive() +const runSchema = z.object({ + id: positive, run_attempt: positive, head_sha: z.string().regex(/^[a-f0-9]{40}$/u), + path: z.string(), conclusion: z.literal('success'), event: z.literal('workflow_dispatch'), + repository: z.object({ full_name: z.string() }), +}) +const requestSchema = z.object({ source: z.object({ + repository: z.string(), runId: z.string(), attempt: z.string(), commit: z.string().regex(/^[a-f0-9]{40}$/u), + workflowCommit: z.string(), sourceRef: z.string().regex(/^(?:master|dsh-v[0-9][0-9A-Za-z.-]*)$/u), + channel: z.enum(['nightly', 'latest']), + runnerName: z.string(), target: z.enum(['mac-arm64', 'win-x64']), preset: z.enum(['test', 'production']), + publishRequested: z.literal(true), qualification: z.enum(['signed', 'notarized']), +}) }) +const jobsSchema = z.array(z.object({ runner_name: z.string().nullable(), conclusion: z.string().nullable() })) +const routesSchema = z.record(z.string(), z.object({ label: z.string().regex(/^dsh-[a-z0-9-]+$/u), target: z.enum(['mac-arm64', 'win-x64']) })) + +/** Validated values handed to the privileged publisher; no caller-selected shell fragments. */ +export interface DesktopPublishRoute { + readonly runId: string + readonly attempt: string + readonly commit: string + readonly sourceRef: string + readonly channel: 'nightly' | 'latest' + readonly runnerName: string + readonly runnerLabel: string + readonly target: 'mac-arm64' | 'win-x64' + readonly preset: 'test' | 'production' +} + +/** + * Match a public publication request to GitHub's attempt/job evidence and configured machine labels. + * @param repository Current repository, not a value selected by the artifact. + * @param runValue GitHub response for the exact packaging attempt. + * @param requestValue Retained manifest downloaded from that attempt's request artifact. + * @param jobsValue GitHub jobs for the same packaging attempt. + * @param routesValue Administrator-maintained runner-name to unique-label mapping. + * @returns Route to the original machine. Unknown runners, modes and mismatches reject. + */ +export function resolveDesktopPublishRoute( + repository: string, runValue: unknown, requestValue: unknown, jobsValue: unknown, routesValue: unknown, +): DesktopPublishRoute { + const run = runSchema.parse(runValue) + const { source } = requestSchema.parse(requestValue) + const jobs = jobsSchema.parse(jobsValue) + const routes = routesSchema.parse(routesValue) + const workflow = source.target === 'win-x64' ? '.github/workflows/windows-package.yml' : '.github/workflows/macos-package.yml' + if (run.repository.full_name !== repository || source.repository !== repository || run.path !== workflow + || source.runId !== String(run.id) || source.attempt !== String(run.run_attempt) + || source.workflowCommit !== run.head_sha + || source.qualification !== (source.target === 'win-x64' ? 'signed' : 'notarized')) { + throw new Error('desktop publish: request does not match the successful packaging attempt') + } + const route = routes[source.runnerName] + if (!route || route.target !== source.target) throw new Error('desktop publish: original machine has no configured publishing route') + if (new Set(Object.values(routes).map(value => value.label)).size !== Object.keys(routes).length) { + throw new Error('desktop publish: each configured machine needs its own unique runner label') + } + const matches = jobs.filter(job => job.runner_name === source.runnerName && job.conclusion === 'success') + if (matches.length !== 1) throw new Error('desktop publish: original job does not establish a single successful packaging runner') + return { runId: source.runId, attempt: source.attempt, commit: source.commit, sourceRef: source.sourceRef, channel: source.channel, + runnerName: source.runnerName, + runnerLabel: route.label, target: source.target, preset: source.preset } +} diff --git a/apps/desktop/scripts/desktop-publish-tag.ts b/apps/desktop/scripts/desktop-publish-tag.ts new file mode 100644 index 0000000000..f47cc698a6 --- /dev/null +++ b/apps/desktop/scripts/desktop-publish-tag.ts @@ -0,0 +1,47 @@ +/** Create immutable Desktop release tags using the explicitly supplied GitHub App installation token. */ +import { valid } from 'semver' +import { z } from 'zod' +import { desktopReleaseTag } from './desktop-release-tag.ts' + +const refSchema = z.object({ object: z.object({ type: z.string(), sha: z.string().regex(/^[a-f0-9]{40}$/u) }) }) + +/** + * Create a lightweight release tag, or verify that an existing tag names the same packaged commit. + * @param repository Owner/name of the package source repository. + * @param version Validated packaged version. + * @param commit Actual packaged commit, never the publishing workflow checkout commit. + * @param installationToken Explicit GitHub App token; no ambient authentication fallback is used. + * @param request HTTP implementation; defaults to fetch, tests inject an isolated fake. + * @returns Created or already-present status. Conflicts and unknown responses reject without updating a ref. + */ +export async function publishDesktopTag( + repository: string, version: string, commit: string, installationToken: string, + request: typeof fetch = fetch, +): Promise<'created' | 'present'> { + if (!/^[\w.-]+\/[\w.-]+$/u.test(repository) || valid(version) !== version || !/^[a-f0-9]{40}$/u.test(commit)) { + throw new Error('desktop tag: invalid repository, version or packaged commit') + } + if (!installationToken.trim()) throw new Error('desktop tag: GitHub App installation token is required') + const tag = desktopReleaseTag(version) + const base = `https://api.github.com/repos/${repository}/git` + const headers = { Authorization: `Bearer ${installationToken}`, Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28' } + const get = () => request(`${base}/ref/tags/${encodeURIComponent(tag)}`, { headers, redirect: 'error', signal: AbortSignal.timeout(30_000) }) + const matches = async (response: Response) => { + if (!response.ok) throw new Error(`desktop tag: cannot verify tag (HTTP ${response.status})`) + const ref = refSchema.parse(await response.json()) + if (ref.object.type !== 'commit') throw new Error('desktop tag: existing ref is not a lightweight commit tag; inspect it manually, refusing to replace it') + if (ref.object.sha !== commit) throw new Error('desktop tag: existing tag points to another commit; refusing to move it') + } + const existing = await get() + if (existing.status !== 404) { await matches(existing); return 'present' } + await existing.body?.cancel() + const response = await request(`${base}/refs`, { + method: 'POST', headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ ref: `refs/tags/${tag}`, sha: commit }), redirect: 'error', signal: AbortSignal.timeout(30_000), + }) + if (response.status === 201) { await matches(response); return 'created' } + // A parallel platform release may have created the same version tag after our first read. + if (response.status === 422) { await response.body?.cancel(); await matches(await get()); return 'present' } + await response.body?.cancel() + throw new Error(`desktop tag: creation failed (HTTP ${response.status}); reconcile the tag before retrying`) +} diff --git a/apps/desktop/scripts/desktop-publish.ts b/apps/desktop/scripts/desktop-publish.ts new file mode 100644 index 0000000000..1270f7229e --- /dev/null +++ b/apps/desktop/scripts/desktop-publish.ts @@ -0,0 +1,132 @@ +/** Resume Desktop publication by reconciling object bytes before changing update feeds. */ +import { createHash } from 'node:crypto' +import { createReadStream } from 'node:fs' +import { load } from 'js-yaml' +import { gt, valid } from 'semver' +import { z } from 'zod' +import type { DesktopUploadArtifact, DesktopUploadPlan } from './desktop-upload-plan.ts' + +/** File identity computed from bytes, never inferred from COS ETags. */ +export interface DesktopObjectDigest { readonly size: number; readonly sha512: string } +/** Remote object observation; channel objects additionally carry their exact YAML. */ +export interface DesktopRemoteObject extends DesktopObjectDigest { readonly contents?: string } +/** Transport owned by the caller holding the destination channel lock. */ +export interface DesktopPublishTransport { + /** Read and hash remote bytes; only a confirmed missing object returns null. */ + read(key: string, channel: boolean): Promise + /** Write once. Immutable binaries must use COS overwrite prevention. */ + put(artifact: DesktopUploadArtifact, digest: DesktopObjectDigest, forbidOverwrite: boolean): Promise +} +/** Credential-free, durably flushed publication event. */ +export interface DesktopPublishEvent { + readonly type: 'object-present' | 'put-intent' | 'put-verified' | 'feeds-complete' | 'tag-pending' | 'complete' | 'failed' + readonly failure?: { readonly phase: string; readonly code: string; readonly message: string } + readonly key?: string + readonly before?: string | null + readonly after?: string +} +/** Caller owns audit persistence and the separately authorized GitHub App identity. */ +export interface DesktopPublishOperations { + readonly transport: DesktopPublishTransport + record(event: DesktopPublishEvent): Promise + /** Ensure the production tag names the packaged commit; never move an existing tag. */ + tag(): Promise +} +/** Published files and feeds can succeed even when tag creation remains outstanding. */ +export type DesktopPublishResult = { readonly status: 'complete' } | { readonly status: 'published-tag-pending'; readonly cause: unknown } + +/** A publication failure that requires reconciliation rather than a blind retry. */ +export class DesktopPublishError extends Error { + constructor(readonly code: 'conflict' | 'superseded' | 'invalid-plan' | 'remote-changed' | 'verification-failed', message: string) { + super(message) + this.name = 'DesktopPublishError' + } +} + +/** + * Hash exactly the file or generated metadata selected by a validated upload plan. + * @param artifact File-backed installer or in-memory channel metadata. + * @returns Byte length and SHA-512 for input reconciliation. + */ +export async function desktopArtifactDigest(artifact: DesktopUploadArtifact): Promise { + const hash = createHash('sha512') + let size = 0 + const stream = artifact.contents === undefined ? createReadStream(artifact.path) : [Buffer.from(artifact.contents)] + for await (const bytes of stream) { hash.update(bytes); size += bytes.length } + return { size, sha512: hash.digest('base64') } +} + +function same(left: DesktopObjectDigest | null, right: DesktopObjectDigest | null): boolean { + return left === null || right === null ? left === right : left.size === right.size && left.sha512 === right.sha512 +} + +function version(contents: string, remote = false): string { + try { + const parsed = z.object({ version: z.string() }).parse(load(contents)) + if (valid(parsed.version) !== null) return parsed.version + } catch (error) { /* Discard parser error details, which may include raw remote YAML. */ } + throw new DesktopPublishError(remote ? 'verification-failed' : 'invalid-plan', + `desktop publish: invalid ${remote ? 'remote' : 'planned'} channel version`) +} + +/** + * Reconcile every binary, then publish feeds, then create the production tag. + * The caller must hold the channel lock through this entire operation. No writes are retried here; + * a later invocation hashes remote bytes to recover confirmed and ambiguous earlier writes. + * @param plan Validated package plan containing both binaries and channel metadata. + * @param operations Locked transport, durable audit sink, and production tag operation. + * @returns Complete or published-with-tag-pending; other failures stop all dependent work. + */ +export async function publishDesktopRelease(plan: DesktopUploadPlan, operations: DesktopPublishOperations): Promise { + const files = plan.artifacts.filter(file => !file.channelMetadata) + const feeds = plan.artifacts.filter(file => file.channelMetadata) + if (files.length === 0 || feeds.length === 0 || new Set(plan.artifacts.map(file => file.key)).size !== plan.artifacts.length) { + throw new DesktopPublishError('invalid-plan', 'desktop publish: complete files and update feeds are required') + } + if (plan.environment === 'production' && (!plan.commit || !/^[a-f0-9]{40}$/u.test(plan.commit) || plan.dirty !== false)) { + throw new DesktopPublishError('invalid-plan', 'desktop publish: production requires a clean packaged commit') + } + const snapshots = new Map() + // Reject obsolete requests before even uploading their missing binary files. + for (const feed of feeds) { + if (feed.contents === undefined || version(feed.contents) !== plan.version) { + throw new DesktopPublishError('invalid-plan', 'desktop publish: channel version differs from package') + } + const remote = await operations.transport.read(feed.key, true) + if (remote !== null) { + if (remote.contents === undefined) throw new DesktopPublishError('verification-failed', 'desktop publish: channel bytes were not returned') + if (gt(version(remote.contents, true), plan.version)) throw new DesktopPublishError('superseded', 'desktop publish: a newer version already owns this channel') + if (version(remote.contents, true) === plan.version && !same(remote, await desktopArtifactDigest(feed))) { + throw new DesktopPublishError('conflict', 'desktop publish: same version has different channel metadata') + } + } + snapshots.set(feed.key, remote) + } + for (const artifact of [...files, ...feeds]) { + const digest = await desktopArtifactDigest(artifact) + const remote = await operations.transport.read(artifact.key, artifact.channelMetadata) + if (same(remote, digest)) { + await operations.record({ type: 'object-present', key: artifact.key }) + continue + } + if (!artifact.channelMetadata && remote !== null) throw new DesktopPublishError('conflict', `desktop publish: object differs: ${artifact.key}`) + if (artifact.channelMetadata && !same(remote, snapshots.get(artifact.key) ?? null)) { + throw new DesktopPublishError('remote-changed', 'desktop publish: channel changed after preflight; inspect before retrying') + } + await operations.record({ type: 'put-intent', key: artifact.key, + ...(artifact.channelMetadata ? { before: remote?.contents ?? null, after: artifact.contents } : {}) }) + await operations.transport.put(artifact, digest, !artifact.channelMetadata) + const actual = await operations.transport.read(artifact.key, artifact.channelMetadata) + if (!same(actual, digest)) throw new DesktopPublishError('verification-failed', `desktop publish: remote bytes differ: ${artifact.key}`) + await operations.record({ type: 'put-verified', key: artifact.key }) + } + await operations.record({ type: 'feeds-complete' }) + if (plan.environment === 'production') { + try { await operations.tag() } catch (cause) { + await operations.record({ type: 'tag-pending' }) + return { status: 'published-tag-pending', cause } + } + } + await operations.record({ type: 'complete' }) + return { status: 'complete' } +} diff --git a/apps/desktop/scripts/desktop-release-store.ts b/apps/desktop/scripts/desktop-release-store.ts new file mode 100644 index 0000000000..904d65af07 --- /dev/null +++ b/apps/desktop/scripts/desktop-release-store.ts @@ -0,0 +1,143 @@ +/** Retain immutable Desktop build files outside disposable runner checkouts. */ +import { createHash } from 'node:crypto' +import { createReadStream, constants } from 'node:fs' +import { copyFile, lstat, mkdir, mkdtemp, readFile, realpath, rename, rm, writeFile } from 'node:fs/promises' +import { basename, isAbsolute, join } from 'node:path' +import { z } from 'zod' + +const positiveId = z.string().regex(/^[1-9]\d*$/u) +const filename = z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/u) +const sourceSchema = z.strictObject({ + repository: z.string().regex(/^[\w.-]+\/[\w.-]+$/u), + runId: positiveId, + attempt: positiveId, + commit: z.string().regex(/^[a-f0-9]{40}$/u), + workflowCommit: z.string().regex(/^[a-f0-9]{40}$/u), + sourceRef: z.string().regex(/^(?:master|dsh-v[0-9][0-9A-Za-z.-]*)$/u), + channel: z.enum(['nightly', 'latest']), + runnerName: z.string().min(1), + // Storage accepts every Desktop target; CI routing separately limits currently provisioned platforms. + target: z.enum(['mac-arm64', 'mac-x64', 'win-x64']), + preset: z.enum(['test', 'production']), + productVersion: z.string().min(1), + version: z.string().min(1), + testReleaseId: z.string().regex(/^[a-f0-9]{32}$/u).nullable(), + publishRequested: z.boolean(), + qualification: z.enum(['signed', 'notarized', 'unsigned', 'unnotarized']), +}) +const manifestSchema = z.strictObject({ + schemaVersion: z.literal(1), + source: sourceSchema, + files: z.array(z.strictObject({ name: filename, size: z.number().int().positive(), + sha512: z.string().regex(/^[A-Za-z0-9+/]{86}==$/u) })).min(1), +}) + +/** Public, credential-free identity of one packaging attempt. */ +export type DesktopRetainedSource = z.infer +/** File inventory bound to the source packaging attempt. */ +export type DesktopRetainedManifest = z.infer + +async function fingerprint(path: string) { + const info = await lstat(path) + if (!info.isFile() || info.isSymbolicLink()) throw new Error('desktop release store: expected a regular file') + const hash = createHash('sha512') + let size = 0 + for await (const bytes of createReadStream(path)) { size += bytes.length; hash.update(bytes) } + if (size === 0) throw new Error('desktop release store: empty artifact') + return { size, sha512: hash.digest('base64') } +} + +async function directory(path: string): Promise { + const info = await lstat(path) + if (!info.isDirectory() || info.isSymbolicLink()) throw new Error('desktop release store: expected a real directory') + return realpath(path) +} + +/** + * Resolve an attempt beneath an absolute, machine-owned releases root. + * @param root Persistent releases directory, outside the checkout. + * @param runId GitHub packaging run ID. + * @param attempt Packaging attempt, not the publishing attempt. + * @returns Deterministic path after validating both identifiers. + */ +export function retainedDesktopPath(root: string, runId: string, attempt: string): string { + if (!isAbsolute(root)) throw new Error('desktop release store: root must be absolute') + positiveId.parse(runId) + positiveId.parse(attempt) + return join(root, runId, attempt) +} + +/** + * Copy an explicit file inventory, verify the copies, then expose one complete attempt atomically. + * @param root Persistent releases root owned by this runner. + * @param source Packaging identity and selected public settings. + * @param paths Complete artifact and completion-record paths; directory trees are not accepted. + * @returns Published attempt directory. An existing attempt is never replaced. + */ +export async function retainDesktopRelease(root: string, source: DesktopRetainedSource, paths: readonly string[]): Promise { + sourceSchema.parse(source) + const destination = retainedDesktopPath(root, source.runId, source.attempt) + const names = paths.map(path => filename.parse(basename(path))) + if (names.length === 0 || new Set(names).size !== names.length) throw new Error('desktop release store: empty or duplicate file inventory') + await mkdir(root, { recursive: true, mode: 0o700 }) + await directory(root) + const parent = join(root, source.runId) + await mkdir(parent, { recursive: true, mode: 0o700 }) + await directory(parent) + const claim = join(parent, `${source.attempt}.lock`) + await mkdir(claim, { mode: 0o700 }) + let staging: string | undefined + try { + try { + await lstat(destination) + throw new Error('desktop release store: attempt already exists') + } catch (error) { + if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) throw error + } + staging = await mkdtemp(join(parent, '.retaining-')) + const files = [] + for (const path of paths) { + const name = basename(path) + if (name === 'manifest.json') throw new Error('desktop release store: manifest filename is reserved') + const before = await fingerprint(path) + const copied = join(staging, name) + await copyFile(path, copied, constants.COPYFILE_EXCL) + const after = await fingerprint(copied) + if (before.size !== after.size || before.sha512 !== after.sha512) throw new Error('desktop release store: source changed during retention') + files.push({ name, ...after }) + } + const manifest: DesktopRetainedManifest = { schemaVersion: 1, source, files } + manifestSchema.parse(manifest) + await writeFile(join(staging, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`, { flag: 'wx', mode: 0o600, flush: true }) + await rename(staging, destination) + staging = undefined + return destination + } finally { + if (staging !== undefined) await rm(staging, { recursive: true, force: true }) + await rm(claim, { recursive: true }) + } +} + +/** + * Read an attempt and rehash every retained file before a consumer uses it. + * @param root Persistent releases root. + * @param runId Expected source run ID. + * @param attempt Expected source attempt. + * @returns Parsed inventory; missing, linked, changed or mismatched files reject. + */ +export async function verifyRetainedDesktopRelease(root: string, runId: string, attempt: string): Promise { + const path = retainedDesktopPath(root, runId, attempt) + await directory(root) + await directory(join(root, runId)) + await directory(path) + const manifestPath = join(path, 'manifest.json') + if (!(await lstat(manifestPath)).isFile() || (await lstat(manifestPath)).isSymbolicLink()) throw new Error('desktop release store: invalid manifest file') + const manifest = manifestSchema.parse(JSON.parse(await readFile(manifestPath, 'utf8'))) + if (manifest.source.runId !== runId || manifest.source.attempt !== attempt) throw new Error('desktop release store: attempt identity mismatch') + if (new Set(manifest.files.map(file => file.name)).size !== manifest.files.length) throw new Error('desktop release store: duplicate file inventory') + for (const file of manifest.files) { + const current = await fingerprint(join(path, file.name)) + if (current.size !== file.size || current.sha512 !== file.sha512) throw new Error(`desktop release store: changed file ${file.name}`) + } + return manifest +} diff --git a/apps/desktop/scripts/desktop-rollback.ts b/apps/desktop/scripts/desktop-rollback.ts new file mode 100644 index 0000000000..a96e5d652b --- /dev/null +++ b/apps/desktop/scripts/desktop-rollback.ts @@ -0,0 +1,58 @@ +/** Restore historical update feeds without uploading binaries or moving release tags. */ +import { load } from 'js-yaml' +import { z } from 'zod' +import type { DesktopUploadPlan } from './desktop-upload-plan.ts' +import { desktopArtifactDigest, type DesktopPublishOperations, type DesktopRemoteObject } from './desktop-publish.ts' + +/** Outcome of an explicitly requested channel restoration. */ +export interface DesktopRollbackResult { + readonly status: 'preview' | 'complete' + readonly feeds: readonly { key: string; before: string; after: string }[] +} + +/** + * Verify historical binaries and restore only the selected feed YAML under a caller-held channel lock. + * @param plan Previously published release reconstructed from retained immutable files. + * @param expectedVersion Version the operator expects to find online; another version stops the operation. + * @param execute False only inspects and previews; true writes after all historical bytes are verified. + * @param operations Remote transport and audit sink. The tag operation is never called. + * @returns Before/after YAML and whether it was written. Partial writes remain recorded for reconciliation. + */ +export async function rollbackDesktopChannel( + plan: DesktopUploadPlan, expectedVersion: string, execute: boolean, operations: DesktopPublishOperations, +): Promise { + const files = plan.artifacts.filter(file => !file.channelMetadata) + const feeds = plan.artifacts.filter(file => file.channelMetadata) + if (files.length === 0 || feeds.length === 0) throw new Error('desktop rollback: a complete historical release is required') + const equal = (left: DesktopRemoteObject | null, right: { size: number; sha512: string }) => + left !== null && left.size === right.size && left.sha512 === right.sha512 + for (const file of files) { + if (!equal(await operations.transport.read(file.key, false), await desktopArtifactDigest(file))) { + throw new Error(`desktop rollback: historical object is missing or different: ${file.key}`) + } + } + const changes = [] + for (const feed of feeds) { + const before = await operations.transport.read(feed.key, true) + if (!before || before.contents === undefined || feed.contents === undefined) throw new Error('desktop rollback: channel YAML is unavailable') + if (equal(before, await desktopArtifactDigest(feed))) { + await operations.record({ type: 'object-present', key: feed.key }) + continue + } + const current = z.object({ version: z.string() }).parse(load(before.contents)).version + if (current !== expectedVersion) throw new Error('desktop rollback: current version differs from the expected version') + changes.push({ key: feed.key, before: before.contents, after: feed.contents, observed: before, artifact: feed }) + } + if (execute) { + for (const change of changes) { + if (!equal(await operations.transport.read(change.key, true), change.observed)) throw new Error('desktop rollback: channel changed after preview') + await operations.record({ type: 'put-intent', key: change.key, before: change.before, after: change.after }) + const digest = await desktopArtifactDigest(change.artifact) + await operations.transport.put(change.artifact, digest, false) + if (!equal(await operations.transport.read(change.key, true), digest)) throw new Error('desktop rollback: restored channel could not be verified') + await operations.record({ type: 'put-verified', key: change.key }) + } + await operations.record({ type: 'complete' }) + } + return { status: execute ? 'complete' : 'preview', feeds: changes.map(({ key, before, after }) => ({ key, before, after })) } +} diff --git a/apps/desktop/scripts/desktop-upload-plan.ts b/apps/desktop/scripts/desktop-upload-plan.ts index 439a4fa6e1..aa05966c4a 100644 --- a/apps/desktop/scripts/desktop-upload-plan.ts +++ b/apps/desktop/scripts/desktop-upload-plan.ts @@ -5,7 +5,6 @@ import { createReadStream } from 'node:fs' import { readFile, stat } from 'node:fs/promises' import { basename, join, resolve } from 'node:path' import { dump, load } from 'js-yaml' -import { prerelease } from 'semver' import type { DesktopPackageTargetName } from './package-target.ts' import { desktopBuildRecordFilename, @@ -63,6 +62,8 @@ export interface DesktopUploadPlanOptions { readonly repositoryRoot?: string readonly appRoot?: string readonly artifactsRoot?: string + /** Original product version from a verified retained build; absent reads the checkout manifests. */ + readonly packagedProductVersion?: string } interface UpdateFileInfo { @@ -188,8 +189,8 @@ export async function createDesktopUploadPlan( const repositoryRoot = options.repositoryRoot ?? REPOSITORY_ROOT const appRoot = options.appRoot ?? APP_ROOT const artifactsRoot = options.artifactsRoot ?? desktopTargetBuildPaths(targetName).artifacts - const dshVersion = await manifestVersion(join(repositoryRoot, 'package.json'), 'dsh package') - const desktopVersion = await manifestVersion(join(appRoot, 'package.json'), 'desktop package') + const dshVersion = options.packagedProductVersion ?? await manifestVersion(join(repositoryRoot, 'package.json'), 'dsh package') + const desktopVersion = options.packagedProductVersion ?? await manifestVersion(join(appRoot, 'package.json'), 'desktop package') if (dshVersion !== desktopVersion) { throw new Error(`desktop upload: desktop version ${desktopVersion} does not match current dsh version ${dshVersion}`) } @@ -211,12 +212,14 @@ export async function createDesktopUploadPlan( } if (buildRecord.schemaVersion !== 1 || buildRecord.target !== targetName + || (buildRecord.channel ?? 'nightly') !== update.channel || buildRecord.environment !== update.environment || buildRecord.publicUrl !== update.publicUrl) { throw new Error(`desktop upload: ${targetName} package completion record for ${buildVersion} does not match the ${update.environment} update destination`) } - const metadataFilename = desktopUpdateMetadataFilename(buildVersion, target.platform) + if (update.channel === 'latest' && buildVersion.includes('-')) throw new Error('desktop upload: latest requires a stable version') + const metadataFilename = desktopUpdateMetadataFilename(buildVersion, target.platform, update.channel) const metadataPath = join(artifactsRoot, metadataFilename) let metadataValue: unknown try { @@ -274,10 +277,6 @@ export async function createDesktopUploadPlan( contents: dump(published), } artifacts.push(channelArtifact) - if (prerelease(buildVersion) === null) { - const stableFilename = metadataFilename.replace('nightly', 'latest') - artifacts.push({ ...channelArtifact, filename: stableFilename, key: `${update.keyPrefix}/${stableFilename}` }) - } const latestFilename = `dsh-latest-${target.platform === 'darwin' ? 'macos' : 'windows'}-${target.arch}.${target.platform === 'darwin' ? 'dmg' : 'exe'}` const latestKey = `desktop/${latestFilename}` return { diff --git a/apps/desktop/scripts/dev.ts b/apps/desktop/scripts/dev.ts index 1ae2f3e952..37515c20b0 100644 --- a/apps/desktop/scripts/dev.ts +++ b/apps/desktop/scripts/dev.ts @@ -93,7 +93,7 @@ async function launchElectron(): Promise { async function main(): Promise { const { values } = parseArgs({ options: { 'skip-build': { type: 'boolean', default: false } } }) if (!values['skip-build']) { - await runPackageScript('build', REPOSITORY_ROOT) + await runPackageScript('build:artifacts', REPOSITORY_ROOT) await runPackageScript('build', APP_ROOT) } for (const path of [ diff --git a/apps/desktop/scripts/electron-builder-config.mjs b/apps/desktop/scripts/electron-builder-config.mjs index 747d20d841..4b9cd27a8d 100644 --- a/apps/desktop/scripts/electron-builder-config.mjs +++ b/apps/desktop/scripts/electron-builder-config.mjs @@ -12,7 +12,7 @@ import { resolveMacOSSigningEnvironment, } from './desktop-release-environment.mjs' import { notarizeMacOSDiskImageArtifact } from './notarize-macos-disk-images.mjs' -import { verifyMacOSSignatureAfterSign } from './verify-macos-signature.mjs' +import { verifyMacOSDiskImageSignature, verifyMacOSSignatureAfterSign } from './verify-macos-signature.mjs' import { createWindowsTokenSigner, installWindowsNsisBootstrapSigner, @@ -23,7 +23,7 @@ import { resolveDesktopAutoUpdateConfig } from './desktop-auto-update-environmen import { resolveDesktopBuildCommit } from './desktop-build-commit.mjs' import { resolveDesktopBuildVersion } from './desktop-build-version.mjs' import { resolveDesktopPolicyEnvironment } from './desktop-policy-environment.mjs' -import { desktopTargetBuildPaths, resolveDesktopBuildTarget } from './desktop-build-paths.mjs' +import { desktopArtifactOutput, desktopTargetBuildPaths, resolveDesktopBuildTarget } from './desktop-build-paths.mjs' import { installWindowsDirectoryInstaller } from './windows-directory-installer.mjs' import { preserveWindowsRuntimeSignature, signWindowsCode } from './windows-runtime-signature.mjs' import { prepareWindowsAsarUnpack, verifyWindowsAsarUnpack } from './windows-asar-unpack.mjs' @@ -58,13 +58,19 @@ export function createElectronBuilderConfig( throw new Error('desktop package: DSH_DESKTOP_UNSIGNED must be 0 or 1') } const unsigned = env.DSH_DESKTOP_UNSIGNED === '1' + const skipNotarization = env.DSH_DESKTOP_MACOS_SKIP_NOTARIZATION === '1' + if (env.DSH_DESKTOP_MACOS_SKIP_NOTARIZATION !== undefined && !['0', '1'].includes(env.DSH_DESKTOP_MACOS_SKIP_NOTARIZATION)) { + throw new Error('desktop package: DSH_DESKTOP_MACOS_SKIP_NOTARIZATION must be 0 or 1') + } + if (skipNotarization && (unsigned || resolvedPlatform !== 'darwin')) throw new Error('Skipping notarization requires a signed macOS build') const packagesMacOS = targetPlatform === 'darwin' || (targetPlatform === undefined && hostPlatform === 'darwin') const packagesWindows = resolvedPlatform === 'win32' const policy = packagesMacOS && unsigned ? undefined : resolveDesktopPolicyEnvironment(env) if (resolvedPlatform === 'win32') installWindowsDirectoryInstaller() const macOSSigning = packagesMacOS && !unsigned ? resolveMacOSSigningEnvironment(env) : undefined - if (packagesMacOS && !unsigned) resolveMacOSNotarizationEnvironment(env) + if (packagesMacOS && !unsigned && !skipNotarization) resolveMacOSNotarizationEnvironment(env) const buildPaths = desktopTargetBuildPaths(resolveDesktopBuildTarget(env, hostPlatform, hostArch)) + const output = desktopArtifactOutput(buildPaths, { unsigned, skipNotarization }) let primaryRuntimeDestination let dshDestination let windowsCode = [] @@ -95,20 +101,22 @@ export function createElectronBuilderConfig( // the artifact names, the update feed, and the installed app.getVersion() the updater compares against. const productVersion = JSON.parse(readFileSync(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8')).version const buildVersion = resolveDesktopBuildVersion(env, productVersion) + if (update?.channel === 'latest' && buildVersion.includes('-')) throw new Error('desktop package: latest requires a stable version') const packaged = resolveDesktopBuildCommit(env) return { appId, protocols: [{ name: 'DeepSeek Harness', schemes: ['dsh'] }], extraMetadata: { dshDesktopAppId: appId, + dshDesktopUpdateChannel: update?.channel ?? 'nightly', ...(policy === undefined ? {} : { dshMandatoryUpdatePolicy: policy }), ...buildVersion === productVersion ? {} : { version: buildVersion }, ...packaged === undefined ? {} : { dshBuildCommit: packaged.commit, dshBuildDirty: packaged.dirty }, }, productName: 'DeepSeek Harness', // Unsigned builds carry their own suffix so a shared file can never pass for a release artifact. - artifactName: `deepseek-harness-\${version}-\${os}-\${arch}${unsigned ? '-unsigned' : ''}.\${ext}`, - directories: { output: unsigned ? buildPaths.unsignedArtifacts : buildPaths.artifacts }, + artifactName: `deepseek-harness-\${version}-\${os}-\${arch}${output.suffix}.\${ext}`, + directories: { output: output.directory }, asar: true, electronDist: buildPaths.electron, electronFuses: { runAsNode: true }, @@ -162,10 +170,11 @@ export function createElectronBuilderConfig( entitlementsInherit: fileURLToPath(new URL('./macos-entitlements.plist', import.meta.url)), // Prepared runtime files retain their signatures; PAK resources are sealed by their enclosing bundle. signIgnore: ['/Contents/Resources/app\\.asar\\.unpacked/dsh(?:/|$)', '/Contents/Resources/runtime/primary-runtime(?:/|$)', '\\.pak$'], - notarize: !unsigned, + notarize: !unsigned && !skipNotarization, target: unsigned ? ['dmg'] : ['dmg', 'zip'], }, dmg: { + format: 'ULMO', sign: !unsigned, writeUpdateInfo: false, }, @@ -216,6 +225,10 @@ export function createElectronBuilderConfig( }, artifactBuildCompleted: artifact => { if (unsigned || !artifact.file.endsWith('.dmg')) return + if (skipNotarization) { + verifyMacOSDiskImageSignature(artifact.file, macOSSigning ?? resolveMacOSSigningEnvironment(env)) + return + } return notarizeMacOSDiskImageArtifact( artifact, env, @@ -248,6 +261,6 @@ export function createElectronBuilderConfig( differentialPackage: true, }, detectUpdateChannel: false, - publish: update === undefined ? null : [{ provider: 'generic', url: update.publicUrl, channel: 'nightly' }], + publish: update === undefined ? null : [{ provider: 'generic', url: update.publicUrl, channel: update.channel }], } } diff --git a/apps/desktop/scripts/macos-app-update-config.d.mts b/apps/desktop/scripts/macos-app-update-config.d.mts index 624ab0d341..ce18266c48 100644 --- a/apps/desktop/scripts/macos-app-update-config.d.mts +++ b/apps/desktop/scripts/macos-app-update-config.d.mts @@ -1,13 +1,14 @@ /** Resolved fields required to embed a macOS updater feed. */ export interface MacOSAppUpdateFeed { readonly publicUrl: string + readonly channel?: 'nightly' | 'latest' } /** Packaged electron-updater configuration for macOS. */ export interface MacOSAppUpdateConfig { readonly provider: 'generic' readonly url: string - readonly channel: 'nightly' + readonly channel: 'nightly' | 'latest' readonly updaterCacheDirName: string } diff --git a/apps/desktop/scripts/macos-app-update-config.mjs b/apps/desktop/scripts/macos-app-update-config.mjs index 3b9e0019c6..d6b4132c71 100644 --- a/apps/desktop/scripts/macos-app-update-config.mjs +++ b/apps/desktop/scripts/macos-app-update-config.mjs @@ -5,7 +5,7 @@ import { join } from 'node:path' import { dump, load } from 'js-yaml' const CONFIG_FILENAME = 'app-update.yml' -const CHANNEL = 'nightly' +import { resolveDesktopUpdateChannel } from './desktop-auto-update-environment.mjs' function object(value, label) { if (typeof value !== 'object' || value === null || Array.isArray(value)) { @@ -24,30 +24,30 @@ function nonEmptyString(value, label) { /** * Resolve the one generic macOS feed from the final electron-builder configuration. * @param {unknown} publish - Final electron-builder publish setting. - * @returns {{ publicUrl: string }} Resolved feed used by the packaged App. + * @returns {{ publicUrl: string, channel?: 'nightly' | 'latest' }} Resolved feed used by the packaged App. */ export function resolveMacOSAppUpdateFeed(publish) { if (!Array.isArray(publish) || publish.length !== 1) { throw new Error('desktop macOS update config: publish must contain exactly one provider') } const provider = object(publish[0], 'publish provider') - if (provider.provider !== 'generic' || provider.channel !== CHANNEL) { - throw new Error('desktop macOS update config: publish provider must be generic Nightly') + if (provider.provider !== 'generic' || !['nightly', 'latest'].includes(provider.channel)) { + throw new Error('desktop macOS update config: publish provider must be generic with nightly or latest channel') } - return { publicUrl: nonEmptyString(provider.url, 'publish provider URL') } + return { publicUrl: nonEmptyString(provider.url, 'publish provider URL'), channel: resolveDesktopUpdateChannel({ DSH_DESKTOP_UPDATE_CHANNEL: provider.channel }) } } /** * Create the electron-updater configuration embedded before code signing. - * @param {{ publicUrl: string }} update - Resolved update feed. + * @param {{ publicUrl: string, channel?: 'nightly' | 'latest' }} update - Resolved update feed. * @param {string} updaterCacheDirName - electron-builder application cache directory. - * @returns {{ provider: 'generic', url: string, channel: 'nightly', updaterCacheDirName: string }} Packaged updater fields. + * @returns {{ provider: 'generic', url: string, channel: 'nightly' | 'latest', updaterCacheDirName: string }} Packaged updater fields. */ export function createMacOSAppUpdateConfig(update, updaterCacheDirName) { return { provider: 'generic', url: nonEmptyString(update.publicUrl, 'public URL'), - channel: CHANNEL, + channel: resolveDesktopUpdateChannel({ DSH_DESKTOP_UPDATE_CHANNEL: update.channel }), updaterCacheDirName: nonEmptyString(updaterCacheDirName, 'updater cache directory'), } } @@ -55,7 +55,7 @@ export function createMacOSAppUpdateConfig(update, updaterCacheDirName) { /** * Write the updater configuration into an assembled App before signing. * @param {string} resourcesDir - App Contents/Resources directory. - * @param {{ publicUrl: string }} update - Resolved update feed. + * @param {{ publicUrl: string, channel?: 'nightly' | 'latest' }} update - Resolved update feed. * @param {string} updaterCacheDirName - electron-builder application cache directory. * @returns {Promise} Resolves after the configuration is durable. */ @@ -67,7 +67,7 @@ export async function writeMacOSAppUpdateConfig(resourcesDir, update, updaterCac /** * Verify the updater configuration inside an assembled macOS App. * @param {string} appPath - Application bundle path. - * @param {{ publicUrl: string }} update - Expected update feed. + * @param {{ publicUrl: string, channel?: 'nightly' | 'latest' }} update - Expected update feed. * @param {string | undefined} updaterCacheDirName - Exact cache directory when known. * @returns {Promise} Resolves when the packaged configuration matches the release destination. */ @@ -81,7 +81,7 @@ export async function verifyMacOSAppUpdateConfig(appPath, update, updaterCacheDi throw new Error(`desktop macOS update config: cannot read ${path}: ${error instanceof Error ? error.message : String(error)}`) } const config = object(parsed, CONFIG_FILENAME) - if (config.provider !== 'generic' || config.url !== update.publicUrl || config.channel !== CHANNEL) { + if (config.provider !== 'generic' || config.url !== update.publicUrl || config.channel !== resolveDesktopUpdateChannel({ DSH_DESKTOP_UPDATE_CHANNEL: update.channel })) { throw new Error(`desktop macOS update config: ${path} does not match ${update.publicUrl}`) } const actualCacheDirName = nonEmptyString(config.updaterCacheDirName, `${CONFIG_FILENAME}.updaterCacheDirName`) diff --git a/apps/desktop/scripts/macos-runner-service.sh b/apps/desktop/scripts/macos-runner-service.sh new file mode 100644 index 0000000000..f3fd74eb94 --- /dev/null +++ b/apps/desktop/scripts/macos-runner-service.sh @@ -0,0 +1,61 @@ +#!/bin/bash +# Manage a registered runner in the current user's logged-in macOS session. +set -euo pipefail +if [[ $# -lt 2 ]]; then + echo 'Usage: bash macos-runner-service.sh install RUNNER_ROOT CONFIG_ROOT NODE_BIN | start|stop|status|uninstall RUNNER_ROOT' >&2 + exit 1 +fi +action=$1 +runner_root=$2 +[[ $(uname -s) = Darwin && $EUID -ne 0 ]] || { echo 'Run as the registered macOS runner user, without sudo' >&2; exit 1; } +[[ "$runner_root" = /* && -f "$runner_root/.runner" && -x "$runner_root/svc.sh" ]] || { echo 'Provide an absolute registered runner directory' >&2; exit 1; } +source_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +cd "$runner_root" +case "$action" in + install) + [[ $# = 4 ]] || { echo 'install requires RUNNER_ROOT CONFIG_ROOT NODE_BIN' >&2; exit 1; } + config_root=$3 + node_bin=$4 + [[ ! -f .service ]] || { echo 'Service already installed; stop and uninstall it before reinstalling' >&2; exit 1; } + launchctl print "gui/$(id -u)" >/dev/null || { echo 'Log into the macOS desktop as this user first' >&2; exit 1; } + bash "$source_dir/start-macos-packaging-runner.sh" "$runner_root" "$config_root" "$node_bin" --check + umask 077 + service_dir="$runner_root/dsh-service" + mkdir -p "$service_dir" + cp "$source_dir/start-macos-packaging-runner.sh" "$service_dir/start.sh" + # runsvc.sh reloads .path; replace the interactive PATH saved by registration. + printf '%s\n' "$node_bin:/usr/bin:/bin:/usr/sbin:/sbin" > .path + "$node_bin/node" --input-type=module - "$service_dir" "$runner_root" "$config_root" "$node_bin" <<'NODE' +import { writeFileSync } from 'node:fs' +const [directory, runner, config, node] = process.argv.slice(2) +const xml = value => value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", ''') +// svc.sh substitutes its own placeholders with sed; prohibit template syntax in paths. +for (const value of [directory, runner, config, node]) { + if (/[\r\n]|\{\{/u.test(value)) throw new Error('Service paths cannot contain newlines or template placeholders') +} +const args = ['/bin/bash', `${directory}/start.sh`, runner, config, node, '--service'] +writeFileSync(`${directory}/service.plist.template`, ` + + +Label{{SvcName}} +ProgramArguments${args.map(arg => `${xml(arg)}`).join('')} +WorkingDirectory${xml(runner)} +RunAtLoad +StandardOutPath${xml(directory)}/stdout.log +StandardErrorPath${xml(directory)}/stderr.log +EnvironmentVariablesACTIONS_RUNNER_SVC1 +ProcessTypeInteractive +SessionCreate + +`, { mode: 0o600 }) +NODE + GITHUB_ACTIONS_RUNNER_SERVICE_TEMPLATE="$service_dir/service.plist.template" ./svc.sh install + /usr/bin/plutil -lint "$(cat .service)" + echo 'Installed. Run start, then status. This LaunchAgent requires a desktop login after reboot.' + ;; + start|stop|status|uninstall) + [[ $# = 2 && -f .service ]] || { echo 'Provide an installed runner service directory' >&2; exit 1; } + ./svc.sh "$action" + ;; + *) echo "Unknown action: $action" >&2; exit 1 ;; +esac diff --git a/apps/desktop/scripts/macos-signature-cache-directory.d.mts b/apps/desktop/scripts/macos-signature-cache-directory.d.mts new file mode 100644 index 0000000000..163905a886 --- /dev/null +++ b/apps/desktop/scripts/macos-signature-cache-directory.d.mts @@ -0,0 +1,15 @@ +/** Optional machine-owned macOS signature storage. */ +/** + * Require existing private storage with no linked ancestors. + * @param path Explicit absolute cache directory. + * @returns Normalized directory; throws on invalid paths, ownership, permissions or access. + */ +export function validateMacOSSignatureCacheDirectory(path: string): string + +/** + * Select machine storage or retain the target-local default when the setting is absent. + * @param environment File-owned packaging configuration. + * @param targetBuildRoot Target build location. + * @returns Validated external directory, or the default directory created later by signing. + */ +export function resolveMacOSSignatureCacheDirectory(environment: NodeJS.ProcessEnv, targetBuildRoot: string): string diff --git a/apps/desktop/scripts/macos-signature-cache-directory.mjs b/apps/desktop/scripts/macos-signature-cache-directory.mjs new file mode 100644 index 0000000000..6a78af8e13 --- /dev/null +++ b/apps/desktop/scripts/macos-signature-cache-directory.mjs @@ -0,0 +1,38 @@ +/** Resolve optional machine-owned macOS signature storage before signing begins. */ +import { accessSync, constants, lstatSync } from 'node:fs' +import { dirname, isAbsolute, join, resolve } from 'node:path' + +/** + * Require existing private storage with no linked ancestors; never create or repair it during packaging. + * @param path Explicit absolute cache directory. + * @returns Normalized directory; invalid paths, ownership, permissions and access fail before signing. + */ +export function validateMacOSSignatureCacheDirectory(path) { + if (!path || path.includes('\0') || !isAbsolute(path)) { + throw new Error('DSH_DESKTOP_MACOS_SIGNATURE_CACHE_DIR must be a nonempty absolute directory') + } + const root = resolve(path) + for (let ancestor = root; ; ancestor = dirname(ancestor)) { + const info = lstatSync(ancestor) + if (!info.isDirectory() || info.isSymbolicLink()) { + throw new Error(`macOS signature cache: expected an unlinked directory: ${ancestor}`) + } + if (ancestor === root && (info.uid !== process.getuid() || (info.mode & 0o077) !== 0 || (info.mode & 0o700) !== 0o700)) { + throw new Error(`macOS signature cache: directory must be private, accessible and owned by the current account: ${root}`) + } + if (ancestor === dirname(ancestor)) break + } + accessSync(root, constants.R_OK | constants.W_OK | constants.X_OK) + return root +} + +/** + * Select validated machine storage or retain the target-local cache when the setting is absent. + * @param environment File-owned packaging configuration. + * @param targetBuildRoot Existing target build location used by local packaging. + * @returns Selected cache directory; only the default may be created later by the signer. + */ +export function resolveMacOSSignatureCacheDirectory(environment, targetBuildRoot) { + const selected = environment.DSH_DESKTOP_MACOS_SIGNATURE_CACHE_DIR + return selected === undefined ? join(targetBuildRoot, 'signature-cache') : validateMacOSSignatureCacheDirectory(selected) +} diff --git a/apps/desktop/scripts/manage-windows-signature-cache.mjs b/apps/desktop/scripts/manage-windows-signature-cache.mjs index de44540c70..f36aaf5925 100644 --- a/apps/desktop/scripts/manage-windows-signature-cache.mjs +++ b/apps/desktop/scripts/manage-windows-signature-cache.mjs @@ -11,7 +11,7 @@ const { values } = parseArgs({ options: { if ([values.from !== undefined, values.usage === true, values.clear === true].filter(Boolean).length !== 1) { throw new Error('Specify exactly one of --from , --usage or --clear') } -const environment = values.directory === undefined ? loadDesktopPackageEnvironment('win32') +const environment = values.directory === undefined ? loadDesktopPackageEnvironment('win32', process.env, undefined, { unsigned: true }) : { DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR: values.directory } const root = resolveWindowsSignatureCacheDirectory(environment) const record = event => process.stdout.write(`${JSON.stringify(event)}\n`) diff --git a/apps/desktop/scripts/package-macos.ts b/apps/desktop/scripts/package-macos.ts index c1a2cb5c71..4ba0fd12ca 100644 --- a/apps/desktop/scripts/package-macos.ts +++ b/apps/desktop/scripts/package-macos.ts @@ -115,7 +115,7 @@ export async function packageMacOSArtifacts( [dmgOutput, `${base}.dmg`], [zipOutput, `${base}.zip`], [zipOutput, `${base}.zip.blockmap`], - [zipOutput, desktopUpdateMetadataFilename(version, 'darwin')], + [zipOutput, desktopUpdateMetadataFilename(version, 'darwin', update.channel)], ] as const for (const [output, filename] of artifacts) { const file = join(output, filename) diff --git a/apps/desktop/scripts/package-target.ts b/apps/desktop/scripts/package-target.ts index a133ccdcf8..be2f577e1b 100644 --- a/apps/desktop/scripts/package-target.ts +++ b/apps/desktop/scripts/package-target.ts @@ -8,7 +8,7 @@ import { desktopBuildRecordFilename, resolveDesktopAutoUpdateConfig, } from './desktop-auto-update-environment.mjs' -import { desktopTargetBuildPaths } from './desktop-build-paths.mjs' +import { desktopArtifactOutput, desktopTargetBuildPaths } from './desktop-build-paths.mjs' import { packageMacOSArtifacts, type DesktopPrepackagedArtifact } from './package-macos.ts' import { loadDesktopPackageEnvironment, localMacOSPackageEnvironment, validateDesktopPackageEnvironment } from './desktop-package-environment.mjs' import { createPackagingRun, recordPackagingEvent } from './packaging-run.mjs' @@ -153,6 +153,7 @@ function writeReleaseRecord( target: target.name, version: buildVersion, environment: update.environment, + channel: update.channel, publicUrl: update.publicUrl, // Upload reads this to tag the commit a production release was packaged from. ...packaged === undefined ? {} : { commit: packaged.commit, dirty: packaged.dirty }, @@ -196,6 +197,7 @@ interface DesktopPackageInvocation { readonly directory: boolean readonly prepareOnly: boolean readonly unsigned: boolean + readonly skipNotarization: boolean readonly check: boolean /** Build identifier to publish under, when this build does not publish the product version. */ readonly requestedBuildVersion: string | undefined @@ -228,6 +230,7 @@ export function parseDesktopPackageInvocation( dir: { type: 'boolean', default: false }, 'prepare-only': { type: 'boolean', default: false }, unsigned: { type: 'boolean', default: false }, + 'skip-notarization': { type: 'boolean', default: false }, check: { type: 'boolean', default: false }, 'build-version': { type: 'string' }, }, @@ -235,6 +238,9 @@ export function parseDesktopPackageInvocation( if (positionals.length > 1) throw new Error('desktop package: expected at most one target') const name = positionals[0] ?? hostTargetName(hostPlatform, hostArch) if (values.unsigned && values['prepare-only']) throw new Error('desktop package: --unsigned cannot use --prepare-only') + if (values['skip-notarization'] && (name === 'win-x64' || values.unsigned || values['prepare-only'])) { + throw new Error('desktop package: --skip-notarization requires a signed macOS build, without --prepare-only') + } const requestedBuildVersion = values['build-version']?.trim() if (values['build-version'] !== undefined && (requestedBuildVersion === undefined || requestedBuildVersion === '')) { throw new Error('desktop package: --build-version requires a value') @@ -244,6 +250,7 @@ export function parseDesktopPackageInvocation( directory: values.dir, prepareOnly: values['prepare-only'], unsigned: values.unsigned, + skipNotarization: values['skip-notarization'], check: values.check, requestedBuildVersion, } @@ -322,20 +329,27 @@ async function resolveRequestedBuildVersion( if (requested !== AUTOMATIC_BUILD_VERSION) return validateDesktopBuildVersion(requested, productVersion) const paths = desktopTargetBuildPaths(invocation.target.name) return suggestDesktopBuildVersion({ - productVersion, target: invocation.target.name, environment, + productVersion, target: invocation.target.name, + // Machine presets own packaging, not upload credentials; local auto numbering uses existing artifacts. + environment: environment.DSH_PACKAGING_PRESET === undefined ? environment : {}, // Unsigned builds land beside the signed output, so numbering has to read the directory this run writes. - artifactsRoot: invocation.unsigned ? paths.unsignedArtifacts : paths.artifacts, + artifactsRoot: desktopArtifactOutput(paths, invocation).directory, }) } async function main(): Promise { const invocation = parseDesktopPackageInvocation(process.argv.slice(2)) const { target } = invocation + if (invocation.skipNotarization) { + process.stdout.write('desktop package: skipping notarization and stapling; internal debugging only, not qualified for distribution or update publishing\n') + } const environment = target.platform === 'darwin' && invocation.unsigned ? localMacOSPackageEnvironment() - : loadDesktopPackageEnvironment(target.platform) + : loadDesktopPackageEnvironment(target.platform, process.env, APP_ROOT, { + unsigned: invocation.unsigned, prepareOnly: invocation.prepareOnly, + }) const productVersion = packageVersion(join(APP_ROOT, 'package.json'), 'desktop package') - // Release settings come from the target dotenv file alone, so the version this run publishes is an + // Release settings come from the selected configuration files, so the version this run publishes is an // argument; the environment variable below only carries it to the child processes that build. const buildVersion = await resolveRequestedBuildVersion(invocation, productVersion, environment) environment[DESKTOP_BUILD_VERSION_ENV] = buildVersion @@ -350,7 +364,8 @@ async function main(): Promise { Object.assign(environment, desktopBuildCommitEnvironment(packaged)) const secrets = Object.entries(environment).filter(([name]) => /KEY|SECRET|TOKEN|PASSWORD|APPLE_ID/iu.test(name)).map(([, value]) => value ?? '') const run = createPackagingRun(join(APP_ROOT, '.desktop-build', 'packaging-runs'), { - target: target.name, unsigned: invocation.unsigned, directory: invocation.directory, prepareOnly: invocation.prepareOnly, + target: target.name, unsigned: invocation.unsigned, skipNotarization: invocation.skipNotarization, + directory: invocation.directory, prepareOnly: invocation.prepareOnly, version: buildVersion, productVersion, node: process.version, commit: packaged.commit, dirty: packaged.dirty, @@ -385,8 +400,9 @@ async function main(): Promise { } if (success && target.platform === 'darwin' && !invocation.directory && !invocation.prepareOnly) { const paths = desktopTargetBuildPaths(target.name) - const filename = `deepseek-harness-${buildVersion}-mac-${target.arch}${invocation.unsigned ? '-unsigned' : ''}.dmg` - console.log(`DMG: ${join(invocation.unsigned ? paths.unsignedArtifacts : paths.artifacts, filename)}`) + const output = desktopArtifactOutput(paths, invocation) + const filename = `deepseek-harness-${buildVersion}-mac-${target.arch}${output.suffix}.dmg` + console.log(`DMG: ${join(output.directory, filename)}`) } } @@ -410,14 +426,16 @@ export async function packageTarget( const packArguments = mac === undefined ? [] : ['--concurrency', String(mac.packConcurrency)] const buildPaths = desktopTargetBuildPaths(target.name) const releaseRecordPath = join(buildPaths.artifacts, desktopBuildRecordFilename(target.name)) - if (!invocation.prepareOnly && !invocation.unsigned) { + if (!invocation.prepareOnly && !invocation.unsigned && !invocation.skipNotarization) { rmSync(releaseRecordPath, { force: true }) rmSync(`${releaseRecordPath}.tmp`, { force: true }) } + const artifactsRoot = desktopArtifactOutput(buildPaths, invocation).directory const buildEnv = withoutWindowsSigningEnvironment(withoutDesktopUploadCredentials( desktopElectronBuilderEnvironment(environment, invocation.unsigned))) const targetEnv: NodeJS.ProcessEnv = { ...buildEnv, + DSH_DESKTOP_MACOS_SKIP_NOTARIZATION: invocation.skipNotarization ? '1' : '0', DSH_DESKTOP_TARGET_PLATFORM: target.platform, DSH_DESKTOP_TARGET_ARCH: target.arch, } @@ -481,7 +499,7 @@ export async function packageTarget( await execute(['run', 'prepare:dsh', ...(signPrimaryRuntime ? ['--defer-runtime-smoke'] : [])], downloadEnv) if (signPrimaryRuntime) await execute(['run', 'sign:primary-runtime', '--dsh'], electronBuilderEnv) if (invocation.prepareOnly) return - if (target.platform === 'darwin' && !invocation.unsigned && !invocation.directory) { + if (target.platform === 'darwin' && !invocation.unsigned && !invocation.skipNotarization && !invocation.directory) { await execute([ ...desktopElectronBuilderArguments(target, true), '--config.mac.notarize=false', @@ -494,7 +512,7 @@ export async function packageTarget( artifactsRoot: buildPaths.artifacts, environment: electronBuilderEnv, }, artifact => execute(desktopElectronBuilderArguments(target, false, artifact), electronBuilderEnv)), undefined, undefined, proxyEvent) - } else if (target.platform === 'darwin' && !invocation.unsigned) { + } else if (target.platform === 'darwin' && !invocation.unsigned && !invocation.skipNotarization) { await execute([...desktopElectronBuilderArguments(target, true), '--config.mac.notarize=false'], electronBuilderEnv) await execute(['exec', 'tsx', 'scripts/smoke-packaged-runtime.ts'], targetEnv) const appPath = join(buildPaths.artifacts, target.arch === 'arm64' ? 'mac-arm64' : 'mac', 'DeepSeek Harness.app') @@ -502,10 +520,13 @@ export async function packageTarget( () => notarizeMacOS({ appPath, ...resolveMacOSNotarizationEnvironment(environment) }), undefined, undefined, proxyEvent) } else { await signedStage('artifacts', () => execute(desktopElectronBuilderArguments(target, invocation.directory), electronBuilderEnv)) - await execute(['exec', 'tsx', 'scripts/smoke-packaged-runtime.ts', ...(invocation.unsigned ? ['--unsigned'] : [])], targetEnv) + await execute(['exec', 'tsx', 'scripts/smoke-packaged-runtime.ts', + ...(invocation.unsigned ? ['--unsigned'] : []), ...(invocation.skipNotarization ? ['--skip-notarization'] : [])], targetEnv) } - if (!invocation.directory && !invocation.unsigned) writeReleaseRecord(target, electronBuilderEnv, buildPaths.artifacts) - if (journal) recordPackagingEvent(journal, { type: 'artifacts', directory: invocation.unsigned ? buildPaths.unsignedArtifacts : buildPaths.artifacts }) + if (!invocation.directory && !invocation.unsigned && !invocation.skipNotarization) { + writeReleaseRecord(target, electronBuilderEnv, buildPaths.artifacts) + } + if (journal) recordPackagingEvent(journal, { type: 'artifacts', directory: artifactsRoot }) } if (process.argv[1] !== undefined && import.meta.filename === resolve(process.argv[1])) await main() diff --git a/apps/desktop/scripts/prepare-dsh.ts b/apps/desktop/scripts/prepare-dsh.ts index 808b8ae8b5..9de39d3ff2 100644 --- a/apps/desktop/scripts/prepare-dsh.ts +++ b/apps/desktop/scripts/prepare-dsh.ts @@ -1,5 +1,6 @@ /** Materialize the complete production runtime before publishing Desktop resources. */ +import { resolveMacOSSignatureCacheDirectory } from './macos-signature-cache-directory.mjs' import { packagingStep } from './packaging-step.mjs' import { spawn } from 'node:child_process' import { copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' @@ -157,8 +158,9 @@ async function main(): Promise { throw new Error(`desktop runtime: missing required LibreOffice engine ${officeEngine}`) } if (process.platform === 'darwin' && process.env.DSH_DESKTOP_UNSIGNED !== '1') { - await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'sign:dsh-native', () => signMacOSRuntime(DSH_OUTPUT_ROOT, resolveDesktopAppId(process.env), resolveMacOSSigningEnvironment(process.env), target.arch, join(BUILD_PATHS.root, 'signature-cache'))) - await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'sign:primary-native', () => signMacOSRuntime(join(RUNTIME_ROOT, 'primary-runtime'), resolveDesktopAppId(process.env), resolveMacOSSigningEnvironment(process.env), target.arch, join(BUILD_PATHS.root, 'signature-cache'), 'primary')) + const cacheDirectory = resolveMacOSSignatureCacheDirectory(process.env, BUILD_PATHS.root) + await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'sign:dsh-native', () => signMacOSRuntime(DSH_OUTPUT_ROOT, resolveDesktopAppId(process.env), resolveMacOSSigningEnvironment(process.env), target.arch, cacheDirectory)) + await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'sign:primary-native', () => signMacOSRuntime(join(RUNTIME_ROOT, 'primary-runtime'), resolveDesktopAppId(process.env), resolveMacOSSigningEnvironment(process.env), target.arch, cacheDirectory, 'primary')) } await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:manifests', () => prepareRuntimeManifests(DSH_OUTPUT_ROOT)) await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:primary-smoke', async () => smokePrimaryRuntime(join(RUNTIME_ROOT, 'primary-runtime'))) diff --git a/apps/desktop/scripts/prepare-macos-ci.mjs b/apps/desktop/scripts/prepare-macos-ci.mjs new file mode 100644 index 0000000000..5b51052cb6 --- /dev/null +++ b/apps/desktop/scripts/prepare-macos-ci.mjs @@ -0,0 +1,26 @@ +/** Validate machine-owned macOS signing settings and record a manual Actions build version. */ +import { appendFileSync, readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { execFileSync } from 'node:child_process' +import { desktopCiVersion } from './desktop-ci-version.mjs' +import { loadDesktopPackageEnvironment, validateDesktopPackageEnvironment } from './desktop-package-environment.mjs' + +const appRoot = resolve(import.meta.dirname, '..') +const product = JSON.parse(readFileSync(resolve(appRoot, 'package.json'), 'utf8')) +const version = desktopCiVersion(process.env, product.version) +if (!process.env.DSH_PACKAGING_PRESET) throw new Error('Select a machine preset') +const skipNotarization = process.env.PACKAGE_SKIP_NOTARIZATION === 'true' +if (process.env.PACKAGE_PUBLISH === 'true' && skipNotarization) throw new Error('Publishing requires notarization and stapling') +if (!['true', 'false', undefined].includes(process.env.PACKAGE_SKIP_NOTARIZATION)) throw new Error('Invalid skip notarization selection') +const environment = loadDesktopPackageEnvironment('darwin', process.env, appRoot) +for (const [name, value] of Object.entries(environment)) { + if (/KEY|TOKEN|PASSWORD|SECRET|APPLE_ID/iu.test(name) && value) { + const escaped = value.replaceAll('%', '%25').replaceAll('\r', '%0D').replaceAll('\n', '%0A') + process.stdout.write(`::add-mask::${escaped}\n`) + } +} +validateDesktopPackageEnvironment(environment, { platform: 'darwin', arch: 'arm64' }, { skipNotarization }) +if (!process.env.GITHUB_OUTPUT || !process.env.GITHUB_STEP_SUMMARY) throw new Error('Run this adapter from GitHub Actions') +appendFileSync(process.env.GITHUB_OUTPUT, `version=${version}\nartifacts=${skipNotarization ? 'unnotarized-artifacts' : 'artifacts'}\nmode=${skipNotarization ? 'unnotarized-debug' : 'signed'}\n`) +appendFileSync(process.env.GITHUB_STEP_SUMMARY, `macOS arm64 signed installer\n\nVersion: ${version}\n\nCommit: ${execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim()}\n\nChannel: ${process.env.DSH_DESKTOP_UPDATE_CHANNEL}\n\nSource: ${process.env.PACKAGE_SOURCE_REF}\n\nPreset: ${process.env.DSH_PACKAGING_PRESET}\n`) +if (skipNotarization) appendFileSync(process.env.GITHUB_STEP_SUMMARY, '\nWARNING: signed but NOT notarized or stapled. Internal debugging only; Gatekeeper may block downloaded copies. Not qualified for distribution or update publishing.\n') diff --git a/apps/desktop/scripts/prepare-windows-ci.d.mts b/apps/desktop/scripts/prepare-windows-ci.d.mts index 0fa67ecf7f..3b0196dc57 100644 --- a/apps/desktop/scripts/prepare-windows-ci.d.mts +++ b/apps/desktop/scripts/prepare-windows-ci.d.mts @@ -1,14 +1,10 @@ -/** Public settings prepared for a manual Windows package run. */ -export interface WindowsCiSettings { - version: string - settings: string -} +/** Resolve the version used by the manual Windows packaging workflow. */ /** - * Validate manual inputs without local credentials or release storage. - * @param environment Manual inputs and GitHub run identity. + * Resolve an explicit version or a UTC date/run/attempt identifier without release storage access. + * @param environment Manual version and GitHub run identity. * @param productVersion Version declared by the selected checkout. * @param now Time used for the UTC date segment. - * @returns Validated version and public dotenv content. + * @returns Validated build version. */ -export function windowsCiSettings(environment: NodeJS.ProcessEnv, productVersion: string, now?: Date): WindowsCiSettings +export function windowsCiVersion(environment: NodeJS.ProcessEnv, productVersion: string, now?: Date): string diff --git a/apps/desktop/scripts/prepare-windows-ci.mjs b/apps/desktop/scripts/prepare-windows-ci.mjs index c86c303d41..307d9b0fcf 100644 --- a/apps/desktop/scripts/prepare-windows-ci.mjs +++ b/apps/desktop/scripts/prepare-windows-ci.mjs @@ -1,54 +1,29 @@ -/** Prepare public Windows packaging settings and a version for a manual GitHub run. */ -import { appendFileSync, readFileSync, writeFileSync } from 'node:fs' +/** Resolve a manual Windows build version and validate the selected machine preset. */ +import { appendFileSync, readFileSync } from 'node:fs' import { resolve } from 'node:path' -import { desktopBuildVersionPrefix, validateDesktopBuildVersion } from './desktop-build-version.mjs' -import { resolveDesktopPolicyEnvironment } from './desktop-policy-environment.mjs' - -/** - * Validate manual inputs without reading local credentials or querying release storage. - * @param {NodeJS.ProcessEnv} environment Manual inputs and GitHub run identity. - * @param {string} productVersion Version declared by the selected checkout. - * @param {Date} now Time used for the UTC date segment. - * @returns {{ version: string, settings: string }} Validated version and public dotenv content. - */ -export function windowsCiSettings(environment, productVersion, now = new Date()) { - const deployment = environment.PACKAGE_DEPLOYMENT - if (deployment !== 'test' && deployment !== 'production') throw new Error('Select test or production deployment') - const loginOrigins = (environment.PACKAGE_LOGIN_ORIGINS ?? '').split(',').map(value => value.trim()).filter(Boolean) - if (deployment === 'production' && loginOrigins.length > 0) throw new Error('Production must not supply login origins') - const originKey = deployment === 'test' ? 'DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN' : 'DSH_DESKTOP_MANDATORY_UPDATE_PROD_ORIGIN' - const config = deployment === 'test' ? { allowedAuthOrigins: loginOrigins } : {} - const policy = resolveDesktopPolicyEnvironment({ - DSH_DESKTOP_AUTO_UPDATE_ENV: deployment, - [originKey]: environment.PACKAGE_POLICY_ORIGIN, - DSH_DESKTOP_MANDATORY_UPDATE_CONFIG: JSON.stringify(config), - }) - const requested = environment.PACKAGE_BUILD_VERSION?.trim() - if (!requested) throw new Error('Build version is required; use auto or a full version') - let candidate = requested - if (requested === 'auto') { - for (const name of ['GITHUB_RUN_NUMBER', 'GITHUB_RUN_ATTEMPT']) { - if (!/^[1-9]\d*$/u.test(environment[name] ?? '')) throw new Error(`${name} must be a positive integer`) - } - const date = now.toISOString().slice(0, 10).replaceAll('-', '') - candidate = `${desktopBuildVersionPrefix(productVersion)}${date}.${environment.GITHUB_RUN_NUMBER}.${environment.GITHUB_RUN_ATTEMPT}` - } - const version = validateDesktopBuildVersion(candidate, productVersion) - const settings = [ - 'DSH_DESKTOP_APP_ID=com.deepseek.harness', - `DSH_DESKTOP_AUTO_UPDATE_ENV=${deployment}`, - `${originKey}=${policy.origin}`, - `DSH_DESKTOP_MANDATORY_UPDATE_CONFIG=${JSON.stringify(config)}`, - ].join('\n') + '\n' - return { version, settings } -} +import { execFileSync } from 'node:child_process' +import { desktopCiVersion as windowsCiVersion } from './desktop-ci-version.mjs' +export { desktopCiVersion as windowsCiVersion } from './desktop-ci-version.mjs' +import { loadDesktopPackageEnvironment, validateDesktopPackageEnvironment } from './desktop-package-environment.mjs' if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.filename) { const appRoot = resolve(import.meta.dirname, '..') const product = JSON.parse(readFileSync(resolve(appRoot, 'package.json'), 'utf8')) - const result = windowsCiSettings(process.env, product.version) + const version = windowsCiVersion(process.env, product.version) + const mode = process.env.PACKAGE_SIGNING + if (process.env.PACKAGE_PUBLISH === 'true' && mode !== 'signed') throw new Error('Publishing requires signed Windows packages') + if (mode !== 'unsigned' && mode !== 'signed') throw new Error('Select unsigned or signed packaging') + if (!process.env.DSH_PACKAGING_PRESET) throw new Error('Select a machine preset') + const environment = loadDesktopPackageEnvironment('win32', process.env, appRoot, { unsigned: mode === 'unsigned' }) + // Machine-owned secrets are not automatically known to the Actions log masker. + for (const [name, value] of Object.entries(environment)) { + if (/TOKEN|PASSWORD|SECRET/iu.test(name) && value) { + const escaped = value.replaceAll('%', '%25').replaceAll('\r', '%0D').replaceAll('\n', '%0A') + process.stdout.write(`::add-mask::${escaped}\n`) + } + } + validateDesktopPackageEnvironment(environment, { platform: 'win32', arch: 'x64' }, { unsigned: mode === 'unsigned' }) if (!process.env.GITHUB_OUTPUT || !process.env.GITHUB_STEP_SUMMARY) throw new Error('Run this adapter from GitHub Actions') - writeFileSync(resolve(appRoot, '.env.windows'), result.settings, { flag: 'wx' }) - appendFileSync(process.env.GITHUB_OUTPUT, `version=${result.version}\n`) - appendFileSync(process.env.GITHUB_STEP_SUMMARY, `Windows x64 unsigned installer\n\nVersion: ${result.version}\n\nCommit: ${process.env.GITHUB_SHA}\n\nDeployment: ${process.env.PACKAGE_DEPLOYMENT}\n`) + appendFileSync(process.env.GITHUB_OUTPUT, `version=${version}\n`) + appendFileSync(process.env.GITHUB_STEP_SUMMARY, `Windows x64 ${mode} installer\n\nVersion: ${version}\n\nCommit: ${execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim()}\n\nChannel: ${process.env.DSH_DESKTOP_UPDATE_CHANNEL}\n\nSource: ${process.env.PACKAGE_SOURCE_REF}\n\nPreset: ${process.env.DSH_PACKAGING_PRESET}\n`) } diff --git a/apps/desktop/scripts/publish-desktop-ci.ts b/apps/desktop/scripts/publish-desktop-ci.ts new file mode 100644 index 0000000000..0abc317463 --- /dev/null +++ b/apps/desktop/scripts/publish-desktop-ci.ts @@ -0,0 +1,126 @@ +/** Publish a retained package selected by the routing job; --check never reads secrets or contacts COS. */ +import { createHash } from 'node:crypto' +import { appendFile } from 'node:fs/promises' +import { dirname, join, resolve } from 'node:path' +import { z } from 'zod' +import { readDesktopPublishConfig, readDesktopUploadCredentials } from './desktop-publish-config.ts' +import { desktopReleasesRoot } from './retain-desktop-ci.ts' +import { retainedDesktopPath, verifyRetainedDesktopRelease } from './desktop-release-store.ts' +import { verifyRetainedDesktopSignatures } from './verify-retained-desktop.ts' +import { createDesktopUploadPlan } from './desktop-upload-plan.ts' +import { desktopPublishCos } from './desktop-publish-cos.ts' +import { createDesktopPublicationRecord, wasDesktopAttemptPublished, withDesktopChannelLock } from './desktop-publish-record.ts' +import { publishDesktopRelease } from './desktop-publish.ts' +import { desktopPublicationFailure } from './desktop-publication-failure.ts' +import { publishDesktopTag } from './desktop-publish-tag.ts' + +const routeSchema = z.object({ runId: z.string(), attempt: z.string(), commit: z.string(), runnerName: z.string(), + sourceRef: z.string(), channel: z.enum(['nightly', 'latest']), + target: z.enum(['mac-arm64', 'win-x64']), preset: z.enum(['test', 'production']) }) + +/** + * Check retained bytes, original machine, destination and signatures before reading any upload secret. + * @param env Router output and machine configuration directory. + * @param verifySignatures Platform verifier; tests inject a verifier for fixture bytes. + * @returns Validated local publication context; no network operation is performed. + */ +export async function prepareDesktopPublication(env: NodeJS.ProcessEnv, verifySignatures = verifyRetainedDesktopSignatures) { + const route = routeSchema.parse(JSON.parse(env.DESKTOP_PUBLISH_ROUTE || '{}')) + if (route.runnerName !== env.RUNNER_NAME) throw new Error('desktop publish: task was dispatched to a different machine') + const root = desktopReleasesRoot(env) + const manifest = await verifyRetainedDesktopRelease(root, route.runId, route.attempt) + const source = manifest.source + if (source.repository !== env.GITHUB_REPOSITORY || source.runnerName !== route.runnerName || source.target !== route.target + || source.channel !== route.channel || source.sourceRef !== route.sourceRef || source.commit !== route.commit + || source.preset !== route.preset || !source.publishRequested) { + throw new Error('desktop publish: local package differs from the routed request') + } + if (source.qualification !== (source.target === 'win-x64' ? 'signed' : 'notarized')) throw new Error('desktop publish: debugging artifacts cannot be published') + const config = await readDesktopPublishConfig(env.DSH_PACKAGING_CONFIG_DIR!, source.preset) + const directory = retainedDesktopPath(root, route.runId, route.attempt) + const environment = source.preset === 'test' + ? { DSH_DESKTOP_AUTO_UPDATE_ENV: 'test', DOWNLOAD_TEST_ORIGIN: config.origin, + DOWNLOAD_TEST_RELEASE_ID: source.testReleaseId ?? '', DOWNLOAD_TEST_COS_BUCKET: config.bucket } + : { DSH_DESKTOP_AUTO_UPDATE_ENV: 'production', DOWNLOAD_PROD_COS_BUCKET: config.bucket } + const plan = await createDesktopUploadPlan(source.target, { artifactsRoot: directory, + packagedProductVersion: source.productVersion, environment: { ...environment, DSH_DESKTOP_UPDATE_CHANNEL: source.channel } }) + if (plan.version !== source.version || plan.commit !== source.commit || plan.dirty !== false) throw new Error('desktop publish: completion record differs from retained source or has uncommitted changes') + await verifySignatures(directory, manifest, config) + const approval = createHash('sha256').update(JSON.stringify({ manifest, plan })).digest('hex') + if (env.DESKTOP_APPROVED_PACKAGE && env.DESKTOP_APPROVED_PACKAGE !== approval) throw new Error('desktop publish: package or destination changed after approval') + return { route, manifest, config, plan, root, approval } +} + +if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.filename) { + let phase = 'local package, destination and signature verification' + try { + const mode = process.argv[2] + if (process.argv.length !== 3 || (mode !== '--check' && mode !== '--publish')) throw new Error('Usage: publish-desktop-ci.ts --check|--publish') + const env = process.env + const { route, manifest, config, plan, root, approval } = await prepareDesktopPublication(env) + if (mode === '--publish' && !env.DESKTOP_APPROVED_PACKAGE) throw new Error('desktop publish: approved package digest is required') + if (mode === '--check') { + if (env.GITHUB_OUTPUT) await appendFile(env.GITHUB_OUTPUT, `approval=${approval}\nlabel=${plan.version} → ${manifest.source.channel} / ${plan.target} / ${plan.environment}\n`) + if (env.GITHUB_STEP_SUMMARY) await appendFile(env.GITHUB_STEP_SUMMARY, + `\n## Confirm Desktop publication\n\nVersion: **${plan.version}**; channel: **${manifest.source.channel}**; environment: **${plan.environment}**; platform: **${plan.target}**\n\nSource: ${manifest.source.sourceRef} @ ${route.commit}\n\nPackage: ${route.runId}, attempt ${route.attempt}\n\nTag: ${plan.environment === 'production' ? `desktop-v${plan.version}` : 'none (test)'}\n\nApproved package SHA-256: ${approval}\n\n` + + plan.artifacts.map(file => `- ${file.channelMetadata ? 'Feed' : 'Artifact'}: ${file.key}`).join('\n') + '\n') + if (env.GITHUB_OUTPUT && config.githubApp) await appendFile(env.GITHUB_OUTPUT, + `app-client-id=${config.githubApp.clientId}\napp-key-file=${config.githubApp.privateKeyFile}\n`) + process.stdout.write(`desktop publish: verified ${plan.target} ${plan.version}; no credentials read or COS requests made\n`) + } else { + if (plan.environment === 'production' && !env.DESKTOP_TAG_TOKEN) throw new Error('desktop publish: GitHub App token is required before production upload') + phase = 'upload credential loading' + const credentials = await readDesktopUploadCredentials(config) + const destination = `${plan.bucket}/${plan.publicUrl}${manifest.source.channel}` + const recordsRoot = join(dirname(root), 'upload-records') + phase = 'channel lock and publication journal acquisition' + await withDesktopChannelLock(join(dirname(root), 'publish-locks'), destination, async () => { + const published = await wasDesktopAttemptPublished(recordsRoot, + { repository: manifest.source.repository, runId: route.runId, attempt: route.attempt, destination }) + const journal = await createDesktopPublicationRecord(recordsRoot, { + repository: manifest.source.repository, runId: route.runId, attempt: route.attempt, + actor: env.GITHUB_TRIGGERING_ACTOR || env.GITHUB_ACTOR || 'unknown', + destination, operation: 'publish', reason: 'Actions publication', + }) + if (env.GITHUB_OUTPUT) await appendFile(env.GITHUB_OUTPUT, `record=${journal.directory}\n`) + let status: 'complete' | 'failed' | 'published-tag-pending' = 'failed' + try { + phase = 'remote object reconciliation and publication' + const tag = async () => { await publishDesktopTag(manifest.source.repository, + plan.version, route.commit, env.DESKTOP_TAG_TOKEN || '') } + const result = published + ? await (async () => { + await journal.record({ type: 'feeds-complete' }) + if (plan.environment === 'production') { + try { await tag() } catch (cause) { + await journal.record({ type: 'tag-pending' }) + return { status: 'published-tag-pending' as const, cause } + } + } + await journal.record({ type: 'complete' }) + return { status: 'complete' as const } + })() + : await publishDesktopRelease(plan, { + transport: desktopPublishCos(plan, credentials), record: event => journal.record(event), + tag, + }) + status = result.status + if (env.GITHUB_STEP_SUMMARY) await appendFile(env.GITHUB_STEP_SUMMARY, + `\nDesktop publication: **${status}**\n\n${plan.target} ${plan.version}\n\nSource: ${route.runId}, attempt ${route.attempt}\n`) + if (result.status === 'published-tag-pending') { + const failure = desktopPublicationFailure(result.cause, 'release tag creation') + await journal.record({ type: 'failed', failure }) + process.stderr.write(`${JSON.stringify(failure)}\n`) + process.exitCode = 1 + } + } catch (error) { + await journal.record({ type: 'failed', failure: desktopPublicationFailure(error, phase) }) + throw error + } finally { await journal.finish(status) } + }) + } + } catch (error) { + process.stderr.write(`${JSON.stringify(desktopPublicationFailure(error, phase))}\n`) + process.exitCode = 1 + } +} diff --git a/apps/desktop/scripts/read-desktop-app-key.mjs b/apps/desktop/scripts/read-desktop-app-key.mjs new file mode 100644 index 0000000000..590f7898d2 --- /dev/null +++ b/apps/desktop/scripts/read-desktop-app-key.mjs @@ -0,0 +1,14 @@ +/** Hand a machine App key to the token action after Environment approval, masking before output. */ +import { readFileSync, appendFileSync } from 'node:fs' +import { randomUUID } from 'node:crypto' + +try { + if (!process.env.APP_KEY_FILE || !process.env.GITHUB_OUTPUT) throw new Error('Actions key file and output are required') + const key = readFileSync(process.env.APP_KEY_FILE, 'utf8') + console.log('::add-mask::' + key.replaceAll('%', '%25').replaceAll('\r', '%0D').replaceAll('\n', '%0A')) + const delimiter = randomUUID() + appendFileSync(process.env.GITHUB_OUTPUT, `private-key<<${delimiter}\n${key}\n${delimiter}\n`) +} catch { + process.stderr.write('Desktop publication: cannot hand the configured App key to the token action.\n') + process.exitCode = 1 +} diff --git a/apps/desktop/scripts/resolve-desktop-publish-ci.ts b/apps/desktop/scripts/resolve-desktop-publish-ci.ts new file mode 100644 index 0000000000..cd1326dceb --- /dev/null +++ b/apps/desktop/scripts/resolve-desktop-publish-ci.ts @@ -0,0 +1,57 @@ +/** Resolve a workflow_run publication request without reading machine or COS credentials. */ +import { execFileSync } from 'node:child_process' +import { appendFile, mkdir, readFile } from 'node:fs/promises' +import { join, resolve } from 'node:path' +import { z } from 'zod' +import { resolveDesktopPublishRoute } from './desktop-publish-route.ts' + +const eventSchema = z.object({ workflow_run: z.object({ id: z.number().int().positive(), run_attempt: z.number().int().positive() }) }) +const artifactsSchema = z.object({ artifacts: z.array(z.object({ name: z.string(), expired: z.boolean() })) }) + +if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.filename) { + const env = process.env + if (!env.GITHUB_EVENT_PATH || !env.GITHUB_REPOSITORY || !env.GITHUB_OUTPUT || !env.RUNNER_TEMP) throw new Error('desktop publish: Actions context required') + const event = eventSchema.parse(JSON.parse(await readFile(env.GITHUB_EVENT_PATH, 'utf8'))) + const repository = env.GITHUB_REPOSITORY + if (!/^[\w.-]+\/[\w.-]+$/u.test(repository)) throw new Error('desktop publish: invalid repository') + const { id, run_attempt: attempt } = event.workflow_run + const api = (path: string): unknown => JSON.parse(execFileSync('gh', ['api', + path], { encoding: 'utf8', timeout: 60_000, maxBuffer: 8 * 1024 * 1024 })) + const base = `repos/${repository}/actions/runs/${id}` + const name = `desktop-publish-request-${id}-${attempt}` + const pages: unknown = JSON.parse(execFileSync('gh', ['api', '--paginate', '--slurp', + `${base}/artifacts?per_page=100`], { encoding: 'utf8', timeout: 60_000, maxBuffer: 8 * 1024 * 1024 })) + const match = z.array(artifactsSchema).parse(pages).flatMap(page => page.artifacts) + .find(artifact => artifact.name === name) + if (match === undefined) { + await appendFile(env.GITHUB_OUTPUT, 'publish=false\n') + } else { + if (match.expired) throw new Error('desktop publish: request artifact expired') + const destination = join(env.RUNNER_TEMP, `desktop-request-${id}-${attempt}-${env.GITHUB_RUN_ID}-${env.GITHUB_RUN_ATTEMPT}`) + await mkdir(destination, { recursive: false, mode: 0o700 }) + execFileSync('gh', ['run', 'download', String(id), '--repo', repository, + '--name', name, '--dir', destination], { timeout: 60_000, stdio: 'pipe' }) + const request: unknown = JSON.parse(await readFile(join(destination, 'manifest.json'), 'utf8')) + const jobs = z.object({ jobs: z.array(z.unknown()) }).parse(api(`${base}/attempts/${attempt}/jobs?per_page=100`)) + const route = resolveDesktopPublishRoute(repository, api(`${base}/attempts/${attempt}`), + request, jobs.jobs, JSON.parse(env.DESKTOP_PUBLISH_RUNNERS || '{}')) + const source = z.object({ sha: z.string() }).parse(api(`repos/${repository}/commits/${route.commit}`)) + if (source.sha !== route.commit) throw new Error('desktop publish: source commit not found') + if (route.sourceRef === 'master') { + const comparison = z.object({ status: z.string() }).parse(api(`repos/${repository}/compare/${route.commit}...master`)) + if (!['ahead', 'identical'].includes(comparison.status)) throw new Error('desktop publish: source is not on master') + } else { + const tagged = z.object({ sha: z.string() }).parse(api(`repos/${repository}/commits/${route.sourceRef}`)) + if (tagged.sha !== route.commit) throw new Error('desktop publish: source tag changed') + } + { + const environment = z.object({ protection_rules: z.array(z.object({ type: z.string(), + reviewers: z.array(z.unknown()).optional() })) }) + .parse(api(`repos/${repository}/environments/desktop-${route.preset}`)) + if (!environment.protection_rules.some(rule => rule.type === 'required_reviewers' && (rule.reviewers?.length ?? 0) > 0)) { + throw new Error(`desktop publish: desktop-${route.preset} requires configured reviewers`) + } + } + await appendFile(env.GITHUB_OUTPUT, `publish=true\nroute=${JSON.stringify(route)}\nrunner=${route.runnerLabel}\npreset=${route.preset}\n`) + } +} diff --git a/apps/desktop/scripts/retain-desktop-ci.ts b/apps/desktop/scripts/retain-desktop-ci.ts new file mode 100644 index 0000000000..9fddc8d995 --- /dev/null +++ b/apps/desktop/scripts/retain-desktop-ci.ts @@ -0,0 +1,81 @@ +/** Retain completed CI packages and a credential-free request for the publishing workflow. */ +import { appendFile, readFile } from 'node:fs/promises' +import { dirname, isAbsolute, join, resolve } from 'node:path' +import { desktopTargetBuildPaths, resolveDesktopBuildTarget } from './desktop-build-paths.mjs' +import { desktopBuildRecordFilename, desktopUpdateMetadataFilename, resolveDesktopUpdateChannel } from './desktop-auto-update-environment.mjs' +import { loadDesktopPackageEnvironment } from './desktop-package-environment.mjs' +import { retainDesktopRelease, type DesktopRetainedSource } from './desktop-release-store.ts' +import { validateDesktopBuildVersion } from './desktop-build-version.mjs' + +function required(env: NodeJS.ProcessEnv, name: string): string { + const value = env[name] + if (!value) throw new Error(`desktop retention: ${name} is required`) + return value +} + +/** + * Resolve the persistent release root without assuming a particular user's home directory. + * @param env Machine packaging configuration; an explicit releases directory overrides its sibling default. + * @returns Absolute directory outside the disposable checkout. + */ +export function desktopReleasesRoot(env: NodeJS.ProcessEnv): string { + const configured = required(env, 'DSH_PACKAGING_CONFIG_DIR') + const root = env.DSH_DESKTOP_RELEASES_DIR || join(dirname(configured), 'releases') + if (!isAbsolute(configured) || !isAbsolute(root)) throw new Error('desktop retention: machine directories must be absolute') + return root +} + +/** + * Save the complete installer inventory for a successful package job, without reading signing secrets. + * @param env Actions identity, resolved package version and machine preset selection. + * @param appRoot Desktop checkout directory; defaults to this script's owning app. + * @returns Retained directory. Publish requests for debugging packages reject before copying. + */ +export async function retainDesktopCi(env: NodeJS.ProcessEnv, appRoot = resolve(import.meta.dirname, '..')): Promise { + const target = resolveDesktopBuildTarget(env) + const windows = target === 'win-x64' + const platform = windows ? 'win32' : 'darwin' + const settings = loadDesktopPackageEnvironment(platform, env, appRoot, { unsigned: true }) + const preset = settings.DSH_PACKAGING_PRESET + if (preset !== 'test' && preset !== 'production') throw new Error('desktop retention: a machine preset is required') + const publish = env.PACKAGE_PUBLISH === 'true' + if (!['true', 'false', undefined].includes(env.PACKAGE_PUBLISH)) throw new Error('desktop retention: invalid publish selection') + if (windows && !['signed', 'unsigned'].includes(env.PACKAGE_SIGNING ?? '')) throw new Error('desktop retention: invalid signing selection') + if (!windows && !['true', 'false'].includes(env.PACKAGE_SKIP_NOTARIZATION ?? '')) throw new Error('desktop retention: invalid notarization selection') + const qualification = windows ? (env.PACKAGE_SIGNING === 'signed' ? 'signed' : 'unsigned') + : (env.PACKAGE_SKIP_NOTARIZATION === 'true' ? 'unnotarized' : 'notarized') + if (publish && (qualification === 'unsigned' || qualification === 'unnotarized')) { + throw new Error('desktop retention: publishing requires signed Windows or notarized macOS packages') + } + const product: { version: string } = JSON.parse(await readFile(join(appRoot, 'package.json'), 'utf8')) + const version = validateDesktopBuildVersion(required(env, 'PACKAGE_VERSION'), product.version) + const source: DesktopRetainedSource = { + repository: required(env, 'GITHUB_REPOSITORY'), runId: required(env, 'GITHUB_RUN_ID'), + attempt: required(env, 'GITHUB_RUN_ATTEMPT'), commit: required(env, 'PACKAGE_SOURCE_SHA'), + workflowCommit: required(env, 'GITHUB_SHA'), sourceRef: required(env, 'PACKAGE_SOURCE_REF'), + channel: resolveDesktopUpdateChannel(settings), + runnerName: required(env, 'RUNNER_NAME'), target, preset, productVersion: product.version, version, + testReleaseId: preset === 'test' ? (settings.DOWNLOAD_TEST_RELEASE_ID || null) : null, + publishRequested: publish, qualification, + } + const paths = desktopTargetBuildPaths(target) + // The CLI's checkout owns these paths; alternate app roots are used by fixtures. + const mode = qualification === 'unsigned' ? 'unsigned-artifacts' : qualification === 'unnotarized' ? 'unnotarized-artifacts' : 'artifacts' + const artifacts = appRoot === resolve(import.meta.dirname, '..') + ? join(paths.root, mode) : join(appRoot, '.desktop-build', 'targets', target, mode) + const base = `deepseek-harness-${version}-${windows ? 'win-x64' : target}` + const debugSuffix = qualification === 'unnotarized' ? '-unnotarized' : qualification === 'unsigned' ? '-unsigned' : '' + const names = windows ? [`${base}${debugSuffix}.exe`] : [`${base}${debugSuffix}.dmg`, `${base}${debugSuffix}.zip`] + if (qualification === 'signed' || qualification === 'notarized') { + names.push(`${base}.${windows ? 'exe' : 'zip'}.blockmap`, desktopBuildRecordFilename(target), desktopUpdateMetadataFilename(version, platform, source.channel)) + } + const retained = await retainDesktopRelease(desktopReleasesRoot(env), source, names.map(name => join(artifacts, name))) + if (env.GITHUB_OUTPUT) await appendFile(env.GITHUB_OUTPUT, `directory=${retained}\n`) + if (env.GITHUB_STEP_SUMMARY) await appendFile(env.GITHUB_STEP_SUMMARY, + `\nRetained package: ${env.GITHUB_RUN_ID}/${env.GITHUB_RUN_ATTEMPT}\n\nPublish requested: ${publish}\n`) + return retained +} + +if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.filename) { + await retainDesktopCi(process.env) +} diff --git a/apps/desktop/scripts/rollback-desktop-channel.ts b/apps/desktop/scripts/rollback-desktop-channel.ts new file mode 100644 index 0000000000..fbefbe4f8b --- /dev/null +++ b/apps/desktop/scripts/rollback-desktop-channel.ts @@ -0,0 +1,60 @@ +/** Manual channel restoration: read-only preview unless --execute is explicitly present. */ +import { readFile } from 'node:fs/promises' +import { dirname, join, resolve } from 'node:path' +import { parseArgs } from 'node:util' +import { z } from 'zod' +import { prepareDesktopPublication } from './publish-desktop-ci.ts' +import { readDesktopUploadCredentials } from './desktop-publish-config.ts' +import { desktopPublishCos } from './desktop-publish-cos.ts' +import { createDesktopPublicationRecord, withDesktopChannelLock } from './desktop-publish-record.ts' +import { rollbackDesktopChannel } from './desktop-rollback.ts' +import { desktopPublicationFailure } from './desktop-publication-failure.ts' + +if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.filename) { + let phase = 'rollback arguments and local publication verification' + try { + const { values } = parseArgs({ options: { + record: { type: 'string' }, 'expected-version': { type: 'string' }, + reason: { type: 'string' }, execute: { type: 'boolean', default: false }, + } }) + if (!values.record || !values['expected-version'] || !values.reason?.trim()) { + throw new Error('Usage: rollback-desktop-channel.ts --record --expected-version --reason [--execute]') + } + const operation = z.object({ repository: z.string(), runId: z.string(), + attempt: z.string(), destination: z.string(), operation: z.literal('publish') }) + .parse(JSON.parse(await readFile(join(values.record, 'operation.json'), 'utf8'))) + z.object({ status: z.enum(['complete', 'published-tag-pending']) }) + .parse(JSON.parse(await readFile(join(values.record, 'result.json'), 'utf8'))) + const { plan, config, root, manifest } = await prepareDesktopPublication(process.env) + const route = z.object({ runId: z.string(), attempt: z.string() }).parse(JSON.parse(process.env.DESKTOP_PUBLISH_ROUTE || '{}')) + const destination = `${plan.bucket}/${plan.publicUrl}${manifest.source.channel}` + if (operation.runId !== route.runId || operation.attempt !== route.attempt + || operation.repository !== process.env.GITHUB_REPOSITORY || operation.destination !== destination) { + throw new Error('desktop rollback: successful publication record does not match the selected package') + } + const credentials = await readDesktopUploadCredentials(config) + phase = 'rollback channel lock and audit creation' + await withDesktopChannelLock(join(dirname(root), 'publish-locks'), destination, async () => { + const journal = await createDesktopPublicationRecord(join(dirname(root), 'upload-records'), { + ...operation, actor: process.env.USER || process.env.USERNAME || 'unknown', operation: 'rollback', reason: values.reason!, + }) + process.stdout.write(`desktop rollback: audit ${journal.directory}\n`) + let complete = false + try { + phase = 'historical object verification and channel restoration' + const outcome = await rollbackDesktopChannel(plan, values['expected-version']!, values.execute!, { + transport: desktopPublishCos(plan, credentials), record: event => journal.record(event), + tag: async () => { throw new Error('desktop rollback never changes tags') }, + }) + process.stdout.write(`${JSON.stringify(outcome, null, 2)}\n`) + complete = true + } catch (error) { + await journal.record({ type: 'failed', failure: desktopPublicationFailure(error, phase) }) + throw error + } finally { await journal.finish(complete ? (values.execute ? 'complete' : 'preview') : 'failed') } + }) + } catch (error) { + process.stderr.write(`${JSON.stringify(desktopPublicationFailure(error, phase))}\n`) + process.exitCode = 1 + } +} diff --git a/apps/desktop/scripts/runtime-patch-policy.ts b/apps/desktop/scripts/runtime-patch-policy.ts index f688db356d..d2ea174f2c 100644 --- a/apps/desktop/scripts/runtime-patch-policy.ts +++ b/apps/desktop/scripts/runtime-patch-policy.ts @@ -8,17 +8,18 @@ export type DesktopRuntimePatch = /** Every workspace patch requires an explicit Desktop decision; runtime-only entries may add or replace a patch. */ export const DESKTOP_RUNTIME_PATCHES: Readonly> = { - '@earendil-works/pi-ai@1.0.2': { scope: 'shared', reason: 'Runtime providers need the streamed argument parsing fix.' }, + '@earendil-works/pi-ai@1.1.0': { scope: 'shared', reason: 'Runtime providers need the streamed argument parsing fix.' }, '@electron/osx-sign@1.3.3': { scope: 'workspace-only', reason: 'Signing executes on the build host.' }, '@fortune-sheet/core@1.0.4': { scope: 'workspace-only', reason: 'Patched spreadsheet code is embedded in the client bundle.' }, '@fortune-sheet/react@1.0.4': { scope: 'workspace-only', reason: 'Patched spreadsheet code is embedded in the client bundle.' }, '@yao-pkg/pkg@6.21.0': { scope: 'workspace-only', reason: 'The executable packer executes on the build host.' }, + 'app-builder-lib@26.17.0': { scope: 'workspace-only', reason: 'DMG format validation executes on the build host.' }, 'exceljs@4.4.0': { scope: 'workspace-only', reason: 'The patched parser is embedded in the client bundle and worker.' }, 'node-pty@1.2.0-beta.15': { scope: 'shared', reason: 'Runtime terminals need helper resolution and Windows worker cleanup.' }, } /** Reviewed Desktop dependency constraints, independent of patch selection. */ export const DESKTOP_RUNTIME_DEPENDENCY_OVERRIDES: Readonly> = { - // The provider declares ^1.0.2; its patch is qualified against 1.0.2. - '@earendil-works/pi-ai@^1.0.2': '1.0.2', + // The provider declares ^1.1.0; its patch is qualified against 1.1.0. + '@earendil-works/pi-ai@^1.1.0': '1.1.0', } diff --git a/apps/desktop/scripts/setup-macos-packaging.mjs b/apps/desktop/scripts/setup-macos-packaging.mjs new file mode 100644 index 0000000000..d04200f058 --- /dev/null +++ b/apps/desktop/scripts/setup-macos-packaging.mjs @@ -0,0 +1,34 @@ +/** Prepare private machine directories and templates without registering a runner or overwriting configuration. */ +import { readFileSync, writeFileSync, existsSync, lstatSync, mkdirSync } from 'node:fs' +import { dirname, isAbsolute, join, resolve } from 'node:path' + +const [root, mode] = process.argv.slice(2) +if (process.platform !== 'darwin') throw new Error('Run macOS packaging setup on a Mac') +if (!root || !isAbsolute(root) || (mode !== undefined && mode !== '--check') || process.argv.length > 4) { + throw new Error('Usage: node setup-macos-packaging.mjs ABSOLUTE_ROOT [--check]') +} +if (/[\r\n']/u.test(root)) throw new Error('Packaging root cannot contain single quotes or newlines') +for (let path = resolve(root); ; path = dirname(path)) { + if (existsSync(path) && lstatSync(path).isSymbolicLink()) throw new Error('Packaging root ancestors cannot be links') + if (path === dirname(path)) break +} +for (const relative of ['', 'packaging', 'packaging/presets', 'packaging/secrets', 'packaging/certificates', 'runner', 'cache', 'cache/macos-signatures']) { + const path = join(root, relative) + if (!existsSync(path) && mode !== '--check') mkdirSync(path, { recursive: true, mode: 0o700 }) + const info = lstatSync(path) + if (!info.isDirectory() || info.isSymbolicLink() || info.uid !== process.getuid() || (info.mode & 0o077) !== 0) { + throw new Error(`Packaging directories must be real, private and owned by the current account: ${path}`) + } +} +if (mode !== '--check') { + for (const [name, directory] of [['test', 'presets'], ['production', 'presets'], ['macos-signing', 'secrets']]) { + const destination = join(root, 'packaging', directory, `${name}.env.example`) + if (!existsSync(destination)) { + const template = readFileSync(resolve(import.meta.dirname, '../config/macos-packaging', `${name}.env.example`), 'utf8') + const cache = join(root, 'cache/macos-signatures') + writeFileSync(destination, template.replace('# DSH_DESKTOP_MACOS_SIGNATURE_CACHE_DIR=', + `DSH_DESKTOP_MACOS_SIGNATURE_CACHE_DIR='${cache}'`), { flag: 'wx', mode: 0o600 }) + } + } +} +process.stdout.write(`Private packaging directories verified: ${resolve(root)}\n`) diff --git a/apps/desktop/scripts/setup-windows-packaging.ps1 b/apps/desktop/scripts/setup-windows-packaging.ps1 new file mode 100644 index 0000000000..9e686fb2fe --- /dev/null +++ b/apps/desktop/scripts/setup-windows-packaging.ps1 @@ -0,0 +1,49 @@ +# Prepare or inspect machine-owned Windows packaging directories without reading secret values. +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][string]$Root, + [switch]$CheckOnly +) +$ErrorActionPreference = 'Stop' +if ($Root -notmatch '^[A-Za-z]:[\\/]') { throw 'Root must be an absolute local drive directory' } +$rootPath = [IO.Path]::GetFullPath($Root) +$drive = [IO.DriveInfo]::new([IO.Path]::GetPathRoot($rootPath)) +if ($drive.DriveType -ne [IO.DriveType]::Fixed) { throw 'Packaging root requires a fixed local drive' } +for ($ancestor = [IO.DirectoryInfo]::new($rootPath); $null -ne $ancestor; $ancestor = $ancestor.Parent) { + if ($ancestor.Exists -and ($ancestor.Attributes -band [IO.FileAttributes]::ReparsePoint)) { throw 'Packaging root cannot contain linked directories' } +} +$identity = [Security.Principal.WindowsIdentity]::GetCurrent().User +$allowed = @($identity.Value, 'S-1-5-18', 'S-1-5-32-544') +foreach ($relative in @('packaging', 'packaging/presets', 'packaging/secrets', 'packaging/certificates', 'cache', 'cache/windows-signatures')) { + $path = Join-Path $rootPath $relative + if (-not (Test-Path -LiteralPath $path)) { + if ($CheckOnly) { throw "Missing directory: $path" } + $null = New-Item -ItemType Directory -Path $path + $acl = [Security.AccessControl.DirectorySecurity]::new() + $acl.SetOwner($identity) + $acl.SetAccessRuleProtection($true, $false) + foreach ($sid in $allowed) { + $rule = [Security.AccessControl.FileSystemAccessRule]::new([Security.Principal.SecurityIdentifier]::new($sid), 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow') + $null = $acl.AddAccessRule($rule) + } + Set-Acl -LiteralPath $path -AclObject $acl + } + $item = Get-Item -LiteralPath $path + if (-not $item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) { throw "Not a real directory: $path" } + $acl = Get-Acl -LiteralPath $path + if ($acl.GetOwner([Security.Principal.SecurityIdentifier]).Value -ne $identity.Value) { throw "Directory must belong to current account: $path" } + foreach ($rule in $acl.GetAccessRules($true, $true, [Security.Principal.SecurityIdentifier])) { + if ($rule.AccessControlType -eq 'Allow' -and $rule.IdentityReference.Value -notin $allowed) { throw "Directory permits another account: $path" } + } +} +if (-not $CheckOnly) { + $templates = Join-Path $PSScriptRoot '../config/windows-packaging' + foreach ($name in @('test', 'production')) { + $destination = Join-Path $rootPath "packaging/presets/$name.env.example" + if (-not (Test-Path -LiteralPath $destination)) { Copy-Item -LiteralPath (Join-Path $templates "$name.env.example") -Destination $destination } + } + $destination = Join-Path $rootPath 'packaging/secrets/windows-signing.env.example' + if (-not (Test-Path -LiteralPath $destination)) { Copy-Item -LiteralPath (Join-Path $templates 'windows-signing.env.example') -Destination $destination } +} +Write-Output "Packaging directories verified. Configuration root: $(Join-Path $rootPath 'packaging')" +Write-Output 'Existing configuration, caches, runner registration and signing failure records were not overwritten.' diff --git a/apps/desktop/scripts/smoke-packaged-runtime.ts b/apps/desktop/scripts/smoke-packaged-runtime.ts index b91030008b..6c3c12c857 100644 --- a/apps/desktop/scripts/smoke-packaged-runtime.ts +++ b/apps/desktop/scripts/smoke-packaged-runtime.ts @@ -1,17 +1,17 @@ /** Validate the assembled application, including native Office conversion outside ASAR. */ import { join } from 'node:path' import { parseArgs } from 'node:util' -import { resolveDesktopBuildTarget, resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs' +import { desktopArtifactOutput, resolveDesktopBuildTarget, resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs' import { readDesktopRuntime, verifyDesktopRuntime } from '../src/runtime-tree.ts' import { verifyWindowsCode } from './windows-runtime-signature.mjs' import { smokePreparedRuntime } from './smoke-prepared-runtime.ts' import { resolveDesktopPackageTarget } from './package-target.ts' const paths = resolveDesktopTargetBuildPaths() -const { values } = parseArgs({ options: { unsigned: { type: 'boolean', default: false } }, allowPositionals: false }) +const { values } = parseArgs({ options: { unsigned: { type: 'boolean', default: false }, 'skip-notarization': { type: 'boolean', default: false } }, allowPositionals: false }) const target = resolveDesktopBuildTarget() const windows = target === 'win-x64' -const artifacts = values.unsigned ? paths.unsignedArtifacts : paths.artifacts +const artifacts = desktopArtifactOutput(paths, { unsigned: values.unsigned, skipNotarization: values['skip-notarization'] }).directory const application = windows ? join(artifacts, 'win-unpacked') : join(artifacts, target === 'mac-arm64' ? 'mac-arm64' : 'mac', 'DeepSeek Harness.app', 'Contents') const resources = join(application, windows ? 'resources' : 'Resources') diff --git a/apps/desktop/scripts/smoke-runtime.ts b/apps/desktop/scripts/smoke-runtime.ts index dec4f8fbc6..bbc2d3e7ae 100644 --- a/apps/desktop/scripts/smoke-runtime.ts +++ b/apps/desktop/scripts/smoke-runtime.ts @@ -104,7 +104,7 @@ export function apply(ctx) { 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 skill = await ctx.skills.get('office-export-pdf') 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) diff --git a/apps/desktop/scripts/start-macos-packaging-runner.sh b/apps/desktop/scripts/start-macos-packaging-runner.sh new file mode 100644 index 0000000000..a171001a4a --- /dev/null +++ b/apps/desktop/scripts/start-macos-packaging-runner.sh @@ -0,0 +1,42 @@ +#!/bin/bash +# Run an already registered Apple Silicon runner with explicit tools and machine configuration. +set -euo pipefail +if [[ $# -lt 3 || $# -gt 4 || (${4:-} != '' && ${4:-} != '--check' && ${4:-} != '--service') ]]; then + echo 'Usage: bash start-macos-packaging-runner.sh RUNNER_ROOT CONFIG_ROOT NODE_BIN [--check|--service]' >&2 + exit 1 +fi +runner_root=$1 +config_root=$2 +node_bin=$3 +for directory in "$runner_root" "$config_root" "$node_bin"; do + [[ "$directory" = /* && -d "$directory" ]] || { echo 'All directories must exist and be absolute' >&2; exit 1; } +done +[[ $(uname -s) = Darwin && $(uname -m) = arm64 ]] || { echo 'Requires an Apple Silicon Mac' >&2; exit 1; } +[[ -x "$node_bin/node" ]] || { echo 'NODE_BIN must contain Node 24' >&2; exit 1; } +[[ $("$node_bin/node" -p 'process.versions.node.split(".")[0]') = 24 ]] || { echo 'Requires Node 24' >&2; exit 1; } +[[ -f "$config_root/presets/test.env" && -f "$config_root/secrets/macos-signing.env" ]] || { echo 'Configure the test preset and macos-signing.env before starting' >&2; exit 1; } +developer_dir=$(xcode-select -p) +sdk_root=$(DEVELOPER_DIR="$developer_dir" xcrun --sdk macosx --show-sdk-path) +xcrun --find notarytool +xcrun --find stapler +probe_dir=$(mktemp -d "${TMPDIR:-/tmp}/dsh-macos-toolchain.XXXXXX") +trap 'rm -rf "$probe_dir"' EXIT +DEVELOPER_DIR="$developer_dir" SDKROOT="$sdk_root" /usr/bin/cc -x c - -o "$probe_dir/toolchain-probe" <<<'int main(void) { return 0; }' +rm -rf "$probe_dir" +trap - EXIT +if [[ ${4:-} = '--check' ]]; then + echo 'Runner tool and configuration-file checks passed; registration and credentials were not tested.' + exit 0 +fi +[[ -f "$runner_root/.runner" && -x "$runner_root/run.sh" ]] || { echo 'Register the runner before starting' >&2; exit 1; } +entry=./run.sh +if [[ ${4:-} = '--service' ]]; then + [[ -x "$runner_root/runsvc.sh" ]] || { echo 'Install the runner service first' >&2; exit 1; } + entry=./runsvc.sh +fi +cd "$runner_root" +exec /usr/bin/env -i HOME="$HOME" USER="$(id -un)" LOGNAME="$(id -un)" \ + PATH="$node_bin:/usr/bin:/bin:/usr/sbin:/sbin" TMPDIR="${TMPDIR:-/tmp}" \ + DEVELOPER_DIR="$developer_dir" SDKROOT="$sdk_root" \ + ACTIONS_RUNNER_SVC="${ACTIONS_RUNNER_SVC:-0}" LANG=en_US.UTF-8 DSH_PACKAGING_CONFIG_DIR="$config_root" DSH_TELEMETRY_DISABLED=1 \ + /usr/bin/caffeinate -i "$entry" diff --git a/apps/desktop/scripts/start-windows-packaging-runner.ps1 b/apps/desktop/scripts/start-windows-packaging-runner.ps1 new file mode 100644 index 0000000000..13251a110a --- /dev/null +++ b/apps/desktop/scripts/start-windows-packaging-runner.ps1 @@ -0,0 +1,38 @@ +# Start the existing registered runner with explicit machine configuration and a scrubbed environment. +[CmdletBinding()] +param([Parameter(Mandatory=$true)][string]$RunnerRoot, [Parameter(Mandatory=$true)][string]$ConfigRoot, [switch]$CheckOnly) +$ErrorActionPreference='Stop' +$pwshRoot=Join-Path $RunnerRoot 'tools/pwsh-7.6.6' +$runnerPath = $pwshRoot + ';' + [Environment]::GetEnvironmentVariable('Path','Machine') + ';' + [Environment]::GetEnvironmentVariable('Path','User') +$keep=@('SystemRoot','windir','SystemDrive','ComSpec','TEMP','TMP','USERPROFILE','HOMEDRIVE','HOMEPATH','LOCALAPPDATA','APPDATA','ProgramData','ProgramFiles','ProgramFiles(x86)','CommonProgramFiles','CommonProgramFiles(x86)','CommonProgramW6432','ProgramW6432','PROCESSOR_ARCHITECTURE','NUMBER_OF_PROCESSORS','OS','PATHEXT','USERNAME','USERDOMAIN','COMPUTERNAME','PUBLIC','ALLUSERSPROFILE') +foreach($entry in @(Get-ChildItem Env:)) { + if($entry.Name -notin $keep) { [Environment]::SetEnvironmentVariable($entry.Name,$null,'Process') } +} +$env:PATH=$runnerPath +if (-not [IO.Path]::IsPathRooted($ConfigRoot) -or -not (Test-Path -LiteralPath $ConfigRoot -PathType Container)) { throw 'ConfigRoot must identify an existing absolute directory' } +if ($ConfigRoot -notmatch '^[A-Za-z]:[\\/]') { throw 'ConfigRoot requires an absolute local drive directory' } +$drive = [IO.DriveInfo]::new([IO.Path]::GetPathRoot([IO.Path]::GetFullPath($ConfigRoot))) +if ($drive.DriveType -ne [IO.DriveType]::Fixed) { throw 'ConfigRoot requires a fixed local drive' } +$env:DSH_PACKAGING_CONFIG_DIR=[IO.Path]::GetFullPath($ConfigRoot) +$env:DSH_TELEMETRY_DISABLED='1' +$env:POWERSHELL_TELEMETRY_OPTOUT='1' +foreach($tool in @('pwsh','node','git','tar','python')) { + $resolved=Get-Command $tool -CommandType Application -ErrorAction Stop | Select-Object -First 1 + Write-Output ('Tool: '+$tool+' -> '+$resolved.Source) +} +$env:PYTHON=(Get-Command python -CommandType Application | Select-Object -First 1).Source +& pwsh -NoProfile -Command '$PSVersionTable.PSVersion.ToString()' +if($LASTEXITCODE -ne 0){throw 'PowerShell probe failed'} +& node --version +if($LASTEXITCODE -ne 0){throw 'Node probe failed'} +& python --version +if($LASTEXITCODE -ne 0){throw 'Python probe failed'} +$vswhere=Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio/Installer/vswhere.exe' +$visualStudio=& $vswhere -latest -products '*' -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath +if($LASTEXITCODE -ne 0 -or -not $visualStudio){throw 'Visual C++ tools unavailable'} +Write-Output 'Visual C++ tools located.' +if($CheckOnly){Write-Output 'Runner environment check passed.'; exit 0} +if(Get-Process -Name Runner.Listener -ErrorAction SilentlyContinue){throw 'A runner listener is already running; inspect before starting another.'} +Set-Location $RunnerRoot +& .\run.cmd +exit $LASTEXITCODE diff --git a/apps/desktop/scripts/verify-macos-signature.d.mts b/apps/desktop/scripts/verify-macos-signature.d.mts index a33c3f4213..cf1434b2d2 100644 --- a/apps/desktop/scripts/verify-macos-signature.d.mts +++ b/apps/desktop/scripts/verify-macos-signature.d.mts @@ -80,3 +80,10 @@ export function verifyMacOSSignatureAfterSign( context: MacOSAfterSignContext, expected: MacOSSigningEnvironment, ): void + +/** + * Verify a disk image's code signature and Developer ID without requiring a notarization ticket. + * @param diskImagePath Path to the packaged `.dmg` file. + * @param expected Public signing identity. + */ +export function verifyMacOSDiskImageSignature(diskImagePath: string, expected: MacOSSigningEnvironment): void diff --git a/apps/desktop/scripts/verify-macos-signature.mjs b/apps/desktop/scripts/verify-macos-signature.mjs index 6817f0962f..a906f88ab7 100644 --- a/apps/desktop/scripts/verify-macos-signature.mjs +++ b/apps/desktop/scripts/verify-macos-signature.mjs @@ -172,9 +172,7 @@ export function verifyMacOSNotarizedApplication(appPath, expected) { * @returns {void} */ export function verifyMacOSDiskImage(diskImagePath, expected) { - runCodeSign(['--verify', '--strict', '--verbose=2', diskImagePath]) - const details = runCodeSign(['--display', '--verbose=4', diskImagePath]) - assertMacOSSignatureDetails(details, expected) + verifyMacOSDiskImageSignature(diskImagePath, expected) runAppleCommand('/usr/bin/xcrun', ['stapler', 'validate', diskImagePath], 'stapler validate') runAppleCommand('/usr/sbin/spctl', ['--assess', '--type', 'install', '--verbose=4', diskImagePath], 'spctl') } @@ -202,3 +200,15 @@ if (process.argv[1] !== undefined && import.meta.filename === resolve(process.ar verifyMacOSSignature(resolve(appPath), expected) process.stdout.write(`desktop macOS signing: verified Developer ID Application: ${expected.signingIdentity} (${expected.teamId})\n`) } + +/** + * Verify a disk image's code signature and Developer ID without requiring a notarization ticket. + * @param {string} diskImagePath Path to the packaged `.dmg` file. + * @param {{ signingIdentity: string, teamId: string }} expected Public signing identity. + * @returns {void} + */ +export function verifyMacOSDiskImageSignature(diskImagePath, expected) { + runCodeSign(['--verify', '--strict', '--verbose=2', diskImagePath]) + const details = runCodeSign(['--display', '--verbose=4', diskImagePath]) + assertMacOSSignatureDetails(details, expected) +} diff --git a/apps/desktop/scripts/verify-published-windows.ps1 b/apps/desktop/scripts/verify-published-windows.ps1 new file mode 100644 index 0000000000..2e2f772451 --- /dev/null +++ b/apps/desktop/scripts/verify-published-windows.ps1 @@ -0,0 +1,16 @@ +# Verify the existing installer; never sign or access the hardware token. +param( + [Parameter(Mandatory = $true)][string]$Installer, + [Parameter(Mandatory = $true)][string]$Thumbprint +) +$ErrorActionPreference = 'Stop' +$signature = Get-AuthenticodeSignature -LiteralPath $Installer +if ($signature.Status -ne 'Valid' -or $null -eq $signature.SignerCertificate) { + throw 'Installer does not have a valid Authenticode signature.' +} +if ($signature.SignerCertificate.Thumbprint -ne $Thumbprint) { + throw 'Installer signer does not match the configured release certificate.' +} +if ($null -eq $signature.TimeStamperCertificate) { + throw 'Installer has no verified signing timestamp.' +} diff --git a/apps/desktop/scripts/verify-retained-desktop.ts b/apps/desktop/scripts/verify-retained-desktop.ts new file mode 100644 index 0000000000..613580da9a --- /dev/null +++ b/apps/desktop/scripts/verify-retained-desktop.ts @@ -0,0 +1,44 @@ +/** Recheck distribution signatures on retained installers without signing credentials. */ +import { execFileSync } from 'node:child_process' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { DesktopRetainedManifest } from './desktop-release-store.ts' +import type { DesktopPublishConfig } from './desktop-publish-config.ts' +import { verifyMacOSDiskImage, verifyMacOSNotarizedApplication } from './verify-macos-signature.mjs' + +/** + * Verify Windows Authenticode or macOS DMG and archived app notarization on the original platform. + * @param directory Retained attempt whose file hashes were already checked. + * @param manifest Validated inventory and packaging mode. + * @param config Public expected signer configuration. + * @returns Resolves after platform verification; extracted ZIP contents are removed on all exits. + */ +export async function verifyRetainedDesktopSignatures( + directory: string, manifest: DesktopRetainedManifest, config: DesktopPublishConfig, +): Promise { + const { source } = manifest + const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !/KEY|SECRET|TOKEN|PASSWORD/iu.test(key))) + const run = (command: string, args: string[]) => execFileSync(command, args, { env, encoding: 'utf8', timeout: 120_000, maxBuffer: 16 * 1024 * 1024 }) + if (source.target === 'win-x64') { + if (process.platform !== 'win32' || source.qualification !== 'signed' || !config.windows) throw new Error('desktop publish: signed Windows package and signer configuration required on Windows') + const installer = manifest.files.find(file => file.name.endsWith('.exe')) + if (!installer) throw new Error('desktop publish: installer missing') + run('pwsh', ['-NoProfile', '-NonInteractive', '-File', join(import.meta.dirname, 'verify-published-windows.ps1'), + '-Installer', join(directory, installer.name), '-Thumbprint', config.windows.certificateThumbprint]) + return + } + if (process.platform !== 'darwin' || source.qualification !== 'notarized' || !config.macOS) throw new Error('desktop publish: notarized package and signer configuration required on macOS') + const dmg = manifest.files.find(file => file.name.endsWith('.dmg')) + const zip = manifest.files.find(file => file.name.endsWith('.zip')) + if (!dmg || !zip) throw new Error('desktop publish: DMG and ZIP are required') + verifyMacOSDiskImage(join(directory, dmg.name), config.macOS) + const zipPath = join(directory, zip.name) + const listing = run('/usr/bin/unzip', ['-Z1', zipPath]).trim().split('\n') + if (listing.some(name => name.startsWith('/') || name.includes('\\') || name.split('/').includes('..'))) throw new Error('desktop publish: unsafe ZIP path') + const extracted = await mkdtemp(join(tmpdir(), 'desktop-publish-verify-')) + try { + run('/usr/bin/ditto', ['-x', '-k', zipPath, extracted]) + verifyMacOSNotarizedApplication(join(extracted, 'DeepSeek Harness.app'), config.macOS) + } finally { await rm(extracted, { recursive: true, force: true }) } +} diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 1202670a3e..2afdfd7194 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -33,6 +33,7 @@ import { DESKTOP_IPC, SCHEME, assertDesktopSender, type DesktopUpdateState } fro import { readDeviceInfo } from './device-info.ts' import { desktopUpdateReadyConfirmation, formatDesktopMessage, resolveDesktopLocale, resolveDesktopStartupLocale } from './locale.ts' import { claimDesktopSingleInstance } from './single-instance.ts' +import { desktopUpdateChannel } from './update-channel.ts' import { DesktopUpdateCoordinator } from './update-coordinator.ts' import { DesktopCommandManager } from './command-management.ts' import { serveWebDocument, authenticateWebHost, forwardWebRequest } from './web-document.ts' @@ -583,6 +584,8 @@ async function main(): Promise { return startup } + const manifest: unknown = JSON.parse(await readFile(join(app.getAppPath(), 'package.json'), 'utf8')) + const updateChannel = desktopUpdateChannel(manifest) const updates = new DesktopUpdateCoordinator( publishUpdate, async () => { @@ -636,7 +639,7 @@ async function main(): Promise { }, undefined, undefined, undefined, (success, reason) => { void track('desktop_upgrade_download_result', { is_success: success, ...reason === undefined ? {} : { error_reason: reason } }) }, - + updateChannel, ) const updateSchedule = new DesktopUpdateSchedule(updates, resolveDesktopUpdateScheduleConfig(process.env)) @@ -1285,7 +1288,6 @@ async function main(): Promise { }) mainWindow = createMainWindow() - const manifest: unknown = JSON.parse(await readFile(join(app.getAppPath(), 'package.json'), 'utf8')) if (typeof manifest !== 'object' || manifest === null) throw new Error('desktop policy: invalid application manifest') const developmentPolicy = app.isPackaged ? undefined : process.env.DSH_DESKTOP_MANDATORY_UPDATE_CONFIG const policyInput: unknown = app.isPackaged @@ -1302,6 +1304,7 @@ async function main(): Promise { let wasBlocking = false mandatoryPolicy = new DesktopMandatoryUpdatePolicy(policyConfig, { platform: process.platform as 'win32' | 'darwin', arch: process.arch as 'x64' | 'arm64', + channel: updateChannel, bundledDshVersion: app.isPackaged ? readDesktopRuntime(resources.dsh).release.version : app.getVersion(), }, (state) => { if (state.error !== 'authentication-required') policyAuthenticationQueued = false diff --git a/apps/desktop/src/mandatory-update-policy.ts b/apps/desktop/src/mandatory-update-policy.ts index 27fd51bd44..92168a7ea3 100644 --- a/apps/desktop/src/mandatory-update-policy.ts +++ b/apps/desktop/src/mandatory-update-policy.ts @@ -8,6 +8,7 @@ export interface DesktopPolicyIdentity { readonly platform: 'win32' | 'darwin' readonly bundledDshVersion: string readonly arch: 'x64' | 'arm64' + readonly channel: 'nightly' | 'latest' } /** Validated deployment choices; test authentication is explicitly enabled, never inferred from a redirect. */ @@ -158,7 +159,7 @@ export class DesktopMandatoryUpdatePolicy { private requestHeaders(): Record { return { ...platformClientHeaders(this.identity.platform, this.client()), - 'x-client-arch': this.identity.arch, 'x-client-update-channel': 'nightly', + 'x-client-arch': this.identity.arch, 'x-client-update-channel': this.identity.channel, 'x-client-bundled-dsh-version': this.identity.bundledDshVersion, } } diff --git a/apps/desktop/src/update-channel.ts b/apps/desktop/src/update-channel.ts new file mode 100644 index 0000000000..ac767ea642 --- /dev/null +++ b/apps/desktop/src/update-channel.ts @@ -0,0 +1,17 @@ +/** Resolve the update channel sealed into the installed application manifest. */ + +/** Installer-owned channel, independent of release version and upload environment. */ +export type DesktopUpdateChannel = 'nightly' | 'latest' + +/** + * Read the packaged channel; older manifests retain their existing nightly behavior. + * @param manifest Parsed application manifest. + * @returns Validated channel. Invalid explicit values reject instead of changing feeds. + */ +export function desktopUpdateChannel(manifest: unknown): DesktopUpdateChannel { + if (typeof manifest !== 'object' || manifest === null) throw new Error('desktop update: invalid application manifest') + if (!('dshDesktopUpdateChannel' in manifest)) return 'nightly' + const channel = manifest.dshDesktopUpdateChannel + if (channel !== 'nightly' && channel !== 'latest') throw new Error('desktop update: invalid packaged channel') + return channel +} diff --git a/apps/desktop/src/update-coordinator.ts b/apps/desktop/src/update-coordinator.ts index 8d6eb808e7..3b83cea08f 100644 --- a/apps/desktop/src/update-coordinator.ts +++ b/apps/desktop/src/update-coordinator.ts @@ -45,6 +45,7 @@ export class DesktopUpdateCoordinator { * @param updater - Process-owned Electron updater, replaceable at the network/platform test boundary. * @param enabled - Whether this process has a packaged update source. * @param currentVersion - Actual installed application version. + * @param channel - Installed package channel; omitted test/local callers retain nightly. * @param downloadResult - Once per completed download attempt, including platform preparation failures. */ constructor( @@ -54,6 +55,7 @@ export class DesktopUpdateCoordinator { private readonly enabled: () => boolean = () => app.isPackaged && existsSync(join(process.resourcesPath, 'app-update.yml')), private readonly currentVersion: () => string = () => app.getVersion(), private readonly downloadResult?: (success: boolean, reason?: string) => void, + private readonly channel: 'nightly' | 'latest' = 'nightly', ) { if (updater === autoUpdater) { // electron-updater omits this internal transport property from its public declarations. @@ -66,8 +68,8 @@ export class DesktopUpdateCoordinator { } this.updater.autoDownload = false this.updater.autoInstallOnAppQuit = false - this.updater.channel = 'nightly' - this.updater.allowPrerelease = true + this.updater.channel = channel + this.updater.allowPrerelease = channel === 'nightly' // Selecting a channel can enable downgrade in electron-updater. this.updater.allowDowngrade = false this.updater.on('download-progress', this.onProgress) @@ -187,6 +189,7 @@ export class DesktopUpdateCoordinator { if (result === null) throw new Error('desktop update: no check result was returned') const version = result.updateInfo.version if (valid(version) === null) throw new Error('desktop update: feed version is invalid') + if (this.channel === 'latest' && version.includes('-')) throw new Error('desktop update: latest feed contains a prerelease') this.candidate = result.isUpdateAvailable && gt(version, this.currentVersion()) ? version : undefined return this.setState(this.candidate === undefined ? { phase: 'idle' } : { phase: 'available', version }) } catch (error) { diff --git a/apps/desktop/src/update-dialog.ts b/apps/desktop/src/update-dialog.ts index 249294f8ae..8f263ca38e 100644 --- a/apps/desktop/src/update-dialog.ts +++ b/apps/desktop/src/update-dialog.ts @@ -136,7 +136,11 @@ export class DesktopUpdateDialog { const window = this.window this.window = undefined this.parent = undefined - if (window !== undefined && !window.isDestroyed()) window.destroy() + if (window !== undefined && !window.isDestroyed()) { + // Release Windows modality before destruction so another application cannot become active. + if (process.platform === 'win32' && window.isVisible()) window.hide() + window.destroy() + } } private owned(event: IpcMainInvokeEvent): void { diff --git a/apps/desktop/tests/desktop-auto-update-environment.spec.ts b/apps/desktop/tests/desktop-auto-update-environment.spec.ts index c33a9384df..9fbe629c89 100644 --- a/apps/desktop/tests/desktop-auto-update-environment.spec.ts +++ b/apps/desktop/tests/desktop-auto-update-environment.spec.ts @@ -40,7 +40,7 @@ describe('desktop auto-update environment', () => { DOWNLOAD_TEST_ORIGIN: 'https://desktop-updates.example.com/', DOWNLOAD_TEST_RELEASE_ID: RELEASE_ID, }, 'darwin', 'arm64')).toEqual({ - environment: 'test', + environment: 'test', channel: 'nightly', target: 'mac-arm64', origin: 'https://desktop-updates.example.com', publicUrl: `https://desktop-updates.example.com/dsh-desk/${RELEASE_ID}/feeds/mac-arm64/`, diff --git a/apps/desktop/tests/desktop-build-paths.spec.ts b/apps/desktop/tests/desktop-build-paths.spec.ts index a5e9faf296..ede5c5e3d3 100644 --- a/apps/desktop/tests/desktop-build-paths.spec.ts +++ b/apps/desktop/tests/desktop-build-paths.spec.ts @@ -16,6 +16,7 @@ describe('desktop build paths', () => { 'root', 'artifacts', 'unsignedArtifacts', + 'unnotarizedArtifacts', 'runtime', 'packageSet', 'dsh', diff --git a/apps/desktop/tests/desktop-build-version-discovery.spec.ts b/apps/desktop/tests/desktop-build-version-discovery.spec.ts index 8438e076ad..0fe063d1e2 100644 --- a/apps/desktop/tests/desktop-build-version-discovery.spec.ts +++ b/apps/desktop/tests/desktop-build-version-discovery.spec.ts @@ -71,6 +71,16 @@ describe('desktop build version discovery', () => { .resolves.toBe(`${PRERELEASE}.${DATE}.4`) }) + it('numbers after existing unnotarized DMG and ZIP artifacts', async () => { + const artifactsRoot = await artifactsWith([ + `deepseek-harness-${PRERELEASE}.${DATE}.2-mac-arm64-unnotarized.dmg`, + `deepseek-harness-${PRERELEASE}.${DATE}.4-mac-arm64-unnotarized.zip`, + `deepseek-harness-${PRERELEASE}.${DATE}.9-mac-arm64-unnotarized.zip.blockmap`, + ]) + await expect(suggestDesktopBuildVersion({ productVersion: PRERELEASE, target: 'mac-arm64', environment: {}, date: DATE, artifactsRoot })) + .resolves.toBe(`${PRERELEASE}.${DATE}.5`) + }) + it('counts unsigned Windows artifacts written under their own suffix', async () => { const artifactsRoot = await artifactsWith([ `deepseek-harness-${PRERELEASE}.${DATE}.2-win-x64-unsigned.exe`, diff --git a/apps/desktop/tests/desktop-package-environment.spec.ts b/apps/desktop/tests/desktop-package-environment.spec.ts index a892a8548a..1c3eb02fc0 100644 --- a/apps/desktop/tests/desktop-package-environment.spec.ts +++ b/apps/desktop/tests/desktop-package-environment.spec.ts @@ -229,3 +229,27 @@ it('owns macOS tuning in the local file and validates it before signing credenti expect(() =>{ validateDesktopPackageEnvironment({ ...RELEASE, DSH_DESKTOP_MACOS_NOTARIZATION_PROXY: 'socks5://localhost:8080' }, MACOS) }).toThrow('NOTARIZATION_PROXY') }) }) + +it('rejects an explicit invalid macOS cache before signing while leaving unsigned builds independent', () => { + const environment = { ...RELEASE, DSH_DESKTOP_MACOS_SIGNATURE_CACHE_DIR: '' } + for (const options of [{}, { prepareOnly: true }]) { + expect(() => { validateDesktopPackageEnvironment(environment, MACOS, options) }).toThrow('SIGNATURE_CACHE_DIR') + } + expect(() => { validateDesktopPackageEnvironment(environment, MACOS, { unsigned: true }) }).not.toThrow() +}) + +it('skips Apple credentials but still requires signing credentials for unnotarized packages', async () => { + await withDirectory(async (directory) => { + const certificate = join(directory, 'signing.p12') + await writeFile(certificate, 'fixture') + const environment = { ...RELEASE, ...MAC_IDENTITY, CSC_LINK: certificate, CSC_KEY_PASSWORD: '' } + expect(() => { validateDesktopPackageEnvironment(environment, MACOS, { skipNotarization: true }) }).not.toThrow() + expect(() => { validateDesktopPackageEnvironment(environment, MACOS) }).toThrow() + expect(() => { + validateDesktopPackageEnvironment({ ...environment, CSC_LINK: undefined }, MACOS, { skipNotarization: true }) + }).toThrow(/CSC_LINK/u) + expect(() => { + validateDesktopPackageEnvironment({ ...environment, CSC_KEY_PASSWORD: undefined }, MACOS, { skipNotarization: true }) + }).toThrow(/CSC_KEY_PASSWORD/u) + }) +}) diff --git a/apps/desktop/tests/desktop-publication-failure.spec.ts b/apps/desktop/tests/desktop-publication-failure.spec.ts new file mode 100644 index 0000000000..f19e1f76b5 --- /dev/null +++ b/apps/desktop/tests/desktop-publication-failure.spec.ts @@ -0,0 +1,36 @@ +import { spawnSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import { expect, it } from 'vitest' +import { z } from 'zod' +import { desktopPublicationFailure } from '../scripts/desktop-publication-failure.ts' +import { DesktopPublishError } from '../scripts/desktop-publish.ts' + +it('preserves actionable validation and filesystem diagnostics', () => { + expect(desktopPublicationFailure(new DesktopPublishError('conflict', 'desktop publish: object differs: bin/app.exe'), 'upload')) + .toMatchObject({ code: 'conflict', message: 'desktop publish: object differs: bin/app.exe' }) + expect(desktopPublicationFailure(new Error('Usage: rollback-desktop-channel.ts --record '), 'arguments').message).toContain('Usage:') + expect(desktopPublicationFailure(Object.assign(new Error('raw'), { code: 'ENOENT', path: '/fixture/config.json' }), 'config').message) + .toContain('/fixture/config.json') +}) + +it('does not retain provider bodies, unknown messages, JSON input or invalid field contents', () => { + const result = z.object({ preset: z.enum(['test', 'production']) }).safeParse({ preset: 'fixture-secret' }) + if (result.success) throw new Error('fixture must reject') + for (const error of [result.error, new SyntaxError('fixture-secret'), new Error('fixture-secret'), + { code: 'AccessDenied', statusCode: 403, body: 'fixture-secret', message: 'fixture-secret' }]) { + expect(JSON.stringify(desktopPublicationFailure(error, 'fixture'))).not.toContain('fixture-secret') + } + expect(desktopPublicationFailure(result.error, 'config').message).toContain('preset') + expect(desktopPublicationFailure({ code: 'AccessDenied', statusCode: 403 }, 'upload').message).toContain('HTTP 403') +}) + +it('prints rollback usage through the real CLI before any audit or remote operation', () => { + const result = spawnSync(process.execPath, ['--import', 'tsx/esm', + fileURLToPath(new URL('../scripts/rollback-desktop-channel.ts', import.meta.url))], { encoding: 'utf8', timeout: 30_000 }) + expect(result.error).toBeUndefined() + expect(result.signal).toBeNull() + expect(result.status).toBe(1) + const diagnostic = z.object({ code: z.string(), message: z.string() }).parse(JSON.parse(result.stderr)) + expect(diagnostic.code).toBe('validation') + expect(diagnostic.message).toContain('Usage: rollback-desktop-channel.ts') +}) diff --git a/apps/desktop/tests/desktop-publication-preflight.spec.ts b/apps/desktop/tests/desktop-publication-preflight.spec.ts new file mode 100644 index 0000000000..bf4d4e8876 --- /dev/null +++ b/apps/desktop/tests/desktop-publication-preflight.spec.ts @@ -0,0 +1,60 @@ +import { createHash } from 'node:crypto' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, it } from 'vitest' +import { retainDesktopCi } from '../scripts/retain-desktop-ci.ts' +import { prepareDesktopPublication } from '../scripts/publish-desktop-ci.ts' + +it.each(['mac-arm64', 'win-x64'] as const)('preflights a complete retained %s build without credentials or source checkout manifests', async (target) => { + const root = await mkdtemp(join(tmpdir(), 'desktop-preflight-')) + try { + const app = join(root, 'app') + const configRoot = join(root, 'packaging') + const artifacts = join(app, '.desktop-build/targets', target, 'artifacts') + await mkdir(artifacts, { recursive: true }) + await mkdir(join(configRoot, 'presets'), { recursive: true }) + await mkdir(join(configRoot, 'publishing')) + await writeFile(join(app, 'package.json'), JSON.stringify({ version: '1.0.0' })) + const releaseId = 'a'.repeat(32) + await writeFile(join(configRoot, 'presets/test.env'), `DSH_DESKTOP_AUTO_UPDATE_ENV=test\nDOWNLOAD_TEST_RELEASE_ID=${releaseId}\n`) + await writeFile(join(configRoot, 'publishing/test.json'), JSON.stringify({ + bucket: 'fixture-1250000000', origin: 'https://example.com', credentialsFile: join(root, 'absent-credentials.json'), + })) + const commit = 'b'.repeat(40) + const base = `deepseek-harness-1.0.0-${target}` + const extension = target === 'win-x64' ? 'exe' : 'zip' + const bytes = 'fixture installer bytes' + await writeFile(join(artifacts, `${base}.${extension}`), bytes) + await writeFile(join(artifacts, `${base}.${extension}.blockmap`), 'fixture blockmap') + if (target !== 'win-x64') await writeFile(join(artifacts, `${base}.dmg`), 'fixture DMG') + await writeFile(join(artifacts, `${target}-release.json`), JSON.stringify({ + schemaVersion: 1, target, environment: 'test', version: '1.0.0', commit, dirty: false, + publicUrl: `https://example.com/dsh-desk/${releaseId}/feeds/${target}/`, + })) + await writeFile(join(artifacts, target === 'win-x64' ? 'nightly.yml' : 'nightly-mac.yml'), JSON.stringify({ + version: '1.0.0', files: [{ url: `${base}.${extension}`, size: Buffer.byteLength(bytes), + sha512: createHash('sha512').update(bytes).digest('base64') }], + })) + const env = { + DSH_PACKAGING_CONFIG_DIR: configRoot, DSH_PACKAGING_PRESET: 'test', + DSH_DESKTOP_TARGET_PLATFORM: target === 'win-x64' ? 'win32' : 'darwin', DSH_DESKTOP_TARGET_ARCH: target === 'win-x64' ? 'x64' : 'arm64', + PACKAGE_SOURCE_REF: 'master', PACKAGE_SOURCE_SHA: 'b'.repeat(40), PACKAGE_VERSION: '1.0.0', PACKAGE_PUBLISH: 'true', PACKAGE_SIGNING: 'signed', PACKAGE_SKIP_NOTARIZATION: 'false', + GITHUB_RUN_ID: '10', GITHUB_RUN_ATTEMPT: '2', GITHUB_REPOSITORY: 'owner/repo', GITHUB_SHA: commit, RUNNER_NAME: 'fixture-runner', + DESKTOP_PUBLISH_ROUTE: JSON.stringify({ sourceRef: 'master', channel: 'nightly', runId: '10', attempt: '2', commit, runnerName: 'fixture-runner', target, preset: 'test' }), + } + const retained = await retainDesktopCi(env, app) + await rm(app, { recursive: true }) + let signatureChecks = 0 + const signature = async () => { signatureChecks++ } + const prepared = await prepareDesktopPublication(env, signature) + expect(prepared.plan.version).toBe('1.0.0') + expect(prepared.plan.artifacts.every(file => file.path.startsWith(retained))).toBe(true) + expect(signatureChecks).toBe(1) + await expect(prepareDesktopPublication({ ...env, DESKTOP_APPROVED_PACKAGE: 'wrong' }, signature)).rejects.toThrow(/changed after approval/u) + await expect(prepareDesktopPublication({ ...env, RUNNER_NAME: 'other-machine' }, signature)).rejects.toThrow(/different machine/u) + await writeFile(join(retained, `${base}.${extension}`), 'changed') + await expect(prepareDesktopPublication(env, signature)).rejects.toThrow(/changed file/u) + expect(signatureChecks).toBe(2) + } finally { await rm(root, { recursive: true, force: true }) } +}) diff --git a/apps/desktop/tests/desktop-publish-config.spec.ts b/apps/desktop/tests/desktop-publish-config.spec.ts new file mode 100644 index 0000000000..9577581d82 --- /dev/null +++ b/apps/desktop/tests/desktop-publish-config.spec.ts @@ -0,0 +1,47 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, it } from 'vitest' +import { readDesktopPublishConfig, readDesktopUploadCredentials } from '../scripts/desktop-publish-config.ts' + +it('reads public settings without opening the absent credential file and requires production App settings', async () => { + const root = await mkdtemp(join(tmpdir(), 'publish-config-')) + try { + await mkdir(join(root, 'publishing')) + const config = { bucket: 'test-1250000000', origin: 'https://example.com', credentialsFile: join(root, 'absent.json') } + await writeFile(join(root, 'publishing/test.json'), JSON.stringify(config)) + const selected = await readDesktopPublishConfig(root, 'test') + expect(selected).toEqual(config) + await expect(readDesktopUploadCredentials(selected)).rejects.toThrow(/cannot read valid upload credentials/u) + await writeFile(join(root, 'publishing/production.json'), JSON.stringify({ ...config, origin: 'https://download.deepseek.com' })) + await expect(readDesktopPublishConfig(root, 'production')).rejects.toThrow(/GitHub App configuration/u) + await writeFile(config.credentialsFile, JSON.stringify({ secretId: 'fixture', secretKey: 'fixture-only' })) + await expect(readDesktopUploadCredentials(selected)).resolves.toEqual({ secretId: 'fixture', secretKey: 'fixture-only' }) + } finally { await rm(root, { recursive: true, force: true }) } +}) + +it('does not include malformed credential contents in errors', async () => { + const root = await mkdtemp(join(tmpdir(), 'publish-config-')) + try { + const credentialsFile = join(root, 'credentials.json') + await writeFile(credentialsFile, '{secret-value-should-not-appear') + await expect(readDesktopUploadCredentials({ credentialsFile, bucket: 'test-1', origin: 'https://example.com' })) + .rejects.not.toThrow(/secret-value/u) + } finally { await rm(root, { recursive: true, force: true }) } +}) + +it('rejects multiline App outputs before writing Actions output fields', async () => { + const root = await mkdtemp(join(tmpdir(), 'publish-config-')) + try { + await mkdir(join(root, 'publishing')) + const config = { bucket: 'test-1', origin: 'https://download.deepseek.com', credentialsFile: join(root, 'absent.json'), + githubApp: { clientId: 'app-id', privateKeyFile: join(root, 'key.pem') } } + for (const githubApp of [ + { ...config.githubApp, clientId: 'app-id\nother=value' }, + { ...config.githubApp, privateKeyFile: join(root, 'key\nother=value') }, + ]) { + await writeFile(join(root, 'publishing/production.json'), JSON.stringify({ ...config, githubApp })) + await expect(readDesktopPublishConfig(root, 'production')).rejects.toThrow(/single-line/u) + } + } finally { await rm(root, { recursive: true, force: true }) } +}) diff --git a/apps/desktop/tests/desktop-publish-cos.spec.ts b/apps/desktop/tests/desktop-publish-cos.spec.ts new file mode 100644 index 0000000000..848e37f71c --- /dev/null +++ b/apps/desktop/tests/desktop-publish-cos.spec.ts @@ -0,0 +1,56 @@ +import { expect, it } from 'vitest' +import { createDesktopCos } from '../scripts/desktop-cos.ts' +import { desktopPublishCos } from '../scripts/desktop-publish-cos.ts' +import { desktopArtifactDigest } from '../scripts/desktop-publish.ts' +import type { DesktopUploadPlan } from '../scripts/desktop-upload-plan.ts' +import { answer, cosError, startCosLoopback } from './cos-loopback.ts' + +it('uses overwrite prevention, streams verified bytes, and hashes remote readback through the SDK', async () => { + const objects = new Map() + const server = await startCosLoopback((request, response) => { + if (request.path.includes('versioning')) { answer(response, 200, ''); return } + if (request.method === 'PUT') { + expect(request.headers['x-cos-forbid-overwrite']).toBe('true') + objects.set(request.path, request.body) + answer(response) + } else { + const bytes = objects.get(request.path) + if (bytes === undefined) answer(response, 404, cosError('NoSuchKey')) + else answer(response, 200, bytes) + } + }) + try { + const artifact = { path: '', filename: 'file.exe', key: 'bin/file.exe', contents: 'fixture bytes', contentType: 'application/octet-stream', channelMetadata: false } + const plan: DesktopUploadPlan = { + environment: 'test', target: 'win-x64', version: '1.0.0', bucket: 'fixture-1250000000', publicUrl: 'https://example.com/', + secretIdEnvName: 'ID', secretKeyEnvName: 'KEY', artifacts: [artifact], + } + const transport = desktopPublishCos(plan, { secretId: 'fixture-id', secretKey: 'fixture-secret' }, (credentials) => { + const client = createDesktopCos(credentials) + server.redirect(client) + return client + }) + expect(await transport.read(artifact.key, false)).toBeNull() + const digest = await desktopArtifactDigest(artifact) + await transport.put(artifact, digest, true) + expect(await transport.read(artifact.key, false)).toEqual(digest) + await expect(transport.read('elsewhere/file.exe', false)).rejects.toThrow(/outside/u) + expect(server.requests.filter(request => request.method === 'PUT')).toHaveLength(1) + } finally { await server.close() } +}) + +it('propagates an oversized channel stream error through the SDK and closes the request', async () => { + const server = await startCosLoopback((_request, response) => { answer(response, 200, Buffer.alloc(1024 * 1024 + 1, 65)) }) + try { + const artifact = { path: '', filename: 'nightly.yml', key: 'feed/nightly.yml', contents: 'version: 1.0.0\n', contentType: 'application/yaml', channelMetadata: true } + const plan: DesktopUploadPlan = { environment: 'test', target: 'win-x64', version: '1.0.0', bucket: 'fixture-1250000000', + publicUrl: 'https://example.com/', secretIdEnvName: 'ID', secretKeyEnvName: 'KEY', artifacts: [artifact] } + const transport = desktopPublishCos(plan, { secretId: 'fixture-id', secretKey: 'fixture-secret' }, (credentials) => { + const client = createDesktopCos(credentials) + server.redirect(client) + return client + }) + await expect(transport.read(artifact.key, true)).rejects.toThrow(/exceeds 1 MiB/u) + expect(server.requests).toHaveLength(1) + } finally { await server.close() } +}) diff --git a/apps/desktop/tests/desktop-publish-record.spec.ts b/apps/desktop/tests/desktop-publish-record.spec.ts new file mode 100644 index 0000000000..87646edbe1 --- /dev/null +++ b/apps/desktop/tests/desktop-publish-record.spec.ts @@ -0,0 +1,80 @@ +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { execFile } from 'node:child_process' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { promisify } from 'node:util' +import { expect, it } from 'vitest' +import { createDesktopPublicationRecord, wasDesktopAttemptPublished, withDesktopChannelLock } from '../scripts/desktop-publish-record.ts' + +it('excludes another process while a channel lock is held and admits it after release', async () => { + const root = await mkdtemp(join(tmpdir(), 'desktop-process-lock-')) + const script = ` + const { withDesktopChannelLock } = await import(process.argv[1]); + try { + await withDesktopChannelLock(process.argv[2], 'bucket/mac', async () => process.stdout.write('acquired')); + } catch (error) { + if (!error.message.includes('locked')) throw error; + process.stdout.write('locked'); + } + ` + const contender = () => promisify(execFile)(process.execPath, ['--import', 'tsx/esm', '--input-type=module', '-e', script, + new URL('../scripts/desktop-publish-record.ts', import.meta.url).href, root], { encoding: 'utf8' }) + try { + await withDesktopChannelLock(root, 'bucket/mac', async () => { + expect((await contender()).stdout).toBe('locked') + }) + expect((await contender()).stdout).toBe('acquired') + } finally { await rm(root, { recursive: true, force: true }) } +}) + +it('excludes overlapping writers of one channel but permits independent channels and releases after failure', async () => { + const root = await mkdtemp(join(tmpdir(), 'desktop-lock-')) + try { + await withDesktopChannelLock(root, 'bucket/mac', async () => { + await expect(withDesktopChannelLock(root, 'bucket/mac', async () => {})).rejects.toThrow(/locked/u) + await expect(withDesktopChannelLock(root, 'bucket/win', async () => 'independent')).resolves.toBe('independent') + }) + await expect(withDesktopChannelLock(root, 'bucket/mac', async () => { throw new Error('fixture') })).rejects.toThrow('fixture') + await expect(withDesktopChannelLock(root, 'bucket/mac', async () => 'recovered')).resolves.toBe('recovered') + } finally { await rm(root, { recursive: true, force: true }) } +}) + +it('persists intent, before/after bytes and terminal state outside the checkout', async () => { + const root = await mkdtemp(join(tmpdir(), 'desktop-audit-')) + try { + const record = await createDesktopPublicationRecord(root, { repository: 'owner/repo', runId: '1', attempt: '2', + actor: 'operator', destination: 'bucket/feed/mac', operation: 'rollback', reason: 'restore previous release' }) + await record.record({ type: 'put-intent', key: 'feed/mac/nightly.yml', before: 'version: 2.0.0', after: 'version: 1.0.0' }) + await record.finish('complete') + expect(JSON.parse(await readFile(join(record.directory, 'operation.json'), 'utf8'))).toMatchObject({ actor: 'operator', operation: 'rollback' }) + expect(JSON.parse((await readFile(join(record.directory, 'events.jsonl'), 'utf8')).trim())).toMatchObject({ before: 'version: 2.0.0', after: 'version: 1.0.0' }) + expect(JSON.parse(await readFile(join(record.directory, 'result.json'), 'utf8'))).toMatchObject({ status: 'complete' }) + } finally { await rm(root, { recursive: true, force: true }) } +}) + +it('recognizes feed completion even when the later tag failed', async () => { + const root = await mkdtemp(join(tmpdir(), 'desktop-recovery-')) + try { + const identity = { repository: 'owner/repo', runId: '12', attempt: '2', destination: 'bucket/mac' } + const journal = await createDesktopPublicationRecord(root, { ...identity, actor: 'operator', operation: 'publish', reason: 'publish' }) + expect(await wasDesktopAttemptPublished(root, identity)).toBe(false) + await journal.record({ type: 'feeds-complete' }) + await journal.record({ type: 'tag-pending' }) + await journal.finish('published-tag-pending') + expect(await wasDesktopAttemptPublished(root, identity)).toBe(true) + expect(await wasDesktopAttemptPublished(root, { ...identity, attempt: '1' })).toBe(false) + } finally { await rm(root, { recursive: true, force: true }) } +}) + +it('ignores interrupted initialization before an audit directory becomes visible', async () => { + const root = await mkdtemp(join(tmpdir(), 'desktop-init-')) + try { + await mkdir(join(root, '.initializing-interrupted')) + await writeFile(join(root, '.initializing-interrupted/operation.json'), '{') + const identity = { repository: 'owner/repo', runId: '1', attempt: '1', destination: 'bucket/mac' } + expect(await wasDesktopAttemptPublished(root, identity)).toBe(false) + const record = await createDesktopPublicationRecord(root, { ...identity, actor: 'fixture', operation: 'publish', reason: 'fixture' }) + await record.record({ type: 'feeds-complete' }) + expect(await wasDesktopAttemptPublished(root, identity)).toBe(true) + } finally { await rm(root, { recursive: true, force: true }) } +}) diff --git a/apps/desktop/tests/desktop-publish-route.spec.ts b/apps/desktop/tests/desktop-publish-route.spec.ts new file mode 100644 index 0000000000..0db2cb45d6 --- /dev/null +++ b/apps/desktop/tests/desktop-publish-route.spec.ts @@ -0,0 +1,47 @@ +import { expect, it } from 'vitest' +import { resolveDesktopPublishRoute } from '../scripts/desktop-publish-route.ts' + +function fixture() { + const run = { id: 42, run_attempt: 2, head_sha: 'a'.repeat(40), path: '.github/workflows/macos-package.yml', + conclusion: 'success', event: 'workflow_dispatch', repository: { full_name: 'owner/repo' } } + const source = { repository: 'owner/repo', runId: '42', attempt: '2', commit: run.head_sha, runnerName: 'mini', + workflowCommit: run.head_sha, sourceRef: 'master', channel: 'nightly', + target: 'mac-arm64', preset: 'test', publishRequested: true, qualification: 'notarized' } + const jobs = [{ runner_name: 'mini', conclusion: 'success', labels: ['self-hosted', 'dsh-mini'] }] + const routes = { mini: { label: 'dsh-mini', target: 'mac-arm64' } } + return { run, source, jobs, routes } +} + +it('routes the exact successful attempt to its registered machine', () => { + const f = fixture() + expect(resolveDesktopPublishRoute('owner/repo', f.run, { source: f.source }, f.jobs, f.routes)) + .toMatchObject({ runId: '42', attempt: '2', runnerLabel: 'dsh-mini', preset: 'test' }) +}) + +it.each(['attempt', 'repository', 'commit', 'mode', 'request', 'runner', 'workflow'] as const)('rejects mismatched %s evidence', (field) => { + const f = fixture() + if (field === 'attempt') f.source.attempt = '1' + if (field === 'repository') f.source.repository = 'other/repo' + if (field === 'commit') f.source.workflowCommit = 'b'.repeat(40) + if (field === 'mode') f.source.qualification = 'unnotarized' + if (field === 'request') f.source.publishRequested = false + if (field === 'runner') f.jobs[0]!.runner_name = 'laptop' + if (field === 'workflow') f.run.path = '.github/workflows/ci.yml' + expect(() => resolveDesktopPublishRoute('owner/repo', f.run, { source: f.source }, f.jobs, f.routes)).toThrow() +}) + +it('fails closed when a publishing route or its unique label is missing', () => { + const f = fixture() + expect(() => resolveDesktopPublishRoute('owner/repo', f.run, { source: f.source }, f.jobs, {})).toThrow(/no configured/u) + const duplicates = { ...f.routes, laptop: { label: 'dsh-mini', target: 'mac-arm64' } } + expect(() => resolveDesktopPublishRoute('owner/repo', f.run, { source: f.source }, f.jobs, duplicates)).toThrow(/unique runner label/u) +}) + + +it('keeps the artifact source separate from the workflow commit', () => { + const f = fixture() + f.source.commit = 'b'.repeat(40) + f.source.sourceRef = 'dsh-v1.0.0' + expect(resolveDesktopPublishRoute('owner/repo', f.run, { source: f.source }, f.jobs, f.routes)) + .toMatchObject({ commit: 'b'.repeat(40), sourceRef: 'dsh-v1.0.0', channel: 'nightly' }) +}) diff --git a/apps/desktop/tests/desktop-publish-tag.spec.ts b/apps/desktop/tests/desktop-publish-tag.spec.ts new file mode 100644 index 0000000000..7a13daba54 --- /dev/null +++ b/apps/desktop/tests/desktop-publish-tag.spec.ts @@ -0,0 +1,59 @@ +import { expect, it } from 'vitest' +import { publishDesktopTag } from '../scripts/desktop-publish-tag.ts' + +const commit = 'a'.repeat(40) +const tag = (sha = commit) => ({ object: { type: 'commit', sha } }) +function transport(replies: Array<{ status: number; body?: object }>) { + const calls: Array<{ url: string; init: RequestInit | undefined }> = [] + const request: typeof fetch = async (url, init) => { + calls.push({ url: typeof url === 'string' ? url : url instanceof URL ? url.href : url.url, init }) + const reply = replies.shift() + if (!reply) throw new Error('unexpected HTTP request') + return new Response(JSON.stringify(reply.body ?? {}), { status: reply.status }) + } + return { calls, request } +} + +it('tags the packaged commit using explicit App authentication', async () => { + const http = transport([{ status: 404 }, { status: 201, body: tag() }]) + await expect(publishDesktopTag('owner/repo', '1.2.3', commit, 'fixture-token', http.request)).resolves.toBe('created') + expect(http.calls[1]?.init?.body).toBe(JSON.stringify({ ref: 'refs/tags/desktop-v1.2.3', sha: commit })) + expect(http.calls[1]?.init?.headers).toMatchObject({ Authorization: 'Bearer fixture-token' }) +}) + +it('does not write when the same release tag already exists', async () => { + const http = transport([{ status: 200, body: tag() }]) + await expect(publishDesktopTag('owner/repo', '1.2.3', commit, 'fixture-token', http.request)).resolves.toBe('present') + expect(http.calls).toHaveLength(1) +}) + +it('rejects a conflicting tag without updating or deleting it', async () => { + const http = transport([{ status: 200, body: tag('b'.repeat(40)) }]) + await expect(publishDesktopTag('owner/repo', '1.2.3', commit, 'fixture-token', http.request)).rejects.toThrow(/refusing to move/u) + expect(http.calls).toHaveLength(1) +}) + +it('reconciles a create race between two platform releases', async () => { + const http = transport([{ status: 404 }, { status: 422 }, { status: 200, body: tag() }]) + await expect(publishDesktopTag('owner/repo', '1.2.3', commit, 'fixture-token', http.request)).resolves.toBe('present') + expect(http.calls.map(call => call.init?.method ?? 'GET')).toEqual(['GET', 'POST', 'GET']) +}) + +it('does not reinterpret authorization failure as a missing tag', async () => { + const http = transport([{ status: 403 }]) + await expect(publishDesktopTag('owner/repo', '1.2.3', commit, 'fixture-token', http.request)).rejects.toThrow(/HTTP 403/u) + expect(http.calls).toHaveLength(1) +}) + +it('rejects missing credentials and malformed inputs before any request', async () => { + const http = transport([]) + await expect(publishDesktopTag('owner/repo', '1.2.3', commit, '', http.request)).rejects.toThrow(/required/u) + await expect(publishDesktopTag('owner/repo/other', '1.2.3', commit, 'fixture', http.request)).rejects.toThrow(/invalid/u) + expect(http.calls).toHaveLength(0) +}) + +it('explains an existing annotated tag without replacing it', async () => { + const http = transport([{ status: 200, body: { object: { type: 'tag', sha: commit } } }]) + await expect(publishDesktopTag('owner/repo', '1.2.3', commit, 'fixture-token', http.request)).rejects.toThrow(/not a lightweight commit tag/u) + expect(http.calls).toHaveLength(1) +}) diff --git a/apps/desktop/tests/desktop-publish.spec.ts b/apps/desktop/tests/desktop-publish.spec.ts new file mode 100644 index 0000000000..bc5cba2166 --- /dev/null +++ b/apps/desktop/tests/desktop-publish.spec.ts @@ -0,0 +1,114 @@ +import { createHash } from 'node:crypto' +import { expect, it } from 'vitest' +import { publishDesktopRelease, type DesktopPublishEvent, type DesktopPublishOperations, type DesktopRemoteObject } from '../scripts/desktop-publish.ts' +import type { DesktopUploadPlan } from '../scripts/desktop-upload-plan.ts' + +function observed(contents: string): DesktopRemoteObject { + return { contents, size: Buffer.byteLength(contents), sha512: createHash('sha512').update(contents).digest('base64') } +} +function fixture(environment: 'test' | 'production' = 'test') { + const objects = new Map() + const writes: string[] = [] + const events: DesktopPublishEvent[] = [] + let tags = 0 + const plan: DesktopUploadPlan = { + environment, target: 'win-x64', version: '1.0.0', bucket: 'fixture', publicUrl: 'https://example.com/feed/', + secretIdEnvName: 'ID', secretKeyEnvName: 'KEY', commit: 'a'.repeat(40), dirty: false, + artifacts: [ + { path: '', filename: 'installer.exe', key: 'bin/installer.exe', contentType: 'application/octet-stream', channelMetadata: false, contents: 'installer' }, + { path: '', filename: 'installer.blockmap', key: 'bin/installer.blockmap', contentType: 'application/octet-stream', channelMetadata: false, contents: 'blockmap' }, + { path: '', filename: 'nightly.yml', key: 'feed/nightly.yml', contentType: 'application/yaml', channelMetadata: true, contents: 'version: 1.0.0\n' }, + ], + } + const operations: DesktopPublishOperations = { + transport: { + async read(key) { return objects.get(key) ?? null }, + async put(artifact, _digest, forbidOverwrite) { + if (forbidOverwrite && objects.has(artifact.key)) throw new Error('already exists') + writes.push(artifact.key) + objects.set(artifact.key, observed(artifact.contents!)) + }, + }, + async record(event) { events.push(event) }, + async tag() { tags++ }, + } + return { plan, operations, objects, writes, events, tags: () => tags } +} + +it('uploads files before the feed and skips identical bytes on a repeat invocation', async () => { + const f = fixture() + await expect(publishDesktopRelease(f.plan, f.operations)).resolves.toEqual({ status: 'complete' }) + expect(f.writes).toEqual(['bin/installer.exe', 'bin/installer.blockmap', 'feed/nightly.yml']) + await publishDesktopRelease(f.plan, f.operations) + expect(f.writes).toHaveLength(3) + expect(f.tags()).toBe(0) + expect(f.events.find(event => event.key === 'feed/nightly.yml' && event.type === 'put-intent')) + .toMatchObject({ before: null, after: 'version: 1.0.0\n' }) +}) + +it('recovers an ambiguous binary write without repeating it or publishing the feed early', async () => { + const f = fixture() + const put = f.operations.transport.put.bind(f.operations.transport) + let fail = true + f.operations.transport.put = async (...args) => { + await put(...args) + if (fail) { fail = false; throw new Error('connection lost after server stored bytes') } + } + await expect(publishDesktopRelease(f.plan, f.operations)).rejects.toThrow(/connection lost/u) + expect(f.objects.has('feed/nightly.yml')).toBe(false) + await publishDesktopRelease(f.plan, f.operations) + expect(f.writes).toEqual(['bin/installer.exe', 'bin/installer.blockmap', 'feed/nightly.yml']) +}) + +it('reports published but tag pending and retries only the tag', async () => { + const f = fixture('production') + let fail = true + const tag = f.operations.tag.bind(f.operations) + f.operations.tag = async () => { if (fail) throw new Error('GitHub unavailable'); await tag() } + await expect(publishDesktopRelease(f.plan, f.operations)).resolves.toMatchObject({ status: 'published-tag-pending' }) + expect(f.events.at(-1)?.type).toBe('tag-pending') + fail = false + await expect(publishDesktopRelease(f.plan, f.operations)).resolves.toEqual({ status: 'complete' }) + expect(f.writes).toHaveLength(3) + expect(f.tags()).toBe(1) +}) + +it.each(['binary', 'same-version-feed', 'newer-feed'] as const)('stops on %s conflict', async (kind) => { + const f = fixture() + if (kind === 'binary') f.objects.set('bin/installer.exe', observed('different')) + else f.objects.set('feed/nightly.yml', observed(kind === 'newer-feed' ? 'version: 2.0.0\n' : 'version: 1.0.0\nchanged: true\n')) + await expect(publishDesktopRelease(f.plan, f.operations)).rejects.toMatchObject({ code: kind === 'newer-feed' ? 'superseded' : 'conflict' }) + expect(f.writes).toHaveLength(0) +}) + +it('rejects a files-only plan and a production build without clean commit evidence', async () => { + const f = fixture('production') + await expect(publishDesktopRelease({ ...f.plan, artifacts: f.plan.artifacts.slice(0, 2) }, f.operations)).rejects.toMatchObject({ code: 'invalid-plan' }) + await expect(publishDesktopRelease({ ...f.plan, dirty: true }, f.operations)).rejects.toMatchObject({ code: 'invalid-plan' }) + expect(f.writes).toHaveLength(0) +}) + +it('stops when a channel changes between preflight and writing', async () => { + const f = fixture() + const put = f.operations.transport.put.bind(f.operations.transport) + f.operations.transport.put = async (...args) => { + await put(...args) + f.objects.set('feed/nightly.yml', observed('version: 3.0.0\n')) + } + await expect(publishDesktopRelease(f.plan, f.operations)).rejects.toMatchObject({ code: 'remote-changed' }) + expect(f.writes).not.toContain('feed/nightly.yml') +}) + +it('does not update the channel when readback cannot confirm a binary', async () => { + const f = fixture() + f.operations.transport.put = async () => {} + await expect(publishDesktopRelease(f.plan, f.operations)).rejects.toMatchObject({ code: 'verification-failed' }) + expect(f.events.some(event => event.type === 'feeds-complete')).toBe(false) +}) + +it('classifies corrupted remote channel metadata separately from an invalid local plan', async () => { + const f = fixture() + f.objects.set('feed/nightly.yml', observed('version: invalid\n')) + await expect(publishDesktopRelease(f.plan, f.operations)).rejects.toMatchObject({ code: 'verification-failed' }) + expect(f.writes).toEqual([]) +}) diff --git a/apps/desktop/tests/desktop-release-store.spec.ts b/apps/desktop/tests/desktop-release-store.spec.ts new file mode 100644 index 0000000000..7c717aa90e --- /dev/null +++ b/apps/desktop/tests/desktop-release-store.spec.ts @@ -0,0 +1,71 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it } from 'vitest' +import { retainDesktopRelease, retainedDesktopPath, verifyRetainedDesktopRelease, type DesktopRetainedSource } from '../scripts/desktop-release-store.ts' + +const roots: string[] = [] +afterEach(async () => { await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))) }) + +async function fixture() { + const root = await mkdtemp(join(tmpdir(), 'desktop-release-store-')) + roots.push(root) + const file = join(root, 'installer.exe') + await writeFile(file, 'signed installer fixture') + const source: DesktopRetainedSource = { + repository: 'owner/repository', runId: '100', attempt: '1', commit: 'a'.repeat(40), runnerName: 'windows-runner', + target: 'win-x64', preset: 'test', productVersion: '1.0.0', version: '1.0.0', testReleaseId: 'b'.repeat(32), + workflowCommit: 'a'.repeat(40), sourceRef: 'master', channel: 'nightly', + publishRequested: true, qualification: 'signed', + } + return { root: join(root, 'releases'), file, source } +} + +it('retains independent attempts after source removal and rejects replacing an existing attempt', async () => { + const { root, file, source } = await fixture() + const first = await retainDesktopRelease(root, source, [file]) + const second = await retainDesktopRelease(root, { ...source, attempt: '2' }, [file]) + expect(first).not.toBe(second) + await expect(retainDesktopRelease(root, source, [file])).rejects.toThrow(/already exists/u) + await rm(file) + const manifest = await verifyRetainedDesktopRelease(root, '100', '1') + expect(manifest.source).toEqual(source) + expect(await readFile(join(first, 'installer.exe'), 'utf8')).toBe('signed installer fixture') +}) + +it('detects changed bytes even when file length is unchanged', async () => { + const { root, file, source } = await fixture() + const directory = await retainDesktopRelease(root, source, [file]) + const path = join(directory, 'installer.exe') + await writeFile(path, Buffer.alloc((await readFile(path)).length)) + await expect(verifyRetainedDesktopRelease(root, '100', '1')).rejects.toThrow(/changed file/u) +}) + +it('does not expose an incomplete copy and releases its claim after failure', async () => { + const { root, file, source } = await fixture() + await expect(retainDesktopRelease(root, source, [file, join(root, 'missing.zip')])).rejects.toThrow() + await expect(verifyRetainedDesktopRelease(root, '100', '1')).rejects.toThrow() + await retainDesktopRelease(root, source, [file]) + await expect(verifyRetainedDesktopRelease(root, '100', '1')).resolves.toMatchObject({ source }) +}) + +it('permits only one writer for a concurrently retained attempt', async () => { + const { root, file, source } = await fixture() + const results = await Promise.allSettled([ + retainDesktopRelease(root, source, [file]), retainDesktopRelease(root, source, [file]), + ]) + expect(results.filter(result => result.status === 'fulfilled')).toHaveLength(1) + await expect(verifyRetainedDesktopRelease(root, '100', '1')).resolves.toMatchObject({ source }) +}) + +it('rejects traversal and manifest identity or file inventory tampering', async () => { + const { root, file, source } = await fixture() + expect(() => retainedDesktopPath(root, '../100', '1')).toThrow() + expect(() => retainedDesktopPath(root, '100', '0')).toThrow() + const path = await retainDesktopRelease(root, source, [file]) + const manifest = await verifyRetainedDesktopRelease(root, '100', '1') + await writeFile(join(path, 'manifest.json'), JSON.stringify({ ...manifest, source: { ...source, attempt: '2' } })) + await expect(verifyRetainedDesktopRelease(root, '100', '1')).rejects.toThrow(/identity mismatch/u) + await writeFile(join(path, 'manifest.json'), JSON.stringify({ ...manifest, files: [{ ...manifest.files[0], name: '../installer.exe' }] })) + await expect(verifyRetainedDesktopRelease(root, '100', '1')).rejects.toThrow() +}) diff --git a/apps/desktop/tests/desktop-rollback.spec.ts b/apps/desktop/tests/desktop-rollback.spec.ts new file mode 100644 index 0000000000..2bf2151626 --- /dev/null +++ b/apps/desktop/tests/desktop-rollback.spec.ts @@ -0,0 +1,79 @@ +import { createHash } from 'node:crypto' +import { expect, it } from 'vitest' +import { rollbackDesktopChannel } from '../scripts/desktop-rollback.ts' +import type { DesktopPublishOperations } from '../scripts/desktop-publish.ts' +import type { DesktopUploadPlan } from '../scripts/desktop-upload-plan.ts' + +function fixture() { + const objects = new Map([['bin/old.exe', 'old installer'], ['feed/nightly.yml', 'version: 2.0.0\n']]) + const writes: string[] = [] + const plan: DesktopUploadPlan = { + environment: 'test', target: 'win-x64', version: '1.0.0', bucket: 'fixture', publicUrl: 'https://example.com', secretIdEnvName: 'ID', secretKeyEnvName: 'KEY', + artifacts: [ + { key: 'bin/old.exe', path: '', filename: 'old.exe', contentType: 'application/octet-stream', channelMetadata: false, contents: 'old installer' }, + { key: 'feed/nightly.yml', path: '', filename: 'nightly.yml', contentType: 'application/yaml', channelMetadata: true, contents: 'version: 1.0.0\n' }, + ], + } + const operations: DesktopPublishOperations = { + transport: { + async read(key) { + const contents = objects.get(key) + return contents === undefined ? null : { contents, size: Buffer.byteLength(contents), sha512: createHash('sha512').update(contents).digest('base64') } + }, + async put(artifact, _digest, forbidOverwrite) { + expect(forbidOverwrite).toBe(false) + writes.push(artifact.key) + objects.set(artifact.key, artifact.contents!) + }, + }, + async record() {}, + async tag() { throw new Error('must not create a tag') }, + } + return { plan, operations, writes, objects } +} + +it('previews the exact YAML change without writes', async () => { + const f = fixture() + const result = await rollbackDesktopChannel(f.plan, '2.0.0', false, f.operations) + expect(result).toMatchObject({ status: 'preview', feeds: [{ before: 'version: 2.0.0\n', after: 'version: 1.0.0\n' }] }) + expect(f.writes).toEqual([]) +}) + +it('only restores the YAML after checking the historical binary', async () => { + const f = fixture() + await expect(rollbackDesktopChannel(f.plan, '2.0.0', true, f.operations)).resolves.toMatchObject({ status: 'complete' }) + expect(f.writes).toEqual(['feed/nightly.yml']) + expect(f.objects.get('bin/old.exe')).toBe('old installer') +}) + +it.each(['missing-file', 'changed-file', 'changed-channel'] as const)('rejects %s without writes', async (problem) => { + const f = fixture() + if (problem === 'missing-file') f.objects.delete('bin/old.exe') + if (problem === 'changed-file') f.objects.set('bin/old.exe', 'changed') + if (problem === 'changed-channel') f.objects.set('feed/nightly.yml', 'version: 3.0.0\n') + await expect(rollbackDesktopChannel(f.plan, '2.0.0', true, f.operations)).rejects.toThrow() + expect(f.writes).toEqual([]) +}) + +it('restores nightly when latest already matches and resumes a partially restored pair', async () => { + for (const latestAlreadyMatches of [true, false]) { + const f = fixture() + const nightly = f.plan.artifacts[1]! + const plan = { ...f.plan, artifacts: [...f.plan.artifacts, { ...nightly, key: 'feed/latest.yml', filename: 'latest.yml' }] } + f.objects.set('feed/latest.yml', latestAlreadyMatches ? nightly.contents! : 'version: 2.0.0\n') + if (!latestAlreadyMatches) { + const put = f.operations.transport.put.bind(f.operations.transport) + const interrupted = { ...f.operations, transport: { ...f.operations.transport, + async put(...args: Parameters) { + if (args[0].key === 'feed/latest.yml') throw new Error('fixture interruption') + return put(...args) + }, + } } + await expect(rollbackDesktopChannel(plan, '2.0.0', true, interrupted)).rejects.toThrow('fixture interruption') + } + await expect(rollbackDesktopChannel(plan, '2.0.0', true, f.operations)).resolves.toMatchObject({ status: 'complete' }) + expect(f.objects.get('feed/nightly.yml')).toBe(nightly.contents) + expect(f.objects.get('feed/latest.yml')).toBe(nightly.contents) + expect(f.writes.filter(key => key === 'feed/nightly.yml')).toHaveLength(1) + } +}) diff --git a/apps/desktop/tests/desktop-upload-plan.spec.ts b/apps/desktop/tests/desktop-upload-plan.spec.ts index f1e2097075..def8cb19bf 100644 --- a/apps/desktop/tests/desktop-upload-plan.spec.ts +++ b/apps/desktop/tests/desktop-upload-plan.spec.ts @@ -38,6 +38,7 @@ async function fixture( target: DesktopPackageTargetName, version = '1.2.3', environment: 'test' | 'production' = 'test', + channel: 'nightly' | 'latest' = 'nightly', ): Promise { const root = await mkdtemp(join(tmpdir(), 'dsh-desktop-upload-')) temporaryDirectories.push(root) @@ -55,6 +56,7 @@ async function fixture( : 'https://download.deepseek.com' await writeFile(join(artifactsRoot, `${target}-release.json`), `${JSON.stringify({ schemaVersion: 1, + channel, target, version, environment, @@ -66,7 +68,7 @@ async function fixture( await writeFile(join(artifactsRoot, `${base}.zip`), zip) await writeFile(join(artifactsRoot, `${base}.zip.blockmap`), 'blockmap') await writeFile(join(artifactsRoot, `${base}.dmg`), 'notarized DMG fixture') - await writeFile(join(artifactsRoot, desktopUpdateMetadataFilename(version, 'darwin')), `${JSON.stringify({ + await writeFile(join(artifactsRoot, desktopUpdateMetadataFilename(version, 'darwin', channel)), `${JSON.stringify({ version, path: `${base}.zip`, files: [{ url: `${base}.zip`, size: Buffer.byteLength(zip), sha512: digest(zip) }], @@ -78,7 +80,7 @@ async function fixture( const info = await createBlockmap(join(artifactsRoot, `${base}.exe`), {}, { info: { emitArtifactBuildCompleted: async () => {} } }, `${base}.exe`) expect(Object.hasOwn(info, 'blockMapSize')).toBe(false) - await writeFile(join(artifactsRoot, desktopUpdateMetadataFilename(version, 'win32')), `${JSON.stringify({ + await writeFile(join(artifactsRoot, desktopUpdateMetadataFilename(version, 'win32', channel)), `${JSON.stringify({ version, path: `${base}.exe`, files: [{ @@ -93,12 +95,14 @@ async function fixture( artifactsRoot, environment: environment === 'test' ? { + DSH_DESKTOP_UPDATE_CHANNEL: channel, DSH_DESKTOP_AUTO_UPDATE_ENV: 'test', DOWNLOAD_TEST_ORIGIN: TEST_ORIGIN, DOWNLOAD_TEST_RELEASE_ID: RELEASE_ID, DOWNLOAD_TEST_COS_BUCKET: TEST_BUCKET, } : { + DSH_DESKTOP_UPDATE_CHANNEL: channel, DSH_DESKTOP_AUTO_UPDATE_ENV: 'production', DOWNLOAD_PROD_COS_BUCKET: PRODUCTION_BUCKET, }, @@ -171,6 +175,16 @@ describe('desktop upload plan', () => { .rejects.toThrow(failure === 'checksum' ? /SHA-512/u : /completion record/u) }) + it.each(['test', 'production'] as const)('isolates stable latest packages from nightly in %s', async (environment) => { + const nightly = await createDesktopUploadPlan('win-x64', await fixture('win-x64', '1.2.3', environment, 'nightly')) + const latest = await createDesktopUploadPlan('win-x64', await fixture('win-x64', '1.2.3', environment, 'latest')) + expect(latest.artifacts.filter(file => file.channelMetadata).map(file => file.filename)).toEqual(['latest.yml']) + expect(nightly.artifacts.filter(file => file.channelMetadata).map(file => file.filename)).toEqual(['nightly.yml']) + expect(latest.artifacts.every(file => !nightly.artifacts.some(other => other.key === file.key))).toBe(true) + const prerelease = await fixture('win-x64', '1.2.3-alpha.1', environment, 'latest') + await expect(createDesktopUploadPlan('win-x64', prerelease)).rejects.toThrow(/latest requires a stable version/u) + }) + it('publishes fixed feeds referencing versioned binaries without overriding CDN cache policy', async () => { const paths = await fixture('win-x64', '1.2.3', 'production') const plan = await createDesktopUploadPlan('win-x64', paths) @@ -178,7 +192,6 @@ describe('desktop upload plan', () => { 'dsh-desk/bin/win-x64/deepseek-harness-1.2.3-win-x64.exe', 'dsh-desk/bin/win-x64/deepseek-harness-1.2.3-win-x64.exe.blockmap', 'dsh-desk/feeds/win-x64/nightly.yml', - 'dsh-desk/feeds/win-x64/latest.yml', ]) expect(load(plan.artifacts[2]!.contents!)).toMatchObject({ version: '1.2.3', @@ -187,7 +200,6 @@ describe('desktop upload plan', () => { sha512: digest('signed NSIS executable fixture'), }], }) - expect(plan.artifacts[2]!.contents).toBe(plan.artifacts[3]!.contents) expect(plan.artifacts.every(artifact => !('cacheControl' in artifact))).toBe(true) }) @@ -205,7 +217,6 @@ describe('desktop upload plan', () => { 'deepseek-harness-1.2.3-mac-arm64.zip', 'deepseek-harness-1.2.3-mac-arm64.zip.blockmap', 'nightly-mac.yml', - 'latest-mac.yml', ]) expect(plan.artifacts.at(-1)).toMatchObject({ channelMetadata: true, @@ -258,7 +269,6 @@ describe('desktop upload plan', () => { 'deepseek-harness-2.0.0-win-x64.exe', 'deepseek-harness-2.0.0-win-x64.exe.blockmap', 'nightly.yml', - 'latest.yml', ]) expect(plan).toMatchObject({ publicUrl: 'https://download.deepseek.com/dsh-desk/feeds/win-x64/', @@ -292,6 +302,20 @@ describe('desktop upload plan', () => { })).rejects.toThrow(/completion record.*test/u) }) + it('validates retained builds against their original product version after the checkout advances', async () => { + const paths = await fixture('mac-arm64', '1.2.3') + await writeFile(join(paths.repositoryRoot, 'package.json'), '{"version":"2.0.0"}\n') + await writeFile(join(paths.appRoot, 'package.json'), '{"version":"2.0.0"}\n') + await expect(createDesktopUploadPlan('mac-arm64', paths)).rejects.toThrow(/completion record/u) + const plan = await createDesktopUploadPlan('mac-arm64', { ...paths, packagedProductVersion: '1.2.3' }) + expect(plan.version).toBe('1.2.3') + await expect(createDesktopUploadPlan('mac-arm64', { ...paths, packagedProductVersion: '1.2.4' })) + .rejects.toThrow(/completion record/u) + await writeFile(join(paths.artifactsRoot, 'deepseek-harness-1.2.3-mac-arm64.zip'), 'tampered') + await expect(createDesktopUploadPlan('mac-arm64', { ...paths, packagedProductVersion: '1.2.3' })) + .rejects.toThrow(/size.*metadata/u) + }) + it('rejects stale architecture metadata and modified updater bytes', async () => { const paths = await fixture('mac-arm64') const metadataPath = join(paths.artifactsRoot, 'nightly-mac.yml') diff --git a/apps/desktop/tests/expected/test-release-upload-mac-arm64.json b/apps/desktop/tests/expected/test-release-upload-mac-arm64.json index 515f0234f9..69af29ec01 100644 --- a/apps/desktop/tests/expected/test-release-upload-mac-arm64.json +++ b/apps/desktop/tests/expected/test-release-upload-mac-arm64.json @@ -11,9 +11,5 @@ { "key": "dsh-desk/0123456789abcdef0123456789abcdef/feeds/mac-arm64/nightly-mac.yml", "contents": "version: 1.2.3\npath: >-\n https://desktop-updates.example.com/dsh-desk/0123456789abcdef0123456789abcdef/bin/mac-arm64/deepseek-harness-1.2.3-mac-arm64.zip\nfiles:\n - url: >-\n https://desktop-updates.example.com/dsh-desk/0123456789abcdef0123456789abcdef/bin/mac-arm64/deepseek-harness-1.2.3-mac-arm64.zip\n size: 24\n sha512: >-\n IgtiHRPFjickTxiSTW6k0ltyBNOF/gpMGj/xflzVV2iRIzUSFPY6BG45u9fR4GmAJSVtN+FMh3NZx7mddUfJLw==\n" - }, - { - "key": "dsh-desk/0123456789abcdef0123456789abcdef/feeds/mac-arm64/latest-mac.yml", - "contents": "version: 1.2.3\npath: >-\n https://desktop-updates.example.com/dsh-desk/0123456789abcdef0123456789abcdef/bin/mac-arm64/deepseek-harness-1.2.3-mac-arm64.zip\nfiles:\n - url: >-\n https://desktop-updates.example.com/dsh-desk/0123456789abcdef0123456789abcdef/bin/mac-arm64/deepseek-harness-1.2.3-mac-arm64.zip\n size: 24\n sha512: >-\n IgtiHRPFjickTxiSTW6k0ltyBNOF/gpMGj/xflzVV2iRIzUSFPY6BG45u9fR4GmAJSVtN+FMh3NZx7mddUfJLw==\n" } ] diff --git a/apps/desktop/tests/expected/test-release-upload-mac-x64.json b/apps/desktop/tests/expected/test-release-upload-mac-x64.json index 5039a3aede..2c085140ce 100644 --- a/apps/desktop/tests/expected/test-release-upload-mac-x64.json +++ b/apps/desktop/tests/expected/test-release-upload-mac-x64.json @@ -11,9 +11,5 @@ { "key": "dsh-desk/0123456789abcdef0123456789abcdef/feeds/mac-x64/nightly-mac.yml", "contents": "version: 1.2.3\npath: >-\n https://desktop-updates.example.com/dsh-desk/0123456789abcdef0123456789abcdef/bin/mac-x64/deepseek-harness-1.2.3-mac-x64.zip\nfiles:\n - url: >-\n https://desktop-updates.example.com/dsh-desk/0123456789abcdef0123456789abcdef/bin/mac-x64/deepseek-harness-1.2.3-mac-x64.zip\n size: 24\n sha512: >-\n IgtiHRPFjickTxiSTW6k0ltyBNOF/gpMGj/xflzVV2iRIzUSFPY6BG45u9fR4GmAJSVtN+FMh3NZx7mddUfJLw==\n" - }, - { - "key": "dsh-desk/0123456789abcdef0123456789abcdef/feeds/mac-x64/latest-mac.yml", - "contents": "version: 1.2.3\npath: >-\n https://desktop-updates.example.com/dsh-desk/0123456789abcdef0123456789abcdef/bin/mac-x64/deepseek-harness-1.2.3-mac-x64.zip\nfiles:\n - url: >-\n https://desktop-updates.example.com/dsh-desk/0123456789abcdef0123456789abcdef/bin/mac-x64/deepseek-harness-1.2.3-mac-x64.zip\n size: 24\n sha512: >-\n IgtiHRPFjickTxiSTW6k0ltyBNOF/gpMGj/xflzVV2iRIzUSFPY6BG45u9fR4GmAJSVtN+FMh3NZx7mddUfJLw==\n" } ] diff --git a/apps/desktop/tests/expected/test-release-upload-win-x64.json b/apps/desktop/tests/expected/test-release-upload-win-x64.json index cb60e0ce29..9c7db67400 100644 --- a/apps/desktop/tests/expected/test-release-upload-win-x64.json +++ b/apps/desktop/tests/expected/test-release-upload-win-x64.json @@ -8,9 +8,5 @@ { "key": "dsh-desk/0123456789abcdef0123456789abcdef/feeds/win-x64/nightly.yml", "contents": "version: 1.2.3\npath: >-\n https://desktop-updates.example.com/dsh-desk/0123456789abcdef0123456789abcdef/bin/win-x64/deepseek-harness-1.2.3-win-x64.exe\nfiles:\n - url: >-\n https://desktop-updates.example.com/dsh-desk/0123456789abcdef0123456789abcdef/bin/win-x64/deepseek-harness-1.2.3-win-x64.exe\n size: 30\n sha512: >-\n eVumVOW1yvT3fAgGKUnliEzSfhmn8jUfzpNaUPufU1yekWrwJe8wRPC+JOrHH2Hmc/eLynwxjX1yIiER8tVOTA==\n" - }, - { - "key": "dsh-desk/0123456789abcdef0123456789abcdef/feeds/win-x64/latest.yml", - "contents": "version: 1.2.3\npath: >-\n https://desktop-updates.example.com/dsh-desk/0123456789abcdef0123456789abcdef/bin/win-x64/deepseek-harness-1.2.3-win-x64.exe\nfiles:\n - url: >-\n https://desktop-updates.example.com/dsh-desk/0123456789abcdef0123456789abcdef/bin/win-x64/deepseek-harness-1.2.3-win-x64.exe\n size: 30\n sha512: >-\n eVumVOW1yvT3fAgGKUnliEzSfhmn8jUfzpNaUPufU1yekWrwJe8wRPC+JOrHH2Hmc/eLynwxjX1yIiER8tVOTA==\n" } ] diff --git a/apps/desktop/tests/installed-update/README.i18n.yaml b/apps/desktop/tests/installed-update/README.i18n.yaml index 1ac20fbb75..cfd4135420 100644 --- a/apps/desktop/tests/installed-update/README.i18n.yaml +++ b/apps/desktop/tests/installed-update/README.i18n.yaml @@ -15,8 +15,8 @@ en: bd61ff806d57cbb1 zh: 8b09719bc3b247c8 /windows-installed-update-walkthrough/prepare-materials: - en: 1f7d9426be57a23a - zh: 9f2be099d3d1a026 + en: ab2cf13a10671564 + zh: f2c944df56f1e8d7 /windows-installed-update-walkthrough/operator-sequence: en: 8a7f275812dbe11f zh: 24dbf32328f9c0dd diff --git a/apps/desktop/tests/installed-update/README.md b/apps/desktop/tests/installed-update/README.md index b847960862..77c7ae460f 100644 --- a/apps/desktop/tests/installed-update/README.md +++ b/apps/desktop/tests/installed-update/README.md @@ -23,7 +23,7 @@ Prepare a private test application and fresh test distribution namespace, then l From the repository root with dependencies installed, allocate a run with two increasing derived test versions. This writes only an ignored local manifest; it does not build, sign, upload, install, or read credentials. -The examples use base version `0.1.6-alpha.1`. Before creating new material, substitute the actual base, Asia/Shanghai date, and unused index according to the [release version rules](../../README.md#release-versions). +The examples use base version `0.1.6-alpha.1`. Before creating new material, substitute the actual base, Asia/Shanghai date, and unused index according to the [release version rules](../../packaging/README.md#release-versions). ```powershell node --import tsx apps/desktop/scripts/prepare-installed-update.ts init 0.1.6-alpha.1.20260916.1 0.1.6-alpha.1.20260916.2 @@ -44,7 +44,7 @@ Package both files from the generated `bootstrap` directory beside `lib/`, and s The `application` command copies built main/preload modules, renderer files, and the prepared bootstrap into `application/files`, with SHA-256 values in `application/result.json`. It does not copy the app's root `.env.windows` or freeze `node_modules` and build tools. The [qualification builder configuration](../../scripts/installed-update-builder.ts) verifies this inventory and the selected private runtime, selects the shared frozen files and isolated package metadata, and preserves ordinary installer and signing hooks. Its tests substitute the signer and pass the pinned builder's configuration validator; they do not produce an installer. A separately authorized supervised build must still record dependency/tool inputs and verify final package contents and signatures. -The [packaging entry](../../scripts/package-installed-update.ts) defaults to checks. A retained signing interlock rejects before credentials are loaded; it never clears that interlock. With no interlock, checks load `.env.windows`, validate prepared inputs, and launch no child. The shared builder requires a valid `DOWNLOAD_TEST_RELEASE_ID` from the [test release settings](../../README.md#upload-updates); qualification still publishes through the manifest’s separate `qualification/` paths. Run this from the repository root with one exact version: +The [packaging entry](../../scripts/package-installed-update.ts) defaults to checks. A retained signing interlock rejects before credentials are loaded; it never clears that interlock. With no interlock, checks load `.env.windows`, validate prepared inputs, and launch no child. The shared builder requires a valid `DOWNLOAD_TEST_RELEASE_ID` from the [test release settings](../../publishing/README.md#upload-updates); qualification still publishes through the manifest’s separate `qualification/` paths. Run this from the repository root with one exact version: ```powershell node --import tsx apps/desktop/scripts/package-installed-update.ts "" 0.1.6-alpha.1.20260916.1 --check @@ -52,7 +52,7 @@ node --import tsx apps/desktop/scripts/package-installed-update.ts "" Actual packaging remains unverified and requires separate hardware-recovery approval and a present operator. The `--execute` mode requires a terminal and an exact confirmation containing the version and run ID; there is no piped approval option. It rechecks the interlock and exclusively allocates that version's `packaging` directory. The supervised child builds only that version, disables publication, strips unrelated credentials, and stops on failure or a 15-minute overall deadline. This deadline does not bound individual CSP authentication attempts. Records retain source/tool hashes, redacted output, events, and artifact file hashes; an existing attempt or output refuses reuse. `builderCompleted` and a successful supervisor result do not establish package verification, signature acceptance, or installation success; `packageVerification` remains `pending` until those checks are performed independently. -Before the operator starts, supply both verified installers, generated metadata and blockmaps, packaging and signature records, the exact installed executable path, and fixed test feed URL. Both packages must use the same test identity, isolated data directories, and absolute external `DSH_DESKTOP_UPDATE_JOURNAL_DIR` on every launch, including an installer-triggered restart. A variable set only in the original launch terminal is insufficient. The [Windows signing rules](../../README.md#windows-ev-signing) still apply; a manifest never clears a signing interlock. +Before the operator starts, supply both verified installers, generated metadata and blockmaps, packaging and signature records, the exact installed executable path, and fixed test feed URL. Both packages must use the same test identity, isolated data directories, and absolute external `DSH_DESKTOP_UPDATE_JOURNAL_DIR` on every launch, including an installer-triggered restart. A variable set only in the original launch terminal is insufficient. The [Windows signing rules](../../windows-signing/README.md#windows-ev-signing) still apply; a manifest never clears a signing interlock. The Windows [read-only signature helper](../../scripts/installed-update-signature.mjs) uses the real updater verifier with a publisher derived from the trusted public certificate, then requires valid Authenticode and timestamp attributes and unchanged SHA-512. Verification children receive no inherited signing/upload secrets or PowerShell module-path overrides. The helper never loads `.env.windows`, signs a file, or executes the inspected executable. Signed-probe acceptance and unsigned-installer rejection have been observed locally; they do not certify either prepared version, package identity, or installation behavior. diff --git a/apps/desktop/tests/installed-update/README.zh.md b/apps/desktop/tests/installed-update/README.zh.md index ac2bed4e2a..618eb3c578 100644 --- a/apps/desktop/tests/installed-update/README.zh.md +++ b/apps/desktop/tests/installed-update/README.zh.md @@ -23,7 +23,7 @@ description: "Windows 已安装应用更新的人工清单,覆盖下载失败 在已安装依赖的仓库根目录中,用两个递增的派生测试版本创建批次。这只写入已忽略的本地清单,不构建、签名、上传、安装或读取凭据。 -示例以 `0.1.6-alpha.1` 为基础版本。创建新物料前,按[发布版本规则](../../README.zh.md#release-versions)替换为实际基础版本、北京时间日期和未使用的序号。 +示例以 `0.1.6-alpha.1` 为基础版本。创建新物料前,按[发布版本规则](../../packaging/README.zh.md#release-versions)替换为实际基础版本、北京时间日期和未使用的序号。 ```powershell node --import tsx apps/desktop/scripts/prepare-installed-update.ts init 0.1.6-alpha.1.20260916.1 0.1.6-alpha.1.20260916.2 @@ -44,7 +44,7 @@ node --import tsx apps/desktop/scripts/prepare-installed-update.ts application " `application` 命令将已构建的主程序/预加载模块、界面文件和准备好的启动入口复制到 `application/files`,并在 `application/result.json` 中记录 SHA-256。它不复制应用根目录的 `.env.windows`,也不冻结 `node_modules` 与构建工具。[验收打包配置](../../scripts/installed-update-builder.ts)校验该清单与选定的独立运行时,选用共享冻结文件和隔离包元数据,并保留常规安装器与签名钩子。其测试替换签名器,并通过固定版本构建器的配置校验;测试不生成安装器。另行授权的受监督构建仍须记录依赖/工具输入,并验证最终包内容及签名。 -[打包入口](../../scripts/package-installed-update.ts)默认只检查。保留的签名保护锁会在读取凭据前使其拒绝;入口绝不清除此锁。没有保护锁时,检查会加载 `.env.windows`、校验准备输入,不启动子进程。共享构建器要求[测试发布配置](../../README.zh.md#upload-updates)中有效的 `DOWNLOAD_TEST_RELEASE_ID`;资格验收仍使用清单中独立的 `qualification/` 路径发布。在仓库根目录使用一个准确版本执行: +[打包入口](../../scripts/package-installed-update.ts)默认只检查。保留的签名保护锁会在读取凭据前使其拒绝;入口绝不清除此锁。没有保护锁时,检查会加载 `.env.windows`、校验准备输入,不启动子进程。共享构建器要求[测试发布配置](../../publishing/README.zh.md#upload-updates)中有效的 `DOWNLOAD_TEST_RELEASE_ID`;资格验收仍使用清单中独立的 `qualification/` 路径发布。在仓库根目录使用一个准确版本执行: ```powershell node --import tsx apps/desktop/scripts/package-installed-update.ts "" 0.1.6-alpha.1.20260916.1 --check @@ -52,7 +52,7 @@ node --import tsx apps/desktop/scripts/package-installed-update.ts "" 实际打包仍未验证,必须另行获得硬件恢复授权并有操作者在场。`--execute` 模式要求终端和包含版本、批次 ID 的准确确认,没有管道批准选项。它再次检查保护锁,并独占创建该版本的 `packaging` 目录。受监督子进程只构建该版本、禁用发布、移除无关凭据,失败或达到 15 分钟整体期限时停止。该期限不限制单次 CSP 内部认证尝试。记录保留源码/工具哈希、脱敏输出、事件和产物文件哈希;已有尝试或输出拒绝复用。`builderCompleted` 与监督程序成功结果不证明包验证、验签或安装成功;独立完成这些检查前,`packageVerification` 保持 `pending`。 -操作者开始前,提供两个已验证安装包、生成的元数据与 blockmap、打包和签名记录、准确的安装后可执行文件路径与固定 test feed URL。两个安装包必须使用相同的测试身份、隔离数据目录,并在每次启动时设置同一个安装目录外的绝对路径 `DSH_DESKTOP_UPDATE_JOURNAL_DIR`,包括安装器触发的重启。仅在首次启动终端中设置变量是不够的。[Windows 签名规则](../../README.zh.md#windows-ev-signing)仍适用;清单绝不解除签名保护锁。 +操作者开始前,提供两个已验证安装包、生成的元数据与 blockmap、打包和签名记录、准确的安装后可执行文件路径与固定 test feed URL。两个安装包必须使用相同的测试身份、隔离数据目录,并在每次启动时设置同一个安装目录外的绝对路径 `DSH_DESKTOP_UPDATE_JOURNAL_DIR`,包括安装器触发的重启。仅在首次启动终端中设置变量是不够的。[Windows 签名规则](../../windows-signing/README.zh.md#windows-ev-signing)仍适用;清单绝不解除签名保护锁。 Windows [只读签名检查器](../../scripts/installed-update-signature.mjs)使用真实 updater 验签,发布者来自可信公钥证书,随后要求 Authenticode 与时间戳属性有效且 SHA-512 未变。验签子进程不继承签名/上传秘密或 PowerShell 模块路径覆盖。检查器绝不加载 `.env.windows`、签名文件或执行被检查的可执行文件。本地已观察到签名探针通过、未签名安装包被拒绝;这些结果不能证明任一准备版本、包身份或安装行为已验收。 diff --git a/apps/desktop/tests/macos-app-update-config.spec.ts b/apps/desktop/tests/macos-app-update-config.spec.ts index 72a003734a..bba859fa7c 100644 --- a/apps/desktop/tests/macos-app-update-config.spec.ts +++ b/apps/desktop/tests/macos-app-update-config.spec.ts @@ -28,9 +28,9 @@ afterEach(async () => { describe('macOS packaged updater configuration', () => { it('uses the final generic Nightly provider configured for the build', () => { expect(resolveMacOSAppUpdateFeed([{ provider: 'generic', url: update.publicUrl, channel: 'nightly' }])) - .toEqual(update) + .toEqual({ ...update, channel: 'nightly' }) for (const publish of [undefined, [], [{ provider: 'github', channel: 'nightly', url: update.publicUrl }], - [{ provider: 'generic', channel: 'latest', url: update.publicUrl }]]) { + [{ provider: 'generic', channel: 'invalid', url: update.publicUrl }]]) { expect(() => resolveMacOSAppUpdateFeed(publish)).toThrow(/macOS update config/u) } }) @@ -64,3 +64,12 @@ describe('macOS packaged updater configuration', () => { .rejects.toThrow(/expected deepseek-harness-updater/u) }) }) + + +it('seals latest into the macOS updater file and rejects a different expected channel', async () => { + const paths = await fixture() + const latest = resolveMacOSAppUpdateFeed([{ provider: 'generic', url: update.publicUrl, channel: 'latest' }]) + await writeMacOSAppUpdateConfig(paths.resourcesDir, latest, 'fixture-updater') + await expect(verifyMacOSAppUpdateConfig(paths.appPath, latest)).resolves.toBeUndefined() + await expect(verifyMacOSAppUpdateConfig(paths.appPath, update)).rejects.toThrow(/does not match/u) +}) diff --git a/apps/desktop/tests/macos-disk-image-signature.spec.ts b/apps/desktop/tests/macos-disk-image-signature.spec.ts new file mode 100644 index 0000000000..5aaf21f969 --- /dev/null +++ b/apps/desktop/tests/macos-disk-image-signature.spec.ts @@ -0,0 +1,58 @@ +import { spawnSync } from 'node:child_process' +import { afterEach, expect, it, vi } from 'vitest' +import { createElectronBuilderConfig } from '../scripts/electron-builder-config.mjs' +import { verifyMacOSDiskImage } from '../scripts/verify-macos-signature.mjs' + +vi.mock('node:child_process', async importOriginal => ({ + ...await importOriginal(), spawnSync: vi.fn(), +})) +afterEach(() => { vi.resetAllMocks() }) + +const signingIdentity = 'Example Company (TEAMID1234)' +const teamId = 'TEAMID1234' +const environment = { + DSH_DESKTOP_APP_ID: 'com.example.desktop', DSH_DESKTOP_AUTO_UPDATE_ENV: 'production', + DSH_DESKTOP_MANDATORY_UPDATE_PROD_ORIGIN: 'https://policy.example.com', + DOWNLOAD_PROD_ORIGIN: 'https://updates.example.com', + DSH_DESKTOP_MACOS_SIGNING_IDENTITY: signingIdentity, DSH_DESKTOP_MACOS_TEAM_ID: teamId, + DSH_DESKTOP_MACOS_SKIP_NOTARIZATION: '1', +} + +function toolResult(details = `Authority=Developer ID Application: ${signingIdentity}\nTeamIdentifier=${teamId}`) { + vi.mocked(spawnSync).mockReturnValue({ + pid: 1, output: [null, '', details], stdout: '', stderr: details, status: 0, signal: null, + }) +} + +it('verifies an unnotarized DMG signature and identity without invoking Apple notarization tools', () => { + toolResult() + const config = createElectronBuilderConfig(environment, 'darwin', 'arm64') + expect(config.artifactBuildCompleted({ file: '/fixture/debug.dmg' })).toBeUndefined() + expect(vi.mocked(spawnSync).mock.calls.map(call => call.slice(0, 2))).toEqual([ + ['/usr/bin/codesign', ['--verify', '--strict', '--verbose=2', '/fixture/debug.dmg']], + ['/usr/bin/codesign', ['--display', '--verbose=4', '/fixture/debug.dmg']], + ]) +}) + +it('rejects an unnotarized DMG signed by another team', () => { + toolResult('Authority=Developer ID Application: Other (OTHERID123)\nTeamIdentifier=OTHERID123') + const config = createElectronBuilderConfig(environment, 'darwin', 'arm64') + expect(() => config.artifactBuildCompleted({ file: '/fixture/debug.dmg' })).toThrow(/release identity/u) +}) + +it('rejects a corrupt unnotarized DMG before inspecting its identity', () => { + toolResult() + vi.mocked(spawnSync).mockReturnValueOnce({ pid: 1, output: [null, '', 'invalid signature'], + stdout: '', stderr: 'invalid signature', status: 1, signal: null }) + const config = createElectronBuilderConfig(environment, 'darwin', 'arm64') + expect(() => config.artifactBuildCompleted({ file: '/fixture/debug.dmg' })).toThrow(/invalid signature/u) + expect(spawnSync).toHaveBeenCalledOnce() +}) + +it('still requires a stapled ticket and Gatekeeper acceptance for release DMGs', () => { + toolResult() + verifyMacOSDiskImage('/fixture/release.dmg', { signingIdentity, teamId }) + expect(vi.mocked(spawnSync).mock.calls.map(call => call[0])).toEqual([ + '/usr/bin/codesign', '/usr/bin/codesign', '/usr/bin/xcrun', '/usr/sbin/spctl', + ]) +}) diff --git a/apps/desktop/tests/macos-package-preset.spec.ts b/apps/desktop/tests/macos-package-preset.spec.ts new file mode 100644 index 0000000000..a4ab457ec0 --- /dev/null +++ b/apps/desktop/tests/macos-package-preset.spec.ts @@ -0,0 +1,101 @@ +import { spawnSync } from 'node:child_process' +import { mkdir, mkdtemp, realpath, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { parseEnv } from 'node:util' +import { expect, it } from 'vitest' +import { loadDesktopPackageEnvironment } from '../scripts/desktop-package-environment.mjs' + +it('loads machine signing credentials independently of the checkout and rejects misplaced settings', async () => { + const root = await mkdtemp(join(tmpdir(), 'macos-preset-')) + try { + await mkdir(join(root, 'presets')) + await mkdir(join(root, 'secrets')) + const preset = join(root, 'presets/test.env') + const secrets = join(root, 'secrets/macos-signing.env') + await writeFile(preset, 'DSH_DESKTOP_AUTO_UPDATE_ENV=test\nDSH_DESKTOP_MACOS_PACK_CONCURRENCY=2\nDSH_DESKTOP_MACOS_SIGNATURE_CACHE_DIR=/machine/cache\n') + await writeFile(secrets, 'CSC_LINK=../certificates/app.p12\nCSC_KEY_PASSWORD=machine-secret\nAPPLE_API_KEY=../certificates/key.p8\n') + const env = { DSH_PACKAGING_PRESET: 'test', DSH_PACKAGING_CONFIG_DIR: root, CSC_KEY_PASSWORD: 'ambient-secret' } + for (const options of [{}, { prepareOnly: true }]) { + const selected = loadDesktopPackageEnvironment('darwin', env, join(root, 'absent-checkout'), options) + expect(selected.CSC_LINK).toBe(join(root, 'certificates/app.p12')) + expect(selected.APPLE_API_KEY).toBe(join(root, 'certificates/key.p8')) + expect(selected.CSC_KEY_PASSWORD).toBe('machine-secret') + expect(selected.DSH_DESKTOP_MACOS_PACK_CONCURRENCY).toBe('2') + expect(selected.DSH_DESKTOP_MACOS_SIGNATURE_CACHE_DIR).toBe('/machine/cache') + } + await rm(secrets) + expect(loadDesktopPackageEnvironment('darwin', env, root, { unsigned: true }).CSC_KEY_PASSWORD).toBeUndefined() + expect(() => loadDesktopPackageEnvironment('darwin', env)).toThrow(/cannot read/u) + await writeFile(secrets, 'DSH_DESKTOP_MACOS_DOWNLOAD_PROXY=http://localhost:1234\n') + expect(() => loadDesktopPackageEnvironment('darwin', env)).toThrow(/unsupported setting/u) + await writeFile(preset, 'DSH_DESKTOP_AUTO_UPDATE_ENV=test\nCSC_KEY_PASSWORD=private-value\n') + expect(() => loadDesktopPackageEnvironment('darwin', env)).toThrow(/unsupported setting CSC_KEY_PASSWORD/u) + expect(() => loadDesktopPackageEnvironment('darwin', env)).not.toThrow(/private-value/u) + } finally { + await rm(root, { recursive: true, force: true }) + } +}) + +it.skipIf(process.platform !== 'darwin')('prepares private machine directories without overwriting existing files', async () => { + const root = await mkdtemp(join(tmpdir(), 'macos-setup-')) + try { + const machine = join(await realpath(root), 'machine') + const run = (...args: string[]) => spawnSync(process.execPath, + [join(import.meta.dirname, '../scripts/setup-macos-packaging.mjs'), machine, ...args], { encoding: 'utf8' }) + expect(run().status).toBe(0) + const config = join(machine, 'packaging/presets/test.env.example') + expect(parseEnv(await readFile(config, 'utf8')).DSH_DESKTOP_MACOS_SIGNATURE_CACHE_DIR).toBe(join(machine, 'cache/macos-signatures')) + await writeFile(config, 'keep existing configuration\n') + expect(run().status).toBe(0) + expect(await readFile(config, 'utf8')).toBe('keep existing configuration\n') + expect(run('--check').status).toBe(0) + await rm(join(machine, 'packaging/secrets'), { recursive: true }) + const missing = run('--check') + expect(missing.error).toBeUndefined() + expect(missing.signal).toBeNull() + expect(missing.status).toBe(1) + } finally { + await rm(root, { recursive: true, force: true }) + } +}) + +it('records debugging artifact paths only for an explicit CI selection without notarization credentials', async () => { + const root = await mkdtemp(join(tmpdir(), 'macos-ci-settings-')) + try { + await mkdir(join(root, 'presets')) + await mkdir(join(root, 'secrets')) + await writeFile(join(root, 'certificate.p12'), 'fixture') + await writeFile(join(root, 'presets/test.env'), [ + 'DSH_DESKTOP_APP_ID=com.example.fixture', 'DSH_DESKTOP_AUTO_UPDATE_ENV=test', + 'DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN=https://policy.example.com', + 'DSH_DESKTOP_MANDATORY_UPDATE_CONFIG={"allowedAuthOrigins":["https://login.example.com"]}', + 'DOWNLOAD_TEST_ORIGIN=https://updates.example.com', 'DOWNLOAD_TEST_RELEASE_ID=0123456789abcdef0123456789abcdef', + ].join('\n')) + await writeFile(join(root, 'secrets/macos-signing.env'), [ + 'CSC_LINK=../certificate.p12', 'CSC_KEY_PASSWORD=fixture-password', + 'DSH_DESKTOP_MACOS_SIGNING_IDENTITY=Example Company (TEAMID1234)', 'DSH_DESKTOP_MACOS_TEAM_ID=TEAMID1234', + ].join('\n')) + const output = join(root, 'output') + const summary = join(root, 'summary') + const run = (skip: string) => spawnSync(process.execPath, + [join(import.meta.dirname, '../scripts/prepare-macos-ci.mjs')], { encoding: 'utf8', env: { + ...process.env, DSH_PACKAGING_CONFIG_DIR: root, DSH_PACKAGING_PRESET: 'test', + PACKAGE_SKIP_NOTARIZATION: skip, PACKAGE_BUILD_VERSION: 'auto', + GITHUB_RUN_NUMBER: '1', GITHUB_RUN_ATTEMPT: '1', GITHUB_OUTPUT: output, GITHUB_STEP_SUMMARY: summary, + } }) + const missingCredentials = run('false') + expect(missingCredentials.status).toBe(1) + expect(missingCredentials.stderr).toContain('macOS packaging requires APPLE_API_KEY') + const invalidSelection = run('yes') + expect(invalidSelection.status).toBe(1) + expect(invalidSelection.stderr).toContain('Invalid skip notarization selection') + const debugging = run('true') + expect(debugging.stderr).toBe('') + expect(debugging.status).toBe(0) + expect(await readFile(output, 'utf8')).toContain('artifacts=unnotarized-artifacts\nmode=unnotarized-debug\n') + expect(await readFile(summary, 'utf8')).toContain('Internal debugging only') + } finally { + await rm(root, { recursive: true, force: true }) + } +}) diff --git a/apps/desktop/tests/macos-signature-cache-directory.spec.ts b/apps/desktop/tests/macos-signature-cache-directory.spec.ts new file mode 100644 index 0000000000..adefc7a060 --- /dev/null +++ b/apps/desktop/tests/macos-signature-cache-directory.spec.ts @@ -0,0 +1,63 @@ +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { resolveMacOSSignatureCacheDirectory, validateMacOSSignatureCacheDirectory } from '../scripts/macos-signature-cache-directory.mjs' +import { cachedMacOSSignature } from '../scripts/macos-signature-cache.ts' + +const roots: string[] = [] +afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) }) +function fixture(): string { + const root = realpathSync(mkdtempSync(join(tmpdir(), 'mac-cache-directory-'))) + roots.push(root) + return root +} + +it('retains target-local storage only when the setting is absent', () => { + expect(resolveMacOSSignatureCacheDirectory({}, '/build/mac-arm64')).toBe(join('/build/mac-arm64', 'signature-cache')) + for (const path of ['', 'relative/cache', '~/ci/cache', '/cache\0invalid']) { + expect(() => resolveMacOSSignatureCacheDirectory({ DSH_DESKTOP_MACOS_SIGNATURE_CACHE_DIR: path }, '/build')).toThrow(/absolute directory/u) + } +}) + +// Machine storage requires POSIX ownership and permissions, which Windows cannot represent. +describe.skipIf(process.platform === 'win32')('private external macOS storage', () => { + it('rejects missing, non-directory, linked and non-private storage without repairing it', () => { + const root = fixture() + expect(() => validateMacOSSignatureCacheDirectory(join(root, 'absent'))).toThrow(/ENOENT/u) + const file = join(root, 'file') + writeFileSync(file, '') + expect(() => validateMacOSSignatureCacheDirectory(file)).toThrow(/unlinked directory/u) + const cache = join(root, 'cache') + mkdirSync(cache, { mode: 0o700 }) + const link = join(root, 'link') + symlinkSync(root, link) + expect(() => validateMacOSSignatureCacheDirectory(join(link, 'cache'))).toThrow(/unlinked directory/u) + for (const mode of [0o755, 0o500]) { + chmodSync(cache, mode) + expect(() => validateMacOSSignatureCacheDirectory(cache)).toThrow(/private, accessible/u) + } + chmodSync(cache, 0o700) + expect(validateMacOSSignatureCacheDirectory(cache)).toBe(cache) + }) + + it('restores a verified signature after removing and recreating the checkout', async () => { + const root = fixture() + const cache = join(root, 'machine-cache') + mkdirSync(cache, { mode: 0o700 }) + const checkout = join(root, 'checkout') + const sign = vi.fn(async (path: string) => { writeFileSync(path, 'signed bytes') }) + const verify = vi.fn((path: string) => { expect(readFileSync(path, 'utf8')).toBe('signed bytes') }) + for (const hit of [false, true]) { + mkdirSync(checkout) + const file = join(checkout, 'runtime') + writeFileSync(file, 'unsigned bytes', { mode: 0o755 }) + const selected = resolveMacOSSignatureCacheDirectory({ DSH_DESKTOP_MACOS_SIGNATURE_CACHE_DIR: cache }, checkout) + expect(await cachedMacOSSignature(file, selected, { policy: 'same certificate and tools', sign, verify })).toBe(hit) + expect(readFileSync(file, 'utf8')).toBe('signed bytes') + rmSync(checkout, { recursive: true }) + } + expect(sign).toHaveBeenCalledTimes(1) + expect(verify).toHaveBeenCalledTimes(2) + }) +}) diff --git a/apps/desktop/tests/macos-signature.spec.ts b/apps/desktop/tests/macos-signature.spec.ts index 186b597b07..0d9f486bdd 100644 --- a/apps/desktop/tests/macos-signature.spec.ts +++ b/apps/desktop/tests/macos-signature.spec.ts @@ -1,5 +1,6 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' import { readFileSync } from 'node:fs' +import { createRequire } from 'node:module' import type { NotarizeOptions } from '@electron/notarize' import { resolveDesktopAppId, @@ -12,6 +13,11 @@ import { assertMacOSSignatureDetails, } from '../scripts/verify-macos-signature.mjs' +const require = createRequire(import.meta.url) +const { validateConfiguration } = require('app-builder-lib/out/util/config/config.js') as { + validateConfiguration: (config: object, logger: { isEnabled: false }) => Promise +} + const RELEASE_ENVIRONMENT = { DSH_DESKTOP_APP_ID: 'com.example.desktop', DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN: 'https://policy.example.com', @@ -75,6 +81,7 @@ describe('desktop macOS release signature', () => { signIgnore: ['/Contents/Resources/app\\.asar\\.unpacked/dsh(?:/|$)', '/Contents/Resources/runtime/primary-runtime(?:/|$)', '\\.pak$'], }, dmg: { + format: 'ULMO', sign: true, writeUpdateInfo: false, }, @@ -87,6 +94,23 @@ describe('desktop macOS release signature', () => { expect(typeof config.artifactBuildCompleted).toBe('function') }) + it('accepts LZMA DMGs through builder validation while rejecting unknown formats', async () => { + const { createElectronBuilderConfig } = await import('../scripts/electron-builder-config.mjs') + for (const arch of ['arm64', 'x64']) { + for (const unsigned of ['0', '1']) { + const config = createElectronBuilderConfig({ + ...RELEASE_ENVIRONMENT, + DSH_DESKTOP_TARGET_ARCH: arch, + DSH_DESKTOP_UNSIGNED: unsigned, + }, 'darwin', arch) + expect(config.dmg.format).toBe('ULMO') + await expect(validateConfiguration(config, { isEnabled: false })).resolves.toBeUndefined() + } + } + await expect(validateConfiguration({ dmg: { format: 'invalid' } }, { isEnabled: false })) + .rejects.toThrow('configuration.dmg.format') + }) + it('seals PAK resources with their enclosing bundle while signing executable code', async () => { const { createElectronBuilderConfig } = await import('../electron-builder.config.mjs') const config = createElectronBuilderConfig(RELEASE_ENVIRONMENT, 'darwin', 'arm64') @@ -255,3 +279,19 @@ describe('desktop macOS release signature', () => { expect(verified).toEqual(['/tmp/release.dmg']) }) }) + +it('signs debugging artifacts without Apple credentials and isolates them from releases', async () => { + const { createElectronBuilderConfig } = await import('../scripts/electron-builder-config.mjs') + const config = createElectronBuilderConfig({ ...RELEASE_ENVIRONMENT, + APPLE_API_KEY: undefined, APPLE_API_KEY_ID: undefined, APPLE_API_ISSUER: undefined, + DSH_DESKTOP_MACOS_SKIP_NOTARIZATION: '1', + }, 'darwin', 'arm64') + expect(config.mac.forceCodeSigning).toBe(true) + expect(config.mac.notarize).toBe(false) + expect(config.dmg.sign).toBe(true) + expect(config.mac).toMatchObject({ target: ['dmg', 'zip'] }) + expect(config.artifactName).toContain('-unnotarized.') + expect(portablePath(config.directories.output)).toContain('/unnotarized-artifacts') + expect(() => createElectronBuilderConfig({ ...RELEASE_ENVIRONMENT, DSH_DESKTOP_MACOS_SKIP_NOTARIZATION: 'yes' }, 'darwin', 'arm64')).toThrow(/must be 0 or 1/u) + expect(() => createElectronBuilderConfig({ ...RELEASE_ENVIRONMENT, DSH_DESKTOP_MACOS_SKIP_NOTARIZATION: '1', DSH_DESKTOP_UNSIGNED: '1' }, 'darwin', 'arm64')).toThrow(/requires a signed macOS build/u) +}) diff --git a/apps/desktop/tests/mandatory-update-policy.spec.ts b/apps/desktop/tests/mandatory-update-policy.spec.ts index 84a584389b..a04353810d 100644 --- a/apps/desktop/tests/mandatory-update-policy.spec.ts +++ b/apps/desktop/tests/mandatory-update-policy.spec.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AccountClientMetadata } from '@deepseek-ai/dsh-deepseek-account/types' import { DesktopMandatoryUpdatePolicy, desktopPolicyPage, resolveDesktopPolicyConfig, type DesktopPolicyState } from '../src/mandatory-update-policy.ts' -const identity = { platform: 'win32', arch: 'x64', bundledDshVersion: '0.1.5-rc.1' } as const +const identity = { platform: 'win32', arch: 'x64', channel: 'nightly', bundledDshVersion: '0.1.5-rc.1' } as const const client: AccountClientMetadata = { version: '1.2.3', locale: 'zh-CN', timezoneOffsetSeconds: 28_800 } const force = { code: 40005, data: { show_content: { title: 'Update', detail: 'Required upgrade' }, desktop_app_link: 'https://downloads.example.com/desktop?os=win' } } diff --git a/apps/desktop/tests/package-target-stages.spec.ts b/apps/desktop/tests/package-target-stages.spec.ts index cde1b0079d..1d8b067390 100644 --- a/apps/desktop/tests/package-target-stages.spec.ts +++ b/apps/desktop/tests/package-target-stages.spec.ts @@ -197,3 +197,27 @@ it('does not write a release completion record when Apple proxy cleanup fails', .rejects.toThrow('proxy restoration failed') expect(writeFileSync).not.toHaveBeenCalled() }) + +it.each([false, true])('keeps signing and runtime verification when notarization is skipped (directory=%s)', async (directory) => { + const { run, stages } = supervisor() + await packageTarget(parseDesktopPackageInvocation(['mac-arm64', '--skip-notarization', ...(directory ? ['--dir'] : [])], 'darwin', 'arm64'), { + ...environment, CSC_LINK: 'signing.p12', CSC_KEY_PASSWORD: 'fixture-password', + }, run) + expect(stages.at(-2)).toBe(`exec electron-builder --config electron-builder.config.mjs --mac --arm64 --publish never${directory ? ' --dir' : ''}`) + expect(stages.at(-1)).toBe('exec tsx scripts/smoke-packaged-runtime.ts --skip-notarization') + expect(run.run.mock.calls.at(-2)![3].env).toMatchObject({ + DSH_DESKTOP_UNSIGNED: '0', DSH_DESKTOP_MACOS_SKIP_NOTARIZATION: '1', CSC_LINK: 'signing.p12', + }) + expect(withMacOSNotarizationProxy).not.toHaveBeenCalled() + expect(packageMacOSArtifacts).not.toHaveBeenCalled() + expect(writeFileSync).not.toHaveBeenCalled() +}) + +it('fails debugging packages when runtime verification fails', async () => { + const failure = 'exec tsx scripts/smoke-packaged-runtime.ts --skip-notarization' + const { run, stages } = supervisor(failure) + await expect(packageTarget(parseDesktopPackageInvocation(['mac-arm64', '--skip-notarization'], 'darwin', 'arm64'), environment, run)) + .rejects.toThrow('stage refused') + expect(stages.at(-1)).toBe(failure) + expect(writeFileSync).not.toHaveBeenCalled() +}) diff --git a/apps/desktop/tests/package-target.spec.ts b/apps/desktop/tests/package-target.spec.ts index 5019841e84..ba647d76ac 100644 --- a/apps/desktop/tests/package-target.spec.ts +++ b/apps/desktop/tests/package-target.spec.ts @@ -148,3 +148,12 @@ describe('desktop package target', () => { }) }) }) + +it('allows skipping notarization only for signed macOS packages', () => { + expect(parseDesktopPackageInvocation(['mac-arm64'], 'darwin', 'arm64').skipNotarization).toBe(false) + expect(parseDesktopPackageInvocation(['mac-arm64', '--skip-notarization'], 'darwin', 'arm64').skipNotarization).toBe(true) + for (const flag of ['--unsigned', '--prepare-only']) { + expect(() => parseDesktopPackageInvocation(['mac-arm64', '--skip-notarization', flag], 'darwin', 'arm64')).toThrow(/requires a signed macOS build/u) + } + expect(() => parseDesktopPackageInvocation(['win-x64', '--skip-notarization'], 'win32', 'x64')).toThrow(/requires a signed macOS build/u) +}) diff --git a/apps/desktop/tests/prepare-runtime-patches.spec.ts b/apps/desktop/tests/prepare-runtime-patches.spec.ts index 951724e114..84c71a1884 100644 --- a/apps/desktop/tests/prepare-runtime-patches.spec.ts +++ b/apps/desktop/tests/prepare-runtime-patches.spec.ts @@ -42,11 +42,11 @@ it('classifies the current workspace and shares only runtime patch bytes', () => const repository = resolve(import.meta.dirname, '../../..') const before = readFileSync(join(repository, 'pnpm-lock.yaml')) const plan = prepareRuntimePatches(project, repository) - expect(plan.patches.map(entry => entry.spec)).toEqual(['@earendil-works/pi-ai@1.0.2', 'node-pty@1.2.0-beta.15']) - expect(plan.workspaceOnly).toHaveLength(5) + expect(plan.patches.map(entry => entry.spec)).toEqual(['@earendil-works/pi-ai@1.1.0', 'node-pty@1.2.0-beta.15']) + expect(plan.workspaceOnly).toHaveLength(6) const settings = yaml.load(readFileSync(join(project, 'pnpm-workspace.yaml'), 'utf8')) as { overrides: Record } expect(settings.overrides['@earendil-works/pi-ai']).toBeUndefined() - expect(settings.overrides['@earendil-works/pi-ai@^1.0.2']).toBe('1.0.2') + expect(settings.overrides['@earendil-works/pi-ai@^1.1.0']).toBe('1.1.0') expect(readFileSync(join(repository, 'pnpm-lock.yaml'))).toEqual(before) }) diff --git a/apps/desktop/tests/prepare-windows-ci.spec.ts b/apps/desktop/tests/prepare-windows-ci.spec.ts index 4633a77a12..f445682467 100644 --- a/apps/desktop/tests/prepare-windows-ci.spec.ts +++ b/apps/desktop/tests/prepare-windows-ci.spec.ts @@ -1,56 +1,31 @@ -import { parseEnv } from 'node:util' import { describe, expect, it } from 'vitest' -import { windowsCiSettings } from '../scripts/prepare-windows-ci.mjs' -import { validateDesktopPackageEnvironment } from '../scripts/desktop-package-environment.mjs' +import { windowsCiVersion } from '../scripts/prepare-windows-ci.mjs' -const inputs = { - PACKAGE_BUILD_VERSION: 'auto', - PACKAGE_DEPLOYMENT: 'test', - PACKAGE_POLICY_ORIGIN: 'https://policy.example.com', - PACKAGE_LOGIN_ORIGINS: 'https://login.example.com, https://sso.example.com', - GITHUB_RUN_NUMBER: '42', - GITHUB_RUN_ATTEMPT: '2', -} -const productVersion = '0.2.1-alpha.1' -const now = new Date('2026-10-08T23:59:59Z') +const inputs = { PACKAGE_BUILD_VERSION: 'auto', GITHUB_RUN_NUMBER: '42', GITHUB_RUN_ATTEMPT: '2' } +const now = new Date('2026-10-09T01:02:03Z') -describe('manual Windows package inputs', () => { - it('numbers fresh runners and reruns without storage credentials', () => { - const result = windowsCiSettings(inputs, productVersion, now) - expect(result.version).toBe('0.2.1-alpha.1.20261008.42.2') - const settings = parseEnv(result.settings) - expect(() => { validateDesktopPackageEnvironment(settings, { platform: 'win32', arch: 'x64' }, { unsigned: true }) }).not.toThrow() - expect(Object.keys(settings).sort()).toEqual([ - 'DSH_DESKTOP_APP_ID', 'DSH_DESKTOP_AUTO_UPDATE_ENV', - 'DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN', 'DSH_DESKTOP_MANDATORY_UPDATE_CONFIG', - ].sort()) +describe('manual Windows CI version', () => { + it('uses the selected product prefix and UTC run identity', () => { + expect(windowsCiVersion(inputs, '0.2.1-alpha.1', now)).toBe('0.2.1-alpha.1.20261009.42.2') + expect(windowsCiVersion(inputs, '0.2.1', now)).toBe('0.2.1-test.20261009.42.2') }) - - it('accepts an explicit version without a GitHub run identity', () => { - const result = windowsCiSettings({ ...inputs, PACKAGE_BUILD_VERSION: '0.2.1-alpha.1.20261008.7', - GITHUB_RUN_NUMBER: undefined, GITHUB_RUN_ATTEMPT: undefined }, productVersion, now) - expect(result.version).toBe('0.2.1-alpha.1.20261008.7') + it.each(['0.2.1-alpha.1', '0.2.1-alpha.1.20261009.7'])('accepts explicit version %s without run identity', (version) => { + expect(windowsCiVersion({ PACKAGE_BUILD_VERSION: version }, '0.2.1-alpha.1', now)).toBe(version) }) - - it('uses the stable product test prefix and anonymous production policy', () => { - const result = windowsCiSettings({ ...inputs, PACKAGE_DEPLOYMENT: 'production', PACKAGE_LOGIN_ORIGINS: '' }, '0.2.1', now) - expect(result.version).toBe('0.2.1-test.20261008.42.2') - expect(() => { validateDesktopPackageEnvironment(parseEnv(result.settings), { platform: 'win32', arch: 'x64' }, { unsigned: true }) }).not.toThrow() + it.each(['', 'bad', '0.3.0'])('reports checkout-specific guidance for %s', (version) => { + expect(() => windowsCiVersion({ PACKAGE_BUILD_VERSION: version }, '0.2.1-alpha.1', now)) + .toThrow(/auto, 0\.2\.1-alpha\.1, or e\.g\. 0\.2\.1-alpha\.1\.20261009\.1/u) }) - - it.each([ - { PACKAGE_BUILD_VERSION: '' }, - { PACKAGE_BUILD_VERSION: '0.3.0' }, - { PACKAGE_DEPLOYMENT: 'staging' }, - { PACKAGE_POLICY_ORIGIN: '' }, - { PACKAGE_POLICY_ORIGIN: 'http://policy.example.com' }, - { PACKAGE_POLICY_ORIGIN: 'https://policy.example.com/path' }, - { PACKAGE_LOGIN_ORIGINS: '' }, - { PACKAGE_LOGIN_ORIGINS: 'https://user:password@login.example.com' }, - { PACKAGE_DEPLOYMENT: 'production' }, - { GITHUB_RUN_NUMBER: '0' }, - { GITHUB_RUN_ATTEMPT: '1\nINJECTED=value' }, - ])('rejects invalid manual settings %j', (override) => { - expect(() => windowsCiSettings({ ...inputs, ...override }, productVersion, now)).toThrow() + it.each([{ GITHUB_RUN_NUMBER: '0' }, { GITHUB_RUN_ATTEMPT: '1\nINJECTED=value' }])('rejects invalid run identity %j', (override) => { + expect(() => windowsCiVersion({ ...inputs, ...override }, '0.2.1', now)).toThrow(/positive integer/u) }) }) + + +it('resolves the selected version policy independently of the update channel', () => { + expect(windowsCiVersion({ ...inputs, PACKAGE_VERSION_MODE: 'default', DSH_PACKAGING_PRESET: 'production' }, '1.2.3', now)).toBe('1.2.3') + expect(windowsCiVersion({ ...inputs, PACKAGE_VERSION_MODE: 'default', DSH_PACKAGING_PRESET: 'test' }, '1.2.3', now)).toBe('1.2.3-test.20261009.42.2') + expect(windowsCiVersion({ PACKAGE_VERSION_MODE: 'product' }, '1.2.3-alpha.1', now)).toBe('1.2.3-alpha.1') + expect(() => windowsCiVersion({ PACKAGE_VERSION_MODE: 'manual', PACKAGE_BUILD_VERSION: 'auto' }, '1.2.3', now)).toThrow(/explicit version/u) + expect(() => windowsCiVersion({ PACKAGE_VERSION_MODE: 'invalid' }, '1.2.3', now)).toThrow(/version mode/u) +}) diff --git a/apps/desktop/tests/read-desktop-app-key.spec.ts b/apps/desktop/tests/read-desktop-app-key.spec.ts new file mode 100644 index 0000000000..4b67345f7e --- /dev/null +++ b/apps/desktop/tests/read-desktop-app-key.spec.ts @@ -0,0 +1,26 @@ +import { execFile } from 'node:child_process' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { promisify } from 'node:util' +import { expect, it } from 'vitest' + +it('masks multiline key bytes and passes the exact fixture to the token action without a shell', async () => { + const root = await mkdtemp(join(tmpdir(), 'desktop-app-key-')) + try { + const path = join(root, 'fixture.pem') + const output = join(root, 'output') + const key = 'fixture%key\r\nsecond-line\n' + await writeFile(path, key) + const result = await promisify(execFile)(process.execPath, + [fileURLToPath(new URL('../scripts/read-desktop-app-key.mjs', import.meta.url))], { + env: { ...process.env, APP_KEY_FILE: path, GITHUB_OUTPUT: output }, encoding: 'utf8', + }) + expect(result.stdout).toBe('::add-mask::fixture%25key%0D%0Asecond-line%0A\n') + expect(result.stderr).toBe('') + const contents = await readFile(output, 'utf8') + const delimiter = contents.slice('private-key<<'.length, contents.indexOf('\n')) + expect(contents).toBe(`private-key<<${delimiter}\n${key}\n${delimiter}\n`) + } finally { await rm(root, { recursive: true, force: true }) } +}) diff --git a/apps/desktop/tests/retain-desktop-ci.spec.ts b/apps/desktop/tests/retain-desktop-ci.spec.ts new file mode 100644 index 0000000000..0fb2b14bc5 --- /dev/null +++ b/apps/desktop/tests/retain-desktop-ci.spec.ts @@ -0,0 +1,37 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it } from 'vitest' +import { desktopReleasesRoot, retainDesktopCi } from '../scripts/retain-desktop-ci.ts' +import { verifyRetainedDesktopRelease } from '../scripts/desktop-release-store.ts' + +const roots: string[] = [] +afterEach(async () => { await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))) }) + +it.each(['mac-arm64', 'win-x64'] as const)('retains the complete %s release without signing or upload credentials', async (target) => { + const root = await mkdtemp(join(tmpdir(), 'retain-ci-')) + roots.push(root) + const appRoot = join(root, 'checkout') + const config = join(root, 'packaging') + const artifacts = join(appRoot, '.desktop-build', 'targets', target, 'artifacts') + await mkdir(artifacts, { recursive: true }) + await mkdir(join(config, 'presets'), { recursive: true }) + await writeFile(join(appRoot, 'package.json'), '{"version":"1.0.0"}') + await writeFile(join(config, 'presets/test.env'), `DSH_DESKTOP_AUTO_UPDATE_ENV=test\nDOWNLOAD_TEST_RELEASE_ID=${'a'.repeat(32)}\n`) + const names = target === 'win-x64' + ? ['deepseek-harness-1.0.0-win-x64.exe', 'deepseek-harness-1.0.0-win-x64.exe.blockmap', 'win-x64-release.json', 'nightly.yml'] + : ['deepseek-harness-1.0.0-mac-arm64.dmg', 'deepseek-harness-1.0.0-mac-arm64.zip', 'deepseek-harness-1.0.0-mac-arm64.zip.blockmap', 'mac-arm64-release.json', 'nightly-mac.yml'] + await Promise.all(names.map(name => writeFile(join(artifacts, name), 'fixture'))) + const env = { + DSH_PACKAGING_CONFIG_DIR: config, DSH_PACKAGING_PRESET: 'test', DSH_DESKTOP_TARGET_PLATFORM: target === 'win-x64' ? 'win32' : 'darwin', + DSH_DESKTOP_TARGET_ARCH: target === 'win-x64' ? 'x64' : 'arm64', PACKAGE_SOURCE_REF: 'master', PACKAGE_SOURCE_SHA: 'b'.repeat(40), PACKAGE_VERSION: '1.0.0', PACKAGE_PUBLISH: 'true', + PACKAGE_SIGNING: 'signed', PACKAGE_SKIP_NOTARIZATION: 'false', GITHUB_RUN_ID: '123', GITHUB_RUN_ATTEMPT: '2', + GITHUB_REPOSITORY: 'owner/repository', GITHUB_SHA: 'b'.repeat(40), RUNNER_NAME: 'dedicated', + } + await retainDesktopCi(env, appRoot) + const manifest = await verifyRetainedDesktopRelease(desktopReleasesRoot(env), '123', '2') + expect(manifest.files.map(file => file.name)).toEqual(names) + expect(manifest.source).toMatchObject({ target, publishRequested: true, qualification: target === 'win-x64' ? 'signed' : 'notarized' }) + await expect(retainDesktopCi({ ...env, PACKAGE_SIGNING: 'unsigned', PACKAGE_SKIP_NOTARIZATION: 'true' }, appRoot)) + .rejects.toThrow(/publishing requires/u) +}) diff --git a/apps/desktop/tests/update-channel.spec.ts b/apps/desktop/tests/update-channel.spec.ts new file mode 100644 index 0000000000..8ea7bb98a4 --- /dev/null +++ b/apps/desktop/tests/update-channel.spec.ts @@ -0,0 +1,10 @@ +import { expect, it } from 'vitest' +import { desktopUpdateChannel } from '../src/update-channel.ts' + +it('reads the installed channel independently from the current version', () => { + expect(desktopUpdateChannel({ version: '1.0.0', dshDesktopUpdateChannel: 'nightly' })).toBe('nightly') + expect(desktopUpdateChannel({ version: '1.0.0', dshDesktopUpdateChannel: 'latest' })).toBe('latest') + expect(desktopUpdateChannel({ version: '1.0.0' })).toBe('nightly') + expect(() => desktopUpdateChannel({ dshDesktopUpdateChannel: 'beta' })).toThrow(/channel/u) + expect(() => desktopUpdateChannel(null)).toThrow(/manifest/u) +}) diff --git a/apps/desktop/tests/update-coordinator.spec.ts b/apps/desktop/tests/update-coordinator.spec.ts index 790ad63fcf..1c6e9da1c7 100644 --- a/apps/desktop/tests/update-coordinator.spec.ts +++ b/apps/desktop/tests/update-coordinator.spec.ts @@ -47,7 +47,7 @@ describe('desktop release metadata', () => { const coordinators: InstanceType[] = [] afterEach(() => { for (const item of coordinators.splice(0)) item.dispose() }) -function fixture() { +function fixture(channel: 'nightly' | 'latest' = 'nightly', version = '1.1.0-alpha.1') { const events = new EventEmitter() const checkForUpdates = vi.fn(async () => ({ isUpdateAvailable: true, @@ -66,7 +66,7 @@ function fixture() { const updater = Object.assign(events, { checkForUpdates, downloadUpdate, quitAndInstall }) as unknown as AppUpdater const coordinator = new DesktopUpdateCoordinator( (state) => { states.push(state); return state }, - beforeRestart, updater, () => true, () => '1.1.0-alpha.1', downloadResult, + beforeRestart, updater, () => true, () => version, downloadResult, channel, ) coordinators.push(coordinator) return { coordinator, updater, events, states, checkForUpdates, downloadUpdate, quitAndInstall, beforeRestart, downloadResult } @@ -242,3 +242,18 @@ it.each([new DesktopUpdatePreparationError('stop-failed', 'private diagnostic'), expect(f.downloadResult).toHaveBeenCalledExactlyOnceWith(false, error instanceof DesktopUpdatePreparationError ? 'stop-failed' : 'download_failed') expect(JSON.stringify(f.downloadResult.mock.calls)).not.toContain('private') }) + + +it('keeps a stable-version nightly installation on nightly and rejects prereleases on latest', async () => { + const nightly = fixture('nightly', '1.0.0') + expect(nightly.updater.channel).toBe('nightly') + expect(nightly.updater.allowPrerelease).toBe(true) + expect((await nightly.coordinator.check()).phase).toBe('available') + expect(nightly.updater.channel).toBe('nightly') + const latest = fixture('latest', '1.0.0') + expect(latest.updater.channel).toBe('latest') + expect(latest.updater.allowPrerelease).toBe(false) + expect(latest.updater.allowDowngrade).toBe(false) + expect((await latest.coordinator.check()).phase).toBe('error') + expect(latest.downloadUpdate).not.toHaveBeenCalled() +}) diff --git a/apps/desktop/tests/update-dialog.spec.ts b/apps/desktop/tests/update-dialog.spec.ts index 5e9f26e716..16b5888e53 100644 --- a/apps/desktop/tests/update-dialog.spec.ts +++ b/apps/desktop/tests/update-dialog.spec.ts @@ -19,6 +19,7 @@ const fixture = await vi.hoisted(async () => { }) readonly focus = vi.fn() readonly show = vi.fn() + readonly hide = vi.fn() readonly setBounds = vi.fn() readonly loadURL = vi.fn(async () => {}) constructor(readonly options: unknown) { super(); windows.push(this) } @@ -79,6 +80,23 @@ it('accepts only a displayed choice from its own main frame and retains cancella expect((await next).response).toBe(0) }) +it('releases visible Windows modality before destroying a dismissed dialog', async () => { + vi.useFakeTimers() + const f = setup() + const pending = f.show() + const window = fixture.windows.at(-1)! + const destroy = vi.spyOn(window, 'destroy').mockImplementation(() => { + expect(window.hide).toHaveBeenCalledTimes(process.platform === 'win32' ? 1 : 0) + window.destroyed = true + window.emit('closed') + }) + f.invoke(UPDATE_DIALOG_IPC.respond, 0) + await pending + expect(destroy).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(150) + expect(destroy).toHaveBeenCalledOnce() +}) + it('follows the parent geometry and removes listeners when closed or replaced', async () => { const f = setup() const first = f.show() diff --git a/apps/desktop/tests/windows-package-preset.spec.ts b/apps/desktop/tests/windows-package-preset.spec.ts new file mode 100644 index 0000000000..84cb72921c --- /dev/null +++ b/apps/desktop/tests/windows-package-preset.spec.ts @@ -0,0 +1,112 @@ +import { spawnSync } from 'node:child_process' +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, it } from 'vitest' +import { loadDesktopPackageEnvironment } from '../scripts/desktop-package-environment.mjs' + +async function withPreset(run: (root: string) => Promise) { + const root = await mkdtemp(join(tmpdir(), 'dsh-windows-preset-')) + try { + await mkdir(join(root, 'presets')) + await mkdir(join(root, 'secrets')) + await writeFile(join(root, 'presets/test.env'), 'DSH_DESKTOP_AUTO_UPDATE_ENV=test\nDSH_DESKTOP_APP_ID=com.example.desktop\n') + await run(root) + } finally { + await rm(root, { recursive: true, force: true }) + } +} + +it('loads unsigned public settings without requiring the signing secret file or personal dotenv', async () => { + await withPreset(async (root) => { + const environment = loadDesktopPackageEnvironment('win32', { + DSH_PACKAGING_CONFIG_DIR: root, DSH_PACKAGING_PRESET: 'test', DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'ambient-secret', + }, join(root, 'missing-checkout'), { unsigned: true }) + expect(environment.DSH_DESKTOP_APP_ID).toBe('com.example.desktop') + expect(environment.DSH_DESKTOP_WINDOWS_TOKEN_PIN).toBeUndefined() + const prepared = loadDesktopPackageEnvironment('win32', { + DSH_PACKAGING_CONFIG_DIR: root, DSH_PACKAGING_PRESET: 'test', + }, root, { prepareOnly: true }) + expect(prepared.DSH_DESKTOP_WINDOWS_TOKEN_PIN).toBeUndefined() + expect(prepared.DSH_PACKAGING_PRESET).toBe('test') + expect(() => loadDesktopPackageEnvironment('win32', { DSH_PACKAGING_CONFIG_DIR: root, DSH_PACKAGING_PRESET: 'test' })) + .toThrow(/cannot read/u) + }) +}) + +it('resolves signing file paths relative to the secret file and rejects public overrides there', async () => { + await withPreset(async (root) => { + const env = { DSH_PACKAGING_CONFIG_DIR: root, DSH_PACKAGING_PRESET: 'test' } + const file = join(root, 'secrets/windows-signing.env') + await writeFile(file, 'DSH_DESKTOP_WINDOWS_CER_FILE=../certificates/ev.cer\nDSH_DESKTOP_WINDOWS_TOKEN_PIN="private-value"\n') + const result = loadDesktopPackageEnvironment('win32', env) + expect(result.DSH_DESKTOP_WINDOWS_CER_FILE).toBe(join(root, 'certificates/ev.cer')) + expect(result.DSH_DESKTOP_WINDOWS_TOKEN_PIN).toBe('private-value') + await writeFile(file, 'DSH_DESKTOP_AUTO_UPDATE_ENV=production\n') + expect(() => loadDesktopPackageEnvironment('win32', env)).toThrow(/unsupported setting/u) + }) +}) + +it('preserves the selected preset when Windows environment enumeration uses lowercase keys', async () => { + await withPreset(async (root) => { + const values: NodeJS.ProcessEnv = { dsh_packaging_config_dir: root, dsh_packaging_preset: 'test' } + const environment = new Proxy(values, { + get: (target, name) => typeof name === 'string' ? target[name.toLowerCase()] : undefined, + }) + const selected = loadDesktopPackageEnvironment('win32', environment, root, { unsigned: true }) + expect(selected.DSH_PACKAGING_PRESET).toBe('test') + }) +}) + +it('rejects missing presets, traversal, deployment mismatches and secrets in public configuration', async () => { + await withPreset(async (root) => { + const env = { DSH_PACKAGING_CONFIG_DIR: root, DSH_PACKAGING_PRESET: 'test' } + const load = (override = {}) => loadDesktopPackageEnvironment('win32', { ...env, ...override }, root, { unsigned: true }) + expect(() => load({ DSH_PACKAGING_CONFIG_DIR: 'relative' })).toThrow(/absolute/u) + expect(() => load({ DSH_PACKAGING_CONFIG_DIR: undefined })).toThrow(/absolute/u) + expect(() => load({ DSH_PACKAGING_PRESET: '../secrets' })).toThrow(/preset must/u) + expect(() => load({ DSH_PACKAGING_PRESET: 'production' })).toThrow(/cannot read/u) + await writeFile(join(root, 'presets/test.env'), 'DSH_DESKTOP_AUTO_UPDATE_ENV=production\n') + expect(() => load()).toThrow(/must match/u) + await writeFile(join(root, 'presets/test.env'), 'DSH_DESKTOP_WINDOWS_TOKEN_PIN=private-value\n') + expect(() => load()).toThrow(/unsupported setting DSH_DESKTOP_WINDOWS_TOKEN_PIN/u) + expect(() => load()).not.toThrow(/private-value/u) + }) +}) + +it('cache maintenance reaches cache path validation without a signing file', async () => { + await withPreset(async (root) => { + await writeFile(join(root, 'presets/test.env'), + 'DSH_DESKTOP_AUTO_UPDATE_ENV=test\nDSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR=relative-cache\n') + const result = spawnSync(process.execPath, + [join(import.meta.dirname, '../scripts/manage-windows-signature-cache.mjs'), '--usage'], { + env: { ...process.env, DSH_PACKAGING_CONFIG_DIR: root, DSH_PACKAGING_PRESET: 'test' }, + encoding: 'utf8', timeout: 10_000, windowsHide: true, + }) + expect(result.error).toBeUndefined() + expect(result.signal).toBeNull() + expect(result.status).toBe(1) + expect(result.stderr).toContain('absolute local') + expect(result.stderr).not.toContain('cannot read') + }) +}) + +it.each(['win32', 'darwin'] as const)('applies an explicit test channel override on %s without changing the preset file', async (platform) => { + await withPreset(async (root) => { + const path = join(root, 'presets/test.env') + const original = 'a'.repeat(32) + await writeFile(path, `DSH_DESKTOP_AUTO_UPDATE_ENV=test\nDOWNLOAD_TEST_RELEASE_ID=${original}\n`) + const env = { DSH_PACKAGING_CONFIG_DIR: root, DSH_PACKAGING_PRESET: 'test' } + const select = (id: string) => loadDesktopPackageEnvironment(platform, + { ...env, PACKAGE_TEST_RELEASE_ID: id }, root, { unsigned: true }) + expect(select('b'.repeat(32)).DOWNLOAD_TEST_RELEASE_ID).toBe('b'.repeat(32)) + expect(select('').DOWNLOAD_TEST_RELEASE_ID).toBe(original) + expect(select(' ').DOWNLOAD_TEST_RELEASE_ID).toBe(original) + expect(() => select('../other')).toThrow(/32 lowercase hexadecimal/u) + expect(() => select('A'.repeat(32))).toThrow(/32 lowercase hexadecimal/u) + await writeFile(join(root, 'presets/production.env'), 'DSH_DESKTOP_AUTO_UPDATE_ENV=production\n') + expect(() => loadDesktopPackageEnvironment(platform, + { ...env, DSH_PACKAGING_PRESET: 'production', PACKAGE_TEST_RELEASE_ID: original }, root, { unsigned: true })) + .toThrow(/requires the test preset/u) + }) +}) diff --git a/apps/desktop/windows-signing/README.i18n.yaml b/apps/desktop/windows-signing/README.i18n.yaml new file mode 100644 index 0000000000..c063a69c88 --- /dev/null +++ b/apps/desktop/windows-signing/README.i18n.yaml @@ -0,0 +1,40 @@ +# Bilingual-pair consistency record for README.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write apps/desktop/windows-signing/README.md +/: + en: 5b2af461afadb867 + zh: c7558c795a752944 +/windows-signing-and-runner-operations: + en: 65f1ac70a69830fd + zh: cd5825b41e9e30f4 +/windows-signing-and-runner-operations/summary: + en: 3eec637b807a2d4c + zh: e042992b9ea83345 +/windows-signing-and-runner-operations/table-of-contents: + en: 3a812cc5ad554fd3 + zh: 0401b5764c155c0b +/windows-signing-and-runner-operations/trust-and-isolation-limits: + en: 0c261f3948178d55 + zh: 1086cd882468ad8f +/windows-signing-and-runner-operations/machine-setup-and-runner-startup: + en: ba3de07cc9553056 + zh: 887e652c40d57091 +/windows-signing-and-runner-operations/signing-configuration: + en: 4b5548818375eb9a + zh: 8c975adbb1dca9c8 +/windows-signing-and-runner-operations/signing-and-timestamp-completion: + en: e42565e00283e3f6 + zh: 584a33f07b50fd5b +/windows-signing-and-runner-operations/failure-records-and-operator-recovery: + en: 5401b605a2f3a44e + zh: fc1f6ee667bcd20d +/windows-signing-and-runner-operations/signature-cache-and-concurrent-stages: + en: 24032af67e83a003 + zh: 76894f57a9dfed52 +/windows-signing-and-runner-operations/verification-limits: + en: 94a471410c84a1c7 + zh: 115228e4ca36581d +/windows-signing-and-runner-operations/dev-note: + en: b75c8458928b72b9 + zh: 003ba4e17b4b5269 diff --git a/apps/desktop/windows-signing/README.md b/apps/desktop/windows-signing/README.md new file mode 100644 index 0000000000..eb3fd4a76d --- /dev/null +++ b/apps/desktop/windows-signing/README.md @@ -0,0 +1,93 @@ +--- +description: "Configure and operate the Windows packaging runner, signing credentials, EV token and signature cache. Read the accepted isolation risks before running branch code or accessing the token. Hardware failures require operator inspection; packaging success does not establish installation or production readiness." +--- + +# Windows signing and runner operations + +English | [中文](README.zh.md) + +## Summary + +Configure and operate the Windows packaging runner, signing credentials, EV token and signature cache. Read the accepted isolation risks before running branch code or accessing the token. Hardware failures require operator inspection; packaging success does not establish installation or production readiness. + +## Table of Contents + +- [Trust and isolation limits](#trust-and-isolation) +- [Machine setup and runner startup](#machine-setup) +- [Signing configuration](#signing-configuration) +- [Signing and timestamp completion](#windows-ev-signing) +- [Failure records and operator recovery](#failure-and-recovery) +- [Signature cache and concurrent stages](#signature-cache) +- [Verification limits](#verification) + + + +## Trust and isolation limits + +The shared packaging runner assumes trusted internal-team code and operators. Under this assumption, credential and device isolation are deferred and the residual risk is accepted (tracked in #5973); signed/master-only access is not enforced. Unsigned mode skips signing-file loading but cannot prevent branch code running as the same Windows account from reading those files or accessing the token. SafeNet Single Logon may reuse authentication. Direct device calls can bypass the signing scripts and exhaust PIN retries; the persistent failure interlock only protects calls through those scripts. Cross-account token isolation and remaining retry counts are unverified. Reassess runner/account/device isolation before expanding access, executing untrusted contributions, changing runner sharing, or after suspicious activity. + + + +## Machine setup and runner startup + +Use a runner-owned checkout and keep configuration, certificates and caches outside it. Registration and tool installation remain operator tasks. Packaging inputs and artifact retrieval are described in the [packaging guide](../packaging/README.md#windows-actions). + +Run `pwsh -NoProfile -File apps/desktop/scripts/setup-windows-packaging.ps1 -Root ` to prepare private `packaging/{presets,secrets,certificates}` and `cache/windows-signatures` directories and copy templates without overwriting existing files. Add `-CheckOnly` to inspect without writes. Fill the [preset templates](../config/windows-packaging/test.env.example) and [signing template](../config/windows-packaging/windows-signing.env.example) on the machine. Run `start-windows-packaging-runner.ps1 -RunnerRoot -ConfigRoot /packaging -CheckOnly` from the same scripts directory to inspect PowerShell 7.6.6 under the runner's `tools` directory, Node, Python, Git, tar and Visual C++ tools; packaging `--check` performs the remaining toolchain validation. Omit `-CheckOnly` to start the existing runner with an explicit config root and scrubbed inherited environment. Registration and tool installation are separate; these scripts never create tokens, alter system security settings or clear signing failure records. Use the signing cache maintenance command below to migrate existing entries before changing the preset's cache path. + + + +## Signing configuration + +The [machine preset configuration](../packaging/README.md#configuration-and-local-builds) selects public settings and the signing file. Without a preset, use the target dotenv file. + +Fill in `.env.windows` with `DSH_DESKTOP_WINDOWS_CER_FILE` (public EV leaf certificate), `DSH_DESKTOP_WINDOWS_SIGNTOOL` (SafeNet-compatible SignTool), `DSH_DESKTOP_WINDOWS_KEY_CONTAINER` (matching private-key container), and `DSH_DESKTOP_WINDOWS_TOKEN_PIN` (Token Password). The private key stays on the USB token; keep the certificate and local credential file out of Git. + +The PIN cannot contain `]`, a quote, or a line break because those characters delimit the SafeNet `/kc` value or its CMD argument. The CMD disables delayed expansion so a PIN containing `!` reaches SafeNet unchanged. Packaging withholds every `DSH_DESKTOP_WINDOWS_*` field from build and runtime-preparation subprocesses, gives signing preflight, the dedicated runtime signing stages and electron-builder only the four configured inputs, gives the signing CMD only the validated signing fields in an otherwise scrubbed environment, clears those fields before SignTool starts, and redacts SignTool diagnostics. SafeNet still requires the PIN in the SignTool process command line. The local `.env.windows` or machine-owned `secrets/windows-signing.env` stores the PIN in plaintext and needs restricted file access. CI reads the machine-owned file outside the checkout and retains it across jobs. Do not commit or share its contents or print credentials in logs. Configuration checks consume no token PIN attempts; signing still stops the batch on its first failure. + +Signed Windows configuration derives the updater's `publisherName` from the same public certificate's `CN`, `O`, and `C` attributes. Each must be present, nonempty, and single-valued. These identity attributes allow certificate renewal without pinning a leaf thumbprint. The installed application's `app-update.yml` carries the expected publisher; the downloaded feed does not choose it. Unsigned test builds omit updater configuration. See the [signature qualification record](../tests/README.md) for real-file verification and its limits. + + + +## Signing and timestamp completion + +Follow the [version and build instructions](../packaging/README.md#release-versions) before starting a signed build. Configuration checks do not establish token authentication. + +Signed Windows builds run a supervised signing preflight before compilation or dependency preparation. Static configuration, certificate validity, audit storage, compiler availability and any retained signing interlock are checked without token access. The local .NET Framework C# compiler creates a small private probe; the production signer signs it once, and verification requires the configured certificate and a timestamp before building continues. The probe is never executed. The overall 60-second preflight deadline includes timestamp attempts; deadline expiry, hardware signing errors and verification failures stop the run without another hardware attempt. Success proves the current signing path works, not that the PIN was independently authenticated: SafeNet may reuse login state. Do not log out or repeat authentication to test the PIN. `--check`, preparation-only and `--unsigned` modes do not run this hardware preflight; unsigned artifacts remain ineligible for release upload. Automated regression tests use a fake signer; release operators qualify real hardware separately. + +Insert and unlock the token before packaging. The electron-builder hook passes each artifact to the CRLF `scripts/windows-sign.cmd`, which invokes the configured SignTool once with `/f`, SafeNet `/kc "[{{PIN}}]=container"`, `/csp "eToken Base Cryptographic Provider"`, a SHA-256 file digest, without a timestamp. The hook then completes a DigiCert SHA-256 RFC 3161 timestamp on isolated copies without signing credentials. The hook never substitutes electron-builder's bundled SignTool and never retries a failed signing request. Windows release packaging fails instead of emitting unsigned artifacts when the SignTool, certificate, container, PIN, token, or signature is unavailable. + +Timestamp completion retries only a normally exited timestamp command returning failure or warning, at most three times with one- and two-second delays. Each attempt starts from the same verified primary signature. Launch errors, uncertain termination and verification failures stop immediately. SignTool operates on short private paths; publication copies verified bytes to the target volume before atomic replacement. Final Windows trust, certificate, timestamp and normalized full-file equality are required. Exhaustion stops packaging and preserves evidence without another hardware call. See the [signature completion decision](../../../.agents/notes/implemented/process/2026-09-17-windows-signature-completion.md). + + + +## Failure records and operator recovery + +Treat `SignTool Error: No private key is available.` as a signing failure: stop all signing attempts and have the operator inspect the signing configuration and token state before continuing. The device-specific PIN retry limit and remaining attempts are unverified; do not probe them with authentication attempts. Do not retry packaging or signing probes after this error. The signer serializes token operations and rejects all queued tasks after the first failure. + +Windows package commands print `DESKTOP_PACKAGING_RECORD` with a unique directory under `.desktop-build/packaging-runs/`. Each run retains `run.json`, timestamped `events.jsonl`, redacted `stdout.log` and `stderr.log`, and `result.json`. A signing failure also writes `fatal.json` and notifies the parent through stderr; the supervisor immediately requests termination of its stage process tree and waits for exit. Failed stages cannot start subsequent stages or create a release completion record. Journal failures also stop the run. Termination errors remain failures and require operator inspection; a missing final record means completion was not established. + +Hardware signing requires a supervised run. Before invoking the command interpreter, the signer atomically acquires `%USERPROFILE%/.dsh-desktop-signing/attempt.json` and records the attempt. Only successful signing followed by verification of the configured primary signature releases that file; timestamps are completed afterward without hardware access. Failure, interruption, an existing interlock, or unavailable audit storage prevents further hardware access, including from another signer instance, process, or checkout under the same Windows account. There is no timed recovery or automatic retry. An administrator must inspect the retained evidence and token state before explicitly authorizing interlock recovery; logging into the token or replacing a PIN file does not clear it. The records distinguish signing intent, command-interpreter PID, and completion; they do not measure internal CSP/token authentication attempts. Command arguments, PINs, and credential environments are not recorded. Independent Windows accounts and unrelated signing programs are outside this interlock. + + + +## Signature cache and concurrent stages + +Runtime signing shares complete signed files across the current Windows account's worktrees. `DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR` in `.env.windows` selects an absolute fixed-drive directory; the default is `%USERPROFILE%\.dsh-desktop-signing\signature-cache\v1`. Cache directories must belong to the current account and exclude other ordinary accounts from their access permissions; linked paths are rejected. Entries identify the original bytes, public certificate and signing toolchain. Every restoration checks the digest, Windows trust, timestamp and certificate before replacing the unsigned file; invalid entries stop packaging without hardware fallback. Cache age alone does not trigger signing. The cache trusts programs running as the same account and does not defend against administrators. The [runtime signature cache decision](../../../.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.md) owns qualification and design limits. + +Preflight, primary-runtime signing, application-runtime signing and artifact creation each hold the account's signing-stage lock until their supervised child processes settle. Other builds wait before entering those stages; compilation and preparation do not hold the lock. Runtime signing workers own their locks, so terminating only the outer packaging process does not unlock a surviving cache user. Migration and maintenance acquire the same lock; older or external signing commands do not participate and must be scheduled separately. Stage waiting is outside the preflight deadline. Closing the stage handle releases ordinary contention without removing the separate hardware-attempt interlock; a retained hardware failure still requires operator recovery. + +Cache-hit copying, hashing and per-file trust checks run with `DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY` workers from `.env.windows` (default `4`, integer `1`–`8`). All restores and their post-verification finish before cache misses enter serial hardware signing. A restore or verification failure stops new dispatch and drains active work before releasing the stage lock; hardware signing, runtime smoke checks and final integrity validation remain required. + +Each runtime stage writes `SIGNATURE_CACHE_SUMMARY` to stdout and its packaging journal, including the actual directory, policy identity, hit/miss counts, newly published and retained entries, signing requests, avoided signing requests and validation failures. Timings distinguish signing, trust verification of restored and newly signed files, and restoration; restoration includes its trust check and staging cleanup. These counters exclude preflight, final artifact signing and the outer runtime signature checks. Stage lock events record waiting separately. Concurrent timings sum per-file work rather than stage wall time. + +Use `pnpm --dir apps/desktop run cache:windows-signatures --usage` to inspect structurally complete-entry bytes and counts, or `--from ` to import an explicitly selected cache owned by the same account. Migration leaves the source unchanged, skips staging names, rejects corrupt entries and retains valid existing entries even when their timestamp bytes differ. `--clear` explicitly removes complete entries under the stage lock; no automatic capacity eviction runs. `--directory ` selects a maintenance destination without loading release credentials. Incomplete staging entries remain untouched and are counted separately; inspect them only after all builds have stopped. These commands never clear hardware failure evidence. Default storage is outside AppData so MSIX launcher virtualization does not split the account's cache; redirected overrides fail explicitly. + + + +## Verification limits + +Real hardware and installation evidence is owned by the [Desktop qualification record](../tests/README.md) and [installed-update procedure](../tests/installed-update/README.md). This document does not authorize PIN retries, automatic interlock deletion, device initialization or changes to the accepted isolation policy. + +## Dev Note + +None. diff --git a/apps/desktop/windows-signing/README.zh.md b/apps/desktop/windows-signing/README.zh.md new file mode 100644 index 0000000000..70718a8bf7 --- /dev/null +++ b/apps/desktop/windows-signing/README.zh.md @@ -0,0 +1,93 @@ +--- +description: "配置和维护 Windows 打包 runner、签名凭据、EV Token 与签名缓存。运行分支代码或访问 Token 前,先阅读已接受的隔离风险。硬件失败需要操作者检查;打包成功不代表安装或生产发布已通过验收。" +--- + +# Windows 签名与 runner 运维 + +[English](README.md) | 中文 + +## 概述 + +配置和维护 Windows 打包 runner、签名凭据、EV Token 与签名缓存。运行分支代码或访问 Token 前,先阅读已接受的隔离风险。硬件失败需要操作者检查;打包成功不代表安装或生产发布已通过验收。 + +## 目录 + +- [信任前提与隔离缺陷](#trust-and-isolation) +- [机器配置与 runner 启动](#machine-setup) +- [签名配置](#signing-configuration) +- [签名与时间戳处理](#windows-ev-signing) +- [失败记录与人工恢复](#failure-and-recovery) +- [签名缓存与并发阶段](#signature-cache) +- [验证范围](#verification) + + + +## 信任前提与隔离缺陷 + +共享打包 runner 以内部团队代码和操作人员可信为前提。在此前提下,凭据和设备隔离暂缓实施,剩余风险按已知风险接受(由 #5973 跟踪);尚未强制限制仅 signed/master 可访问。未签名模式跳过签名文件加载,但无法阻止同一 Windows 账户下的分支代码读取这些文件或访问 Token。SafeNet Single Logon 可能复用认证状态。直接调用设备可以绕过签名脚本并耗尽 PIN 重试次数;持久失败 interlock 只保护经过这些脚本的调用。跨账户 Token 隔离和剩余重试次数尚未验证。扩大访问范围、执行不可信贡献、改变 runner 共享方式前,或出现可疑活动后,重新评估 runner、账户和设备隔离。 + + + +## 机器配置与 runner 启动 + +使用 runner 专用 checkout,并将配置、证书和缓存放在其外部。注册和工具安装由操作者负责。打包输入与产物获取见[打包指南](../packaging/README.zh.md#windows-actions)。 + +运行 `pwsh -NoProfile -File apps/desktop/scripts/setup-windows-packaging.ps1 -Root ` 创建私有 `packaging/{presets,secrets,certificates}` 和 `cache/windows-signatures` 目录并复制模板,不覆盖现有文件。添加 `-CheckOnly` 只检查不写入。在机器上填写 [preset 模板](../config/windows-packaging/test.env.example)和[签名模板](../config/windows-packaging/windows-signing.env.example)。从同一 scripts 目录运行 `start-windows-packaging-runner.ps1 -RunnerRoot -ConfigRoot /packaging -CheckOnly`,检查 runner 的 `tools` 目录下 PowerShell 7.6.6、Node、Python、Git、tar 和 Visual C++ 工具;打包 `--check` 完成其余工具链检查。省略 `-CheckOnly`,以显式配置目录和清理后的继承环境启动现有 runner。注册和工具安装单独进行;这些脚本不创建 token、不改系统安全设置、不清除签名失败记录。修改 preset 缓存路径前,使用下文签名缓存维护命令迁移已有条目。 + + + +## 签名配置 + +通过[机器 preset 配置](../packaging/README.zh.md#configuration-and-local-builds)选择公开设置与签名文件;不选择 preset 时使用目标 dotenv 文件。 + +在 `.env.windows` 中填写 `DSH_DESKTOP_WINDOWS_CER_FILE`(公开 EV 叶证书)、`DSH_DESKTOP_WINDOWS_SIGNTOOL`(SafeNet 兼容的 SignTool)、`DSH_DESKTOP_WINDOWS_KEY_CONTAINER`(匹配的私钥容器)和 `DSH_DESKTOP_WINDOWS_TOKEN_PIN`(Token Password)。私钥仍保留在 USB Token;不要把证书或本地凭据文件提交到 Git。 + +PIN 不能包含 `]`、引号或换行,因为这些字符用于分隔 SafeNet `/kc` 值或对应的 CMD 参数。CMD 会禁用延迟展开,因此包含 `!` 的 PIN 可以原样到达 SafeNet。打包流程不会把任何 `DSH_DESKTOP_WINDOWS_*` 字段传给构建与 运行时准备子进程;它只向签名预检、独立的第一方运行时签名阶段与 electron-builder 提供四个配置输入,在其他字段已经清理的环境中只向签名 CMD 提供经过校验的签名字段,在 SignTool 启动前清除这些字段,并遮盖 SignTool 诊断。SafeNet 仍要求 PIN 出现在 SignTool 进程命令行中。本地 `.env.windows` 或机器管理的 `secrets/windows-signing.env` 明文保存 PIN,应限制文件访问权限。CI 读取 checkout 外的机器签名文件,并在任务结束后保留。不要提交或分享文件内容,也不要把凭据写入日志。配置检查不会消耗 Token 的 PIN 尝试次数;签名仍在首次失败后停止整批任务。 + +签名 Windows 配置从同一份公开证书的 `CN`、`O` 和 `C` 属性生成 updater 的 `publisherName`。每个属性都必须存在、非空且只有一个值。这些身份属性允许证书续期,无需固定叶证书指纹。已安装应用的 `app-update.yml` 保存预期发布者,下载的清单不能选择该身份。未签名测试构建省略 updater 配置。真实文件验证及其限制见[签名验收记录](../tests/README.zh.md)。 + + + +## 签名与时间戳处理 + +启动签名构建前,先按[版本与打包说明](../packaging/README.zh.md#release-versions)确认输入。配置检查不代表 Token 认证成功。 + +Windows 签名构建在编译或准备依赖前执行受监督的签名预检。静态配置、证书有效期、审计存储、编译器可用性及遗留签名锁的检查不访问 Token。本地 .NET Framework C# 编译器生成一个专用小探针,由正式签名器仅签名一次,随后必须验出配置的证书和时间戳才能继续构建。探针绝不执行。预检的整体 60 秒期限包含时间戳尝试;超时、硬件签名报错或验签失败都会停止本轮流程,不再次调用硬件。成功只证明当前签名路径可用,不证明 PIN 已独立认证:SafeNet 可能复用登录状态。不要为了验证 PIN 而注销或重复认证。`--check`、仅准备和 `--unsigned` 模式不执行此硬件预检;未签名产物仍不能发布上传。自动回归测试使用假签名器,真实硬件由发布操作人员单独验收。 + +打包前插入并解锁 Token。electron-builder 钩子把每个产物交给采用 CRLF 的 `scripts/windows-sign.cmd`;该 CMD 只调用一次已配置的 SignTool,并指定 `/f`、SafeNet `/kc "[{{PIN}}]=容器"`、`/csp "eToken Base Cryptographic Provider"`和 SHA-256 文件摘要,不请求时间戳。随后钩子在隔离副本上完成 DigiCert SHA-256 RFC 3161 时间戳,不传递签名凭据。钩子不会改用 electron-builder 内置的 SignTool,也不会重试失败的签名请求。SignTool、证书、容器、PIN、Token 或签名不可用时,Windows 发布打包会失败,不会生成未签名产物。 + +时间戳处理仅对正常退出但返回失败或警告的时间戳命令重试,最多尝试三次,间隔为一秒和两秒。每次均从同一份已验证的主签名开始。启动错误、终止状态不确定或验签失败会立即停止。SignTool 使用短的私有路径;发布时先把已验证字节复制到目标卷,再原子替换。最终必须通过 Windows 信任、证书、时间戳和规范化全文件相等检查。尝试耗尽后停止打包并保留证据,不再次调用硬件。参见[签名完成决策](../../../.agents/notes/implemented/process/2026-09-17-windows-signature-completion.zh.md)。 + + + +## 失败记录与人工恢复 + +将 `SignTool Error: No private key is available.` 视为签名失败:立即停止所有签名尝试,由操作者检查签名配置和 Token 状态后再继续。设备具体的 PIN 重试上限和剩余次数尚未验证,不得通过认证尝试探测。出现此错误后不要重试打包或签名探针。签名器串行执行 Token 操作,并在首次失败后拒绝所有排队任务。 + +Windows 打包命令通过 `DESKTOP_PACKAGING_RECORD` 输出 `.desktop-build/packaging-runs/` 下的唯一目录。每次运行保留 `run.json`、带时间戳的 `events.jsonl`、脱敏后的 `stdout.log` 和 `stderr.log`,以及 `result.json`。签名失败还会写入 `fatal.json` 并通过 stderr 通知父进程;监督程序立即请求终止当前阶段的进程树并等待退出。失败阶段不能启动后续阶段或生成发布完成记录。日志写入失败也会停止运行。终止错误仍按失败处理,需要操作者检查;缺少最终记录表示尚未确认完成。 + +硬件签名必须属于受监督的打包运行。调用命令解释器前,签名器原子获取 `%USERPROFILE%/.dsh-desktop-signing/attempt.json` 并记录本次尝试。只有签名成功且配置证书的主签名通过验证后才释放该文件;随后完成时间戳,不再访问硬件。失败、中断、已有锁定文件或审计存储不可用都会阻止再次访问硬件,包括同一 Windows 账户下的另一个签名器实例、进程或代码检出目录。没有定时恢复或自动重试。管理员必须检查保留的证据及令牌状态,再明确授权恢复锁定状态;登录令牌或替换 PIN 文件不会清除它。记录区分签名意图、命令解释器 PID 和完成结果,不计量 CSP/令牌内部的认证次数。不记录命令参数、PIN 或凭据环境。其他 Windows 账户及无关签名程序不在此锁定机制的保护范围内。 + + + +## 签名缓存与并发阶段 + +运行时签名在当前 Windows 账户的各 worktree 间共享完整的已签名文件。`.env.windows` 中的 `DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR` 指定固定本地磁盘上的绝对目录;默认值为 `%USERPROFILE%\.dsh-desktop-signing\signature-cache\v1`。缓存目录必须属于当前账户,访问权限不得向其他普通账户开放;带链接的路径会被拒绝。缓存项标识原始字节、公钥证书和签名工具链。每次恢复都检查摘要、Windows 信任状态、时间戳和证书,再替换未签名文件;缓存项无效会停止打包,不回退到硬件签名。不会仅因缓存较旧而重新签名。缓存信任同账户运行的程序,不防御管理员。[运行时签名缓存决策](../../../.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.zh.md)定义验收要求和设计限制。 + +预检、主要运行时签名、应用运行时签名和产物生成分别持有账户级签名阶段锁,直到受监督的子进程结束。其他构建进入这些阶段前等待;编译和准备步骤不持锁。运行时签名进程自行持锁,因此仅终止外层打包进程不会释放仍在访问缓存的阶段锁。迁移及维护获取同一把锁;旧版或外部签名命令不参与排队,应另行避免并发。阶段等待不计入预检期限。关闭阶段句柄会释放普通竞争锁,不删除独立的硬件尝试互锁;遗留硬件失败仍需操作人员恢复。 + +缓存命中的复制、摘要和逐文件信任检查使用 `.env.windows` 中的 `DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY` 个工作任务(默认 `4`,整数 `1`–`8`)。所有恢复及后置验签完成后,未命中项才进入串行硬件签名。恢复或验签失败会停止派发新任务,并在释放阶段锁前等待在途工作结束;硬件签名、运行时 smoke 检查和最终完整性校验仍须执行。 + +每个运行时阶段向标准输出及打包日志写入 `SIGNATURE_CACHE_SUMMARY`,包含实际目录、策略标识、命中及未命中数、新发布及保留项数、签名请求数、省去的签名请求数和验证失败数。计时区分签名、恢复文件和新签名文件的信任验证及恢复;恢复耗时包含其信任检查和暂存清理。这些统计不包含预检、最终产物签名及外层运行时验签。阶段锁事件单独记录等待时间。 并发计时累加逐文件工作耗时,不代表阶段墙钟时间。 + +使用 `pnpm --dir apps/desktop run cache:windows-signatures --usage` 查看结构完整的缓存项字节数和数量,或用 `--from ` 导入显式指定、属于同账户的旧缓存。迁移不修改源目录,跳过暂存名称,拒绝损坏项,并保留已有有效项,即使它们的时间戳字节不同。`--clear` 在阶段锁保护下显式删除完整缓存项;不执行自动容量淘汰。`--directory ` 指定维护目标目录,无需加载发布凭据。不完整暂存项保持原状并单独计数;仅在所有构建停止后检查它们。这些命令绝不清除硬件失败证据。默认存储位于 AppData 之外,避免 MSIX 启动器虚拟化将账户缓存拆开;被重定向的覆盖目录会明确失败。 + + + +## 验证范围 + +真实硬件与安装证据由[Desktop 验证记录](../tests/README.zh.md)和[已安装更新流程](../tests/installed-update/README.zh.md)维护。本文档不授权 PIN 重试、自动删除 interlock、设备初始化或改变已接受的隔离策略。 + +## 开发备注 + +无。 diff --git a/apps/web/package.json b/apps/web/package.json index 0b44c5cd00..1684c9a9d8 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -49,8 +49,6 @@ "@deepseek-ai/dsh-session-projection-cache": "workspace:*", "@deepseek-ai/dsh-util-values": "workspace:*", "@types/node": "^22.20.1", - "@types/react": "~18.3.1", - "@types/react-dom": "~18.3.0", "@types/ws": "8.18.1", "@vitejs/plugin-react": "^4.7.0", "http-server": "^14.1.1", diff --git a/apps/web/tests/account-login.e2e.ts b/apps/web/tests/account-login.e2e.ts new file mode 100644 index 0000000000..e20a18c94b --- /dev/null +++ b/apps/web/tests/account-login.e2e.ts @@ -0,0 +1,136 @@ +// Real-composition acceptance for DeepSeek account sign-in from a plain browser. The +// scaffold boots the shipped Web Loader tree, so the real account provider drives +// auth_init, the loopback OAuth callback, and auth_exchange against a loopback Platform +// double, while a real Chromium follows the tab the Web UI preopens. +import { createServer, type Server } from 'node:http' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { chromium, type Browser } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts' +import { saveFailureShot } from './support.ts' + +const OVERLAY_TEMPLATE = fileURLToPath(new URL('./fixtures/account-login/cordis.patch.yml', import.meta.url)) +const MODE = webSnapshotMode() +const TOKEN = 'dsh_web_login_test' +const CODE = 'web-login-code' + +/** Loopback Platform double: authorization handoff, code exchange, and the signed-in profile reads. */ +async function mockPlatform() { + const inits: Record[] = [] + const pages: string[] = [] + let state: string | undefined + let redirectUri: string | undefined + const server: Server = createServer((req, res) => { + const url = new URL(req.url ?? '/', 'http://127.0.0.1') + const reply = (payload: unknown): void => { + res.writeHead(200, { 'content-type': 'application/json' }) + res.end(JSON.stringify({ code: 0, data: { biz_code: 0, biz_data: payload } })) + } + if (url.pathname === '/dsh/authorize') { + pages.push(url.href) + // The user approves at once: Platform returns the browser to the Host callback. + const callback = new URL(redirectUri ?? '/') + callback.searchParams.set('code', CODE) + callback.searchParams.set('state', state ?? '') + res.writeHead(302, { location: callback.href }).end() + return + } + if (url.pathname === '/dsh/authorized') { + pages.push(url.href) + res.writeHead(200, { 'content-type': 'text/html' }).end('Authorized') + return + } + if (req.method === 'POST' && url.pathname.startsWith('/auth-api/v0/dsh/')) { + let body = '' + req.setEncoding('utf8') + req.on('data', (chunk: string) => { body += chunk }) + req.on('end', () => { + const payload = JSON.parse(body) as Record + if (url.pathname === '/auth-api/v0/dsh/auth_init') { + inits.push(payload) + state = String(payload.state) + redirectUri = String(payload.redirect_uri) + reply({ authorize_url: `${origin}/dsh/authorize?authorize_id=web`, authorize_id: 'web', expires_in: 300 }) + return + } + if (url.pathname === '/auth-api/v0/dsh/auth_exchange' && payload.code === CODE) { + reply({ token: TOKEN, authorized_url: `${origin}/dsh/authorized` }) + return + } + res.writeHead(404).end() + }) + return + } + if (req.headers['x-dsh-auth-token'] !== TOKEN) { res.writeHead(401).end(); return } + if (url.pathname === '/auth-api/v0/users/current') { + // `email` is required by the provider's profile schema even when the account has none. + reply({ id: 'web-user', email: '', id_profile: { name: 'Web User', picture: null } }) + return + } + if (url.pathname === '/api/v0/users/get_user_summary') { + reply({ normal_wallets: [{ currency: 'CNY', balance: '1.00' }], bonus_wallets: [] }) + return + } + if (url.pathname === '/api/v0/users/get_unnotified_bonuses') { reply([]); return } + res.writeHead(404).end() + }) + await new Promise((resolve) => { server.listen(0, '127.0.0.1', resolve) }) + const address = server.address() + if (address === null || typeof address === 'string') throw new Error('account login: missing mock listener') + const origin = `http://127.0.0.1:${String(address.port)}` + return { + origin, inits, pages, + close: () => new Promise((resolve) => { server.close(() => { resolve() }); server.closeAllConnections() }), + } +} + +describe.skipIf(MODE === 'record')('web e2e: DeepSeek account sign-in in a plain browser', () => { + let root: string | undefined + let scaffold: WebScaffold + let browser: Browser + let platform: Awaited> + + beforeAll(async () => { + platform = await mockPlatform() + root = await mkdtemp(join(tmpdir(), 'dsh-web-account-login-')) + const overlay = join(root, 'account-login.overlay.yml') + await writeFile(overlay, (await readFile(OVERLAY_TEMPLATE, 'utf8')).replaceAll('{{origin}}', platform.origin)) + scaffold = await launchWebScaffold({ extraOverlayPath: overlay }) + browser = await chromium.launch() + }) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + await platform?.close() + if (root !== undefined) await rm(root, { recursive: true, force: true }) + }) + + it('signs in through a preopened themed tab and reports the Web login source', async () => { + const context = await browser.newContext({ viewport: { width: 1440, height: 960 }, locale: 'en-US', colorScheme: 'dark' }) + const page = await context.newPage() + const console = watchConsole(page) + onTestFailed(() => saveFailureShot(page, 'web-e2e-account-login')) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + const menu = page.getByRole('button', { name: 'Account menu', exact: true }) + await menu.click() + const tab = context.waitForEvent('page') + await page.getByRole('menuitem', { name: 'Sign in', exact: true }).click() + const authorization = await tab + await authorization.waitForURL(url => url.pathname === '/dsh/authorized', { timeout: 30_000 }) + expect(await authorization.evaluate(() => window.opener === null)).toBe(true) + await expect.poll(() => menu.textContent(), { timeout: 30_000 }).toBe('Web User') + + const pageOrigin = new URL(scaffold.authenticatedUrl).origin + expect(platform.inits).toHaveLength(1) + expect(platform.inits[0]).toMatchObject({ login_source: 'web', redirect_uri: `${pageOrigin}/oauth/callback` }) + const [authorize, authorized] = platform.pages.map(href => new URL(href)) + expect(authorize?.searchParams.get('theme')).toBe('dark') + expect(authorized?.searchParams.get('login_source')).toBe('web') + expect(console.pageErrors).toEqual([]) + await context.close() + }, 90_000) +}) diff --git a/apps/web/tests/agent-preset-selection.e2e.ts b/apps/web/tests/agent-preset-selection.e2e.ts index 6fc12aba02..094047c701 100644 --- a/apps/web/tests/agent-preset-selection.e2e.ts +++ b/apps/web/tests/agent-preset-selection.e2e.ts @@ -323,7 +323,7 @@ describe('web e2e: agent-preset selection', () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-preset-slash-catalog')) const composer = page.locator('[data-composer-input][contenteditable="true"]').last() - // `minimal` mounts neither the compaction group nor plan mode nor local + // `minimal` mounts compaction, but neither plan mode nor local // skill discovery, so the catalog the composer warmed under the // deployment default must not survive the switch. await writeComposerDraft(page, composer, '/') @@ -331,7 +331,7 @@ describe('web e2e: agent-preset selection', () => { .not.toEqual(expect.arrayContaining([expect.stringContaining(SKILL_NAME)])) // Rows read as `Title Description`; the title is the capitalized command name. const onMinimal = (await menuOptions(page)).map(option => option.toLowerCase()) - expect(onMinimal.some(option => option.startsWith('compact'))).toBe(false) + expect(onMinimal.some(option => option.startsWith('compact'))).toBe(true) expect(onMinimal.some(option => option.startsWith('plan'))).toBe(false) // Preset-scoped commands follow the switch; the client's own model command // remains outside every preset. @@ -422,7 +422,7 @@ describe('web e2e: agent-preset selection', () => { await compareOrRefreshGolden(HEADER_EXPECTED, snapshot, MODE) expect(snapshot).toContain('Minimal mode') expect(snapshot).toContain('button "1 subagent"') - expect(snapshot.indexOf('button "1 subagent"')).toBeLessThan(snapshot.indexOf('Minimal mode')) + expect(snapshot.indexOf('Minimal mode')).toBeLessThan(snapshot.indexOf('button "1 subagent"')) expect(snapshot.indexOf('button "1 subagent"')).toBeLessThan(snapshot.indexOf('button "More actions"')) // Static chrome, not a control: the header can only report a composition // the host would refuse to change. @@ -455,8 +455,9 @@ it.each([false, true])('starts Creator from Plugins with Coding Tools=%s without await page.getByRole('navigation', { name: 'Global panels' }).getByRole('button', { name: 'Plugins', exact: true }).click() await page.getByRole('button', { name: 'Choose how to add a plugin', exact: true }).click() + // The Creator entry reuses the draft's blank Session and names its preset in the creation request. await Promise.all([ - page.waitForResponse('**/api/agentPresets/select'), + page.waitForResponse('**/api/session/create'), page.getByRole('menuitem', { name: /^Let the agent create a plugin/ }).click(), ]) const picker = page.getByTitle('Choose the agent preset for your new task', { exact: true }).filter({ hasText: 'Creator mode' }) @@ -469,8 +470,9 @@ it.each([false, true])('starts Creator from Plugins with Coding Tools=%s without expect(scaffold.ctx.agentPresets.composedPreset(creator.ctx)).toBe('cordis') expect(scaffold.ctx.tools.schemas(creator).map(tool => tool.name)) .toEqual(expect.arrayContaining(['cordis_inspect_list', 'cordis_inspect_query', 'plugin_manager'])) - expect(creator.session.snapshotEvents().some(event => event.type === 'agent-preset/selected' - && event.data.agentPreset === 'cordis')).toBe(true) + // The chip may also select Creator once the list updates; the registry records the switch only once. + expect(creator.session.snapshotEvents().filter(event => event.type === 'agent-preset/selected' + && event.data.agentPreset === 'cordis')).toHaveLength(1) expect(creator.session.snapshotEvents().some(event => event.type === 'user/message' || event.type === 'turn/start')).toBe(false) expect(scaffold.ctx.settings.describe().find(row => row.ns === 'ui-settings')?.value).toMatchObject({ enabled: developerTools }) expect(scaffold.ctx.agentPresets.defaultId).toBe('standard') diff --git a/apps/web/tests/agent-team-panel.e2e.ts b/apps/web/tests/agent-team-panel.e2e.ts index 8358ef1a20..bd7dcff720 100644 --- a/apps/web/tests/agent-team-panel.e2e.ts +++ b/apps/web/tests/agent-team-panel.e2e.ts @@ -150,7 +150,7 @@ describe('web e2e: Agent Teams panel', () => { expect(await trigger.evaluate(element => element === document.activeElement)).toBe(true) await page.keyboard.press('Enter') await panel.waitFor() - const outside = page.getByRole('button', { name: 'Settings', exact: true }) + const outside = page.getByRole('button', { name: 'Account menu', exact: true }) await outside.focus() expect(await panel.count()).toBe(1) await page.keyboard.press('Escape') diff --git a/apps/web/tests/background-job-list.e2e.ts b/apps/web/tests/background-job-list.e2e.ts index 673270ae86..f407bdb53e 100644 --- a/apps/web/tests/background-job-list.e2e.ts +++ b/apps/web/tests/background-job-list.e2e.ts @@ -95,7 +95,7 @@ describe.skipIf(MODE === 'record')('web e2e: background job list', () => { agent, }) const reported = started.content.map(block => block.type === 'text' ? block.text : '').join('') - const matched = /\bbash-\d+\b/.exec(reported) + const matched = /\bbash-[0-9a-f]{16}\b/.exec(reported) if (matched === null) throw new Error(`background bash reported no job id: ${reported}`) const jobId = JobId(matched[0]) diff --git a/apps/web/tests/blank-preset-reuse.e2e.ts b/apps/web/tests/blank-preset-reuse.e2e.ts new file mode 100644 index 0000000000..5f9958ab5f --- /dev/null +++ b/apps/web/tests/blank-preset-reuse.e2e.ts @@ -0,0 +1,131 @@ +/** Blank Session reuse under the requested or default Agent preset through the shipped Web composition. */ + +import { mkdir } from 'node:fs/promises' +import { join } from 'node:path' +import { chromium, type Browser, type Page } from 'playwright' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { SessionId } from '@deepseek-ai/dsh-session' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { launchWebScaffold, watchConsole, type WebScaffold } from './scaffold.ts' +import { newEnglishPage } from './support.ts' + +/** A preset these blank Sessions recorded that the deployment no longer declares. */ +const UNDECLARED = 'liangshen' + +describe('web e2e: blank Session reuse under the requested preset', () => { + let scaffold: WebScaffold + let browser: Browser + const tripwires: ReturnType[] = [] + + beforeAll(async () => { + scaffold = await launchWebScaffold({}) + browser = await chromium.launch() + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + /** Store a blank Session recorded under `agentPreset`, with a plan-mode switch, as the only member of a new Workspace. */ + async function storeBlank(sessionId: SessionId, folder: string, agentPreset: string): Promise { + const path = join(scaffold.workspaceCwd, folder) + await mkdir(path) + const workspace = await scaffold.ctx.workspaceRegistry.create(path) + const handle = await scaffold.ctx.agents.create({ + sessionId, + meta: { cwd: path, agentPreset }, + agentOptions: scaffold.ctx.agentDefaultModel.currentSelection(), + }) + try { + handle.agent.session.append('plan/mode', { active: true }) + await workspace.attachSession(sessionId) + } finally { + await handle.dispose() + } + } + + async function openPage(saved?: SessionId): Promise { + const page = await newEnglishPage(browser) + tripwires.push(watchConsole(page)) + if (saved !== undefined) { + await page.addInitScript((id) => { + localStorage.setItem('dsh.sessions.current', JSON.stringify({ sessionId: id })) + }, saved) + } + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.locator('[data-composer-input]').first().waitFor({ timeout: 30_000 }) + return page + } + + async function selected(page: Page): Promise { + return page.evaluate(() => { + const value = localStorage.getItem('dsh.sessions.current') + return value === null ? undefined : (JSON.parse(value) as { sessionId?: string }).sessionId + }) + } + + /** The live Agent's mounted preset, its projected preset, and its plan-mode state. */ + function liveComposition(sessionId: SessionId) { + const agent = scaffold.ctx.agents.get(sessionId) + if (agent === undefined) return undefined + return { + mounted: scaffold.ctx.agentPresets.composedPreset(agent.ctx), + projected: scaffold.ctx.sessionProjections.stateOf(agent.session, 'agentPreset'), + plan: scaffold.ctx.sessionProjections.stateOf(agent.session, 'plan'), + } + } + + async function storedSelections(sessionId: SessionId): Promise { + const handle = await scaffold.ctx.sessionPersistence.open(sessionId, 'read') + try { + const { events } = await handle.read() + return events + .filter((event: SessionEvent) => event.type === 'agent-preset/selected') + .map((event: SessionEvent) => event.data) + } finally { + await handle.close() + } + } + + it('reuses a blank recorded under an undeclared preset under the default when startup connects its Workspace', async () => { + const stale = SessionId('blank-undeclared') + await storeBlank(stale, 'undeclared', UNDECLARED) + + const page = await openPage() + await expect.poll(() => selected(page)).toBe(stale) + await expect.poll(() => liveComposition(stale)).toMatchObject({ + mounted: 'standard', projected: 'standard', plan: { active: true }, + }) + expect(await storedSelections(stale)).toEqual([{ agentPreset: 'standard' }]) + expect(scaffold.ctx.workspaceRegistry.list().find(item => item.sessionIds.includes(stale))?.sessionIds).toEqual([stale]) + expect(await page.getByRole('alert').count()).toBe(0) + }) + + it('restores a saved blank under the preset it recorded', async () => { + const picked = SessionId('blank-picked') + await storeBlank(picked, 'picked', 'minimal') + + const page = await openPage(picked) + await expect.poll(() => selected(page)).toBe(picked) + await expect.poll(() => liveComposition(picked)).toMatchObject({ mounted: 'minimal', projected: 'minimal' }) + expect(await storedSelections(picked)).toEqual([]) + }) + + it('restores a saved blank under the default once its recorded preset is undeclared', async () => { + const saved = SessionId('saved-undeclared') + await storeBlank(saved, 'saved-undeclared', UNDECLARED) + + const page = await openPage(saved) + await expect.poll(() => selected(page)).toBe(saved) + await expect.poll(() => liveComposition(saved)).toMatchObject({ + mounted: 'standard', projected: 'standard', plan: { active: true }, + }) + expect(await storedSelections(saved)).toEqual([{ agentPreset: 'standard' }]) + expect(await page.getByRole('alert').count()).toBe(0) + for (const tripwire of tripwires) { + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + } + }) +}) diff --git a/apps/web/tests/collapse-timing.e2e.ts b/apps/web/tests/collapse-timing.e2e.ts index a33867b404..0c6a2ecf02 100644 --- a/apps/web/tests/collapse-timing.e2e.ts +++ b/apps/web/tests/collapse-timing.e2e.ts @@ -61,7 +61,7 @@ describe.skipIf(MODE === 'record')('web e2e: recorded collapse timing', () => { page = await newEnglishPage(browser) tripwire = watchConsole(page) await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) - await page.getByRole('button', { name: 'Settings', exact: true }).waitFor() + await page.getByRole('button', { name: 'Account menu', exact: true }).waitFor() }) afterAll(async () => { diff --git a/apps/web/tests/composer-focus.e2e.ts b/apps/web/tests/composer-focus.e2e.ts index 85de7c7346..39c73a4937 100644 --- a/apps/web/tests/composer-focus.e2e.ts +++ b/apps/web/tests/composer-focus.e2e.ts @@ -47,7 +47,24 @@ function describeSelection(state: Awaited>): s async function exerciseComposer(page: Page): Promise { const input = page.locator('[data-composer-input][contenteditable="true"]') await input.click() + // Let the editor observe its insertText selection before native arrow moves; + // a coalesced first notification can otherwise be treated as its own update. + await input.evaluate((el) => { + const onSelectionChange = (): void => { + const selection = el.ownerDocument.getSelection() + if (selection === null || !el.contains(selection.anchorNode) || !el.contains(selection.focusNode)) return + if (selection.isCollapsed && selection.anchorOffset === 16 && el.textContent === 'alpha beta gamma') { + el.dataset.selectionReady = 'inserted' + return + } + if (selection.toString() !== 'beta' || selection.anchorOffset !== 10 || selection.focusOffset !== 6) return + el.dataset.selectionReady = 'selected' + el.ownerDocument.removeEventListener('selectionchange', onSelectionChange) + } + el.ownerDocument.addEventListener('selectionchange', onSelectionChange) + }) await page.keyboard.insertText('alpha beta gamma') + await expect.poll(() => input.getAttribute('data-selection-ready')).toBe('inserted') await page.keyboard.press('Home') for (let index = 0; index < 10; index++) await page.keyboard.press('ArrowRight') for (let index = 0; index < 4; index++) await page.keyboard.press('Shift+ArrowLeft') @@ -76,6 +93,8 @@ async function exerciseComposer(page: Page): Promise { observations.push(`- ${point.name}: ${describeSelection(await selectionState(page))}`) } + await expect.poll(() => input.getAttribute('data-selection-ready')).toBe('selected') + await input.evaluate((el) => { delete el.dataset.selectionReady }) const top = points[0]! const cardBox = await page.locator('[data-composer-card]').boundingBox() await page.mouse.click(cardBox!.x - 12, top.y) diff --git a/apps/web/tests/cot-translation-paid.e2e.ts b/apps/web/tests/cot-translation-paid.e2e.ts index 3b04ece1e7..572118b7eb 100644 --- a/apps/web/tests/cot-translation-paid.e2e.ts +++ b/apps/web/tests/cot-translation-paid.e2e.ts @@ -58,6 +58,13 @@ async function messageBody(request: IncomingMessage): Promise } +/** Account model listing at the Messages origin; the version name is arbitrary because selection uses the id. */ +function listing(response: ServerResponse): void { + response.writeHead(200, { 'content-type': 'application/json' }) + response.end(JSON.stringify({ object: 'list', data: [{ id: 'deepseek-flash', name: 'Native Flash arbitrary future version', + api_capabilities: { anthropic_messages: {} } }] })) +} + /** Complete external native Messages SSE; no Host method or adapter is replaced. */ function reply(response: ServerResponse, text: string): void { response.writeHead(200, { 'content-type': 'text/event-stream' }) @@ -81,6 +88,7 @@ it.skipIf(MODE === 'record')('reuses saved native Account Flash translations acr let holdNext = false async function respond(request: IncomingMessage, response: ServerResponse): Promise { const path = new URL(request.url ?? '/', 'http://native.test').pathname + if (path === '/models') { listing(response); return } if (path !== '/anthropic/v1/messages') throw new Error(`Unexpected external translation request: ${path}`) const auditSessionId = resources.sessionId if (auditSessionId === undefined || resources.scaffold === undefined) throw new Error('Paid I/O preceded the recorded Session') @@ -123,8 +131,7 @@ it.skipIf(MODE === 'record')('reuses saved native Account Flash translations acr const origin = `http://127.0.0.1:${address.port}` const overlay = join(root, 'paid-translation.patch.yml') await writeFile(overlay, JSON.stringify([ - { id: 'llm-deepseek-account', config: { baseURL: `${origin}/anthropic`, reasoningEffort: 'high', - models: [{ id: 'deepseek-flash', name: 'Native Flash arbitrary future version' }] } }, + { id: 'llm-deepseek-account', config: { baseURL: `${origin}/anthropic`, reasoningEffort: 'high' } }, { id: 'translator', config: { bingEndpoint: `${origin}/unexpected-free`, googleEndpoint: `${origin}/unexpected-free`, deepseekTimeoutMs: 120_000 } }, ])) diff --git a/apps/web/tests/deepseek-messages-settings.e2e.ts b/apps/web/tests/deepseek-messages-settings.e2e.ts index b6ec46c361..225cb37062 100644 --- a/apps/web/tests/deepseek-messages-settings.e2e.ts +++ b/apps/web/tests/deepseek-messages-settings.e2e.ts @@ -39,6 +39,7 @@ describe.skipIf(webSnapshotMode() === 'record')('web e2e: DeepSeek Messages sett expect(scaffold.ctx.llm.listProviders()).toContainEqual({ id: 'deepseek-official', name: 'DeepSeek' }) expect(scaffold.ctx.llm.listProviders().filter(provider => provider.id === 'deepseek-official')).toHaveLength(1) expect(scaffold.ctx.agentDefaultModel.currentSelection()).toEqual({ provider: 'deepseek-official', model: 'deepseek-flash' }) + await page.getByRole('dialog', { name: '开始使用', exact: true }).getByRole('button', { name: '添加 API Key', exact: true }).click() const onboarding = page.getByRole('dialog', { name: '添加一个 API Key 开始使用' }) await onboarding.getByLabel('API 密钥', { exact: true }).fill('sk-messages-onboarding') await onboarding.getByRole('button', { name: '保存并继续' }).click() @@ -52,7 +53,7 @@ describe.skipIf(webSnapshotMode() === 'record')('web e2e: DeepSeek Messages sett const messages = dialog await messages.getByText('自定义设置', { exact: true }).click() expect(await messages.getByLabel('API 地址', { exact: true }).getAttribute('placeholder')) - .toBe('https://api.deepseek.com/anthropic') + .toBe('https://api.deepseek.com/anthropic(默认使用环境 DEEPSEEK_BASE_URL)') await compareOrRefreshGolden(join(EXPECTED, 'cards.expected.md'), await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd), webSnapshotMode()) await messages.getByLabel('API 密钥', { exact: true }).fill('sk-e2e-messages') diff --git a/apps/web/tests/expected/agent-preset-authoring/section.expected.md b/apps/web/tests/expected/agent-preset-authoring/section.expected.md index a7d47106e1..dd8628cd57 100644 --- a/apps/web/tests/expected/agent-preset-authoring/section.expected.md +++ b/apps/web/tests/expected/agent-preset-authoring/section.expected.md @@ -31,7 +31,7 @@ - 'button "设为新任务默认: 极简模式"': - text: 极简模式 内置 - code: minimal - - text: Agent 仅使用终端工具完成任务,适合测试和对比其基础表现。 + - text: Agent 使用终端和图片读取工具,并支持上下文压缩,适合测试和对比其基础表现。 - 'button "模式说明: 极简模式"': 模式说明 - 'button "如何使用: 极简模式"': 如何使用 - 'button "查看配置: 极简模式"': 查看配置 diff --git a/apps/web/tests/expected/agent-preset-selection/header.expected.md b/apps/web/tests/expected/agent-preset-selection/header.expected.md index 81cc3c1175..2b87066124 100644 --- a/apps/web/tests/expected/agent-preset-selection/header.expected.md +++ b/apps/web/tests/expected/agent-preset-selection/header.expected.md @@ -1,5 +1,5 @@ - navigation "Session hierarchy": Seeded turn -- button "1 subagent" - text: Minimal mode +- button "1 subagent" - button "More actions" - button "Open right sidebar" diff --git a/apps/web/tests/expected/agent-preset-selection/menu.expected.md b/apps/web/tests/expected/agent-preset-selection/menu.expected.md index 2f715b99b1..4372c504c0 100644 --- a/apps/web/tests/expected/agent-preset-selection/menu.expected.md +++ b/apps/web/tests/expected/agent-preset-selection/menu.expected.md @@ -1,5 +1,5 @@ - menu: - menuitem "Standard mode Work with code, files, and information. Suitable for most tasks, with search, editing, terminal commands, and other tools available as needed." - menuitem "PTC mode Includes all Standard mode capabilities. Better suited to tasks that call tools in batches and then filter, organize, deduplicate, count, or summarize the results." - - menuitem "Minimal mode The agent works using only a terminal tool. Useful for testing and comparing its basic performance." + - menuitem "Minimal mode The agent uses a terminal and image reading, with context compaction. Useful for testing and comparing its basic performance." - menuitem "Creator mode Customize DSH through conversation. Let the agent write plugins that add features or UI, or combine tools and prompts to create your own mode." diff --git a/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md b/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md index 271acfa0a2..55ebc9b91b 100644 --- a/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md +++ b/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md @@ -15,12 +15,14 @@ - img "API 密钥已配置" - button "编辑 DeepSeek (deepseek-official)": 编辑 - text: DeepSeek deepseek-official API 密钥 + - button "认证说明": "?" - textbox "API 密钥": - - /placeholder: 已配置——输入新值可替换 + - /placeholder: 留空时保留现有认证(DEEPSEEK_API_KEY) + - text: API 密钥来源:已保存凭据 - group: - text: 自定义设置 API 地址 - textbox "API 地址": - - /placeholder: https://api.deepseek.com/anthropic + - /placeholder: https://api.deepseek.com/anthropic(默认使用环境 DEEPSEEK_BASE_URL) - text: 请填写兼容 Anthropic Messages 协议的 API 地址。 - region "模型目录": - text: 模型目录 正在使用适配器默认模型 diff --git a/apps/web/tests/expected/jobs-menu-layering/geometry.expected.md b/apps/web/tests/expected/jobs-menu-layering/geometry.expected.md new file mode 100644 index 0000000000..446843f9dc --- /dev/null +++ b/apps/web/tests/expected/jobs-menu-layering/geometry.expected.md @@ -0,0 +1,35 @@ +# Background jobs above the Browser dock + +```json +{ + "collapsed": { + "dockMode": "push", + "bodyMounted": true, + "fixed": true, + "anchored": true, + "viewportBounded": true, + "overlapsBrowser": true, + "aboveBrowser": true + }, + "expanded": { + "dockMode": "push", + "bodyMounted": true, + "fixed": true, + "anchored": true, + "viewportBounded": true, + "overlapsBrowser": true, + "aboveBrowser": true + }, + "resized": { + "dockMode": "push", + "bodyMounted": true, + "fixed": true, + "anchored": true, + "viewportBounded": true, + "overlapsBrowser": true, + "aboveBrowser": true + }, + "escapeRestoresFocus": true, + "outsideDismisses": true +} +``` diff --git a/apps/web/tests/expected/models-settings/catalog-inputs.expected.md b/apps/web/tests/expected/models-settings/catalog-inputs.expected.md index d1047bfbb2..878d9eaf67 100644 --- a/apps/web/tests/expected/models-settings/catalog-inputs.expected.md +++ b/apps/web/tests/expected/models-settings/catalog-inputs.expected.md @@ -20,12 +20,13 @@ - button "编辑 openai": 编辑 - button "删除 openai": 删除 - text: openai API 密钥 + - button "认证说明": "?" - textbox "API 密钥": - - /placeholder: 输入 API 密钥,或留空使用环境认证 + - /placeholder: 留空时保留现有认证(OPENAI_API_KEY) - group: - text: 自定义设置 API 地址 - textbox "API 地址": - - /placeholder: 提供商默认 + - /placeholder: https://api.openai.com/v1 - region "模型目录": - text: 模型目录 已自定义模型目录 - button "恢复默认模型" diff --git a/apps/web/tests/expected/models-settings/declared-edit.expected.md b/apps/web/tests/expected/models-settings/declared-edit.expected.md index 6bc07d9474..e0c3479659 100644 --- a/apps/web/tests/expected/models-settings/declared-edit.expected.md +++ b/apps/web/tests/expected/models-settings/declared-edit.expected.md @@ -21,7 +21,7 @@ - button "删除 Acme Gateway (acme-gateway)": 删除 - text: Acme Gateway acme-gateway API 密钥 - textbox "API 密钥": - - /placeholder: 输入 API 密钥,或留空使用环境认证 + - /placeholder: 输入 API 密钥 - group: - text: 自定义设置 显示名称 - textbox "显示名称": diff --git a/apps/web/tests/expected/models-settings/empty.expected.md b/apps/web/tests/expected/models-settings/empty.expected.md index 5c2b5c0b72..795e2bb440 100644 --- a/apps/web/tests/expected/models-settings/empty.expected.md +++ b/apps/web/tests/expected/models-settings/empty.expected.md @@ -20,7 +20,7 @@ - option "amazon-bedrock" - option "ant-ling" - option "anthropic" - - option "azure-openai-responses" + - option "azure" - option "baseten" - option "cerebras" - option "cloudflare-ai-gateway" @@ -59,8 +59,9 @@ - option "zai" - option "zai-coding-cn" - text: API 密钥 + - button "认证说明": "?" - textbox "API 密钥": - - /placeholder: 输入 API 密钥,或留空使用环境认证 + - /placeholder: 留空时保留现有认证(MINIMAX_CN_API_KEY) - group: 自定义设置 - button "取消" - button "保存" diff --git a/apps/web/tests/expected/models-settings/image-only.expected.md b/apps/web/tests/expected/models-settings/image-only.expected.md index 55b5f103a0..ccde68253e 100644 --- a/apps/web/tests/expected/models-settings/image-only.expected.md +++ b/apps/web/tests/expected/models-settings/image-only.expected.md @@ -21,7 +21,7 @@ - button "删除 Acme 网关 (acme-gateway)": 删除 - text: Acme 网关 acme-gateway API 密钥 - textbox "API 密钥": - - /placeholder: 输入 API 密钥,或留空使用环境认证 + - /placeholder: 输入 API 密钥 - group: - text: 自定义设置 显示名称 - textbox "显示名称": diff --git a/apps/web/tests/expected/models-settings/native-keyless.expected.md b/apps/web/tests/expected/models-settings/native-keyless.expected.md new file mode 100644 index 0000000000..e8ae4de994 --- /dev/null +++ b/apps/web/tests/expected/models-settings/native-keyless.expected.md @@ -0,0 +1,63 @@ +- dialog "设置": + - navigation: + - text: 设置 + - button "通用设置" + - button "模型" + - button "内置插件" + - button "Agent 预设" + - button "打开配置文件" + - button "关闭" + - heading "模型" [level=2] + - paragraph: 填入各提供商的 API 密钥即可使用其模型。 + - list + - tablist "添加方式": + - tab "第三方模型提供商" [selected] + - tab "自定义模型 API" + - paragraph: 从内置目录中选择 OpenAI、Anthropic、Kimi 等提供商,填入其 API 密钥即可使用。 + - tabpanel "第三方模型提供商": + - text: 提供商 + - combobox "提供商": + - option "amazon-bedrock" + - option "ant-ling" + - option "anthropic" + - option "azure" + - option "baseten" + - option "cerebras" + - option "cloudflare-ai-gateway" + - option "cloudflare-workers-ai" + - option "deepseek" + - option "fireworks" + - option "github-copilot" + - option "google" + - option "google-vertex" + - option "groq" + - option "huggingface" + - option "kimi-coding" + - option "meta" + - option "minimax" + - option "minimax-cn" + - option "mistral" + - option "moonshotai" + - option "moonshotai-cn" + - option "nvidia" + - option "openai" + - option "openai-codex" [selected] + - option "opencode" + - option "opencode-go" + - option "openrouter" + - option "qwen-token-plan" + - option "qwen-token-plan-cn" + - option "qwen-token-plan-individual" + - option "radius" + - option "together" + - option "vercel-ai-gateway" + - option "xai" + - option "xiaomi" + - option "xiaomi-token-plan-ams" + - option "xiaomi-token-plan-cn" + - option "xiaomi-token-plan-sgp" + - option "zai" + - option "zai-coding-cn" + - group: 自定义设置 + - button "取消" + - button "保存" diff --git a/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md b/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md index a4dfae4aa1..9e0deb4b08 100644 --- a/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md +++ b/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md @@ -15,12 +15,14 @@ - img "API 密钥已配置" - button "编辑 DeepSeek (deepseek-official)": 编辑 - text: DeepSeek deepseek-official API 密钥 + - button "认证说明": "?" - textbox "API 密钥": - - /placeholder: 已配置——输入新值可替换 + - /placeholder: 留空时保留现有认证(DEEPSEEK_API_KEY) + - text: API 密钥来源:已保存凭据 - group: - text: 自定义设置 API 地址 - textbox "API 地址": - - /placeholder: https://api.deepseek.com/anthropic + - /placeholder: https://api.deepseek.com/anthropic(默认使用环境 DEEPSEEK_BASE_URL) - text: 请填写兼容 Anthropic Messages 协议的 API 地址。 - region "模型目录": - text: 模型目录 正在使用适配器默认模型 diff --git a/apps/web/tests/expected/onboarding-deepseek-config/missing.expected.md b/apps/web/tests/expected/onboarding-deepseek-config/missing.expected.md index d42cf40681..374d7be11e 100644 --- a/apps/web/tests/expected/onboarding-deepseek-config/missing.expected.md +++ b/apps/web/tests/expected/onboarding-deepseek-config/missing.expected.md @@ -2,7 +2,8 @@ - heading "添加一个 API Key 开始使用" [level=2] - paragraph: 配置 DeepSeek 官方模型,即可开始使用。 - text: API 密钥 + - button "认证说明": "?" - textbox "API 密钥": - - /placeholder: 输入 API 密钥 + - /placeholder: 留空时保留现有认证(DEEPSEEK_API_KEY) - button "稍后配置" - button "保存并继续" [disabled] diff --git a/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md b/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md index 84a7261b65..2bf65bfc78 100644 --- a/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md +++ b/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md @@ -15,12 +15,14 @@ - img "API 密钥已配置" - button "编辑 DeepSeek (deepseek-official)": 编辑 - text: DeepSeek deepseek-official API 密钥 + - button "认证说明": "?" - textbox "API 密钥": - - /placeholder: 已配置——输入新值可替换 + - /placeholder: 留空时保留现有认证(DEEPSEEK_API_KEY) + - text: API 密钥来源:已保存凭据 - group: - text: 自定义设置 API 地址 - textbox "API 地址": - - /placeholder: https://api.deepseek.com/anthropic + - /placeholder: https://api.deepseek.com/anthropic(默认使用环境 DEEPSEEK_BASE_URL) - text: 请填写兼容 Anthropic Messages 协议的 API 地址。 - region "模型目录": - text: 模型目录 已自定义模型目录 diff --git a/apps/web/tests/expected/onboarding-deepseek-config/sign-in.expected.md b/apps/web/tests/expected/onboarding-deepseek-config/sign-in.expected.md new file mode 100644 index 0000000000..a689c62560 --- /dev/null +++ b/apps/web/tests/expected/onboarding-deepseek-config/sign-in.expected.md @@ -0,0 +1,6 @@ +- dialog "开始使用": + - heading "开始使用" [level=2] + - button "关闭" + - paragraph: 登录 DeepSeek 账号,或添加 API Key,即可开始使用。你的项目和文件保存在本地。 + - button "添加 API Key" + - button "登录" diff --git a/apps/web/tests/expected/onboarding-native/models.expected.md b/apps/web/tests/expected/onboarding-native/models.expected.md index ce149bad3e..f8503c31f1 100644 --- a/apps/web/tests/expected/onboarding-native/models.expected.md +++ b/apps/web/tests/expected/onboarding-native/models.expected.md @@ -12,8 +12,9 @@ - list: - listitem: - text: DeepSeek deepseek-official API 密钥 + - button "认证说明": "?" - textbox "API 密钥": - - /placeholder: 输入 API 密钥 + - /placeholder: 留空时保留现有认证(DEEPSEEK_API_KEY) - group: 自定义设置 - button "取消" - button "保存" diff --git a/apps/web/tests/expected/onboarding-usable-provider/dismissed.expected.md b/apps/web/tests/expected/onboarding-usable-provider/dismissed.expected.md index e65df1f213..9d9bae17dd 100644 --- a/apps/web/tests/expected/onboarding-usable-provider/dismissed.expected.md +++ b/apps/web/tests/expected/onboarding-usable-provider/dismissed.expected.md @@ -24,7 +24,7 @@ - option "amazon-bedrock" - option "ant-ling" - option "anthropic" - - option "azure-openai-responses" + - option "azure" - option "baseten" - option "cerebras" - option "cloudflare-ai-gateway" @@ -63,8 +63,9 @@ - option "zai" - option "zai-coding-cn" - text: API 密钥 + - button "认证说明": "?" - textbox "API 密钥": - - /placeholder: 输入 API 密钥,或留空使用环境认证 + - /placeholder: 留空时保留现有认证(MINIMAX_CN_API_KEY) - group: 自定义设置 - button "取消" - button "保存" diff --git a/apps/web/tests/expected/plugin-manager/header-responsive.expected.md b/apps/web/tests/expected/plugin-manager/header-responsive.expected.md new file mode 100644 index 0000000000..7967bfe352 --- /dev/null +++ b/apps/web/tests/expected/plugin-manager/header-responsive.expected.md @@ -0,0 +1,19 @@ +# Plugin header responsive layout + +en 1680px: actions beside the heading, single-line; contained; information follows subtitle; focus retained +en 768px: actions beside or below (according to intrinsic fit) the heading, single-line; contained; information follows subtitle; focus retained +en 520px: actions beside or below (according to intrinsic fit) the heading, single-line; contained; information follows subtitle; focus retained +en 440px: actions beside or below (according to intrinsic fit) the heading, single-line; contained; information follows subtitle; focus retained +en 390px: actions below the heading, single-line; contained; information follows subtitle; focus retained +en 430px: actions below the heading, single-line; contained; information follows subtitle; focus retained +en 1024px: actions beside the heading, single-line; contained; information follows subtitle; focus retained +zh 1680px: actions beside the heading, single-line; contained; information follows subtitle; focus retained +zh 768px: actions beside or below (according to intrinsic fit) the heading, single-line; contained; information follows subtitle; focus retained +zh 520px: actions beside or below (according to intrinsic fit) the heading, single-line; contained; information follows subtitle; focus retained +zh 440px: actions beside or below (according to intrinsic fit) the heading, single-line; contained; information follows subtitle; focus retained +zh 390px: actions below the heading, single-line; contained; information follows subtitle; focus retained +zh 430px: actions below the heading, single-line; contained; information follows subtitle; focus retained +zh 1024px: actions beside the heading, single-line; contained; information follows subtitle; focus retained + +A forced no-wrap layout at 390px fails the same measured layout check in both locales; restoring wrap passes. +Add menu dismisses on Escape and returns focus in both locales. diff --git a/apps/web/tests/expected/schedule-header.expected.md b/apps/web/tests/expected/schedule-header.expected.md new file mode 100644 index 0000000000..96bff76bee --- /dev/null +++ b/apps/web/tests/expected/schedule-header.expected.md @@ -0,0 +1,17 @@ +# Automation tasks header + +en 1680px: title and New single-line, New beside the title; contained; New retains focus +en 800px: title and New single-line, New beside the title; contained; New retains focus +en 520px: title and New single-line, New beside the title; contained; New retains focus +en 440px: title and New single-line, New beside the title; contained; New retains focus +en 390px: title and New single-line, New beside the title; contained; New retains focus +en 430px: title and New single-line, New beside the title; contained; New retains focus +en 1024px: title and New single-line, New beside the title; contained; New retains focus +en 509px with sidebar expanded: title single-line; New moves below the title +zh 1680px: title and New single-line, New beside the title; contained; New retains focus +zh 800px: title and New single-line, New beside the title; contained; New retains focus +zh 520px: title and New single-line, New beside the title; contained; New retains focus +zh 440px: title and New single-line, New beside the title; contained; New retains focus +zh 390px: title and New single-line, New beside the title; contained; New retains focus +zh 430px: title and New single-line, New beside the title; contained; New retains focus +zh 1024px: title and New single-line, New beside the title; contained; New retains focus diff --git a/apps/web/tests/expected/session-row-hover/actions.expected.json b/apps/web/tests/expected/session-row-hover/actions.expected.json new file mode 100644 index 0000000000..2423722518 --- /dev/null +++ b/apps/web/tests/expected/session-row-hover/actions.expected.json @@ -0,0 +1,46 @@ +[ + { + "stage": "details", + "details": true, + "tooltips": [] + }, + { + "stage": "more-hover", + "details": true, + "tooltips": [] + }, + { + "stage": "archive-before-dwell", + "details": true, + "tooltips": [] + }, + { + "stage": "archive", + "details": true, + "tooltips": [ + "Archive" + ] + }, + { + "stage": "pin", + "details": true, + "tooltips": [ + "Pin" + ] + }, + { + "stage": "back-to-title", + "details": true, + "tooltips": [] + }, + { + "stage": "menu-closed", + "details": false, + "tooltips": [] + }, + { + "stage": "row-reentered", + "details": true, + "tooltips": [] + } +] diff --git a/apps/web/tests/expected/session-row-hover/grouped.expected.json b/apps/web/tests/expected/session-row-hover/grouped.expected.json new file mode 100644 index 0000000000..185475c247 --- /dev/null +++ b/apps/web/tests/expected/session-row-hover/grouped.expected.json @@ -0,0 +1,52 @@ +[ + { + "stage": "first-before-dwell", + "first": false, + "second": false + }, + { + "stage": "first-shown", + "first": true, + "second": false + }, + { + "stage": "second-instant", + "first": false, + "second": true + }, + { + "stage": "first-instant", + "first": true, + "second": false + }, + { + "stage": "readable-portal", + "first": true, + "second": false + }, + { + "stage": "copied-portal", + "first": true, + "second": false + }, + { + "stage": "portal-return-instant", + "first": false, + "second": true + }, + { + "stage": "outside-cooled", + "first": false, + "second": false + }, + { + "stage": "list-reentry-before-dwell", + "first": false, + "second": false + }, + { + "stage": "list-reentry-shown", + "first": true, + "second": false + } +] diff --git a/apps/web/tests/expected/session-row-hover/mixed-rows.expected.json b/apps/web/tests/expected/session-row-hover/mixed-rows.expected.json new file mode 100644 index 0000000000..39a40107ad --- /dev/null +++ b/apps/web/tests/expected/session-row-hover/mixed-rows.expected.json @@ -0,0 +1,22 @@ +[ + { + "stage": "first-workspace-after-dwell", + "exclusive": true + }, + { + "stage": "workspace-to-workspace-immediate", + "exclusive": true + }, + { + "stage": "workspace-to-session-immediate", + "exclusive": true + }, + { + "stage": "session-to-workspace-immediate", + "exclusive": true + }, + { + "stage": "list-reentered-after-dwell", + "exclusive": true + } +] diff --git a/apps/web/tests/expected/shortcuts-desktop/font-sizes.expected.md b/apps/web/tests/expected/shortcuts-desktop/font-sizes.expected.md new file mode 100644 index 0000000000..a4afd069e8 --- /dev/null +++ b/apps/web/tests/expected/shortcuts-desktop/font-sizes.expected.md @@ -0,0 +1,38 @@ +# Font sizes + +{ + "initial": { + "text": 14, + "code": 11, + "terminal": 13 + }, + "afterEqual": { + "text": 15, + "code": 12, + "terminal": 14 + }, + "afterPlus": { + "text": 16, + "code": 13, + "terminal": 15 + }, + "afterMinus": { + "text": 15, + "code": 12, + "terminal": 14 + }, + "afterReload": { + "text": 15, + "code": 12, + "terminal": 14 + }, + "restored": { + "text": 14, + "code": 11, + "terminal": 13 + }, + "referenceOrder": [ + "Increase font size", + "Decrease font size" + ] +} diff --git a/apps/web/tests/expected/web-runtime-context/web-surface-prompt.expected.md b/apps/web/tests/expected/web-runtime-context/web-surface-prompt.expected.md index bb37e36cfb..cc84fced0a 100644 --- a/apps/web/tests/expected/web-runtime-context/web-surface-prompt.expected.md +++ b/apps/web/tests/expected/web-runtime-context/web-surface-prompt.expected.md @@ -1 +1 @@ -You are interacting with the user through the DeepSeek Harness Web GUI at {{webUrl}}. When the user refers to "this page", "this GUI", or "this app" without naming another target, they mean this GUI. The browser provides no implicit DOM, route, or screenshot context. The client-plugin HMR receiver is active, but client-plugin changes reload without a refresh only while `pnpm run dev:web` is also running from this same checkout to rebuild their bundles; verify that watcher before promising automatic updates. Every other change — the apps/web shell and plain packages — requires rebuilding the affected Web artifacts and verifying this existing URL after a page refresh. Starting another server does not update this GUI. The apps/web Vite entry builds the shell but is not a standalone application because only dsh web injects window.__DSH_BOOT__. Do not start a replacement server unless the user asks; if one is needed, use a managed background job and verify its exact URL. +You are interacting with the user through the DeepSeek Harness Web GUI whose URL the runtime context names. When the user refers to "this page", "this GUI", or "this app" without naming another target, they mean this GUI. The browser provides no implicit DOM, route, or screenshot context. The client-plugin HMR receiver is active, but client-plugin changes reload without a refresh only while `pnpm run dev:web` is also running from this same checkout to rebuild their bundles; verify that watcher before promising automatic updates. Every other change — the apps/web shell and plain packages — requires rebuilding the affected Web artifacts and verifying this existing URL after a page refresh. Starting another server does not update this GUI. The apps/web Vite entry builds the shell but is not a standalone application because only dsh web injects window.__DSH_BOOT__. Do not start a replacement server unless the user asks; if one is needed, use a managed background job and verify its exact URL. diff --git a/apps/web/tests/expected/workspace-new-session-folding/watermark-kept.expected.md b/apps/web/tests/expected/workspace-new-session-folding/watermark-kept.expected.md new file mode 100644 index 0000000000..153f797a70 --- /dev/null +++ b/apps/web/tests/expected/workspace-new-session-folding/watermark-kept.expected.md @@ -0,0 +1,16 @@ +- tree "Sessions": + - treeitem "{{workspace}} Workspace actions for {{workspace}} New session in {{workspace}}" [expanded]: + - text: {{workspace}} + - button "Workspace actions for {{workspace}}" + - button "New session in {{workspace}}" + - treeitem "New Session" [selected] + - treeitem "Untitled 1min" + - treeitem "Untitled 1min" + - treeitem "Untitled 1min" + - treeitem "Untitled 1min" + - treeitem "Untitled 1min" + - treeitem "Untitled 1min" + - treeitem "Untitled 1min" + - treeitem "Untitled 1min" + - treeitem "Untitled 1min" + - button "Show 7 more sessions" diff --git a/apps/web/tests/fixtures/account-login/cordis.patch.yml b/apps/web/tests/fixtures/account-login/cordis.patch.yml new file mode 100644 index 0000000000..a31eac8f46 --- /dev/null +++ b/apps/web/tests/fixtures/account-login/cordis.patch.yml @@ -0,0 +1,12 @@ +# The Web account login scenario points the shipped account provider at a loopback +# Platform double. The scenario substitutes {{origin}} after the listener binds, +# so no port is ever committed here. +- id: deepseek-account + config: + platformOrigin: "{{origin}}" + allowLoopbackHttp: true +# The scenario signs in from the account menu after initial setup. +- id: ui-settings-account + config: + step: done + completion: completed diff --git a/apps/web/tests/fold-blank-matrix.e2e.ts b/apps/web/tests/fold-blank-matrix.e2e.ts new file mode 100644 index 0000000000..3422c15238 --- /dev/null +++ b/apps/web/tests/fold-blank-matrix.e2e.ts @@ -0,0 +1,875 @@ +/** + * Blank-area matrix for "On next message" folding. A human message folds the + * previous exchange's work details; each case records the largest band between + * the scrollport top and the composer that no visible row covers, after the fold + * settles, after the Turn the message started settles, and on every animation + * frame in between. Cases cover the main Session and a subagent child Session + * hosted in the right Sidebar, across reader positions and input origins. + */ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import type { Browser, Page, Route } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import type { StreamChunk } from '@deepseek-ai/dsh-llm' +import { ToolCallId, createAssistantMessage, createUserMessage } from '@deepseek-ai/dsh-llm' +import type { ReplayEntry } from '@deepseek-ai/dsh-llm-replay' +import { + SESSION_FORMAT_VERSION, SessionId, SessionLogOffset, SessionSeq, type SessionEvent, type SessionHeader, +} from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-system-prompt' +import { sessionFixtureName } from '@deepseek-ai/dsh-session-snapshot' +import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent' +import { createChatScrollFixture, type ChatScrollFixture } from './chat-scroll-fixture.ts' +import { launchWebScaffold, parseSeedFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts' +import { newEnglishPage, openSettings } from './support.ts' + +declare global { + interface Window { + foldProbe?: { readonly samples: Geometry[]; stop(): void } + foldMeasure?: (root: string) => Geometry + } +} + +const MODE = webSnapshotMode() +const SEEDED_TURNS = 8 +const SEEDED_STEPS = 6 +const LIVE_STEPS = 6 +/** Live Steps the steered Turn replays before the steering message is delivered. */ +const STEER_STEPS = 3 +const BLANK_LIMIT = 120 +/** + * Frames whose layout gap may exceed {@link BLANK_LIMIT}. The gap counts rows by their boxes, so a + * closing row that has faded but not finished shrinking still covers its space. + */ +const TRANSIENT_FRAMES = 1 +const EVIDENCE = fileURLToPath(new URL('../../../.artifacts/screenshots/fold-blank-matrix/', import.meta.url)) +const MAIN_ROOT = '[data-conversation-scroll]:not([data-sidebar-chat] [data-conversation-scroll])' +const SIDE_ROOT = '[data-sidebar-chat] [data-conversation-scroll]' +const CHILD_LABEL = 'fold matrix child' +const READ_FILE = 'fold-matrix-notes.txt' +const NEXT_INPUT = 'FOLDMX_NEXT_INPUT' + +/** Where the reader stands when the message is sent. */ +type Reader = + | { readonly kind: 'bottom' } + | { readonly kind: 'marker'; readonly marker: string; readonly align: 'center' | 'top' } + +const AT_BOTTOM: Reader = { kind: 'bottom' } +const SEEDED_MID_BODY: Reader = { kind: 'marker', marker: 'FOLDMX_BODY', align: 'center' } +const SEEDED_BODY_TOP: Reader = { kind: 'marker', marker: 'FOLDMX_BODY', align: 'top' } +const SEEDED_STEPS_READER: Reader = { kind: 'marker', marker: 'FOLDMX_STEP_4', align: 'top' } +const PREVIOUS_TURN: Reader = { kind: 'marker', marker: `FOLDMX_PREVIOUS_${SEEDED_TURNS - 1}`, align: 'top' } +const LIVE_MID_BODY: Reader = { kind: 'marker', marker: 'FOLDMX_LIVE_BODY', align: 'center' } + +/** + * How the folding message arrives. + * - `composer`: this page's composer sends while the Session is idle. + * - `error`: as `composer`, but the Turn fails before its user message is recorded. + * - `other-tab`: a second page sends; this page receives it without a local echo. + * - `queued`: this page queues the message while a long live Turn runs; it is delivered after that Turn ends. + * - `steer`: as `queued`, but the queued message is steered into the running Turn. + * - `parent-message`: the live parent Agent messages the child (side chat only). + * - `big-exchange`: two parent messages wake long child Turns before the human message (side chat only). + */ +type Origin = 'composer' | 'error' | 'other-tab' | 'queued' | 'steer' | 'parent-message' | 'big-exchange' + +interface Case { + readonly id: string + readonly surface: 'main' | 'side' + readonly origin: Origin + readonly reader: Reader + readonly bodyParagraphs: number + readonly mode: 'Detailed' | 'Standard' + readonly timing: 'On next message' | 'On completion' + /** + * `tail`: the view ends at the floor. `reader`: the reader's anchor row keeps its screen position. + */ + readonly landing: 'tail' | 'reader' + /** The reader's anchor row is itself folded, so only the blank area is asserted. */ + readonly anchorFolds?: true + /** Content above the viewport never slides back down, so a short body leaves room until newer content fills it. */ + readonly roomUntilReply?: true +} + +const MAIN = { + surface: 'main', origin: 'composer', bodyParagraphs: 40, mode: 'Detailed', timing: 'On next message', landing: 'tail', +} as const +const SIDE = { ...MAIN, surface: 'side' } as const + +const CASES: readonly Case[] = [ + { ...MAIN, id: 'main/composer/bottom', reader: AT_BOTTOM }, + { ...MAIN, id: 'main/composer/mid-body', reader: SEEDED_MID_BODY }, + { ...MAIN, id: 'main/composer/body-top', reader: SEEDED_BODY_TOP }, + { ...MAIN, id: 'main/composer/steps', reader: SEEDED_STEPS_READER }, + { ...MAIN, id: 'main/composer/previous-turn', reader: PREVIOUS_TURN }, + { ...MAIN, id: 'main/composer/short-body/bottom', reader: AT_BOTTOM, bodyParagraphs: 2, roomUntilReply: true }, + { ...MAIN, id: 'main/composer/short-body/steps', reader: SEEDED_STEPS_READER, bodyParagraphs: 2, roomUntilReply: true }, + { ...MAIN, id: 'main/composer/short-body/previous-turn', reader: PREVIOUS_TURN, bodyParagraphs: 2 }, + { ...MAIN, id: 'main/error-before-message/bottom', origin: 'error', reader: AT_BOTTOM }, + { ...MAIN, id: 'main/error-before-message/mid-body', origin: 'error', reader: SEEDED_MID_BODY }, + { ...MAIN, id: 'main/other-tab/bottom', origin: 'other-tab', reader: AT_BOTTOM }, + { ...MAIN, id: 'main/other-tab/mid-body', origin: 'other-tab', reader: SEEDED_MID_BODY, landing: 'reader' }, + { ...MAIN, id: 'main/other-tab/steps', origin: 'other-tab', reader: SEEDED_STEPS_READER, landing: 'reader', anchorFolds: true }, + { ...MAIN, id: 'main/other-tab/previous-turn', origin: 'other-tab', reader: PREVIOUS_TURN, landing: 'reader' }, + { ...MAIN, id: 'main/queued/bottom', origin: 'queued', reader: AT_BOTTOM }, + { ...MAIN, id: 'main/queued/mid-body', origin: 'queued', reader: LIVE_MID_BODY, landing: 'reader' }, + // Control: steering joins the running Turn, which opens no exchange and folds nothing. + { ...MAIN, id: 'main/steer/no-fold-control', origin: 'steer', reader: AT_BOTTOM }, + { ...MAIN, id: 'main/composer/standard/mid-body', reader: SEEDED_MID_BODY, mode: 'Standard' }, + { ...MAIN, id: 'main/composer/completion/mid-body', reader: SEEDED_MID_BODY, timing: 'On completion' }, + { ...SIDE, id: 'side/composer/bottom', reader: AT_BOTTOM }, + { ...SIDE, id: 'side/composer/mid-body', reader: SEEDED_MID_BODY }, + { ...SIDE, id: 'side/composer/steps', reader: SEEDED_STEPS_READER }, + { ...SIDE, id: 'side/composer/previous-turn', reader: PREVIOUS_TURN }, + { ...SIDE, id: 'side/queued/bottom', origin: 'queued', reader: AT_BOTTOM }, + { ...SIDE, id: 'side/queued/mid-body', origin: 'queued', reader: LIVE_MID_BODY, landing: 'reader' }, + // Control: a parent message is a notice, which opens no exchange and folds nothing. + { ...SIDE, id: 'side/parent-message/no-fold-control', origin: 'parent-message', reader: SEEDED_MID_BODY, landing: 'reader' }, + { ...SIDE, id: 'side/big-exchange/bottom', origin: 'big-exchange', reader: AT_BOTTOM }, +] + +interface Geometry { + readonly top: number + readonly floor: number + readonly spacer: number + /** Largest band between the scrollport top and the composer that no visible row covers. */ + readonly maxGap: number + readonly gapAt: number + /** Empty height between the last visible row and the composer. */ + readonly tailGap: number + readonly following: boolean + /** A row was closing on this sample. */ + readonly collapsing: boolean +} + +/** Installed into each measured page before load; reads one scrollport's blank-area geometry. */ +function installMeasure(): void { + window.foldMeasure = (root) => { + const host = document.querySelector(root) + if (host === null) throw new Error(`scrollport is absent: ${root}`) + const viewport = host.getBoundingClientRect() + const composer = host.querySelector('[data-composer-seat]') + if (composer === null) throw new Error(`composer is absent: ${root}`) + const composerTop = composer.getBoundingClientRect().top + const spacer = host.querySelector('[data-chat-turn-spacer]')?.getBoundingClientRect().height ?? 0 + const intervals = [...host.querySelectorAll('[data-chat-flow-key], [data-submission-echo], [data-pending-steering], [data-streaming], [data-chat-running]')] + .filter(element => element.closest('[hidden]') === null) + .map((element) => { + const rect = element.getBoundingClientRect() + return [Math.max(rect.top, viewport.top), Math.min(rect.bottom, composerTop)] as const + }) + .filter(([top, bottom]) => bottom - top > 0.5) + .sort((left, right) => left[0] - right[0]) + let cursor = viewport.top + let maxGap = 0 + let gapAt = 0 + for (const [top, bottom] of intervals) { + if (top - cursor > maxGap) { + maxGap = top - cursor + gapAt = cursor - viewport.top + } + cursor = Math.max(cursor, bottom) + } + if (composerTop - cursor > maxGap) { + maxGap = composerTop - cursor + gapAt = cursor - viewport.top + } + return { + top: host.scrollTop, floor: Math.max(0, host.scrollHeight - host.clientHeight), spacer, + maxGap, gapAt, tailGap: composerTop - cursor, + following: host.querySelector('[data-chat-following-tail]') !== null, + collapsing: host.querySelector('[data-chat-motion="collapse"]') !== null, + } + } +} + +function paragraphs(marker: string, count: number, text: string): string { + return `${marker}\n\n${Array.from({ length: count }, (_, line) => `${text} ${line}.`).join('\n\n')}` +} + +/** Main-session log: settled Turns, then a long last Turn of expanded reasoning Steps and a final body. */ +function seededFixture(id: string, bodyParagraphs: number): ChatScrollFixture { + const base = createChatScrollFixture({ markerPrefix: 'FOLDMX', title: `FOLDMX ${id}`, turns: SEEDED_TURNS }) + const parsed = parseSeedFixture(base.log) + const boundary = parsed.events.findIndex(event => event.type === 'turn/start' && event.data.turn === SEEDED_TURNS) + const drafts: (SessionEvent | { type: string; data: object; surfaceOp?: 'append' })[] = parsed.events.slice(0, boundary) + .map((event) => { + if (event.type !== 'assistant/message' || event.data.turn !== SEEDED_TURNS - 1) return event + return { ...event, data: { ...event.data, message: createAssistantMessage({ + content: [{ type: 'text', text: paragraphs(`FOLDMX_PREVIOUS_${SEEDED_TURNS - 1}`, 6, 'Previous Turn answer paragraph') }], + source: { provider: 'fixture', model: 'fixture' }, + }) } } + }) + drafts.push( + { type: 'turn/start', data: { turn: SEEDED_TURNS } }, + { type: 'step/start', data: { turn: SEEDED_TURNS, step: 1 } }, + { type: 'user/message', data: createUserMessage({ + content: [{ type: 'text', text: 'FOLDMX_LAST_USER' }], source: { kind: 'user' }, + }), surfaceOp: 'append' }, + ) + for (let step = 1; step <= SEEDED_STEPS; step++) { + if (step > 1) drafts.push({ type: 'step/start', data: { turn: SEEDED_TURNS, step } }) + drafts.push({ type: 'assistant/message', surfaceOp: 'append', data: { + turn: SEEDED_TURNS, step, stream: [], message: createAssistantMessage({ + content: [ + { type: 'reasoning', text: paragraphs(`FOLDMX_STEP_${step}`, 18, `Reasoning stage ${step} explains an intermediate decision, paragraph`) }, + { type: 'text', text: `FOLDMX_PROGRESS_${step}` }, + ], + source: { provider: 'fixture', model: 'fixture' }, + }), + } }) + drafts.push({ type: 'step/end', data: { turn: SEEDED_TURNS, step } }) + } + drafts.push( + { type: 'step/start', data: { turn: SEEDED_TURNS, step: SEEDED_STEPS + 1 } }, + { type: 'assistant/message', surfaceOp: 'append', data: { + turn: SEEDED_TURNS, step: SEEDED_STEPS + 1, stream: [], message: createAssistantMessage({ + content: [{ type: 'text', text: paragraphs('FOLDMX_BODY', bodyParagraphs, 'Final answer paragraph that remains after the work details fold') }], + source: { provider: 'fixture', model: 'fixture' }, + }), + } }, + { type: 'step/end', data: { turn: SEEDED_TURNS, step: SEEDED_STEPS + 1 } }, + { type: 'turn/end', data: { turn: SEEDED_TURNS, reason: { kind: 'completed' } } }, + ) + return { ...base, log: [parsed.headerLine, + ...drafts.map((event, seq) => JSON.stringify({ ...event, seq, time: seq + 1 })), ''].join('\n') } +} + +function finish(kind: 'stop' | 'tool-calls'): StreamChunk[] { + return [ + { type: 'usage', usage: { inputTokens: 512, outputTokens: 64 } }, + { type: 'finish', reason: { kind } }, + ] +} + +function textChunks(text: string): StreamChunk[] { + return [ + { type: 'block-start', index: 0, blockType: 'text' }, + { type: 'text-delta', index: 0, text }, + { type: 'block-end', index: 0, block: { type: 'text', text } }, + ] +} + +function replyEntry(marker: string): ReplayEntry { + return { kind: 'chunks', chunks: [...textChunks(`${marker} short answer.`), ...finish('stop')] } +} + +/** One live Turn: Steps of narration plus a workspace read, then a long final body. */ +function liveLongTurn(prefix: string): ReplayEntry[] { + const entries: ReplayEntry[] = [] + for (let step = 1; step <= LIVE_STEPS; step++) { + const id = ToolCallId(`${prefix.toLowerCase()}-read-${step}`) + const args = JSON.stringify({ file_path: READ_FILE }) + entries.push({ kind: 'chunks', chunks: [ + ...textChunks(paragraphs(`${prefix}_STEP_${step}`, 10, `${prefix} narration for step ${step}, paragraph`)), + { type: 'block-start', index: 1, blockType: 'tool-call' }, + { type: 'tool-call-delta', index: 1, id, name: 'read', argumentsDelta: args }, + { type: 'block-end', index: 1, block: { type: 'tool-call', id, name: 'read', arguments: args } }, + ...finish('tool-calls'), + ] }) + } + entries.push({ kind: 'chunks', chunks: [ + ...textChunks(paragraphs(prefix === 'FOLDMX_LIVE' ? 'FOLDMX_LIVE_BODY' : `${prefix}_BODY`, 40, `${prefix} final answer paragraph`)), + ...finish('stop'), + ] }) + return entries +} + +function replayFor(scenario: Case): ReplayEntry[] { + switch (scenario.origin) { + case 'composer': + case 'other-tab': + return [replyEntry('FOLDMX_REPLY')] + case 'queued': + return [...liveLongTurn('FOLDMX_LIVE'), replyEntry('FOLDMX_REPLY')] + case 'steer': + return [...liveLongTurn('FOLDMX_LIVE').slice(0, STEER_STEPS), replyEntry('FOLDMX_STEERED')] + case 'parent-message': + return [replyEntry('FOLDMX_PARENT_REPLY')] + case 'error': + // The Turn fails before its model call; launchScaffold mounts only the provider catalog. + return [] + case 'big-exchange': + return [...liveLongTurn('FOLDMX_WAKE_A'), ...liveLongTurn('FOLDMX_WAKE_B'), replyEntry('FOLDMX_REPLY')] + } +} + +async function stableScroll(page: Page, root: string, timeoutMs = 20_000): Promise { + await page.evaluate(async ([selector, limit]) => { + const host = document.querySelector(selector) + if (host === null) throw new Error(`scrollport is absent: ${selector}`) + let previous = host.scrollTop + const started = performance.now() + let stableSince = started + await new Promise((resolveStable, reject) => { + const check = (): void => { + const now = performance.now() + const moving = host.querySelector('[data-chat-motion="collapse"], [data-chat-motion="reveal"]') !== null + if (Math.abs(host.scrollTop - previous) > 0.25 || moving) stableSince = now + previous = host.scrollTop + if (now - stableSince >= 300) resolveStable() + else if (now - started >= limit) reject(new Error(`scroll did not settle within ${String(limit)}ms: top ${String(host.scrollTop)}, motion ${String(moving)}`)) + else requestAnimationFrame(check) + } + requestAnimationFrame(check) + }) + }, [root, timeoutMs] as const) +} + +function measure(page: Page, root: string): Promise { + return page.evaluate((selector) => { + if (window.foldMeasure === undefined) throw new Error('fold measurement is not installed') + return window.foldMeasure(selector) + }, root) +} + +async function startTrace(page: Page, root: string): Promise { + await page.evaluate((selector) => { + const read = window.foldMeasure + if (read === undefined) throw new Error('fold measurement is not installed') + const samples: Geometry[] = [] + let frame = 0 + const tick = (): void => { + // A scrollport can remount between frames; that frame contributes no sample. + try { samples.push(read(selector)) } catch (_remounting) { /* skip this frame */ } + frame = requestAnimationFrame(tick) + } + window.foldProbe = { samples, stop: () => { cancelAnimationFrame(frame) } } + frame = requestAnimationFrame(tick) + }, root) +} + +async function stopTrace(page: Page): Promise { + return page.evaluate(() => { + const probe = window.foldProbe + if (probe === undefined) throw new Error('fold trace was not started') + probe.stop() + return probe.samples + }) +} + +/** Screen position of the smallest visible row containing a marker, relative to the scrollport top. */ +function anchorTop(page: Page, root: string, marker: string): Promise { + return page.evaluate(([selector, text]) => { + const host = document.querySelector(selector)! + const row = [...host.querySelectorAll('[data-chat-flow-key], [data-streaming]')] + .filter(candidate => candidate.closest('[hidden]') === null && (candidate.textContent ?? '').includes(text)) + .sort((left, right) => left.getBoundingClientRect().height - right.getBoundingClientRect().height)[0] + return row === undefined ? null : row.getBoundingClientRect().top - host.getBoundingClientRect().top + }, [root, marker] as const) +} + +async function toBottom(page: Page, root: string): Promise { + const button = page.locator(root).getByRole('button', { name: 'Back to bottom', exact: true }) + if (await button.count() > 0) await button.click() + await stableScroll(page, root) + await expect.poll(() => page.locator(`${root} [data-chat-following-tail]`).count()).toBe(1) +} + +async function placeReader(page: Page, root: string, reader: Reader): Promise { + if (reader.kind === 'bottom') { + await toBottom(page, root) + return + } + const delta = await page.evaluate(([selector, marker, align]) => { + const host = document.querySelector(selector)! + const viewport = host.getBoundingClientRect() + const composerTop = host.querySelector('[data-composer-seat]')!.getBoundingClientRect().top + const rows = [...host.querySelectorAll('[data-chat-flow-key], [data-streaming]')] + .filter(row => row.closest('[hidden]') === null && (row.textContent ?? '').includes(marker)) + .sort((left, right) => left.getBoundingClientRect().height - right.getBoundingClientRect().height) + const row = rows[0] + if (row === undefined) throw new Error(`reader anchor is absent: ${marker}`) + const rect = row.getBoundingClientRect() + return align === 'center' + ? rect.top + rect.height / 2 - (viewport.top + composerTop) / 2 + : rect.top - viewport.top - 24 + }, [root, reader.marker, reader.align] as const) + const box = await page.locator(root).boundingBox() + if (box === null) throw new Error('scrollport has no layout box') + await page.mouse.move(box.x + box.width / 2, box.y + Math.min(140, box.height / 3)) + await page.mouse.wheel(0, delta) + await stableScroll(page, root) + // Reader movement is sampled on scrollend or after the sampling interval. + await page.waitForTimeout(700) + expect(await page.locator(`${root} [data-chat-following-tail]`).count()).toBe(0) +} + +async function openSessionBySearch(page: Page, query: string): Promise { + await page.getByText('Ungrouped', { exact: true }).waitFor({ timeout: 30_000 }) + const searchButton = page.getByRole('button', { name: 'Search sessions' }) + if (await searchButton.getAttribute('aria-expanded') !== 'true') await searchButton.click() + await page.getByRole('textbox', { name: 'Search session names', exact: true }).fill(query) + const results = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem') + await expect.poll(() => results.count(), { timeout: 60_000 }).toBe(1) + await results.click() +} + +async function choosePreferences(page: Page, scenario: Case): Promise { + await openSettings(page, 'en') + const dialog = page.getByRole('dialog', { name: 'Settings', exact: true }) + const timing = dialog.getByText('When to Collapse Work Details', { exact: true }).locator('../..') + await timing.getByRole('button').click() + await page.getByRole('menuitem', { name: scenario.timing, exact: true }).click() + const details = dialog.getByText('Work details', { exact: true }).locator('../..') + await details.getByRole('button').click() + await page.getByRole('menuitem', { name: scenario.mode, exact: true }).click() + await dialog.getByRole('button', { name: 'Close', exact: true }).click() +} + +async function expandSeededSteps(page: Page, root: string, scenario: Case): Promise { + if (scenario.mode !== 'Detailed' || scenario.timing !== 'On next message') return + const thoughts = page.locator(`${root} [data-chat-turn="${SEEDED_TURNS}"] [data-variant="think"]`) + await expect.poll(() => thoughts.count()).toBe(SEEDED_STEPS) + for (let index = 0; index < SEEDED_STEPS; index++) { + const button = thoughts.nth(index).getByRole('button').first() + if (await button.getAttribute('aria-expanded') !== 'true') await button.click() + } + // Focus left inside a folding row keeps that row open; a reader who moved on has focused nothing there. + await page.evaluate(() => { if (document.activeElement instanceof HTMLElement) document.activeElement.blur() }) +} + +/** Model-call barrier for one live Turn. */ +interface FinishHold { + /** Resolves when the held call streams its finish; rejects after the timeout. */ + reached(timeoutMs: number): Promise + release(): void + dispose(): void + /** Model calls observed since installation and the target Session's latest event types. */ + describe(): string +} + +/** + * Hold the finish of the first model call for `session` whose streamed text contains `marker`. + * The call is identified by its replayed content, so auxiliary or reordered calls cannot shift the barrier. + */ +function holdFinishOf(scaffold: WebScaffold, session: SessionId, marker: string): FinishHold { + const gate = Promise.withResolvers() + const reached = Promise.withResolvers() + const calls: string[] = [] + let held = false + const dispose = scaffold.ctx.on('llm/stream', async function* (options, next) { + const index = calls.length + calls.push(`#${String(index)} ${options.sessionId ?? 'no-session'}/${options.purpose ?? 'turn'}`) + let matched = false + for await (const chunk of next()) { + if (chunk.type === 'block-end' && chunk.block.type === 'text' && chunk.block.text.includes(marker)) matched = true + if (chunk.type === 'finish') { + calls[index] = `${calls[index] ?? ''} finish=${chunk.reason.kind}${matched ? ` ${marker}` : ''}` + if (!held && matched && options.sessionId === session) { + held = true + reached.resolve(undefined) + await gate.promise + } + } + yield chunk + } + }) + const describe = (): string => { + const events = scaffold.ctx.agents.get(session)?.session.snapshotEvents() ?? [] + return `model calls: [${calls.join('; ')}]; ${session} event tail: ${events.slice(-12).map(event => event.type).join(', ')}` + } + return { + reached: async (timeoutMs) => { + let timer: ReturnType | undefined + const expired = new Promise((_resolve, reject) => { + timer = setTimeout(() => { reject(new Error(`no model call streamed ${marker} within ${String(timeoutMs)}ms`)) }, timeoutMs) + }) + try { + await Promise.race([reached.promise, expired]) + } finally { + clearTimeout(timer) + } + }, + release: () => { gate.resolve(undefined) }, + dispose: () => { gate.resolve(undefined); dispose() }, + describe, + } +} + +interface World { + readonly browser: Browser + readonly scaffold: WebScaffold + readonly page: Page + readonly root: string + /** Session receiving the folding message. */ + readonly target: SessionId + /** Live parent Agent for a side-chat case. */ + readonly parentId?: SessionId + readonly tripwire: ReturnType + /** Session-search text that selects the target's root Session. */ + readonly searchQuery: string +} + +function sessionKey(scenario: Case): string { + return `foldmx-${scenario.id.replaceAll('/', '-')}` +} + +async function createChild(scaffold: WebScaffold, parentId: SessionId, scenario: Case): Promise { + const childId = SessionId(`${sessionKey(scenario)}-child`) + const createdAt = Date.now() - 30_000 + const header: SessionHeader = { + version: SESSION_FORMAT_VERSION, id: childId, createdAt, isSeeded: false, cwd: scaffold.workspaceCwd, + parentSession: parentId, origin: 'subagent', delegationDepth: 1, + } + const parsed = parseSeedFixture(seededFixture(scenario.id, scenario.bodyParagraphs).log) + const firstUser = parsed.events.findIndex(event => event.type === 'user/message') + const ordered = [ + ...parsed.events.slice(0, firstUser + 1), + { type: 'subagent/descriptor', data: snapshotSubagentDescriptor({ + mode: 'continuable', provider: 'spawn', label: CHILD_LABEL, agentProvider: 'deepseek-official', agentModel: 'deepseek-v4-flash', + }) }, + ...parsed.events.slice(firstUser + 1), + ] + const events = ordered.map((event, seq) => ({ ...event, seq: SessionSeq(seq), time: createdAt + seq })) as SessionEvent[] + const handle = await scaffold.ctx.sessionPersistence.create(header) + await handle.append(events) + await handle.close() + scaffold.ctx.sessionProjectionCache.coldSnapshot(header, SessionLogOffset(0), events) + const deadline = Date.now() + 10_000 + while (scaffold.ctx.sessionProjectionCache.cachedSnapshot(header) === undefined) { + if (Date.now() >= deadline) throw new Error('child projection cache row did not land') + await new Promise(resolve => setTimeout(resolve, 10)) + } + const parent = scaffold.ctx.agents.get(parentId) + if (parent === undefined) throw new Error('parent Agent is not live') + parent.session.append('subagent/catalog', { version: 0, childId, childCreatedAt: createdAt, mode: 'continuable', label: CHILD_LABEL }) + return childId +} + +async function openWorld(browser: Browser, scenario: Case, replayDir: string): Promise { + const scaffold = await launchScaffold(scenario, replayDir) + let page: Page | undefined + try { + page = await newEnglishPage(browser, 900) + return await prepareWorld(browser, scenario, scaffold, page) + } catch (error) { + const failures: unknown[] = [error] + if (page !== undefined) await page.context().close().catch((cleanupError: unknown) => failures.push(cleanupError)) + await scaffold.close().catch((cleanupError: unknown) => failures.push(cleanupError)) + if (failures.length === 1) throw error + throw new AggregateError(failures, `${scenario.id}: world setup failed and cleanup was incomplete`) + } +} + +/** + * Launch the scaffold with the case's replay script. A Turn that fails before its model call consumes + * no script, so that case mounts only the provider catalog from a header-only fixture. + */ +async function launchScaffold(scenario: Case, replayDir: string): Promise { + if (scenario.origin === 'error') { + const header = seededFixture(scenario.id, scenario.bodyParagraphs).log.split('\n')[0] ?? '' + const fixture = join(replayDir, sessionFixtureName(0, SESSION_FORMAT_VERSION)) + await writeFile(fixture, `${header}\n`) + return launchWebScaffold({ replayFixture: fixture, replayProvidersOnly: true }) + } + await writeFile(join(replayDir, 'replay.override.json'), JSON.stringify(replayFor(scenario))) + return launchWebScaffold({ + replayFixture: join(replayDir, 'override-only.jsonl'), + replayOverride: join(replayDir, 'replay.override.json'), + paceMs: 16, + replayContextWindow: 10_000_000, + }) +} + +/** Seed the case's Sessions, open the surface, and choose the display preferences. */ +async function prepareWorld(browser: Browser, scenario: Case, scaffold: WebScaffold, page: Page): Promise { + await writeFile(join(scaffold.workspaceCwd, READ_FILE), 'fold matrix notes\n') + const tripwire = watchConsole(page) + await page.addInitScript(installMeasure) + await page.setViewportSize(scenario.surface === 'main' ? { width: 1280, height: 720 } : { width: 1680, height: 900 }) + await page.emulateMedia({ reducedMotion: 'no-preference' }) + const sessionId = SessionId(sessionKey(scenario)) + if (scenario.surface === 'main') { + const fixture = seededFixture(scenario.id, scenario.bodyParagraphs) + await seedSession(scaffold, fixture.log, sessionId) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await openSessionBySearch(page, fixture.markers.user(1)) + await page.locator(MAIN_ROOT).getByText('FOLDMX_BODY', { exact: false }).last().waitFor({ timeout: 30_000 }) + const resolved = await scaffold.ctx.sessionController.resolveAgent(sessionId) + if ('error' in resolved) throw resolved.error + scaffold.ctx.permissionPresets.set(resolved.agent.session, 'danger-full-access') + await choosePreferences(page, scenario) + return { browser, scaffold, page, root: MAIN_ROOT, target: sessionId, tripwire, searchQuery: fixture.markers.user(1) } + } + const parentFixture = createChatScrollFixture({ markerPrefix: 'FOLDMXP', title: `FOLDMX parent ${scenario.id}`, turns: 2 }) + await seedSession(scaffold, parentFixture.log, sessionId) + const resolved = await scaffold.ctx.sessionController.resolveAgent(sessionId) + if ('error' in resolved) throw resolved.error + scaffold.ctx.permissionPresets.set(resolved.agent.session, 'danger-full-access') + const childId = await createChild(scaffold, sessionId, scenario) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await openSessionBySearch(page, parentFixture.markers.user(1)) + await choosePreferences(page, scenario) + const trigger = page.getByRole('button', { name: '1 subagent', exact: true }) + await trigger.waitFor({ timeout: 15_000 }) + await trigger.hover() + await page.getByRole('treeitem', { name: new RegExp(CHILD_LABEL) }) + .getByRole('button', { name: `Open ${CHILD_LABEL} in sidebar`, exact: true }).click() + await page.locator(SIDE_ROOT).getByText('FOLDMX_BODY', { exact: false }).last().waitFor({ timeout: 30_000 }) + await page.mouse.move(0, 0) + return { + browser, scaffold, page, root: SIDE_ROOT, target: childId, parentId: sessionId, tripwire, + searchQuery: parentFixture.markers.user(1), + } +} + +function composerOf(world: World): ReturnType { + return world.page.locator(`${world.root} [data-composer-input][contenteditable="true"]`).last() +} + +async function send(world: World, text: string): Promise { + await composerOf(world).fill(text) + await world.page.locator(world.root).getByRole('button', { name: 'Send message', exact: true }).click() +} + +/** Wait until a Session's Agent ends a Turn. */ +function turnEnded(scaffold: WebScaffold, sessionId: SessionId, count = 1, timeoutMs = 60_000): Promise { + return new Promise((resolveEnded, reject) => { + let remaining = count + const timer = setTimeout(() => { off(); reject(new Error(`no turn/end for ${sessionId} within ${timeoutMs}ms`)) }, timeoutMs) + const off = scaffold.ctx.on('session/event', (session: { id: SessionId }, event: SessionEvent) => { + if (session.id !== sessionId || event.type !== 'turn/end') return + remaining -= 1 + if (remaining > 0) return + clearTimeout(timer) + off() + resolveEnded() + }) + }) +} + +async function settle(world: World): Promise { + await expect.poll(() => world.page.locator(`${world.root} [data-streaming="true"]`).count(), { timeout: 30_000 }).toBe(0) + await stableScroll(world.page, world.root) + await world.page.waitForTimeout(900) + await stableScroll(world.page, world.root) +} + +/** Drive one case from its prepared reader position through the folding message and its Turn. */ +interface RunResult { + readonly afterFold: Geometry + readonly afterTurn: Geometry + readonly samples: Geometry[] + /** Reader anchor row positions when sending, after the fold, and after the Turn. */ + readonly anchors: { before: number | null; fold: number | null; turn: number | null } +} + +async function run(world: World, scenario: Case): Promise { + const { page, root, scaffold, target } = world + const anchors: RunResult['anchors'] = { before: null, fold: null, turn: null } + const readAnchor = (): Promise => scenario.reader.kind === 'marker' + ? anchorTop(page, root, scenario.reader.marker) : Promise.resolve(null) + const begin = async (): Promise => { + anchors.before = await readAnchor() + await startTrace(page, root) + } + const folded = async (): Promise => { + await stableScroll(page, root) + anchors.fold = await readAnchor() + return measure(page, root) + } + let afterFold: Geometry + switch (scenario.origin) { + case 'composer': + case 'error': { + const disposeFailure = scenario.origin === 'error' + ? scaffold.ctx.on('system-prompt/assemble', async () => { throw new Error('FOLDMX_TURN_FAILED') }) + : () => undefined + // A subagent child's composer sends through the subagent continuation route. + const promptRoute = scenario.surface === 'side' ? '**/api/subagents/prompt' : '**/api/session/prompt' + const promptGate = Promise.withResolvers() + const promptHeld = Promise.withResolvers() + const holdPrompt = async (route: Route): Promise => { + promptHeld.resolve(undefined) + await promptGate.promise + await route.continue() + } + await page.route(promptRoute, holdPrompt) + const ended = turnEnded(scaffold, target) + try { + await expandSeededSteps(page, root, scenario) + await placeReader(page, root, scenario.reader) + await begin() + await send(world, NEXT_INPUT) + await promptHeld.promise + await page.locator(`${root} [data-submission-echo]`).waitFor() + afterFold = await folded() + promptGate.resolve(undefined) + await ended + } finally { + promptGate.resolve(undefined) + disposeFailure() + await page.unroute(promptRoute, holdPrompt) + } + break + } + case 'other-tab': { + await expandSeededSteps(page, root, scenario) + const other = await newEnglishPage(world.browser, 720) + await other.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await openSessionBySearch(other, world.searchQuery) + await other.locator(MAIN_ROOT).getByText('FOLDMX_BODY', { exact: false }).last().waitFor({ timeout: 30_000 }) + await placeReader(page, root, scenario.reader) + await begin() + const ended = turnEnded(scaffold, target) + await other.locator(`${MAIN_ROOT} [data-composer-input][contenteditable="true"]`).last().fill(NEXT_INPUT) + await other.locator(MAIN_ROOT).getByRole('button', { name: 'Send message', exact: true }).click() + await page.locator(`${root} [data-chat-flow-kind="user"]`, { hasText: NEXT_INPUT }).waitFor() + afterFold = await folded() + await ended + await other.context().close() + break + } + case 'queued': + case 'steer': { + // Queued: hold the long final body. Steer: hold the last replayed Step so the steering message lands in this Turn. + const gate = holdFinishOf(scaffold, target, scenario.origin === 'queued' ? 'FOLDMX_LIVE_BODY' : `FOLDMX_LIVE_STEP_${String(STEER_STEPS)}`) + const first = turnEnded(scaffold, target, scenario.origin === 'queued' ? 2 : 1) + // A failed earlier step reports itself; the success path still awaits and rethrows this rejection. + first.catch(() => undefined) + try { + await toBottom(page, root) + await send(world, 'FOLDMX_START_LIVE_TURN') + await gate.reached(60_000) + await stableScroll(page, root) + if (scenario.origin === 'queued' && scenario.reader.kind === 'marker') { + await page.locator(root).getByText('FOLDMX_LIVE_BODY', { exact: false }).last().waitFor() + } + await placeReader(page, root, scenario.reader) + await composerOf(world).fill(NEXT_INPUT) + await composerOf(world).press('Enter') + const steerButton = page.getByRole('button', { name: 'Steer queued message' }) + await steerButton.waitFor({ timeout: 15_000 }) + if (scenario.origin === 'steer') { + await expect.poll(() => steerButton.isEnabled(), { timeout: 10_000 }).toBe(true) + await steerButton.click() + } + await begin() + gate.release() + await page.locator(`${root} [data-chat-flow-kind="user"], ${root} [data-chat-flow-kind="steering"]`, { hasText: NEXT_INPUT }) + .first().waitFor({ timeout: 30_000 }) + afterFold = await folded() + await first + } catch (error) { + throw new Error(`${scenario.id}: ${error instanceof Error ? error.message : String(error)}\n${gate.describe()}`, { cause: error }) + } finally { + gate.dispose() + } + break + } + case 'parent-message': { + await expandSeededSteps(page, root, scenario) + await placeReader(page, root, scenario.reader) + await begin() + const parent = scaffold.ctx.agents.get(world.parentId!) + if (parent === undefined) throw new Error('parent Agent is not live') + const ended = turnEnded(scaffold, target) + await scaffold.ctx.subagents.sendMessage(parent, target, [{ type: 'text', text: 'FOLDMX_PARENT_NOTE' }], { signal: new AbortController().signal }) + await page.locator(`${root} [data-chat-turn="${SEEDED_TURNS + 1}"]`).first().waitFor({ timeout: 30_000 }) + afterFold = await folded() + await ended + break + } + case 'big-exchange': { + const parent = scaffold.ctx.agents.get(world.parentId!) + if (parent === undefined) throw new Error('parent Agent is not live') + for (const note of ['FOLDMX_WAKE_A_NOTE', 'FOLDMX_WAKE_B_NOTE']) { + const woken = turnEnded(scaffold, target) + await scaffold.ctx.subagents.sendMessage(parent, target, [{ type: 'text', text: note }], { signal: new AbortController().signal }) + await woken + } + await settle(world) + await placeReader(page, root, scenario.reader) + await begin() + const ended = turnEnded(scaffold, target) + await send(world, NEXT_INPUT) + await page.locator(`${root} [data-chat-flow-kind="user"]`, { hasText: NEXT_INPUT }).waitFor({ timeout: 30_000 }) + afterFold = await folded() + await ended + break + } + } + await settle(world) + anchors.turn = await readAnchor() + const afterTurn = await measure(page, root) + const samples = await stopTrace(page) + return { afterFold, afterTurn, samples, anchors } +} + +function rounded(geometry: Geometry): Record { + return { + top: Math.round(geometry.top), floor: Math.round(geometry.floor), spacer: Math.round(geometry.spacer), + maxGap: Math.round(geometry.maxGap), gapAt: Math.round(geometry.gapAt), following: geometry.following, + } +} + +describe.skipIf(MODE === 'record')('web e2e: blank area after next-message folding', () => { + let browser: Browser + + beforeAll(async () => { + browser = await chromium.launch() + }) + + afterAll(async () => { + await browser?.close() + }) + + for (const scenario of CASES) { + it(scenario.id, async () => { + const replayDir = await mkdtemp(join(tmpdir(), 'dsh-fold-matrix-')) + let world: World | undefined + try { + world = await openWorld(browser, scenario, replayDir) + const { afterFold, afterTurn, samples, anchors } = await run(world, scenario) + const transient = samples.filter(sample => sample.maxGap > BLANK_LIMIT) + const summary = { + id: scenario.id, landing: scenario.landing, before: samples[0] === undefined ? null : rounded(samples[0]), + afterFold: rounded(afterFold), afterTurn: rounded(afterTurn), anchors, + peakGap: Math.round(Math.max(0, ...samples.map(sample => sample.maxGap))), + framesOverLimit: transient.length, frames: samples.length, + peakSpacer: Math.round(Math.max(0, ...samples.map(sample => sample.spacer))), + } + const evidence = async (): Promise => { + await mkdir(EVIDENCE, { recursive: true }) + await writeFile(join(EVIDENCE, `${sessionKey(scenario)}.json`), JSON.stringify({ scenario, ...summary, samples }, null, 2)) + console.log(`FOLDMX ${JSON.stringify(summary)}`) + } + // Per-frame evidence is written for failures, or for every case with FOLDMX_EVIDENCE=1. + if (process.env.FOLDMX_EVIDENCE === '1') await evidence() + else onTestFailed(evidence) + if (scenario.origin === 'parent-message' || scenario.origin === 'steer') { + expect.soft(samples.some(sample => sample.collapsing), 'a control case folds nothing').toBe(false) + } + if (scenario.roomUntilReply !== true) { + expect.soft(afterFold.maxGap, 'layout gap after the fold settles').toBeLessThanOrEqual(BLANK_LIMIT) + expect.soft(transient.length, 'animation frames with a layout gap').toBeLessThanOrEqual(TRANSIENT_FRAMES) + } + expect.soft(afterTurn.maxGap, 'layout gap after the Turn settles').toBeLessThanOrEqual(BLANK_LIMIT) + if (scenario.landing === 'tail') { + expect.soft(afterFold.floor - afterFold.top, 'distance from the floor after the fold').toBeLessThanOrEqual(1) + expect.soft(afterTurn.floor - afterTurn.top, 'distance from the floor after the Turn').toBeLessThanOrEqual(1) + expect.soft(afterTurn.following, 'following after the Turn').toBe(true) + } else if (scenario.anchorFolds !== true) { + expect.soft(anchors.before).not.toBeNull() + expect.soft(Math.abs((anchors.fold ?? Infinity) - (anchors.before ?? 0)), 'anchor movement after the fold').toBeLessThanOrEqual(2) + expect.soft(Math.abs((anchors.turn ?? Infinity) - (anchors.before ?? 0)), 'anchor movement after the Turn').toBeLessThanOrEqual(2) + } + expect.soft(world.tripwire.pageErrors).toEqual([]) + } finally { + // Scaffold close also asserts that every replay entry was consumed; its failures fail the case. + const failures: unknown[] = [] + await world?.page.context().close().catch((error: unknown) => failures.push(error)) + await world?.scaffold.close().catch((error: unknown) => failures.push(error)) + await rm(replayDir, { recursive: true, force: true }).catch((error: unknown) => failures.push(error)) + if (failures.length === 1) throw failures[0] + if (failures.length > 1) throw new AggregateError(failures, `${scenario.id}: cleanup failed`) + } + }) + } +}) + +export {} diff --git a/apps/web/tests/jobs-menu-layering.e2e.ts b/apps/web/tests/jobs-menu-layering.e2e.ts new file mode 100644 index 0000000000..040531ba95 --- /dev/null +++ b/apps/web/tests/jobs-menu-layering.e2e.ts @@ -0,0 +1,199 @@ +/** Background-job menu geometry over the normal Browser dock in the shipped Web composition. */ +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { ToolCallId } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' +import { JobId } from '@deepseek-ai/dsh-jobs' +import { + assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, seedSession, + watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { newEnglishPage, saveFailureShot } from './support.ts' + +const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/session.v3.jsonl', import.meta.url)) +const EXPECTED_DIR = fileURLToPath(new URL('./expected/jobs-menu-layering', import.meta.url)) +const MODE = webSnapshotMode() +const SEED_ID = SessionId('jobs-menu-layering-web-e2e') +// The private scaffold cwd owns the barrier; the loop bounds an orphan's lifetime. +const RELEASE = '.jobs-menu-layering.release' +const COMMAND = `printf 'jobs-menu-%s\\n' output; for _ in $(seq 1 3000); do [ -e ${RELEASE} ] && break; sleep 0.2; done` + +/** Measure actual overlap and hit testing; ARIA alone cannot expose clipping or stacking. */ +function placement(page: Page) { + return page.evaluate(() => { + const list = document.querySelector('ul[aria-label="Background jobs"]') + const anchor = document.querySelector('button[aria-label="1 background job running"]') + const frame = document.querySelector('[data-sidebar-browser-frame]') + const dock = document.querySelector('[data-sidebar-right-panel][data-sidebar-right-open]') + if (list === null || anchor === null || frame === null || dock === null) { + throw new Error('job menu, trigger, or Browser dock is missing') + } + const menu = list.closest('[data-menu-material]') ?? list + const box = menu.getBoundingClientRect() + const trigger = anchor.getBoundingClientRect() + const browser = frame.getBoundingClientRect() + const left = Math.max(box.left, browser.left) + const right = Math.min(box.right, browser.right) + const top = Math.max(box.top, browser.top) + const bottom = Math.min(box.bottom, browser.bottom) + const overlapsBrowser = right - left > 8 && bottom - top > 8 + const aboveBrowser = overlapsBrowser && [0.25, 0.5, 0.75].every(x => + [0.25, 0.5, 0.75].every(y => menu.contains(document.elementFromPoint( + left + (right - left) * x, top + (bottom - top) * y, + )))) + const expectedLeft = Math.min(Math.max(trigger.left, 12), innerWidth - box.width - 12) + const expectedTop = Math.min(Math.max(trigger.bottom + 5, 12), innerHeight - box.height - 12) + return { + dockMode: dock.dataset.sidebarRightPanel, + bodyMounted: menu.parentElement === document.body, + fixed: getComputedStyle(menu).position === 'fixed', + anchored: Math.abs(box.left - expectedLeft) <= 1 && Math.abs(box.top - expectedTop) <= 1, + viewportBounded: box.left >= 11 && box.top >= 11 + && box.right <= innerWidth - 11 && box.bottom <= innerHeight - 11, + overlapsBrowser, + aboveBrowser, + } + }) +} + +describe.skipIf(MODE === 'record')('web e2e: jobs menu layering', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + let agent: Agent + let jobId: JobId | undefined + + beforeAll(async () => { + scaffold = await launchWebScaffold({ + extraOverlayPath: fileURLToPath(new URL('./sidebar-browser.overlay.yml', import.meta.url)), + }) + await seedSession(scaffold, await readFile(FIXTURE, 'utf8'), SEED_ID) + browser = await chromium.launch() + page = await newEnglishPage(browser) + await page.emulateMedia({ reducedMotion: 'reduce' }) + await page.route('https://browser.test/**', async route => route.fulfill({ + contentType: 'text/html', body: '

Jobs menu browser fixture

', + })) + tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + }) + + afterAll(async () => { + const failures: unknown[] = [] + if (scaffold !== undefined) { + try { + await writeFile(join(scaffold.workspaceCwd, RELEASE), '') + if (jobId !== undefined) { + const settled = await scaffold.ctx.jobs.wait(jobId, 15_000, agent.id) + if (settled.status === 'running' || settled.status === 'stopping') { + throw new Error('jobs menu fixture did not settle after releasing its barrier') + } + } + } catch (error) { failures.push(error) } + } + try { await browser?.close() } catch (error) { failures.push(error) } + try { await scaffold?.close() } catch (error) { failures.push(error) } + if (failures.length > 0) throw new AggregateError(failures, 'jobs menu layering teardown failed') + }) + + it('keeps the list and live output above the Browser dock while anchored and dismissable', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-jobs-menu-layering')) + const session = page.locator(`[data-row-key="session:${SEED_ID}"]`) + await expect.poll(async () => { + if (await session.isVisible()) return true + const group = page.locator('[role="treeitem"][aria-expanded]').first() + if (await group.count() > 0 && await group.getAttribute('aria-expanded') === 'false') await group.click() + return session.isVisible() + }, { timeout: 15_000 }).toBe(true) + await session.click() + await expect.poll(() => scaffold.ctx.agents.get(SEED_ID), { timeout: 30_000 }).toBeDefined() + const opened = scaffold.ctx.agents.get(SEED_ID) + if (opened === undefined) throw new Error('opened session has no live Agent') + agent = opened + const started = await scaffold.ctx.tools.execute({ + signal: new AbortController().signal, + callId: ToolCallId('jobs-menu-layering-e2e'), + name: 'bash', + arguments: { command: COMMAND, description: 'Emit output and hold the jobs menu fixture', run_in_background: true }, + agent, + }) + const reported = started.content.map(block => block.type === 'text' ? block.text : '').join('') + const matched = /\bbash-[0-9a-f]{16}\b/.exec(reported) + if (matched === null) throw new Error(`background bash reported no job id: ${reported}`) + const startedJobId = JobId(matched[0]) + jobId = startedJobId + // Registration precedes shell startup; inspect its output before depending on a live row. + await expect.poll(() => { + const job = scaffold.ctx.jobs.get(startedJobId, agent.id) + const output = scaffold.ctx.jobs.readAt(startedJobId, 0, agent.id).chunks.map(chunk => chunk.text).join('') + return job.status !== 'running' || output.includes('jobs-menu-output') + }, { timeout: 15_000 }).toBe(true) + const readyJob = scaffold.ctx.jobs.get(startedJobId, agent.id) + const readyOutput = scaffold.ctx.jobs.readAt(startedJobId, 0, agent.id).chunks.map(chunk => chunk.text).join('') + expect(readyJob.status, `${readyJob.detail ?? ''}\n${readyOutput}`).toBe('running') + + await page.locator('[data-sidebar-right-expand]').click() + const column = page.locator('[data-rightbar-col]') + await column.locator('[data-sidebar-right-guide-entry="browser"]').click() + const address = column.getByRole('textbox', { name: 'Enter an HTTP(S) address' }) + await address.fill('https://browser.test/jobs-menu') + await address.press('Enter') + await page.frameLocator('[data-sidebar-browser-frame]') + .getByRole('heading', { name: 'Jobs menu browser fixture' }).waitFor() + + const trigger = page.getByRole('button', { name: '1 background job running', exact: true }) + await trigger.waitFor({ timeout: 15_000 }) + await trigger.focus() + await page.keyboard.press('Enter') + const list = page.getByRole('list', { name: 'Background jobs' }) + await list.waitFor() + await page.keyboard.press('Tab') + expect(await list.getByRole('button', { name: /Show live output/ }) + .evaluate(element => element === document.activeElement)).toBe(true) + const expectedPlacement = { + dockMode: 'push', bodyMounted: true, fixed: true, anchored: true, + viewportBounded: true, overlapsBrowser: true, aboveBrowser: true, + } + await expect.poll(() => placement(page)).toEqual(expectedPlacement) + const collapsed = await placement(page) + + await list.getByRole('button', { name: /Show live output/ }).click() + await list.getByText('jobs-menu-output', { exact: true }).waitFor({ timeout: 15_000 }) + await expect.poll(() => placement(page)).toEqual(expectedPlacement) + const expanded = await placement(page) + + await page.setViewportSize({ width: 1280, height: 760 }) + await expect.poll(() => placement(page)).toEqual(expectedPlacement) + const resized = await placement(page) + await list.getByRole('button', { name: /Hide live output/ }).focus() + await page.keyboard.press('Escape') + await list.waitFor({ state: 'detached' }) + const escapeRestoresFocus = await trigger.evaluate(node => node === document.activeElement) + expect(escapeRestoresFocus).toBe(true) + + await trigger.click() + await list.waitFor() + await address.click() + await list.waitFor({ state: 'detached' }) + const outsideDismisses = await trigger.getAttribute('aria-expanded') === 'false' + expect(outsideDismisses).toBe(true) + expect(scaffold.ctx.jobs.get(jobId, agent.id).status).toBe('running') + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + + await mkdir(EXPECTED_DIR, { recursive: true }) + await compareOrRefreshGolden(join(EXPECTED_DIR, 'geometry.expected.md'), [ + '# Background jobs above the Browser dock', '', + '```json', + JSON.stringify({ collapsed, expanded, resized, escapeRestoresFocus, outsideDismisses }, null, 2), + '```', + ].join('\n'), MODE) + await assertFixtureInventory(EXPECTED_DIR, ['geometry.expected.md']) + }) +}) diff --git a/apps/web/tests/live-job-stream.e2e.ts b/apps/web/tests/live-job-stream.e2e.ts index c88923e8e6..2b4fb64d68 100644 --- a/apps/web/tests/live-job-stream.e2e.ts +++ b/apps/web/tests/live-job-stream.e2e.ts @@ -95,7 +95,7 @@ describe.skipIf(MODE === 'record')('web e2e: live job stream', () => { agent, }) const reported = started.content.map(block => block.type === 'text' ? block.text : '').join('') - const matched = /\bbash-\d+\b/.exec(reported) + const matched = /\bbash-[0-9a-f]{16}\b/.exec(reported) if (matched === null) throw new Error(`background bash reported no job id: ${reported}`) const jobId = JobId(matched[0]) diff --git a/apps/web/tests/minimal-preset.snapshot.ts b/apps/web/tests/minimal-preset.snapshot.ts index 240ee39d28..16eebb7273 100644 --- a/apps/web/tests/minimal-preset.snapshot.ts +++ b/apps/web/tests/minimal-preset.snapshot.ts @@ -1,4 +1,4 @@ -import { mkdir } from 'node:fs/promises' +import { mkdir, readFile, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import type { Browser, Page } from 'playwright' @@ -6,6 +6,7 @@ import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import type { AgentHandle } from '@deepseek-ai/dsh-agent' import { ToolCallId, createUserMessage } from '@deepseek-ai/dsh-llm' +import type { GenerateOptions } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import type { Session } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-agent-preset-registry' @@ -14,6 +15,7 @@ import { assertFixtureInventory, captureStableAria, compareOrRefreshGolden, + fixtureUserPrompts, launchWebScaffold, watchConsole, webSnapshotMode, @@ -76,7 +78,7 @@ describe('minimal agent preset', () => { if (failures.length > 1) throw new AggregateError(failures, 'minimal preset smoke teardown failed') }) - it('sends the exact RL prompt and shell schema, then executes the persistent shell', async () => { + it('sends the exact RL prompt with shell and image schemas, then executes the persistent shell', async () => { const requestHeader = agentHandle.agent.session.requestHeader() if (requestHeader === undefined) throw new Error('the minimal agent issued no model request') const systemPrompt = systemPromptText(agentHandle.agent.session) @@ -89,7 +91,8 @@ describe('minimal agent preset', () => { `Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: ${JSON.stringify(agentHandle.agent.session.header.cwd)}.`, ]) expect(scaffold.ctx.agentPresets.serviceFor(agentHandle.agent, 'fs')).toBeUndefined() - expect(scaffold.ctx.agentPresets.serviceFor(agentHandle.agent, 'compaction')).toBeUndefined() + expect(scaffold.ctx.agentPresets.serviceFor(agentHandle.agent, 'compaction')).toBeDefined() + expect(scaffold.ctx.commands.find(agentHandle.agent, 'compact')).toBeDefined() const stateDir = join(scaffold.workspaceCwd, 'persistent-state') await mkdir(stateDir) @@ -128,6 +131,7 @@ describe('minimal agent preset', () => { "prompt": "You are a helpful software engineer assistant.", "tools": [ "bash", + "read_image", "working_directory", ], } @@ -192,3 +196,199 @@ describe('minimal agent preset', () => { ]) }) }) + +describe('minimal manual compaction', () => { + const snapshotDir = fileURLToPath(new URL('../../../snapshots/web/minimal-compact', import.meta.url)) + let scaffold: WebScaffold + let handle: AgentHandle + let browser: Browser | undefined + let page: Page | undefined + const requests: GenerateOptions[] = [] + + beforeAll(async () => { + const fixture = join(snapshotDir, 'session.v4.jsonl') + const prompts = fixtureUserPrompts(await readFile(fixture, 'utf8')) + expect(prompts).toHaveLength(2) + const [initial, continuation] = prompts + if (initial === undefined || continuation === undefined) throw new Error('minimal compaction fixture requires two user prompts') + scaffold = await launchWebScaffold({ replayFixture: fixture, compareReplaySession: true, workspacePathLength: 192 }) + scaffold.ctx.on('llm/stream', async function* (options, next) { + requests.push(options) + yield* next() + }) + handle = await scaffold.ctx.agents.create({ + sessionId: SessionId('minimal-compact-smoke'), + meta: { cwd: scaffold.workspaceCwd, agentPreset: 'minimal' }, + agentOptions: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + setup: agentCtx => scaffold.ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined), + }) + handle.agent.followup(createUserMessage({ + content: [{ type: 'text', text: initial }], source: { kind: 'user' }, + })) + await handle.agent.whenIdle() + const session = handle.agent.session + expect(session.snapshotEvents().some(event => event.type === 'compaction/start')).toBe(false) + const generation = session.surface.replaceGeneration + const execution = await scaffold.ctx.commands.execute(handle.agent, '/compact', [], new AbortController().signal) + expect(execution?.result.kind).toBe('success') + expect(session.surface.replaceGeneration).toBe(generation + 1) + handle.agent.followup(createUserMessage({ + content: [{ type: 'text', text: continuation }], source: { kind: 'user' }, + })) + await handle.agent.whenIdle() + }) + + afterAll(async () => { + const failures: unknown[] = [] + await page?.close().catch((error: unknown) => failures.push(error)) + await browser?.close().catch((error: unknown) => failures.push(error)) + await handle?.dispose().catch((error: unknown) => failures.push(error)) + await scaffold?.close().catch((error: unknown) => failures.push(error)) + if (failures.length === 1) throw failures[0] + if (failures.length > 1) throw new AggregateError(failures, 'minimal compaction teardown failed') + }) + + it('records the manual command, summary, and durable history replacement', () => { + const session = handle.agent.session + const events = session.snapshotEvents() + const command = events.find(event => event.type === 'command/run') + const start = events.find(event => event.type === 'compaction/start') + const summary = events.find(event => event.type === 'compaction/summary') + const end = events.find(event => event.type === 'compaction/end') + const done = events.find(event => event.type === 'command/done') + expect(command?.data.name).toBe('compact') + expect(start?.data).toMatchObject({ turn: null, sourceCommandId: command?.data.commandId }) + expect(summary?.data).toMatchObject({ sourceCommandId: command?.data.commandId, llmStreamCall: true }) + expect(end?.data).toEqual(start?.data) + expect(done?.data).toMatchObject({ kind: 'success', sourceEventSeq: summary?.seq }) + const checkpoint = events.find(event => event.type === 'user/message' && event.data.source.kind === 'compact-checkpoint') + if (checkpoint?.type !== 'user/message') throw new Error('manual compaction produced no checkpoint') + expect(checkpoint.surfaceOp).toMatchObject({ + op: 'replace', startSeq: summary?.data.shadowedRange.start, endSeq: summary?.data.shadowedRange.end, + }) + expect(session.deriveMessages().some(message => message.id === checkpoint.data.id)).toBe(true) + const original = events.find(event => event.type === 'user/message' && event.data.source.kind === 'user') + if (original?.type !== 'user/message') throw new Error('minimal compaction has no original user message') + expect(session.deriveMessages().some(message => message.id === original.data.id)).toBe(false) + }) + + it('continues with the summary and the same minimal prompt and tools', () => { + expect(requests.map(request => request.purpose)).toEqual([undefined, 'compaction', undefined]) + const summary = handle.agent.session.snapshotEvents().find(event => event.type === 'compaction/summary') + if (summary === undefined) throw new Error('minimal compaction produced no summary') + const [initial, , continuation] = requests + if (initial === undefined || continuation === undefined) throw new Error('minimal compaction did not continue') + expect(continuation.messages.flatMap(message => message.content)).toEqual(expect.arrayContaining(summary.data.summary)) + expect(continuation.messages[0]).toEqual(initial.messages[0]) + expect(continuation.tools).toEqual(initial.tools) + expect(continuation.messages.flatMap(message => message.content) + .some(block => block.type === 'text' && block.text === 'MINIMAL_COMPACT_READY')).toBe(true) + expect(handle.agent.session.deriveMessages().at(-1)?.content) + .toEqual([{ type: 'text', text: 'MINIMAL_COMPACT_OK' }]) + }) + + it.skipIf(MODE === 'record')('renders the manual compaction card and its summary', async () => { + onTestFailed(() => { if (page !== undefined) void saveFailureShot(page, 'web-minimal-compact') }) + browser = await chromium.launch() + page = await newEnglishPage(browser) + const tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await page.locator('[role="treeitem"]').first().click() + await page.locator('[role="treeitem"]').nth(1).click() + await page.getByText('MINIMAL_COMPACT_OK', { exact: true }).waitFor() + const card = page.getByRole('button', { name: /^compact Compacted / }) + await card.click() + await expect.poll(() => card.getAttribute('aria-expanded')).toBe('true') + await compareOrRefreshGolden(join(snapshotDir, 'ui.expected.md'), + await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd), MODE) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }) + + it('keeps its snapshot inventory closed', async () => { + await assertFixtureInventory(snapshotDir, [ + 'session.v4.jsonl', 'system-prompt.expected.md', 'tool-schemas.expected.json', 'ui.expected.md', + ]) + }) +}) + +describe('minimal image reading', () => { + const snapshotDir = fileURLToPath(new URL('../../../snapshots/web/minimal-read-image', import.meta.url)) + const prompt = 'Use read_image to inspect red.png. Then reply exactly MINIMAL_IMAGE_OK and stop.' + let scaffold: WebScaffold + let handle: AgentHandle + let browser: Browser | undefined + let page: Page | undefined + + beforeAll(async () => { + scaffold = await launchWebScaffold({ + replayFixture: join(snapshotDir, 'session.v4.jsonl'), compareReplaySession: true, paceMs: 10, + }) + await writeFile(join(scaffold.workspaceCwd, 'red.png'), Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC', 'base64', + )) + handle = await scaffold.ctx.agents.create({ + sessionId: SessionId('minimal-image-smoke'), + meta: { cwd: scaffold.workspaceCwd, agentPreset: 'minimal' }, + agentOptions: { provider: 'deepseek-official', model: 'deepseek-v4-flash-vision-exp' }, + setup: agentCtx => scaffold.ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined), + }) + handle.agent.followup(createUserMessage({ content: [{ type: 'text', text: prompt }], source: { kind: 'user' } })) + await handle.agent.whenIdle() + }) + + afterAll(async () => { + const failures: unknown[] = [] + await page?.close().catch((error: unknown) => failures.push(error)) + await browser?.close().catch((error: unknown) => failures.push(error)) + await handle?.dispose().catch((error: unknown) => failures.push(error)) + await scaffold?.close().catch((error: unknown) => failures.push(error)) + if (failures.length === 1) throw failures[0] + if (failures.length > 1) throw new AggregateError(failures, 'minimal image teardown failed') + }) + + it('logs a durable image with the minimal tool roster', async () => { + const session = handle.agent.session + expect(session.requestHeader()?.tools?.map(tool => tool.name)).toEqual(['bash', 'read_image', 'working_directory']) + const result = session.snapshotEvents().find(event => event.type === 'tool/result') + expect(result?.data.message.isError).toBe(false) + expect(result?.data.meta).toEqual({ path: join(scaffold.workspaceCwd, 'red.png') }) + const image = session.deriveMessages().flatMap(message => message.content).find(block => block.type === 'image') + if (image?.type !== 'image') throw new Error('minimal read_image produced no durable image') + expect(image.attachment).toMatchObject({ mediaType: 'image/png', width: 1, height: 1, bytes: 69 }) + expect((await scaffold.ctx.attachments.readImage(image.attachment)).data.byteLength).toBe(69) + }) + + it.skipIf(MODE === 'record')('renders the existing image tool card and preview', async () => { + onTestFailed(() => { if (page !== undefined) void saveFailureShot(page, 'web-minimal-read-image') }) + browser = await chromium.launch() + page = await newEnglishPage(browser) + const tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await page.locator('[role="treeitem"]').first().click() + await page.locator('[role="treeitem"]').nth(1).click() + await page.getByText('MINIMAL_IMAGE_OK', { exact: true }).waitFor() + await page.locator('[data-turn-process]').click() + const group = page.locator('[data-chat-group-key]').first() + await group.locator('[data-process-activity]').click() + const card = group.locator('[data-tool="read_image"]').first() + const row = card.locator('[data-disclosure-row]') + await row.click() + await expect.poll(() => row.getAttribute('aria-expanded')).toBe('true') + const preview = card.locator('img').first() + await preview.waitFor() + await expect.poll(() => preview.evaluate(img => img instanceof HTMLImageElement && img.naturalWidth)).toBe(1) + await compareOrRefreshGolden(join(snapshotDir, 'ui.expected.md'), + await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd), MODE) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }) + + it('keeps its snapshot inventory closed', async () => { + await assertFixtureInventory(snapshotDir, [ + 'session.v4.jsonl', 'system-prompt.expected.md', 'tool-schemas.expected.json', 'ui.expected.md', + ]) + }) +}) diff --git a/apps/web/tests/models-settings.e2e.ts b/apps/web/tests/models-settings.e2e.ts index 9cb6c5bbc2..d199b3b378 100644 --- a/apps/web/tests/models-settings.e2e.ts +++ b/apps/web/tests/models-settings.e2e.ts @@ -20,7 +20,7 @@ import { fileURLToPath } from 'node:url' import { join } from 'node:path' import type { Browser, Page, Request, Route } from 'playwright' import { chromium } from 'playwright' -import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { afterAll, beforeAll, describe, expect, it, onTestFailed, vi } from 'vitest' import { assertFixtureInventory, captureStableAria, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold, @@ -29,6 +29,7 @@ import { openSettings, ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/models-settings', import.meta.url)) const EMPTY_EXPECTED = join(SNAPSHOT_DIR, 'empty.expected.md') +const NATIVE_KEYLESS_EXPECTED = join(SNAPSHOT_DIR, 'native-keyless.expected.md') const CONFIGURED_EXPECTED = join(SNAPSHOT_DIR, 'configured.expected.md') const DECLARED_EXPECTED = join(SNAPSHOT_DIR, 'declared.expected.md') const DECLARED_EDIT_EXPECTED = join(SNAPSHOT_DIR, 'declared-edit.expected.md') @@ -44,6 +45,7 @@ describe('web e2e: Models settings page configures a dormant provider', () => { let tripwire: ReturnType beforeAll(async () => { + vi.stubEnv('OPENAI_API_KEY', 'models-settings-test-key') scaffold = await launchWebScaffold({}) browser = await chromium.launch() // The scenario asserts the shipped Chinese copy, so the browser asks for it. @@ -80,6 +82,7 @@ describe('web e2e: Models settings page configures a dormant provider', () => { }, 120_000) afterAll(async () => { + vi.unstubAllEnvs() await browser?.close() await scaffold?.close() }) @@ -120,6 +123,12 @@ describe('web e2e: Models settings page configures a dormant provider', () => { const options = await pick.locator('option').allTextContents() expect(options).toContain('anthropic') expect(options).toContain('minimax-cn') + await pick.selectOption('openai-codex') + await expect.poll(async () => dialog.getByRole('textbox', { name: 'API 密钥', exact: true }).count(), { timeout: 10_000 }).toBe(0) + expect(await dialog.getByRole('button', { name: '认证说明', exact: true }).count()).toBe(0) + expect(await dialog.textContent()).not.toMatch(/oauth/i) + const nativeSnapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(NATIVE_KEYLESS_EXPECTED, nativeSnapshot, MODE) await pick.selectOption('minimax-cn') await dialog.getByRole('textbox', { name: 'API 密钥', exact: true }).waitFor({ timeout: 10_000 }) const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) @@ -206,6 +215,8 @@ describe('web e2e: Models settings page configures a dormant provider', () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-models-customized')) const dialog = page.getByRole('dialog', { name: '设置' }) await dialog.getByRole('button', { name: '编辑 minimax-cn' }).click() + await dialog.getByText('API 密钥来源:已保存凭据', { exact: true }).waitFor({ timeout: 10_000 }) + expect(await dialog.textContent()).not.toMatch(/oauth/i) await dialog.getByText('自定义设置').click() const url = dialog.getByLabel('API 地址') await url.waitFor({ timeout: 10_000 }) @@ -535,7 +546,7 @@ describe('web e2e: Models settings page configures a dormant provider', () => { await assertFixtureInventory(SNAPSHOT_DIR, [ 'configured.expected.md', 'declared-edit.expected.md', 'declared.expected.md', 'delete.expected.md', 'empty.expected.md', 'model-picker.expected.md', - 'native-delete.expected.md', 'catalog-inputs.expected.md', + 'native-delete.expected.md', 'native-keyless.expected.md', 'catalog-inputs.expected.md', 'refreshing.expected.md', 'image-only.expected.md', ]) }) diff --git a/apps/web/tests/notice-wake-tail.e2e.ts b/apps/web/tests/notice-wake-tail.e2e.ts new file mode 100644 index 0000000000..884adcb9d3 --- /dev/null +++ b/apps/web/tests/notice-wake-tail.e2e.ts @@ -0,0 +1,218 @@ +/** + * A Turn this Client did not submit (a subagent-settled notice) wakes while the + * reader follows the tail of a long Turn under "On next message" folding. The + * notice Turn belongs to the long Turn's exchange, so the long Turn stays + * expanded through the host's pre-Step gap and after the notice arrives, and a + * reader who was following sees the notice and its reply at the tail. + */ +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { ToolCallId, createAssistantMessage, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' +import type { SubagentSettledMessageSource } from '@deepseek-ai/dsh-subagent' +import { launchWebScaffold, watchConsole, type WebScaffold } from './scaffold.ts' +import { connectFreshWorkspace, newEnglishPage } from './support.ts' + +/** Settled Turns before the long one, so every work-details mode overflows the viewport. */ +const HISTORY_TURNS = 3 +const LONG_TURN = HISTORY_TURNS + 1 +const NOTICE_TURN = HISTORY_TURNS + 2 +/** Tool Steps in the finished Turn; their folded height must exceed the notice Turn's height. */ +const TOOL_STEPS = 12 +const OUTPUT_LINES = 40 +/** The recorded host gap between the waking claim and Step 1, spent assembling the system prompt. */ +const PRE_STEP_GAP_MS = 187 +const REPLY = 'NOTICE_WAKE_REPLY' + +interface Tail { + /** Height of the room reserved below the flow. */ + readonly spacer: number + readonly noticeVisible: boolean + readonly replyVisible: boolean + /** Whether the long Turn's process disclosure reports itself expanded. */ + readonly longTurnOpen: boolean +} + +function readTail(page: Page): Promise { + return page.evaluate(([reply, longTurn]) => { + const host = document.querySelector('[data-conversation-scroll]') + if (host === null) throw new Error('conversation scrollport is absent') + const viewTop = host.getBoundingClientRect().top + const viewBottom = host.querySelector('[data-composer-seat]')?.getBoundingClientRect().top + ?? host.getBoundingClientRect().bottom + const inView = (element: Element): boolean => { + const rect = element.getBoundingClientRect() + return rect.height > 0.5 && rect.bottom > viewTop && rect.top < viewBottom + } + const spacer = host.querySelector('[data-chat-turn-spacer]') + return { + spacer: spacer === null ? 0 : Number.parseFloat(spacer.style.height) || 0, + noticeVisible: [...host.querySelectorAll('[data-turn-trigger]')].some(inView), + replyVisible: [...host.querySelectorAll('[data-chat-flow-kind="assistant-step"]')] + .some(row => (row.textContent ?? '').includes(reply) && inView(row)), + longTurnOpen: host.querySelector(`[data-turn-process="${longTurn}"]`)?.getAttribute('aria-expanded') === 'true', + } + }, [REPLY, String(LONG_TURN)] as const) +} + +describe('web e2e: notice Turn after a long Detailed Turn', () => { + let browser: Browser + + beforeAll(async () => { + browser = await chromium.launch({ headless: true }) + }, 60_000) + + afterAll(async () => { + await browser?.close() + }) + + it.each(['detailed', 'standard'] as const)('keeps the new tail visible in %s work details', async (view) => { + const scaffold: WebScaffold = await launchWebScaffold({}) + const page = await newEnglishPage(browser) + const tripwire = watchConsole(page) + try { + await page.setViewportSize({ width: 1280, height: 800 }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await connectFreshWorkspace(page, scaffold.workspaceCwd) + await scaffold.ctx.settings.update('ui-chat', { transcriptView: view, collapseTiming: 'next-input' }) + const agent = scaffold.ctx.agents.list().at(-1) + if (agent === undefined) throw new Error('connected workspace created no Agent') + const session = agent.session + const setRunning = (running: boolean): void => { scaffold.ctx.emit('api-session/status', agent.id, running) } + + for (let turn = 1; turn <= HISTORY_TURNS; turn += 1) { + session.append('turn/start', { turn }) + session.append('step/start', { turn, step: 1 }) + session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: `Earlier question ${String(turn)}.` }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + session.append('assistant/message', { + stream: [], + turn, + step: 1, + message: createAssistantMessage({ + content: [{ type: 'text', text: `Earlier answer ${String(turn)}. ${'This settled reply wraps across several lines of prose. '.repeat(24)}` }], + source: { provider: 'fixture', model: 'fixture' }, + }), + }, { surfaceOp: 'append' }) + session.append('step/end', { turn, step: 1 }) + session.append('turn/end', { turn, reason: { kind: 'completed' } }) + } + + // A long running Turn streams Step by Step while the reader follows the tail. + setRunning(true) + session.append('turn/start', { turn: LONG_TURN }) + session.append('step/start', { turn: LONG_TURN, step: 1 }) + session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'Run the long diagnostic.' }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + for (let step = 1; step <= TOOL_STEPS; step += 1) { + const callId = ToolCallId(`notice-wake-${String(step)}`) + const args = JSON.stringify({ command: `diagnose ${String(step)}`, description: `Diagnostic ${String(step)}` }) + session.append('assistant/message', { + stream: [], + turn: LONG_TURN, + step, + message: createAssistantMessage({ + content: [ + { type: 'reasoning', text: `Checking diagnostic ${String(step)}.` }, + { type: 'tool-call', id: callId, name: 'bash', arguments: args }, + ], + source: { provider: 'fixture', model: 'fixture' }, + }), + }, { surfaceOp: 'append' }) + const call = session.append('tool/call', { turn: LONG_TURN, step, callId, name: 'bash', arguments: args }) + session.append('tool/result', { + turn: LONG_TURN, + step, + message: createToolResultMessage({ + callId, + content: [{ + type: 'text', + text: Array.from({ length: OUTPUT_LINES }, (_, line) => `diagnostic ${String(step)} line ${String(line + 1)}`).join('\n'), + }], + isError: false, + }), + }, { surfaceOp: 'append', sourceEventSeqs: [call.seq] }) + session.append('step/end', { turn: LONG_TURN, step }) + session.append('step/start', { turn: LONG_TURN, step: step + 1 }) + await scaffold.ctx.sessions.flush(session) + await page.locator(`[data-chat-node-key$="${callId}"]`).waitFor({ state: 'attached', timeout: 15_000 }) + } + session.append('assistant/message', { + stream: [], + turn: LONG_TURN, + step: TOOL_STEPS + 1, + message: createAssistantMessage({ + content: [{ type: 'text', text: 'All diagnostics finished.' }], + source: { provider: 'fixture', model: 'fixture' }, + }), + }, { surfaceOp: 'append' }) + session.append('step/end', { turn: LONG_TURN, step: TOOL_STEPS + 1 }) + await scaffold.ctx.sessions.flush(session) + await page.getByText('All diagnostics finished.').waitFor({ timeout: 30_000 }) + await page.evaluate(() => { + const host = document.querySelector('[data-conversation-scroll]') + if (host !== null) host.scrollTop = host.scrollHeight + }) + await page.locator('[data-chat-following-tail]').waitFor({ timeout: 10_000 }) + + // The Turn ends; 15 ms later (the recorded production gap) a settled subagent wakes the next Turn. + session.append('turn/end', { turn: LONG_TURN, reason: { kind: 'completed' } }) + await scaffold.ctx.sessions.flush(session) + setRunning(false) + await page.waitForTimeout(15) + const source: SubagentSettledMessageSource = { + kind: 'subagent-settled', + form: 'notice', + summary: 'Background subagent finished.', + senderSessionId: SessionId('notice-wake-child'), + } + const notice = createUserMessage({ + content: [{ type: 'text', text: 'Background subagent notice-wake-child finished.' }], + source, + }) + session.append('agent/inbox/spliced', { target: 'next-turn', start: 0, inserted: [notice] }) + session.append('turn/start', { turn: NOTICE_TURN }) + session.append('agent/inbox/spliced', { target: 'next-turn', start: 0, removedCount: 1, inserted: [] }) + setRunning(true) + await scaffold.ctx.sessions.flush(session) + // The browser has applied the notice Turn's start before the gap is held and sampled. + await page.locator(`[data-chat-turn="${String(NOTICE_TURN)}"]`).first().waitFor({ state: 'attached', timeout: 15_000 }) + await page.waitForTimeout(PRE_STEP_GAP_MS) + const gap = await readTail(page) + session.append('step/start', { turn: NOTICE_TURN, step: 1 }) + session.append('user/message', notice, { surfaceOp: 'append' }) + await scaffold.ctx.sessions.flush(session) + await page.locator('[data-turn-trigger]').first().waitFor({ state: 'attached', timeout: 15_000 }) + session.append('assistant/message', { + stream: [], + turn: NOTICE_TURN, + step: 1, + message: createAssistantMessage({ + content: [{ type: 'text', text: `${REPLY} the subagent result is acknowledged.` }], + source: { provider: 'fixture', model: 'fixture' }, + }), + }, { surfaceOp: 'append' }) + await scaffold.ctx.sessions.flush(session) + + expect(gap).toMatchObject({ longTurnOpen: true, spacer: 0 }) + const settled = { noticeVisible: true, replyVisible: true, longTurnOpen: true, spacer: 0 } + await expect.poll(() => readTail(page), { timeout: 15_000 }).toMatchObject(settled) + // The settled tail survives later frames instead of passing through on the way to a fold. + await page.evaluate(() => new Promise((resolve) => { + requestAnimationFrame(() => { requestAnimationFrame(() => { resolve() }) }) + })) + expect(await readTail(page)).toMatchObject(settled) + expect(tripwire.warnings).toEqual([]) + expect(tripwire.pageErrors).toEqual([]) + } finally { + await page.context().close() + await scaffold.close() + } + }, 180_000) +}) diff --git a/apps/web/tests/onboarding-deepseek-config.e2e.ts b/apps/web/tests/onboarding-deepseek-config.e2e.ts index c6f965d806..50f6ecd61a 100644 --- a/apps/web/tests/onboarding-deepseek-config.e2e.ts +++ b/apps/web/tests/onboarding-deepseek-config.e2e.ts @@ -21,6 +21,7 @@ import { openSettings, ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, saveFailureSh const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/onboarding-deepseek-config', import.meta.url)) const WELCOME_EXPECTED = join(SNAPSHOT_DIR, 'welcome.expected.md') +const SIGN_IN_EXPECTED = join(SNAPSHOT_DIR, 'sign-in.expected.md') const MISSING_EXPECTED = join(SNAPSHOT_DIR, 'missing.expected.md') const MODELS_EXPECTED = join(SNAPSHOT_DIR, 'models.expected.md') const DEFAULT_MODELS_EXPECTED = join(SNAPSHOT_DIR, 'default-models.expected.md') @@ -73,10 +74,13 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup await welcome.getByRole('button', { name: WELCOME_NOTICE_COPY.zh.continueLabel }).click() await welcome.waitFor({ state: 'detached', timeout: 15_000 }) + const signIn = page.getByRole('dialog', { name: '开始使用', exact: true }) + await signIn.waitFor({ timeout: 15_000 }) + await compareOrRefreshGolden(SIGN_IN_EXPECTED, + await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd), MODE) + await signIn.getByRole('button', { name: '添加 API Key', exact: true }).click() const credentialStep = page.getByRole('dialog', { name: '添加一个 API Key 开始使用' }) await credentialStep.waitFor({ timeout: 15_000 }) - expect(await page.getByRole('dialog', { name: '开始你的创作' }).count()).toBe(0) - expect(await page.getByRole('button', { name: '账号菜单', exact: true }).count()).toBe(0) const keyInput = credentialStep.getByLabel('API 密钥', { exact: true }) await keyInput.waitFor({ timeout: 10_000 }) const initial = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) @@ -111,7 +115,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup await expect.poll( () => configuredInput.getAttribute('placeholder'), { timeout: 10_000 }, - ).toBe('已配置——输入新值可替换') + ).toBe('留空时保留现有认证(DEEPSEEK_API_KEY)') const secondReloadWarnings = tripwire.warnings.length await page.reload({ waitUntil: 'load' }) @@ -159,7 +163,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup setInterval(() => { if (document.querySelector( '[role="dialog"][aria-label="预览版说明"], ' - + '[role="dialog"][aria-label="开始你的创作"], ' + + '[role="dialog"][aria-label="开始使用"], ' + '[role="dialog"][aria-label="添加一个 API Key 开始使用"]', ) !== null) { sightings.push('chrome') @@ -252,7 +256,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup await expect.poll( () => settings.getByLabel('API 密钥', { exact: true }).getAttribute('placeholder'), { timeout: 10_000 }, - ).toBe('已配置——输入新值可替换') + ).toBe('留空时保留现有认证(DEEPSEEK_API_KEY)') const modelEditor = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) await compareOrRefreshGolden(MODELS_EXPECTED, modelEditor, MODE) await settings.getByRole('button', { name: '保存', exact: true }).click() @@ -309,7 +313,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup it('keeps the fixture inventory closed', async () => { await assertFixtureInventory( SNAPSHOT_DIR, - ['welcome.expected.md', 'missing.expected.md', 'models.expected.md', 'default-models.expected.md', 'empty.expected.md'], + ['welcome.expected.md', 'sign-in.expected.md', 'missing.expected.md', 'models.expected.md', 'default-models.expected.md', 'empty.expected.md'], ) }) }) diff --git a/apps/web/tests/onboarding-native.e2e.ts b/apps/web/tests/onboarding-native.e2e.ts index 57f96bb8f0..75c50b8f27 100644 --- a/apps/web/tests/onboarding-native.e2e.ts +++ b/apps/web/tests/onboarding-native.e2e.ts @@ -58,40 +58,34 @@ describe.skipIf(MODE === 'record').each([false, true])('web e2e: native credenti acknowledgeReloadConnectionLoss(tripwire, warningsBefore) } const accountMenu = page.getByRole('button', { name: '账号菜单', exact: true }) - if (desktop) { - await accountMenu.waitFor() - expect(await accountMenu.textContent()).toBe('更多') - const triggerBox = (await accountMenu.boundingBox())! - expect(Math.abs(triggerBox.height - 32)).toBeLessThan(1) - await accountMenu.click() - const menu = page.getByRole('menu') - await menu.waitFor() - expect(await menu.getByRole('menuitem').count()).toBe(3) - // Shortcut hints vary by host platform; accessible names identify the three actions. - for (const name of ['设置', '意见反馈', '登录']) { - expect(await menu.getByRole('menuitem', { name, exact: true }).count()).toBe(1) - } - const menuBox = (await menu.boundingBox())! - expect(Math.abs(menuBox.width - 124)).toBeLessThan(1) - expect(Math.abs(menuBox.height - 128)).toBeLessThan(1) - expect(Math.abs(triggerBox.y - (menuBox.y + menuBox.height) - 4)).toBeLessThan(1) - for (const row of await menu.getByRole('menuitem').all()) { - const rowBox = (await row.boundingBox())! - expect(Math.abs(rowBox.height - 40)).toBeLessThan(1) - expect(Math.abs(menuBox.width - rowBox.width - 8)).toBeLessThan(1) - const glyph = (await row.locator('svg').first().boundingBox())! - expect(Math.abs(glyph.width - 16)).toBeLessThan(1) - expect(Math.abs(glyph.height - 16)).toBeLessThan(1) - } - const menuAria = await captureStableAria(page, '[role="menu"]', scaffold.workspaceCwd) - await compareOrRefreshGolden(SIGNED_OUT_MENU_EXPECTED, menuAria, MODE) - await page.keyboard.press('Escape') - await menu.waitFor({ state: 'detached' }) - } else { - await page.getByRole('button', { name: '设置', exact: true }).waitFor() - expect(await accountMenu.count()).toBe(0) - expect(await page.getByRole('dialog', { name: '开始你的创作' }).count()).toBe(0) + await accountMenu.waitFor() + expect(await accountMenu.textContent()).toBe('更多') + const triggerBox = (await accountMenu.boundingBox())! + expect(Math.abs(triggerBox.height - 32)).toBeLessThan(1) + await accountMenu.click() + const menu = page.getByRole('menu') + await menu.waitFor() + expect(await menu.getByRole('menuitem').count()).toBe(3) + for (const name of ['设置', '意见反馈', '登录']) { + expect(await menu.getByRole('menuitem', { name, exact: true }).count()).toBe(1) } + const menuBox = (await menu.boundingBox())! + expect(Math.abs(menuBox.width - 124)).toBeLessThan(1) + expect(Math.abs(menuBox.height - 128)).toBeLessThan(1) + expect(Math.abs(triggerBox.y - (menuBox.y + menuBox.height) - 4)).toBeLessThan(1) + for (const row of await menu.getByRole('menuitem').all()) { + const rowBox = (await row.boundingBox())! + expect(Math.abs(rowBox.height - 40)).toBeLessThan(1) + expect(Math.abs(menuBox.width - rowBox.width - 8)).toBeLessThan(1) + const glyph = (await row.locator('svg').first().boundingBox())! + expect(Math.abs(glyph.width - 16)).toBeLessThan(1) + expect(Math.abs(glyph.height - 16)).toBeLessThan(1) + } + const menuAria = await captureStableAria(page, '[role="menu"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(SIGNED_OUT_MENU_EXPECTED, menuAria, MODE) + await page.keyboard.press('Escape') + await menu.waitFor({ state: 'detached' }) + expect(await page.getByRole('dialog', { name: '开始使用', exact: true }).count()).toBe(0) await openSettings(page, 'zh') const settings = page.getByRole('dialog', { name: '设置', exact: true }) await settings.getByRole('button', { name: '模型', exact: true }).click() diff --git a/apps/web/tests/onboarding-usable-provider.e2e.ts b/apps/web/tests/onboarding-usable-provider.e2e.ts index ebddfb8e34..4c21dbd9bf 100644 --- a/apps/web/tests/onboarding-usable-provider.e2e.ts +++ b/apps/web/tests/onboarding-usable-provider.e2e.ts @@ -44,10 +44,11 @@ describe.skipIf(MODE === 'record')('web e2e: another usable provider ends first- it('closes the setup card without discarding the add card beside it', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-onboarding-setup-card-cancel')) + const signIn = page.getByRole('dialog', { name: '开始使用', exact: true }) + await signIn.waitFor({ timeout: 15_000 }) + await signIn.getByRole('button', { name: '添加 API Key', exact: true }).click() const credentialStep = page.getByRole('dialog', { name: CREDENTIAL_STEP }) await credentialStep.waitFor({ timeout: 15_000 }) - expect(await page.getByRole('dialog', { name: '开始你的创作' }).count()).toBe(0) - expect(await page.getByRole('button', { name: '账号菜单', exact: true }).count()).toBe(0) await credentialStep.getByRole('button', { name: '稍后配置' }).click() await credentialStep.waitFor({ state: 'detached', timeout: 15_000 }) diff --git a/apps/web/tests/plugin-hmr-lifecycle.e2e.ts b/apps/web/tests/plugin-hmr-lifecycle.e2e.ts new file mode 100644 index 0000000000..966e60548b --- /dev/null +++ b/apps/web/tests/plugin-hmr-lifecycle.e2e.ts @@ -0,0 +1,250 @@ +// Web e2e scenario: the plugin lifecycle a user drives through the plugin manager, with real pnpm on local fixture +// packages and the live profile's HMR. The page installs, enables, disables, uninstalls, and reinstalls; upgrades go +// through the manager methods behind the agent's plugin_manager tool, removing and then installing. Every step checks which +// plugin version runs through the trace its apply and disposal append, and that a change HMR cannot apply asks for a +// restart or names its error. +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { chromium, type Browser, type Locator, type Page } from 'playwright' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import type { ChangeResult } from '@deepseek-ai/dsh-plugin-manager' +import { launchWebScaffold, watchConsole, type WebScaffold } from './scaffold.ts' +import { ZH_BROWSER_LOCALE } from './support.ts' + +const NAME = 'e2e-hmr-addon' + +/** Real pnpm with its store, cache, and metadata under a test directory and no registry access. */ +function isolatedPnpm(scratch: string) { + return { + command: 'pnpm', args: [], + // pnpm 11 reads settings from pnpm_config_* variables. + env: { pnpm_config_store_dir: join(scratch, 'pnpm-store'), pnpm_config_cache_dir: join(scratch, 'pnpm-cache'), pnpm_config_offline: 'true' }, + } +} + +/** The page's toast or inline alert whose text contains the given words. */ +function alertWith(page: Page, text: string): Locator { + return page.getByRole('alert').filter({ hasText: text }) +} + +describe('web e2e: plugin lifecycle with HMR', () => { + let scratch: string + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + let tracePath: string + + /** Write one version of the fixture bundle, whose only row is its own plugin, into a directory of its own. */ + async function version(label: string, body = ''): Promise { + const dir = join(scratch, 'sources', label) + await mkdir(dir, { recursive: true }) + await writeFile(join(dir, 'package.json'), JSON.stringify({ + name: NAME, version: `${label.replace(/\D/gu, '') || '0'}.0.0`, type: 'module', main: './index.mjs', + dsh: { bundle: { patch: './cordis.patch.yml' } }, + })) + await writeFile(join(dir, 'cordis.patch.yml'), `- insert:\n - id: ${NAME}\n name: ${NAME}\n`) + await writeFile(join(dir, 'index.mjs'), `${body} +import { appendFileSync } from 'node:fs' +export const name = '${NAME}' +export function apply(ctx) { + appendFileSync(${JSON.stringify(tracePath)}, 'start:${label}\\n') + ctx.effect(() => () => { appendFileSync(${JSON.stringify(tracePath)}, 'stop:${label}\\n') }) +} +`) + return dir + } + + /** The trace lines since the last call, after the given ones have arrived. */ + async function traced(expected: string[]): Promise { + await expect.poll(async () => (await readFile(tracePath, 'utf8')).split('\n').filter(Boolean), { timeout: 10_000 }).toEqual(expected) + await writeFile(tracePath, '') + } + + /** The running profile's manager, whose methods the agent's plugin_manager tool calls. */ + function manager() { + return scaffold.ctx.get('pluginManager')! + } + + /** Upgrade as the agent's tool does: an installed package is removed before another version is installed. */ + async function reinstall(spec: string): Promise { + const removed = await manager().removeBundle(NAME) + expect(removed.application, JSON.stringify(removed)).not.toBe('failed') + return manager().installBundle(spec) + } + + async function openPanel(): Promise { + if (await page.getByRole('dialog', { name: '设置' }).count() > 0) await page.keyboard.press('Escape') + const panel = page.locator('[data-plugin-panel]') + const list = panel.getByRole('heading', { name: '插件', exact: true }) + // The sidebar entry toggles the page, so it is pressed only while the page is closed. + if (await panel.count() === 0) { + await page.getByRole('navigation', { name: '全局面板' }).getByRole('button', { name: '插件', exact: true }).click() + } else if (!await list.isVisible()) { + await panel.getByRole('button', { name: '返回插件', exact: true }).click() + } + await list.waitFor({ timeout: 10_000 }) + return panel + } + + /** Install a spec through the page's dialog and switch the bundle on from its finished screen. */ + async function installThroughPage(spec: string): Promise { + const panel = await openPanel() + await panel.getByRole('button', { name: '添加插件', exact: true }).click() + const dialog = page.getByRole('dialog') + await dialog.getByRole('textbox', { name: '包名或地址' }).fill(spec) + await dialog.getByRole('button', { name: '安装', exact: true }).click() + await dialog.getByRole('button', { name: '立即启用', exact: true }).click({ timeout: 30_000 }) + await dialog.waitFor({ state: 'detached', timeout: 10_000 }) + return panel + } + + async function uninstallThroughPage(panel: Locator): Promise { + await panel.getByRole('button', { name: `查看 ${NAME}`, exact: true }).click() + await panel.getByRole('button', { name: `卸载 ${NAME}`, exact: true }).click() + await page.getByRole('dialog', { name: `卸载「${NAME}」?` }).getByRole('button', { name: '卸载', exact: true }).click() + // The page leaves the removed package's details for the list. + await panel.getByRole('heading', { name: '插件', exact: true }).waitFor({ timeout: 30_000 }) + await expect.poll(() => panel.locator(`[data-plugin-package="${NAME}"]`).count(), { timeout: 30_000 }).toBe(0) + } + + async function dependency(): Promise { + const manifest = JSON.parse(await readFile(join(scaffold.harnessHome, 'profiles', 'scaffold', 'package.json'), 'utf8')) as { + dependencies?: Record + } + return manifest.dependencies?.[NAME] + } + + beforeAll(async () => { + scratch = await mkdtemp(join(tmpdir(), 'dsh-plugin-hmr-')) + tracePath = join(scratch, 'trace.log') + await writeFile(tracePath, '') + scaffold = await launchWebScaffold({ profile: { packages: [], packageManager: isolatedPnpm(scratch) } }) + browser = await chromium.launch() + page = await browser.newPage({ viewport: { width: 1440, height: 1000 }, locale: ZH_BROWSER_LOCALE }) + tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl) + await page.waitForSelector('[class*="frame"]') + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + await rm(scratch, { recursive: true, force: true }) + }) + + it('installs, switches off and on, and upgrades without a restart', async () => { + const v1 = await version('v1') + const panel = await installThroughPage(`file:${v1}`) + await traced(['start:v1']) + const toggle = panel.getByRole('switch', { name: `启用 ${NAME}`, exact: true }) + await toggle.click() + await traced(['stop:v1']) + await toggle.click() + await traced(['start:v1']) + // Installing over the installed package is refused; the upgrade removes it first. + const v2 = await version('v2') + expect(await manager().installBundle(`file:${v2}`)).toMatchObject({ application: 'failed', error: { code: 'already-installed' } }) + const upgraded = await reinstall(`file:${v2}`) + expect(upgraded, JSON.stringify(upgraded)).toMatchObject({ application: 'applied' }) + await traced(['stop:v1', 'start:v2']) + // The page reads the upgrade from the Host's change announcement. + await panel.getByRole('button', { name: `查看 ${NAME}`, exact: true }).click() + await panel.getByText('v2.0.0', { exact: true }).waitFor({ timeout: 10_000 }) + await panel.getByRole('button', { name: '返回插件' }).click() + }, 90_000) + + it('stops the running version and names the load error when the upgrade cannot load, then applies the fix', async () => { + const broken = await version('v3', "import 'e2e-hmr-missing-dependency'\n") + const failed = await reinstall(`file:${broken}`) + expect(failed).toMatchObject({ application: 'failed', error: { code: 'operation-error' } }) + expect(failed.error?.diagnostic).toContain('e2e-hmr-missing-dependency') + await traced(['stop:v2']) + // Switching the broken plugin off and on again loads the same files, and the page names the missing dependency. + const panel = await openPanel() + const toggle = panel.getByRole('switch', { name: `启用 ${NAME}`, exact: true }) + await toggle.click() + await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('false') + await toggle.click() + await alertWith(page, 'e2e-hmr-missing-dependency').waitFor({ timeout: 10_000 }) + expect(await alertWith(page, 'e2e-hmr-missing-dependency').textContent()).not.toContain('重启') + await traced([]) + const fixed = await version('v4') + expect(await reinstall(`file:${fixed}`)).toMatchObject({ application: 'applied' }) + await traced(['start:v4']) + }, 90_000) + + it('reports an upgrade that loads but cannot start, then recovers with a working version', async () => { + const throwing = await version('v5') + await writeFile(join(throwing, 'index.mjs'), `export const name = '${NAME}'\nexport function apply() { throw new Error('v5 cannot start') }\n`) + const failed = await reinstall(`file:${throwing}`) + expect(failed).toMatchObject({ application: 'failed', error: { code: 'operation-error' } }) + expect(failed.error?.diagnostic).toContain('v5 cannot start') + await traced(['stop:v4']) + const working = await version('v6') + expect(await reinstall(`file:${working}`)).toMatchObject({ application: 'applied' }) + await traced(['start:v6']) + }, 90_000) + + it('uninstalls and reinstalls the same package with its new code', async () => { + const panel = await openPanel() + await uninstallThroughPage(panel) + await traced(['stop:v6']) + // pnpm removes the package after its plugins stop. + await expect.poll(dependency, { timeout: 30_000 }).toBeUndefined() + const v7 = await version('v7') + await installThroughPage(`file:${v7}`) + await traced(['start:v7']) + await uninstallThroughPage(await openPanel()) + await traced(['stop:v7']) + await expect.poll(dependency, { timeout: 30_000 }).toBeUndefined() + }, 90_000) + + it('starts a plugin linked to another directory without a restart', async () => { + // A bare local path links the package to its directory, as for a plugin under development. + const first = await version('v8') + await installThroughPage(first) + await traced(['start:v8']) + expect(await dependency()).toMatch(/^link:/u) + const moved = await version('v9') + expect(await reinstall(moved)).toMatchObject({ application: 'applied' }) + await traced(['stop:v8', 'start:v9']) + expect(await alertWith(page, '重启').count()).toBe(0) + expect(tripwire.pageErrors).toEqual([]) + }, 90_000) +}) + +describe('web e2e: plugin installation without HMR', () => { + it('asks for a restart after installing into a profile that applies changes at startup', async () => { + const scratch = await mkdtemp(join(tmpdir(), 'dsh-plugin-startup-')) + let scaffold: WebScaffold | undefined + let browser: Browser | undefined + try { + const dir = join(scratch, 'addon') + await mkdir(dir) + await writeFile(join(dir, 'package.json'), JSON.stringify({ + name: NAME, version: '1.0.0', type: 'module', main: './index.mjs', dsh: { bundle: { patch: './cordis.patch.yml' } }, + })) + await writeFile(join(dir, 'cordis.patch.yml'), `- insert:\n - id: ${NAME}\n name: ${NAME}\n`) + await writeFile(join(dir, 'index.mjs'), `export const name = '${NAME}'\nexport function apply() {}\n`) + scaffold = await launchWebScaffold({ profile: { packages: [], hmr: false, packageManager: isolatedPnpm(scratch) } }) + browser = await chromium.launch() + const page = await browser.newPage({ viewport: { width: 1440, height: 1000 }, locale: ZH_BROWSER_LOCALE }) + await page.goto(scaffold.authenticatedUrl) + await page.waitForSelector('[class*="frame"]') + if (await page.getByRole('dialog', { name: '设置' }).count() > 0) await page.keyboard.press('Escape') + await page.getByRole('navigation', { name: '全局面板' }).getByRole('button', { name: '插件', exact: true }).click() + const panel = page.locator('[data-plugin-panel]') + await panel.getByRole('button', { name: '添加插件', exact: true }).click() + const dialog = page.getByRole('dialog') + await dialog.getByRole('textbox', { name: '包名或地址' }).fill(`file:${dir}`) + await dialog.getByRole('button', { name: '安装', exact: true }).click() + await dialog.getByText('已安装,请重启 DSH 加载它。这个 profile 只在启动时应用更改。', { exact: true }).waitFor({ timeout: 30_000 }) + } finally { + await browser?.close() + await scaffold?.close() + await rm(scratch, { recursive: true, force: true }) + } + }, 120_000) +}) diff --git a/apps/web/tests/plugin-manager.e2e.ts b/apps/web/tests/plugin-manager.e2e.ts index fa61e50def..5502602721 100644 --- a/apps/web/tests/plugin-manager.e2e.ts +++ b/apps/web/tests/plugin-manager.e2e.ts @@ -96,6 +96,141 @@ describe('web e2e: plugin manager', () => { return readFile(join(scaffold.harnessHome, ...segments), 'utf8').catch(() => '') } + it('keeps plugin header actions on one line as the available width changes', async () => { + const facts: string[] = [] + // A dedicated scaffold keeps the measured header deterministic: an install + // task left by an earlier test swaps the toolbar's add control for the + // view-installation button, which moves the wrap threshold. + const headerScaffold = await launchWebScaffold() + try { + // Pin the extremes; at intermediate widths, the rendered font and locale + // determine whether the two intrinsic items fit beside each other. + const PINNED_PLACEMENT: Record = { 1680: 'beside', 1024: 'beside', 390: 'below', 430: 'below' } + for (const language of ['en', 'zh'] as const) { + const context = await browser.newContext({ + viewport: { width: 1680, height: 1000 }, locale: language === 'en' ? 'en-US' : ZH_BROWSER_LOCALE, + reducedMotion: 'reduce', + }) + try { + const probe = await context.newPage() + const consoleWatch = watchConsole(probe) + await probe.goto(headerScaffold.authenticatedUrl, { waitUntil: 'load' }) + const title = language === 'en' ? 'Plugins' : '插件' + await probe.getByRole('button', { name: title, exact: true }).click() + const panel = probe.locator('[data-plugin-panel]') + const header = panel.locator(':scope > header') + await header.getByRole('heading', { name: title, exact: true }).waitFor() + await probe.evaluate(() => document.fonts.ready) + const add = header.getByRole('button', { name: language === 'en' ? 'Add plugin' : '添加插件', exact: true }) + await expect.poll(() => add.isEnabled()).toBe(true) + await add.focus() + const measure = () => header.evaluate((element) => { + const heading = element.firstElementChild! + const actions = element.lastElementChild! + const headingRect = heading.getBoundingClientRect() + const actionRect = actions.getBoundingClientRect() + const headerRect = element.getBoundingClientRect() + const intrinsicHeading = heading.cloneNode(true) as HTMLElement + Object.assign(intrinsicHeading.style, { + position: 'absolute', width: 'max-content', maxWidth: 'none', visibility: 'hidden', pointerEvents: 'none', + }) + element.append(intrinsicHeading) + let intrinsicHeadingWidth: number + try { + intrinsicHeadingWidth = intrinsicHeading.getBoundingClientRect().width + } finally { + intrinsicHeading.remove() + } + const headerStyle = getComputedStyle(element) + const contentWidth = headerRect.width - Number.parseFloat(headerStyle.paddingLeft) - Number.parseFloat(headerStyle.paddingRight) + const fitsBeside = intrinsicHeadingWidth + actionRect.width + Number.parseFloat(headerStyle.columnGap) <= contentWidth + 1 + const subtitle = heading.querySelector('h1 + div > span')! + const range = document.createRange() + range.selectNodeContents(subtitle) + const lastText = Array.from(range.getClientRects()).at(-1)! + const info = heading.querySelector('button')!.getBoundingClientRect() + const buttons = Array.from(actions.querySelectorAll('button')) + const singleLine = buttons.every((button) => { + const walker = document.createTreeWalker(button, NodeFilter.SHOW_TEXT) + const tops: number[] = [] + while (walker.nextNode()) { + if (!walker.currentNode.textContent?.trim()) continue + range.selectNodeContents(walker.currentNode) + for (const rect of range.getClientRects()) tops.push(rect.top) + } + return tops.length === 0 || Math.max(...tops) - Math.min(...tops) < 1 + }) + return { + singleLine, fitsBeside, + diagnostic: { flexWrap: headerStyle.flexWrap, headingMaxWidth: getComputedStyle(heading).maxWidth, + introDisplay: getComputedStyle(heading.querySelector('h1 + div')!).display, + intrinsicHeadingWidth, headingWidth: headingRect.width, actionsWidth: actionRect.width, + contentWidth, gap: Number.parseFloat(headerStyle.columnGap), + subtitleRight: lastText.right, infoLeft: info.left, + }, + placement: actionRect.left >= headingRect.right - 1 + ? 'beside' as const + : actionRect.top >= headingRect.bottom - 1 ? 'below' as const : 'overlapping' as const, + contained: actionRect.left >= headerRect.left - 1 && actionRect.right <= headerRect.right + 1, + infoFollowsText: info.left >= lastText.right && info.left - lastText.right <= 5 + && info.top < lastText.bottom && info.bottom > lastText.top, + focused: buttons.includes(document.activeElement as HTMLButtonElement), + } + }) + const validLayout = (layout: Awaited>) => + layout.singleLine && layout.contained && layout.infoFollowsText && layout.focused + && layout.placement === (layout.fitsBeside ? 'beside' : 'below') + for (const width of [1680, 768, 520, 440, 390, 430, 1024]) { + await probe.setViewportSize({ width, height: 1000 }) + await expect.poll(async () => { + const layout = await measure() + return validLayout(layout) ? true : layout + }, { message: `${language} ${width}px plugin header geometry` }).toBe(true) + const { placement } = await measure() + const pinned = PINNED_PLACEMENT[width] + if (pinned !== undefined) expect(placement).toBe(pinned) + facts.push(`${language} ${width}px: actions ${pinned === undefined ? 'beside or below (according to intrinsic fit)' : placement} the heading, single-line; contained; information follows subtitle; focus retained`) + } + await probe.setViewportSize({ width: 390, height: 1000 }) + const previousWrap = await header.evaluate((element) => { + const style = element.style + const previous = { value: style.getPropertyValue('flex-wrap'), priority: style.getPropertyPriority('flex-wrap') } + style.setProperty('flex-wrap', 'nowrap') + return previous + }) + try { + const noWrap = await measure() + expect(noWrap.fitsBeside).toBe(false) + expect(noWrap.placement).not.toBe('below') + expect(validLayout(noWrap)).toBe(false) + } finally { + await header.evaluate((element, previous) => { + if (previous.value === '') element.style.removeProperty('flex-wrap') + else element.style.setProperty('flex-wrap', previous.value, previous.priority) + }, previousWrap) + } + await expect.poll(async () => validLayout(await measure())).toBe(true) + const menuTrigger = header.getByRole('button', { name: language === 'en' ? 'Choose how to add a plugin' : '选择添加插件方式' }) + await menuTrigger.click() + await probe.getByRole('menu').waitFor() + await probe.keyboard.press('Escape') + await expect.poll(() => probe.getByRole('menu').count()).toBe(0) + expect(await menuTrigger.evaluate(element => element === document.activeElement)).toBe(true) + expect(consoleWatch.pageErrors).toEqual([]) + } finally { + await context.close() + } + } + await compareOrRefreshGolden( + join(SNAPSHOT_DIR, 'header-responsive.expected.md'), + `# Plugin header responsive layout\n\n${facts.join('\n')}\n\nA forced no-wrap layout at 390px fails the same measured layout check in both locales; restoring wrap passes.\nAdd menu dismisses on Escape and returns focus in both locales.`, + MODE, + ) + } finally { + await headerScaffold.close() + } + }) + it('aligns the first-read skeleton with the loaded plugin cards', async () => { const facts: string[] = [] let aria = '' @@ -932,7 +1067,7 @@ describe('web e2e: plugin manager', () => { expect(tripwire.warnings).toEqual([]) await assertFixtureInventory(SNAPSHOT_DIR, [ 'manager.expected.md', 'live-enabled.expected.md', 'missing-bundle.expected.md', 'exports.expected.md', 'exports-en.expected.md', 'icons.expected.md', 'loading.expected.md', 'refresh.expected.md', - 'ime-enter.expected.md', + 'ime-enter.expected.md', 'header-responsive.expected.md', ]) }) }) @@ -965,7 +1100,7 @@ describe('web e2e: startup-applied plugin management', () => { // The selection is saved and the switch turns on, but nothing mounts before the next start; a toast says so. await expect.poll(bundles, { timeout: PLUGIN_TOGGLE_SETTLE_MS }).toEqual([...SCAFFOLD_BUNDLES, '@fixture/bundle']) await expect.poll(() => toggle.getAttribute('aria-checked'), { timeout: PLUGIN_TOGGLE_SETTLE_MS }).toBe('true') - await page.getByText('更改将在下次启动生效', { exact: true }).waitFor({ timeout: 10_000 }) + await page.getByText('请重启 DSH 使更改生效。这个 profile 只在启动时应用更改。', { exact: true }).waitFor({ timeout: 10_000 }) expect(mounted()?.fiber?.state).toBeUndefined() // The pack's page lists its rows from their declarations, with no live entry to switch. await panel.getByRole('button', { name: '查看 @fixture/bundle' }).click() diff --git a/apps/web/tests/preview-boot.e2e.ts b/apps/web/tests/preview-boot.e2e.ts index 2b1fa6df62..cea516a9de 100644 --- a/apps/web/tests/preview-boot.e2e.ts +++ b/apps/web/tests/preview-boot.e2e.ts @@ -318,6 +318,7 @@ async function bootPreview(origin: string, browser: Browser): Promise { const continueButton = page.getByRole('button', { name: 'Continue' }) await continueButton.waitFor({ timeout: HERO_TIMEOUT_MS }) await continueButton.click() + await page.getByRole('button', { name: 'Add API Key', exact: true }).click() const configureLater = page.getByRole('button', { name: 'Configure later' }) await configureLater.waitFor({ timeout: 30_000 }) await configureLater.click() @@ -583,8 +584,9 @@ async function bootEmptyPreview(origin: string, browser: Browser): Promise // has no host routes — the controller publishes an empty list and the // header renders no button, which is that surface's designed degradation. expect([...failedResponses].sort()).toEqual(['/open-in-app/apps', '/plugins/events']) + // The worker recomposes configuration but exposes no Node module caches for code replacement. expect(consoleErrors.filter(line => !line.includes('Failed to load resource: the server responded with a status of 404'))) - .toEqual([]) + .toEqual(['hmr: restart required: module-comparison']) } catch (error) { await saveFailureShot(page, 'preview-boot-empty') throw pageErrors.length === 0 diff --git a/apps/web/tests/replay-round-trip.e2e.ts b/apps/web/tests/replay-round-trip.e2e.ts index 77d9c9c54d..c5a382a55e 100644 --- a/apps/web/tests/replay-round-trip.e2e.ts +++ b/apps/web/tests/replay-round-trip.e2e.ts @@ -19,7 +19,7 @@ import type { Browser, Page, Route, WebSocketRoute } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { ToolCallId, expandAssistantStream } from '@deepseek-ai/dsh-llm' -import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session' +import { SessionId, type Session, type SessionEvent } from '@deepseek-ai/dsh-session' import type { SessionListValue } from '@deepseek-ai/dsh-api-session-controller/types' import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' import { @@ -213,7 +213,7 @@ describe('web e2e: fresh round trip through the real assembly', () => { } }, 200_000) - it('keeps source checkout and Web context in the system prompt and cwd in user context', async () => { + it('keeps value-free Web guidance in the system prompt and process values in runtime context', async () => { if (settledSessionId === undefined) throw new Error('the drive turn did not publish a session id') const agent = scaffold.ctx.agents.get(settledSessionId) if (agent === undefined) throw new Error(`the settled Web agent ${settledSessionId} is no longer live`) @@ -230,6 +230,8 @@ describe('web e2e: fresh round trip through the real assembly', () => { const userText = agent.session.deriveMessages().filter(message => message.role === 'user') .flatMap(message => message.content.flatMap(block => block.type === 'text' ? [block.text] : [])) expect(userText.some(text => text.includes(`Current working directory: ${JSON.stringify(cwd)}.`))).toBe(true) + expect(system).not.toContain(scaffold.baseUrl) + expect(userText.some(text => text.includes(`Current DeepSeek Harness Web GUI URL: ${JSON.stringify(scaffold.baseUrl)}.`))).toBe(true) const suffix = paragraphs.slice(-2).join('\n\n') .split(REPO_ROOT).join('{{sourceRoot}}') .split(join(scaffold.workspaceCwd, 'workspace')).join('{{cwd}}') @@ -238,22 +240,34 @@ describe('web e2e: fresh round trip through the real assembly', () => { }) it('exposes the assembled Web URL to the real bash tool', async () => { - if (settledSessionId === undefined) throw new Error('the drive turn did not publish a session id') - const agent = scaffold.ctx.agents.get(settledSessionId) - if (agent === undefined) throw new Error(`the settled Web agent ${settledSessionId} is no longer live`) - const result = await scaffold.ctx.tools.execute({ - signal: AbortSignal.timeout(5_000), - callId: ToolCallId('web-url-probe'), - name: 'bash', - arguments: { - command: 'printf \'%s\\n\' "$DSH_WEB_URL"', - description: 'Print current Web runtime', - }, - agent, - }) - expect(result.isError).toBe(false) - expect(result.content.filter(block => block.type === 'text').map(block => block.text).join('')) - .toBe(`${scaffold.baseUrl}\n`) + // This host probe records job events too; keep it out of the shared + // conversation fixture that other browser scenarios replay. + const probe = await launchWebScaffold() + try { + const handle = await probe.ctx.agents.create({ + sessionId: SessionId('web-url-probe'), + setup: agentCtx => probe.ctx.agentPresets.mount(agentCtx).then(() => undefined), + }) + try { + const result = await probe.ctx.tools.execute({ + signal: AbortSignal.timeout(5_000), + callId: ToolCallId('web-url-probe'), + name: 'bash', + arguments: { + command: 'printf \'%s\\n\' "$DSH_WEB_URL"', + description: 'Print current Web runtime', + }, + agent: handle.agent, + }) + expect(result.isError).toBe(false) + expect(result.content.filter(block => block.type === 'text').map(block => block.text).join('')) + .toBe(`${probe.baseUrl}\n`) + } finally { + await handle.dispose() + } + } finally { + await probe.close() + } }) it.skipIf(MODE === 'record')('rendered the settled turn: markdown, tool row, composer restore', async () => { diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index f1524ed0a5..593eb41d7a 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -78,7 +78,7 @@ import { type SessionEvent, type SessionHeader, } from '@deepseek-ai/dsh-session' -import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' +import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' // Empty type imports carry the webServer/agents/sessionPersistence Context merges. import type {} from '@deepseek-ai/dsh-host-webserver' import type {} from '@deepseek-ai/dsh-agent' @@ -310,6 +310,8 @@ export interface LaunchOptions { openInAppEnvironment?: LaunchEnvironmentSnapshot /** Compare the replayed root Session; `read-only` also forbids refresh writes to a borrowed fixture. */ compareReplaySession?: boolean | 'read-only' + /** Fixed JSON-quoted workspace path length for compaction estimates; omitted values use the ordinary prefix. */ + workspacePathLength?: number /** * Optional product overlay applied after the shipped Web surface and before * the scaffold's hermetic test patches, matching the launcher's `--patch` @@ -502,7 +504,15 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise sessionFormatCatalog.encodeCurrentEvent(event)) const header = sessionFormatCatalog.encodeCurrentHeader({ @@ -1126,6 +1138,9 @@ export function normalizeWebSessionVolatiles(log: string, workspaceCwd?: string) let record = normalizeClientTimeZones(mapJsonStringValues(JSON.parse(line), (value) => { let normalized = value .replace(/Anonymous user: [0-9a-f-]{36}(?=\.$)/gi, 'Anonymous user: {{anonymousUserId}}') + // The checkout path and listener port differ per machine and run. + .replace(/Current DeepSeek Harness implementation checkout: "(?:[^"\\]|\\.)*"\./g, 'Current DeepSeek Harness implementation checkout: "{{sourceRoot}}".') + .replace(/Current DeepSeek Harness Web GUI URL: "(?:[^"\\]|\\.)*"\./g, 'Current DeepSeek Harness Web GUI URL: "{{webUrl}}".') for (const cwd of cwdSpellings) normalized = replaceWebCwd(normalized, cwd) return normalized })) as { type?: unknown; data?: { endpoint?: unknown } } @@ -1545,6 +1560,7 @@ function normalizeAria(snapshot: string, workspaceCwd: string, age: boolean): st .split(workspaceCwd).join('{{cwd}}') .split(base).join('{{workspace}}') .replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, '{{uuid}}') + .replace(/(?<=-)[0-9a-f]{16}(?![0-9a-f])/gi, '{{uuid}}') // The optional space in `\d+m ?\d+s` covers both minute spellings: the // stats line's compact `2m42s` and the message-chrome template's `2m 42s`. .replace( diff --git a/apps/web/tests/schedule-header.e2e.ts b/apps/web/tests/schedule-header.e2e.ts new file mode 100644 index 0000000000..fc79fd571c --- /dev/null +++ b/apps/web/tests/schedule-header.e2e.ts @@ -0,0 +1,97 @@ +// UI-only geometry for the Automation tasks page; no model calls or task mutations. +import { fileURLToPath } from 'node:url' +import { chromium } from 'playwright' +import { expect, it } from 'vitest' +import { compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode } from './scaffold.ts' + +it('keeps the automation title intact before moving New to another line', async () => { + const scaffold = await launchWebScaffold() + try { + const browser = await chromium.launch() + try { + const facts: string[] = [] + for (const language of ['en', 'zh'] as const) { + const context = await browser.newContext({ + viewport: { width: 1680, height: 1000 }, + locale: language === 'en' ? 'en-US' : 'zh-CN', + reducedMotion: 'reduce', + }) + try { + const page = await context.newPage() + const consoleWatch = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + const title = language === 'en' ? 'Automation tasks' : '自动化任务' + await page.getByRole('button', { name: title, exact: true }).click() + const panel = page.getByTestId('task-manager-page') + const heading = panel.getByRole('heading', { name: title, exact: true }) + await heading.waitFor() + const header = heading.locator('..') + const create = header.getByRole('button', { name: language === 'en' ? 'New' : '新建', exact: true }) + await page.evaluate(() => document.fonts.ready) + await create.focus() + for (const width of [1680, 800, 520, 440, 390, 430, 1024]) { + await page.setViewportSize({ width, height: 1000 }) + // The title keeps its natural width at every viewport width, so New + // stays beside it here; the squeeze case below is what moves it down. + await expect.poll(() => header.evaluate((element) => { + const title = element.querySelector('h1')! + const button = element.querySelector('button')! + const titleRect = title.getBoundingClientRect() + const buttonRect = button.getBoundingClientRect() + const headerRect = element.getBoundingClientRect() + const range = document.createRange() + range.selectNodeContents(title) + const titleLines = Array.from(range.getClientRects()) + return { + titleSingleLine: titleLines.length === 1, + buttonSingleLine: button.scrollHeight <= button.clientHeight, + placement: buttonRect.left >= titleRect.right - 1 + ? 'beside' as const + : buttonRect.top >= titleRect.bottom - 1 ? 'below' as const : 'overlapping' as const, + contained: titleRect.left >= headerRect.left - 1 && titleRect.right <= headerRect.right + 1 + && buttonRect.left >= headerRect.left - 1 && buttonRect.right <= headerRect.right + 1, + focused: document.activeElement === button, + } + })).toEqual({ + titleSingleLine: true, buttonSingleLine: true, placement: 'beside', contained: true, focused: true, + }) + facts.push(`${language} ${width}px: title and New single-line, New beside the title; contained; New retains focus`) + } + if (language === 'en') { + // 509px collapses the left sidebar to its rail; expanding it leaves the + // page column narrow enough (measured 172px) to force the wrap. + await page.setViewportSize({ width: 509, height: 1000 }) + await page.getByRole('button', { name: 'Open sidebar', exact: true }).first().click() + await expect.poll(() => header.evaluate((element) => { + const title = element.querySelector('h1')! + const button = element.querySelector('button')! + const titleRect = title.getBoundingClientRect() + const buttonRect = button.getBoundingClientRect() + const range = document.createRange() + range.selectNodeContents(title) + return { + titleSingleLine: range.getClientRects().length === 1, + placement: buttonRect.left >= titleRect.right - 1 + ? 'beside' as const + : buttonRect.top >= titleRect.bottom - 1 ? 'below' as const : 'overlapping' as const, + } + })).toEqual({ titleSingleLine: true, placement: 'below' }) + facts.push('en 509px with sidebar expanded: title single-line; New moves below the title') + } + expect(consoleWatch.pageErrors).toEqual([]) + } finally { + await context.close() + } + } + await compareOrRefreshGolden( + fileURLToPath(new URL('./expected/schedule-header.expected.md', import.meta.url)), + `# Automation tasks header\n\n${facts.join('\n')}`, + webSnapshotMode(), + ) + } finally { + await browser.close() + } + } finally { + await scaffold.close() + } +}) diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index 6d6fd3e808..5155daf95c 100644 --- a/apps/web/tests/settings-chrome.e2e.ts +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -75,13 +75,15 @@ describe('web e2e: settings modal and General preferences', () => { if (await dialog.isVisible()) await page.keyboard.press('Escape') await dialog.waitFor({ state: 'hidden' }) }) - const trigger = page.getByRole('button', { name: '设置', exact: true }) - expect(await trigger.getAttribute('aria-haspopup')).toBe('dialog') + const trigger = page.getByRole('button', { name: '账号菜单', exact: true }) + expect(await trigger.getAttribute('aria-haspopup')).toBe('menu') expect(await trigger.getAttribute('aria-expanded')).toBe('false') await trigger.click() + expect(await trigger.getAttribute('aria-expanded')).toBe('true') + await page.getByRole('menuitem', { name: '设置', exact: true }).click() const dialog = page.getByRole('dialog', { name: '设置' }) await dialog.waitFor({ timeout: 10_000 }) - expect(await trigger.getAttribute('aria-expanded')).toBe('true') + expect(await trigger.getAttribute('aria-expanded')).toBe('false') // General is active by default; Permission, Language and Appearance are functional. expect(await dialog.getByRole('button', { name: '通用设置' }).getAttribute('aria-current')).toBe('true') await dialog.getByRole('button', { name: '工作区内修改' }).waitFor({ timeout: 10_000 }) @@ -791,7 +793,7 @@ describe('web e2e: settings modal and General preferences', () => { await page.reload({ waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) acknowledgeReloadConnectionLoss(tripwire, warningStart) - const enTrigger = page.getByRole('button', { name: 'Settings' }) + const enTrigger = page.getByRole('button', { name: 'Account menu' }) await enTrigger.waitFor({ timeout: 10_000 }) // A Chinese browser on another port still receives the explicit English diff --git a/apps/web/tests/shipped-composition.e2e.ts b/apps/web/tests/shipped-composition.e2e.ts index 6eefe81171..9c7d827aba 100644 --- a/apps/web/tests/shipped-composition.e2e.ts +++ b/apps/web/tests/shipped-composition.e2e.ts @@ -689,9 +689,13 @@ it('lets a preset producer reach the background-job registry', async () => { }, agent: handle.agent, }) + const reported = started.content.map(block => block.type === 'text' ? block.text : '').join('') + const matched = /^started background job (bash-[0-9a-f]{16})$/.exec(reported) + if (matched === null) throw new Error(`background bash reported no job id: ${reported}`) + const jobId = matched[1] expect({ isError: started.isError, content: started.content }).toEqual({ isError: false, - content: [{ type: 'text', text: 'started background job bash-1' }], + content: [{ type: 'text', text: `started background job ${jobId}` }], }) // The controller reads what the producer started: same registry, one @@ -704,9 +708,8 @@ it('lets a preset producer reach the background-job registry', async () => { agent: handle.agent, }) expect(listed.isError).toBe(false) - expect(listed.content).toEqual([ - { type: 'text', text: expect.stringContaining('bash-1 [bash]') as unknown as string }, - ]) + expect(listed.content).toHaveLength(1) + expect(listed.content[0]?.type === 'text' ? listed.content[0].text : '').toContain(`${jobId} [bash]`) // The full round trip: the output a host-plane producer wrote is collected // through a preset-plane control, which is the linkage the realm severed. @@ -714,7 +717,7 @@ it('lets a preset producer reach the background-job registry', async () => { signal, callId: ToolCallId('shipped-task-output'), name: 'job_output', - arguments: { job_id: 'bash-1', wait: true }, + arguments: { job_id: jobId, wait: true }, agent: handle.agent, }) expect(collected.isError).toBe(false) diff --git a/apps/web/tests/shortcuts-desktop.e2e.ts b/apps/web/tests/shortcuts-desktop.e2e.ts index 9609e02de8..7560831694 100644 --- a/apps/web/tests/shortcuts-desktop.e2e.ts +++ b/apps/web/tests/shortcuts-desktop.e2e.ts @@ -9,7 +9,7 @@ import { SessionId } from '@deepseek-ai/dsh-session' import { initialShortcutConfig, parseShortcutDefinitions, parseShortcutEdit } from '@deepseek-ai/dsh-client-shortcuts/protocol' import type { DesktopShortcutInput, DesktopShortcutsApi, ShortcutCommandId, ShortcutRevision } from '@deepseek-ai/dsh-client-shortcuts/protocol' import { desktopKeybindings } from '../../desktop/src/keybindings.ts' -import { compareOrRefreshGolden, launchWebScaffold, seedSession, watchConsole, webSnapshotMode } from './scaffold.ts' +import { acknowledgeReloadConnectionLoss, compareOrRefreshGolden, launchWebScaffold, seedSession, watchConsole, webSnapshotMode } from './scaffold.ts' import { writeComposerDraft } from './support.ts' declare global { @@ -63,6 +63,12 @@ it.each([ } }) }, marker) const console = watchConsole(page) + const fontMutations: unknown[][] = [] + page.on('request', (request) => { + if (new URL(request.url()).pathname !== '/api/settings/mutate' || request.method() !== 'POST') return + const input = request.postDataJSON() as { payload: { args: { ns: string; ops: unknown[] } } } + if (input.payload.args.ns === 'ui-theme') fontMutations.push(input.payload.args.ops) + }) await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) const deliverPrimary = (code: string, alt = false) => page.evaluate((input) => { window.shortcutFixture.deliver(input) @@ -131,6 +137,53 @@ it.each([ expect(await composer.innerText()).toBe('Desktop focus draft') await writeComposerDraft(page, composer, '') await expect.poll(() => composer.textContent()).toBe('') + const readFontSizes = () => page.evaluate(() => { + const style = document.body.style + return { + text: Number.parseInt(style.getPropertyValue('--dsh-content-font-size'), 10), + code: Number.parseInt(style.getPropertyValue('--dsh-code-font-size'), 10), + terminal: Number.parseInt(style.getPropertyValue('--dsh-terminal-font-size'), 10), + } + }) + const defaults = { text: 14, code: 11, terminal: 13 } + const patchPath = join(scaffold.harnessHome, 'profiles', 'scaffold', 'cordis.patch.yml') + const stepFontSizes = async (key: string, sizes: Awaited>): Promise => { + const before = fontMutations.length + const [response] = await Promise.all([ + page.waitForResponse((reply) => { + if (new URL(reply.url()).pathname !== '/api/settings/mutate' || reply.request().method() !== 'POST') return false + const input = reply.request().postDataJSON() as { payload: { args: { ns: string } } } + return input.payload.args.ns === 'ui-theme' + }), + page.keyboard.press(key), + ]) + expect(await response.finished()).toBeNull() + const envelope = await response.json() as { result: { ok: boolean } } + expect(envelope.result.ok).toBe(true) + const fields = [['fontSize', sizes.text], ['codeFontSize', sizes.code], ['terminalFontSize', sizes.terminal]] as const + expect(fontMutations.slice(before)).toEqual([fields.map(([field, value]) => ({ op: 'set', path: [field], value }))]) + await expect.poll(readFontSizes).toEqual(sizes) + for (const [field, value] of fields) { + await expect.poll(() => readFile(patchPath, 'utf8')).toContain(`${field}: ${value}`) + } + } + const initial = await readFontSizes() + expect(initial).toEqual(defaults) + await stepFontSizes(`${primary}+Equal`, { text: 15, code: 12, terminal: 14 }) + const afterEqual = await readFontSizes() + await stepFontSizes(`${primary}+Shift+Equal`, { text: 16, code: 13, terminal: 15 }) + const afterPlus = await readFontSizes() + await stepFontSizes(`${primary}+Minus`, { text: 15, code: 12, terminal: 14 }) + const afterMinus = await readFontSizes() + const warningStart = console.warnings.length + await page.reload({ waitUntil: 'load' }) + acknowledgeReloadConnectionLoss(console, warningStart) + await page.getByText('DONE', { exact: true }).waitFor() + await expect.poll(readFontSizes).toEqual(afterMinus) + const afterReload = await readFontSizes() + await composer.focus() + await stepFontSizes(`${primary}+Minus`, defaults) + const restored = await readFontSizes() const dialog = page.getByRole('dialog', { name: 'Keyboard shortcuts', exact: true }) const settings = page.getByRole('dialog', { name: 'Settings', exact: true }) const automaticFocusStyle = () => page.evaluate(() => { @@ -187,6 +240,12 @@ it.each([ expect(await dialog.isVisible()).toBe(false) await openReference() await dialog.waitFor() + const referenceOrder = await dialog.getByRole('listitem').evaluateAll(rows => rows + .map(row => row.querySelector(':scope > span')?.textContent) + .filter(label => label === 'Increase font size' || label === 'Decrease font size')) + expect(referenceOrder).toEqual(['Increase font size', 'Decrease font size']) + await compareOrRefreshGolden(join(expected, 'font-sizes.expected.md'), + `# Font sizes\n\n${JSON.stringify({ initial, afterEqual, afterPlus, afterMinus, afterReload, restored, referenceOrder }, null, 2)}`, mode) expect(await dialog.getByRole('searchbox').evaluate(node => node === document.activeElement)).toBe(true) expect(await automaticFocusStyle()).toEqual(noFocusRing) const referenceShadow = await dialog.evaluate(node => getComputedStyle(node).boxShadow) @@ -299,7 +358,8 @@ it.each([ await expect.poll(() => recorder.isEnabled()).toBe(true) await recorder.focus() const accepted = snapshot.document - for (const key of [`${primary}+Slash`, 'Escape', 'Enter', 'ArrowUp', 'ArrowDown', 'Shift+Enter', `${primary}+Enter`, 'Slash', 'Shift+Digit2']) { + const mutationsBeforeRecording = fontMutations.length + for (const key of [`${primary}+Equal`, `${primary}+Shift+Equal`, `${primary}+Minus`, `${primary}+Slash`, 'Escape', 'Enter', 'ArrowUp', 'ArrowDown', 'Shift+Enter', `${primary}+Enter`, 'Slash', 'Shift+Digit2']) { expect(await recorder.evaluate(node => node === document.activeElement)).toBe(true) const keys = key.split('+') for (const held of keys) await page.keyboard.down(held) @@ -310,6 +370,12 @@ it.each([ if (key === `${primary}+Slash`) { await dialog.getByText('Already used by “Open keyboard shortcuts”', { exact: true }).waitFor() } + if (key.endsWith('Equal') || key.endsWith('Minus')) { + const command = key.endsWith('Minus') ? 'Decrease font size' : 'Increase font size' + await dialog.getByText(`Already used by “${command}”`, { exact: true }).waitFor() + } + expect(await readFontSizes()).toEqual(defaults) + expect(fontMutations).toHaveLength(mutationsBeforeRecording) expect(await recorder.evaluate(node => node === document.activeElement)).toBe(true) expect(snapshot.document).toEqual(accepted) expect(await dialog.getByRole('listitem').evaluateAll(rows => rows.map(row => row.getBoundingClientRect().height))) diff --git a/apps/web/tests/shortcuts-workspace.e2e.ts b/apps/web/tests/shortcuts-workspace.e2e.ts index 3b1e0db1b0..3bb20a0e96 100644 --- a/apps/web/tests/shortcuts-workspace.e2e.ts +++ b/apps/web/tests/shortcuts-workspace.e2e.ts @@ -146,8 +146,11 @@ describe.skipIf(mode === 'record')('web e2e: workspace shortcuts', () => { await workspaceRow.getByText(workspaceLabel, { exact: true }).hover() await details.waitFor() await workspaceRow.getByRole('button', { name: `New session in ${workspaceLabel}`, exact: true }).hover() - await page.getByRole('tooltip').filter({ hasText: 'New Session' }).waitFor() - await expect.poll(() => details.count()).toBe(0) + const newSessionTooltip = page.getByRole('tooltip').filter({ hasText: 'New Session' }) + await newSessionTooltip.waitFor() + expect(await details.isVisible()).toBe(true) + expect(await newSessionTooltip.evaluate(element => Number(getComputedStyle(element).zIndex))) + .toBeGreaterThan(await details.evaluate(element => Number(getComputedStyle(element).zIndex))) await workspaceRow.getByText(workspaceLabel, { exact: true }).hover() await details.waitFor() await page.mouse.move(700, 100) diff --git a/apps/web/tests/shortcuts.e2e.ts b/apps/web/tests/shortcuts.e2e.ts index 7a49455c70..eadc973bff 100644 --- a/apps/web/tests/shortcuts.e2e.ts +++ b/apps/web/tests/shortcuts.e2e.ts @@ -19,9 +19,9 @@ describe('web e2e: shortcut reference', () => { afterAll(async () => { await browser?.close(); await scaffold?.close() }) it.each([ - { locale: 'zh-CN', platform: 'MacIntel', title: '快捷键', settings: '设置', view: '编辑快捷键', search: '搜索快捷键', key: 'Meta' }, - { locale: 'en-US', platform: 'Win32', title: 'Keyboard shortcuts', settings: 'Settings', view: 'Edit shortcuts', search: 'Search shortcuts', key: 'Control' }, - ])('opens, searches, and restores nested focus in $locale', async ({ locale, platform, title, settings, view, search, key }) => { + { locale: 'zh-CN', platform: 'MacIntel', title: '快捷键', accountMenu: '账号菜单', settings: '设置', view: '编辑快捷键', search: '搜索快捷键', key: 'Meta' }, + { locale: 'en-US', platform: 'Win32', title: 'Keyboard shortcuts', accountMenu: 'Account menu', settings: 'Settings', view: 'Edit shortcuts', search: 'Search shortcuts', key: 'Control' }, + ])('opens, searches, and restores nested focus in $locale', async ({ locale, platform, title, accountMenu, settings, view, search, key }) => { const referenceKey = platform === 'Win32' ? 'Control+Slash' : 'Meta+Slash' const context = await browser.newContext({ locale, colorScheme: 'light', viewport: { width: 1440, height: 1000 } }) try { @@ -31,12 +31,12 @@ describe('web e2e: shortcut reference', () => { const console = watchConsole(page) await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) - const trigger = page.getByRole('button', { name: settings, exact: true }) - expect(await trigger.getAttribute('aria-keyshortcuts')).toBe(key === 'Meta' ? 'Alt+Meta+,' : 'Control+Alt+,') - const settingsHint = `${settings} ${key === 'Meta' ? '⌥ ⌘ ,' : 'Ctrl + Alt + ,'}` - await trigger.hover() - await page.getByRole('tooltip', { name: settingsHint, exact: true }).waitFor() + const trigger = page.getByRole('button', { name: accountMenu, exact: true }) await trigger.click() + const settingsItem = page.getByRole('menuitem', { name: settings, exact: true }) + expect(await settingsItem.getAttribute('aria-keyshortcuts')).toBe(key === 'Meta' ? 'Alt+Meta+,' : 'Control+Alt+,') + expect(await settingsItem.innerText()).toMatch(key === 'Meta' ? /⌥\s*⌘\s*,$/ : /Ctrl\s*\+\s*Alt\s*\+\s*,$/) + await settingsItem.click() await page.getByRole('button', { name: view, exact: true }).click() const dialog = page.getByRole('dialog', { name: title, exact: true }) await dialog.waitFor() @@ -297,10 +297,11 @@ describe('web e2e: shortcut reference', () => { await page.keyboard.press(`${key}+Shift+Comma`) await dialog.getByRole('group').waitFor({ state: 'hidden' }) await page.keyboard.press('Escape') - expect(await trigger.getAttribute('aria-keyshortcuts')).toBe(key === 'Meta' ? 'Shift+Meta+,' : 'Control+Shift+,') - await trigger.hover() - await page.getByRole('tooltip', { name: `${settings} ${key === 'Meta' ? '⇧ ⌘ ,' : 'Ctrl + Shift + ,'}`, exact: true }).waitFor() await trigger.click() + expect(await settingsItem.getAttribute('aria-keyshortcuts')).toBe(key === 'Meta' ? 'Shift+Meta+,' : 'Control+Shift+,') + expect(await settingsItem.innerText()).toMatch(key === 'Meta' ? /⇧\s*⌘\s*,$/ : /Ctrl\s*\+?\s*Shift\s*\+?\s*,$/) + await settingsItem.click() + await page.getByRole('dialog', { name: settings, exact: true }).waitFor() await page.keyboard.press('Escape') expect(await trigger.evaluate(element => element === document.activeElement)).toBe(true) await page.keyboard.press(referenceKey) @@ -308,9 +309,10 @@ describe('web e2e: shortcut reference', () => { await dialog.getByRole('button', { name: locale === 'zh-CN' ? '恢复默认' : 'Restore default', exact: true }).click() await dialog.getByRole('group').waitFor({ state: 'hidden' }) await page.keyboard.press('Escape') - expect(await trigger.getAttribute('aria-keyshortcuts')).toBe(key === 'Meta' ? 'Alt+Meta+,' : 'Control+Alt+,') - await trigger.hover() - await page.getByRole('tooltip', { name: settingsHint, exact: true }).waitFor() + await trigger.click() + expect(await settingsItem.getAttribute('aria-keyshortcuts')).toBe(key === 'Meta' ? 'Alt+Meta+,' : 'Control+Alt+,') + await page.keyboard.press('Escape') + await settingsItem.waitFor({ state: 'detached' }) const sidebar = page.getByRole('button', { name: locale === 'zh-CN' ? '收起侧边栏' : 'Collapse sidebar', exact: true }) expect(await sidebar.getAttribute('aria-keyshortcuts')).toBe(key === 'Meta' ? 'Alt+Meta+B' : 'Control+Alt+B') await page.reload({ waitUntil: 'load' }) @@ -390,7 +392,7 @@ describe('web e2e: shortcut reference', () => { await context.addInitScript((value) => { Object.defineProperty(navigator, 'platform', { value }) }, platform) const page = await context.newPage() await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) - await page.getByRole('button', { name: 'Settings', exact: true }).waitFor() + await page.getByRole('button', { name: 'Account menu', exact: true }).waitFor() const referenceKey = `${modifier}+Slash` const open = async () => { await page.keyboard.press(referenceKey) @@ -412,7 +414,7 @@ describe('web e2e: shortcut reference', () => { await page.keyboard.press(sidebarKey) await page.getByRole('button', { name: 'Open sidebar', exact: true }).waitFor() await page.reload({ waitUntil: 'load' }) - await page.getByRole('button', { name: 'Settings', exact: true }).waitFor() + await page.getByRole('button', { name: 'Account menu', exact: true }).waitFor() const toggleAfterReload = page.getByRole('button', { name: /^(Open|Collapse) sidebar$/ }) await expect.poll(() => toggleAfterReload.getAttribute('aria-keyshortcuts')) .toBe(modifier === 'Meta' ? 'Shift+Meta+.' : 'Control+Shift+.') @@ -427,7 +429,7 @@ describe('web e2e: shortcut reference', () => { await editor.getByRole('button', { name: 'Press a shortcut', exact: true }).focus() const other = await context.newPage() await other.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) - await other.getByRole('button', { name: 'Settings', exact: true }).waitFor() + await other.getByRole('button', { name: 'Account menu', exact: true }).waitFor() await other.keyboard.press(referenceKey) await other.getByRole('button', { name: 'Edit shortcut for Toggle left sidebar', exact: true }).click() await other.getByRole('button', { name: 'Remove', exact: true }).click() diff --git a/apps/web/tests/sidebar-terminal.e2e.ts b/apps/web/tests/sidebar-terminal.e2e.ts index 80562071ad..a8173b6d27 100644 --- a/apps/web/tests/sidebar-terminal.e2e.ts +++ b/apps/web/tests/sidebar-terminal.e2e.ts @@ -286,6 +286,10 @@ describe.skipIf(process.platform === 'win32')('Web sidebar terminal', () => { it('completes commands, preserves the process through collapse and reload, resizes, and kills on tab close', async () => { onTestFailed(() => saveFailureShot(page, 'sidebar-terminal')) + const unaryInput: string[] = [] + page.on('request', (request) => { + if (/\/api\/terminal\/(write|resize|follow)$/u.test(new URL(request.url()).pathname)) unaryInput.push(request.url()) + }) await openTerminal(page) const terminal = page.locator('[data-sidebar-terminal]') await command(page, "PS1=''; printf '\\033cTERMINAL_READY\\n'") @@ -370,6 +374,7 @@ describe.skipIf(process.platform === 'win32')('Web sidebar terminal', () => { await expect.poll(() => alive(firstProcess), { timeout: 10_000 }).toBe(false) await expect.poll(() => descendants.some(alive), { timeout: 10_000 }).toBe(false) await expect.poll(() => scaffold.ctx.terminalController.list(scaffold.ctx.agents.list()[0]!.id).length).toBe(0) + expect(unaryInput).toEqual([]) expect(tripwire.pageErrors).toEqual([]) }) @@ -554,6 +559,107 @@ describe.skipIf(process.platform === 'win32')('Web sidebar terminal', () => { expect(tripwire.pageErrors).toEqual([]) }) + it('validates terminal uplink frames and keeps a read-only attachment following output', async () => { + await openTerminal(page) + const sessionId = scaffold.ctx.agents.list()[0]!.id + const terminalId = scaffold.ctx.terminalController.list(sessionId)[0]!.id + const result = await page.evaluate(async ({ sessionId, terminalId }) => { + const socket = new WebSocket(new URL('/api/remote.mux', location.href).href.replace(/^http/u, 'ws')) + const frames: { + type: string + streamId: string + value?: { + type: string + data?: string + sequence?: number + writable?: boolean + refusal?: string + inputWindow?: { maxBytes: number; frameOverheadBytes: number } + } + error?: { code: string } + }[] = [] + const waiters = new Set<() => void>() + socket.addEventListener('message', (event) => { + frames.push(JSON.parse(String(event.data)) as (typeof frames)[number]) + for (const wake of waiters) wake() + }) + const wait = async (predicate: () => boolean) => { + if (predicate()) return + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { waiters.delete(inspect); reject(new Error('Terminal wire event timed out')) }, 10_000) + const inspect = () => { if (predicate()) { clearTimeout(timer); waiters.delete(inspect); resolve() } } + waiters.add(inspect) + }) + } + const send = (frame: object) => { socket.send(JSON.stringify(frame)) } + const open = (streamId: string) => { send({ type: 'open', streamId, endpoint: 'terminal/attach', payload: { args: { agentId: sessionId, id: terminalId } } }) } + await new Promise((resolve, reject) => { + socket.addEventListener('open', () => { resolve() }, { once: true }) + socket.addEventListener('error', () => { reject(new Error('Terminal socket failed')) }, { once: true }) + }) + try { + open('first') + await wait(() => frames.some(frame => frame.streamId === 'first' && frame.value?.type === 'snapshot')) + open('second') + await wait(() => frames.some(frame => frame.streamId === 'second' && frame.value?.type === 'snapshot')) + send({ type: 'item', streamId: 'first', value: { type: 'write', sequence: 1, data: 'IGNORED' } }) + await wait(() => frames.some(frame => frame.streamId === 'first' && frame.value?.type === 'ack')) + send({ type: 'item', streamId: 'second', value: { type: 'write', sequence: 1, data: "printf 'DUPLEX_%s\\n' PROOF\r" } }) + await wait(() => frames.filter(frame => frame.streamId === 'first').map(frame => frame.value?.data ?? '').join('').includes('DUPLEX_PROOF')) + send({ type: 'item', streamId: 'second', value: { type: 'write', sequence: 2, data: 42 } }) + await wait(() => frames.some(frame => frame.streamId === 'second' && frame.type === 'error')) + return frames + } finally { + socket.close() + await new Promise((resolve) => { socket.addEventListener('close', () => { resolve() }, { once: true }) }) + } + }, { sessionId, terminalId }) + const window = result.find(frame => frame.streamId === 'first' && frame.value?.type === 'snapshot')?.value?.inputWindow + expect(window).toEqual({ + maxBytes: 262_144, + frameOverheadBytes: Buffer.byteLength(JSON.stringify({ type: 'item', streamId: 'first', value: null }), 'utf8') - 'null'.length, + }) + expect(result.some(frame => frame.streamId === 'first' && frame.value?.type === 'state' && !frame.value.writable)).toBe(true) + expect(result.find(frame => frame.streamId === 'first' && frame.value?.type === 'ack')?.value) + .toEqual({ type: 'ack', sequence: 1, refusal: 'read-only' }) + expect(result.find(frame => frame.streamId === 'second' && frame.type === 'error')?.error?.code).toBe('gateway/input-invalid') + expect(handles).toHaveLength(1) + expect(tripwire.pageErrors).toEqual([]) + }) + + it('delivers a short-key burst through the real mux while the provider pauses input', async () => { + onTestFailed(() => saveFailureShot(page, 'sidebar-terminal-input-window')) + await openTerminal(page) + const handle = handles[0]! + const write = handle.write.bind(handle) + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + const burst = `printf 'BUFFER_%s\\n' OK # ${'a'.repeat(3000)}` + let delivered = '' + const paused = vi.spyOn(handle, 'write').mockImplementation(async (data) => { + entered.resolve(undefined) + await release.promise + await write(data) + delivered += data + }) + try { + await page.locator('.xterm-helper-textarea:visible').click() + await page.keyboard.type(burst) + await page.keyboard.press('Enter') + await entered.promise + expect(paused).toHaveBeenCalledTimes(1) + release.resolve(undefined) + // The concurrent browser lane needs separate completion waits for queued input and rendered output. + await expect.poll(() => delivered.endsWith('\r'), { timeout: 30_000, message: 'The queued Enter must reach the PTY' }).toBe(true) + expect(delivered).toBe(`${burst}\r`) + await page.locator('.xterm-rows:visible').filter({ hasText: 'BUFFER_OK' }).waitFor({ timeout: 30_000 }) + expect(handles).toHaveLength(1) + expect(tripwire.pageErrors).toEqual([]) + } finally { + release.resolve(undefined) + } + }) + it('offers reconnection after transport loss and resumes the same process without a new terminal', async () => { onTestFailed(() => saveFailureShot(page, 'sidebar-terminal-reconnect')) await openTerminal(page) diff --git a/apps/web/tests/smoke-real.e2e.ts b/apps/web/tests/smoke-real.e2e.ts index 31f102bff7..cf1546ccf2 100644 --- a/apps/web/tests/smoke-real.e2e.ts +++ b/apps/web/tests/smoke-real.e2e.ts @@ -476,8 +476,13 @@ describe('dsh web keyless CLI smoke', () => { .flatMap(message => message.content ?? []) .find(block => block.type === 'text' && block.text?.includes('web-workspace-context-probe')) const expectedWebSection = readFileSync(WEB_SURFACE_PROMPT, 'utf8').trimEnd() - .replace('{{webUrl}}', new URL(baseUrl).origin) expect(captured.system).toContain(expectedWebSection) + expect(captured.system).not.toContain(new URL(baseUrl).origin) + expect(captured.messages?.filter(message => message.role === 'user') + .flatMap(message => message.content ?? []) + .some(block => block.type === 'text' + && block.text?.includes(`Current DeepSeek Harness Web GUI URL: ${JSON.stringify(new URL(baseUrl).origin)}.`))) + .toBe(true) expect(workspaceMessage).toMatchInlineSnapshot(` { "text": " diff --git a/apps/web/tests/subagent-fork-schedule.snapshot.ts b/apps/web/tests/subagent-fork-schedule.snapshot.ts new file mode 100644 index 0000000000..62f42cb69a --- /dev/null +++ b/apps/web/tests/subagent-fork-schedule.snapshot.ts @@ -0,0 +1,105 @@ +/** Forked Web agents inherit the standard preset and refuse every reminder tool. */ +import { readFile, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { expect, it } from 'vitest' +import { createUserMessage } from '@deepseek-ai/dsh-llm' +import { parseSessionLog, prepareSessionSnapshotFixtureForComparison } from '@deepseek-ai/dsh-llm-replay' +import type { Session } from '@deepseek-ai/dsh-session' +import { + fixtureContext, normalizeSessionSnapshots, normalizedSystemPrompts, normalizedToolSchemas, + formatSystemPromptSnapshot, parseToolSchemasSnapshot, redactSessionSnapshotIds, refreshFixtureReplacements, + scrubSessionSnapshot, stabilizeFixtureMessageIds, stabilizeRefreshLog, tokenizeSessionFixtureCwd, +} from '@deepseek-ai/dsh-session-snapshot' +import type {} from '@deepseek-ai/dsh-api-session-controller' +import type {} from '@deepseek-ai/dsh-schedule' +import type {} from '@deepseek-ai/dsh-subagent' +import { + fixtureUserPrompts, launchWebScaffold, normalizeWebSessionVolatiles, rawSessionLog, readPersistedEvents, + recordedSessionFixturePath, selectedSessionFixture, webSnapshotMode, +} from './scaffold.ts' +import { REPO_ROOT } from './support.ts' + +const DIR = fileURLToPath(new URL('../../../snapshots/web/subagent-fork-schedule', import.meta.url)) +const PIN = join(DIR, '../fresh-round-trip') +const MODE = webSnapshotMode() + +it.skipIf(MODE === 'record')('replays inherited schedule tools and refusals in a seeded Web fork', async () => { + const paths = await Promise.all(['session.v4.jsonl', 'session.1.v4.jsonl'] + .map(file => selectedSessionFixture(join(DIR, file)))) + let expected = await Promise.all(paths.map(path => readFile(path, 'utf8'))) + const scaffold = await launchWebScaffold({ + replayFixture: paths[0]!, replayChildFixtures: [paths[1]!], compareReplaySession: false, + }) + try { + const sessions: Session[] = [] + scaffold.ctx.on('session/created', (session) => { sessions.push(session) }) + const { sessionId } = await scaffold.ctx.sessionController.create({ cwd: scaffold.workspaceCwd }) + const parent = scaffold.ctx.agents.get(sessionId) + if (parent === undefined) throw new Error('created parent has no Agent') + const prompt = fixtureUserPrompts(expected[0]!)[0] + if (prompt === undefined) throw new Error('parent fixture has no user prompt') + parent.followup(createUserMessage({ content: [{ type: 'text', text: prompt }], source: { kind: 'user' } })) + await parent.whenIdle() + + const childPrompt = parseSessionLog(expected[1]!).findLast(event => event.type === 'user/message' + && event.data.source.kind === 'user') + if (childPrompt?.type !== 'user/message') throw new Error('child fixture has no user prompt') + const activation = await scaffold.ctx.subagents.startActivation({ + provider: 'fork', label: 'Check child reminder refusal', delivery: 'caller', + signal: new AbortController().signal, + request: { parent, prompt: [...childPrompt.data.content] }, + }) + try { + await activation.result + } finally { + await activation.dispose() + } + await scaffold.ctx.sessions.flush(parent.session) + expect(sessions).toHaveLength(2) + const child = sessions[1]! + expect(child.header).toMatchObject({ parentSession: parent.id, isSeeded: true, agentPreset: 'standard' }) + expect(child.inheritedEventCount).toBeGreaterThan(0) + expect(child.requestHeader()).toEqual(parent.session.requestHeader()) + + for (const session of sessions) { + expect(await readPersistedEvents(scaffold, session.id)).toEqual(session.snapshotEvents()) + } + const raw = sessions.map(session => normalizeWebSessionVolatiles(rawSessionLog(session), scaffold.workspaceCwd)) + const context = { sessionIds: sessions.map(session => String(session.id)), cwd: scaffold.workspaceCwd } + const schemas = parseToolSchemasSnapshot(await readFile(join(PIN, 'tool-schemas.expected.json'), 'utf8')) + const promptPin = await readFile(join(PIN, 'system-prompt.expected.md'), 'utf8') + for (const log of raw) { + const headers = normalizedToolSchemas(log, context) + expect(headers.length).toBeGreaterThan(0) + for (const tools of headers) expect(tools).toEqual(schemas.initial) + for (const prompt of normalizedSystemPrompts(log, context)) { + expect(formatSystemPromptSnapshot(prompt.split(REPO_ROOT).join('{{sourceRoot}}') + .split(scaffold.baseUrl).join('{{webUrl}}'))).toBe(promptPin) + } + } + const results = child.snapshotEvents().slice(child.inheritedEventCount) + .filter(event => event.type === 'tool/result') + expect(results.map(event => event.data.message.content)).toEqual(Array.from({ length: 4 }, () => [ + { type: 'text', text: '{"code":"subagent_session","message":"A delegated subagent cannot use reminders."}' }, + ])) + expect(await scaffold.ctx.schedule.list({ sessionId: child.id })).toEqual([]) + + if (MODE === 'refresh') { + const replacements = refreshFixtureReplacements(raw.map((content, index) => ({ + id: sessions[index]!.id, createdAt: sessions[index]!.header.createdAt, content, + })), expected) + expected = redactSessionSnapshotIds(stabilizeFixtureMessageIds(raw.map((log, index) => + tokenizeSessionFixtureCwd(scrubSessionSnapshot(stabilizeRefreshLog( + prepareSessionSnapshotFixtureForComparison(log), expected[index]!, replacements, context, + ))).split(scaffold.harnessHome).join('{{harnessHome}}')), expected)) + await Promise.all(expected.map((log, index) => writeFile( + recordedSessionFixturePath(paths[index]!, sessions[index]!.header.version), log, + ))) + } + expect(normalizeSessionSnapshots(raw, context)) + .toEqual(normalizeSessionSnapshots(expected, fixtureContext(expected[0]!))) + } finally { + await scaffold.close() + } +}) diff --git a/apps/web/tests/support.ts b/apps/web/tests/support.ts index 021b10e569..d60a81dba5 100644 --- a/apps/web/tests/support.ts +++ b/apps/web/tests/support.ts @@ -327,16 +327,11 @@ export function conversationContextKey(kind: string, id: string): string { return `${kind.length}:${kind}${id}` } -/** Open Settings through the Web gear or Desktop account menu. +/** Open Settings through the sidebar account menu. * @param page - browser page with the mounted sidebar. * @param locale - current UI language. */ export async function openSettings(page: Page, locale: 'en' | 'zh'): Promise { - const label = locale === 'zh' ? '设置' : 'Settings' - if (await page.evaluate(() => 'dshDesktop' in globalThis)) { - await page.getByRole('button', { name: locale === 'zh' ? '账号菜单' : 'Account menu', exact: true }).click() - await page.getByRole('menuitem', { name: label, exact: true }).click() - } else { - await page.getByRole('button', { name: label, exact: true }).click() - } + await page.getByRole('button', { name: locale === 'zh' ? '账号菜单' : 'Account menu', exact: true }).click() + await page.getByRole('menuitem', { name: locale === 'zh' ? '设置' : 'Settings', exact: true }).click() } diff --git a/apps/web/tests/user-text-secret-mask.e2e.ts b/apps/web/tests/user-text-secret-mask.e2e.ts new file mode 100644 index 0000000000..b45755e04f --- /dev/null +++ b/apps/web/tests/user-text-secret-mask.e2e.ts @@ -0,0 +1,74 @@ +/** A cold-seeded user message that carries a credential draws it masked behind its reveal control. */ +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { chromium, type Browser, type Page } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { + assertFixtureInventory, compareOrRefreshGolden, fixtureUserPrompts, + launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { newEnglishPage, saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/user-text-secret-mask', import.meta.url)) +// Borrowed read-only: this scenario needs any settled user message, not a new recording. +const SEED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v3.jsonl', import.meta.url)) +const MODE = webSnapshotMode() +const RECORDED_PROMPT = 'Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop.' +// Assembled at run time so the repository holds no literal in a credential format. +const KEY = ['sk', '-', '0123456789abcdef0123456789abcdef'].join('') +const PROMPT = `${KEY} is the key for this task.` + +describe('web e2e: credential mask in user text', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + scaffold = await launchWebScaffold({}) + const raw = (await readFile(SEED, 'utf8')).replace(RECORDED_PROMPT, PROMPT) + expect(fixtureUserPrompts(raw), 'adapted seed must carry the credential prompt').toContain(PROMPT) + await seedSession(scaffold, raw, 'user-text-secret-mask') + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.locator('[role="treeitem"]').first().click() + await page.locator('[role="treeitem"]').nth(1).click() + await page.getByRole('button', { name: 'Show secret', exact: true }).waitFor() + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('masks the credential in the bubble and reveals it from its own control', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-user-text-secret-mask')) + const text = page.locator('[class*="bubble"]', { has: page.locator('[data-secret]') }) + await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'bubble.expected.md'), await text.ariaSnapshot(), MODE) + expect(await text.textContent()).not.toContain(KEY) + const run = page.locator('[data-secret]') + // The mask marks and the control share one vertical center. + const centers = await run.evaluate((box) => { + const middle = (element: Element): number => { + const rect = element.getBoundingClientRect() + return rect.top + rect.height / 2 + } + const [value, control] = [...box.children] + return { mask: middle(value!.lastElementChild!), control: middle(control!) } + }) + expect(Math.abs(centers.control - centers.mask)).toBeLessThanOrEqual(1) + const masked = { run: await run.boundingBox(), bubble: await text.boundingBox() } + await page.getByRole('button', { name: 'Show secret', exact: true }).click() + await expect.poll(() => text.textContent()).toBe(PROMPT) + // The monospace run keeps one mask character per original character, so revealing does not move the line. + expect({ run: await run.boundingBox(), bubble: await text.boundingBox() }).toEqual(masked) + await page.getByRole('button', { name: 'Hide secret', exact: true }).click() + await expect.poll(() => text.textContent()).not.toContain(KEY) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + await assertFixtureInventory(SNAPSHOT_DIR, ['bubble.expected.md']) + }) +}) diff --git a/apps/web/tests/workspace-management.e2e.ts b/apps/web/tests/workspace-management.e2e.ts index d97a0bd6b9..1ed57cde75 100644 --- a/apps/web/tests/workspace-management.e2e.ts +++ b/apps/web/tests/workspace-management.e2e.ts @@ -17,7 +17,7 @@ import { join, sep } from 'node:path' import type { Browser, Locator, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' -import { SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session' +import { SessionId } from '@deepseek-ai/dsh-session' import { logPath } from '../../../packages/session/session-persistence-jsonl/src/format.ts' import { acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, @@ -507,21 +507,21 @@ describe('web e2e: workspace management (create / rename / grouping / hover affo * blank Session from a deleted Workspace may also be visible, without actions. * @returns the session row locator, already present. */ - async function seededSessionRow() { - const ungroupedRow = page.getByText('Ungrouped', { exact: true }).locator('..').locator('..') + async function seededSessionRow(target = page) { + const ungroupedRow = target.getByText('Ungrouped', { exact: true }).locator('..').locator('..') const ungroupedSection = ungroupedRow.locator('..') // Initial-current auto-expansion can race this gesture; converge on // expanded rather than assuming which update wins first. await expect.poll(async () => { if (await ungroupedRow.getAttribute('aria-expanded') !== 'true') { - await page.getByText('Ungrouped', { exact: true }).click() - await page.waitForTimeout(50) + await target.getByText('Ungrouped', { exact: true }).click() + await target.waitForTimeout(50) } return await ungroupedRow.getAttribute('aria-expanded') }, { timeout: 5_000 }).toBe('true') // CSS includes the actions button while it is hidden until row hover. const rows = ungroupedSection.locator('[role="treeitem"]') - .filter({ has: page.locator('button[aria-label^="Session actions for "]') }) + .filter({ has: target.locator('button[aria-label^="Session actions for "]') }) await expect.poll(() => rows.count(), { timeout: 10_000 }).toBe(1) return rows.first() } @@ -556,11 +556,247 @@ describe('web e2e: workspace management (create / rename / grouping / hover affo expect(await copied.isVisible()).toBe(true) expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(rowTitle) // Leaving anchor and card together closes it after the grace. - await page.getByRole('button', { name: 'Settings' }).hover() + await page.getByRole('button', { name: 'Account menu', exact: true }).hover() await expect.poll(() => card.count(), { timeout: 5_000 }).toBe(0) expect(tripwire.pageErrors).toEqual([]) }, 60_000) + it('keeps session details beside action tooltips with the tooltips above the card', async () => { + const hoverPage = await newEnglishPage(browser) + try { + const consoleWatch = watchConsole(hoverPage) + await hoverPage.goto(scaffold.authenticatedUrl) + const row = await seededSessionRow(hoverPage) + const title = row.locator('[class*="title"]') + const card = hoverPage.getByRole('button', { name: `Copy: ${await title.innerText()}` }) + const tooltip = hoverPage.getByRole('tooltip') + const observations: { stage: string; details: boolean; tooltips: string[] }[] = [] + const observe = async (stage: string): Promise => { + observations.push({ stage, details: await card.count() > 0, tooltips: await tooltip.allTextContents() }) + } + const clockStart = Date.now() + await hoverPage.clock.install({ time: clockStart }) + await hoverPage.clock.pauseAt(clockStart + 1000) + await title.hover() + await hoverPage.clock.runFor(800) + await card.waitFor() + await observe('details') + await row.getByRole('button', { name: /^Session actions for / }).hover() + await hoverPage.clock.runFor(1000) + expect(await card.count()).toBe(1) + await observe('more-hover') + + await row.getByRole('button', { name: 'Archive session', exact: true }).hover() + await hoverPage.clock.runFor(499) + expect(await card.count()).toBe(1) + expect(await tooltip.count()).toBe(0) + await observe('archive-before-dwell') + await hoverPage.clock.runFor(1) + await tooltip.filter({ hasText: 'Archive' }).waitFor() + expect(await card.count()).toBe(1) + expect(await tooltip.evaluate(el => Number(getComputedStyle(el).zIndex))) + .toBeGreaterThan(await card.evaluate(el => Number(getComputedStyle(el).zIndex))) + await observe('archive') + await row.getByRole('button', { name: 'Pin session', exact: true }).hover() + await hoverPage.clock.runFor(500) + await tooltip.filter({ hasText: 'Pin' }).waitFor() + expect(await card.count()).toBe(1) + await observe('pin') + + await title.hover() + await tooltip.waitFor({ state: 'detached' }) + // Advance beyond both the 800ms card dwell and the 500ms action tooltip dwell. + await hoverPage.clock.runFor(1600) + expect(await card.count()).toBe(1) + await observe('back-to-title') + + const menu = row.getByRole('button', { name: /^Session actions for / }) + await menu.click() + await hoverPage.getByRole('menu').waitFor() + expect(await card.count()).toBe(0) + await hoverPage.keyboard.press('Escape') + await hoverPage.getByRole('menu').waitFor({ state: 'detached' }) + await title.hover() + await hoverPage.clock.runFor(1600) + expect(await card.count()).toBe(0) + await observe('menu-closed') + + await hoverPage.getByRole('button', { name: 'Account menu', exact: true }).hover() + await title.hover() + await hoverPage.clock.runFor(799) + expect(await card.count()).toBe(0) + await hoverPage.clock.runFor(1) + await card.waitFor() + await observe('row-reentered') + await compareOrRefreshGolden( + fileURLToPath(new URL('./expected/session-row-hover/actions.expected.json', import.meta.url)), + JSON.stringify(observations, null, 2), MODE, + ) + expect(consoleWatch.pageErrors).toEqual([]) + } finally { + await hoverPage.close() + } + }, 60_000) + + it('shares one dwell and one visible card across Workspace and Session rows', async () => { + const hoverScaffold = await launchWebScaffold({}) + let ownedPage: Page | undefined + try { + for (const title of ['gamma-ws', 'beta-ws']) { + const cwd = join(hoverScaffold.workspaceCwd, title) + await mkdir(cwd, { recursive: true }) + await hoverScaffold.ctx.workspaceRegistry.create(cwd, title) + } + const sessionId = SessionId('mixed-hover-session') + await seedSession(hoverScaffold, await readFile(SEED, 'utf8'), sessionId) + await hoverScaffold.ctx.sessionController.rename({ sessionId, title: 'Mixed hover session' }) + const hoverPage = await newEnglishPage(browser) + ownedPage = hoverPage + const consoleWatch = watchConsole(hoverPage) + await hoverPage.goto(hoverScaffold.authenticatedUrl) + const session = await seededSessionRow(hoverPage) + const sessionTitle = await session.locator('[class*="title"]').innerText() + const workspace = (title: string) => hoverPage.locator('[data-row-key^="workspace:"]') + .filter({ hasText: title }).locator('[class*="title"]') + const first = workspace('gamma-ws') + const second = workspace('beta-ws') + const card = hoverPage.getByRole('button', { name: /^Copy: / }) + const observations: { stage: string; exclusive: boolean }[] = [] + const clockStart = Date.now() + await hoverPage.clock.install({ time: clockStart }) + await hoverPage.clock.pauseAt(clockStart + 1000) + await hoverPage.getByRole('button', { name: 'Account menu', exact: true }).hover() + await hoverPage.clock.runFor(300) + await first.hover() + await hoverPage.clock.runFor(799) + expect(await card.count()).toBe(0) + await hoverPage.clock.runFor(1) + const record = async (stage: string, title: string): Promise => { + // The clock stays paused: wait for React's commit without advancing another hover dwell. + await card.getByText(title, { exact: true }).waitFor() + expect(await card.count()).toBe(1) + expect(await card.getByText(title, { exact: true }).isVisible()).toBe(true) + observations.push({ stage, exclusive: true }) + } + await record('first-workspace-after-dwell', 'gamma-ws') + await second.hover() + await record('workspace-to-workspace-immediate', 'beta-ws') + await session.locator('[class*="title"]').hover() + await record('workspace-to-session-immediate', sessionTitle) + await first.hover() + await record('session-to-workspace-immediate', 'gamma-ws') + await hoverPage.getByRole('button', { name: 'Account menu', exact: true }).hover() + await hoverPage.clock.runFor(300) + expect(await card.count()).toBe(0) + await second.hover() + await hoverPage.clock.runFor(799) + expect(await card.count()).toBe(0) + await hoverPage.clock.runFor(1) + await record('list-reentered-after-dwell', 'beta-ws') + await compareOrRefreshGolden( + fileURLToPath(new URL('./expected/session-row-hover/mixed-rows.expected.json', import.meta.url)), + JSON.stringify(observations, null, 2), MODE, + ) + expect(consoleWatch.pageErrors).toEqual([]) + } finally { + try { await ownedPage?.close() } finally { await hoverScaffold.close() } + } + }, 60_000) + + it('shares the first session dwell across each list and its active hover card', async () => { + const hoverScaffold = await launchWebScaffold({}) + try { + const seed = await readFile(SEED, 'utf8') + await seedSession(hoverScaffold, seed, 'hover-first') + await seedSession(hoverScaffold, seed, 'hover-second') + await hoverScaffold.ctx.sessionController.rename({ sessionId: SessionId('hover-first'), title: 'Hover first' }) + await hoverScaffold.ctx.sessionController.rename({ sessionId: SessionId('hover-second'), title: 'Hover second' }) + for (const layout of ['In one list', 'Workspace Tree']) { + // Each page owns a browser context, so layout preferences and paused clocks cannot leak between layouts. + const ui = await newEnglishPage(browser) + try { + const consoleWatch = watchConsole(ui) + await ui.context().grantPermissions(['clipboard-read', 'clipboard-write']) + await ui.goto(hoverScaffold.authenticatedUrl) + await ui.getByRole('button', { name: 'View options' }).click() + await ui.getByRole('menuitem', { name: layout, exact: true }).click() + if (layout !== 'In one list') { + const header = ui.getByText('Ungrouped', { exact: true }).locator('..').locator('..') + await expect.poll(async () => { + if (await header.getAttribute('aria-expanded') !== 'true') await ui.getByText('Ungrouped', { exact: true }).click() + return header.getAttribute('aria-expanded') + }).toBe('true') + } + const first = ui.locator('[data-row-key="session:hover-first"]').getByText('Hover first', { exact: true }) + const second = ui.locator('[data-row-key="session:hover-second"]').getByText('Hover second', { exact: true }) + await first.waitFor() + await second.waitFor() + const firstCard = ui.getByRole('button', { name: 'Copy: Hover first', exact: true }) + const secondCard = ui.getByRole('button', { name: 'Copy: Hover second', exact: true }) + const observations: { stage: string; first: boolean; second: boolean }[] = [] + const observe = async (stage: string): Promise => { + observations.push({ stage, first: await firstCard.count() > 0, second: await secondCard.count() > 0 }) + } + const clockStart = Date.now() + await ui.clock.install({ time: clockStart }) + await ui.clock.pauseAt(clockStart + 1000) + await first.hover() + await ui.clock.runFor(799) + expect(await firstCard.count()).toBe(0) + await observe('first-before-dwell') + await ui.clock.runFor(1) + await firstCard.waitFor() + await observe('first-shown') + await second.hover() + // Wait for React's commit with the clock paused: an immediate handoff must not need another dwell. + await secondCard.waitFor() + expect(await firstCard.count()).toBe(0) + await observe('second-instant') + await first.hover() + await firstCard.waitFor() + expect(await secondCard.count()).toBe(0) + await observe('first-instant') + + await firstCard.hover() + await ui.clock.runFor(500) + expect(await firstCard.isVisible()).toBe(true) + await observe('readable-portal') + await firstCard.click() + await ui.getByRole('status').getByText('Copied', { exact: true }).waitFor() + expect(await ui.evaluate(() => navigator.clipboard.readText())).toBe('Hover first') + await observe('copied-portal') + await second.hover() + await secondCard.waitFor() + expect(await firstCard.count()).toBe(0) + await observe('portal-return-instant') + + await secondCard.hover() + await ui.getByRole('button', { name: 'Account menu', exact: true }).hover() + await ui.clock.runFor(300) + await secondCard.waitFor({ state: 'detached' }) + expect(await firstCard.count()).toBe(0) + await observe('outside-cooled') + await first.hover() + await ui.clock.runFor(799) + expect(await firstCard.count()).toBe(0) + await observe('list-reentry-before-dwell') + await ui.clock.runFor(1) + await firstCard.waitFor() + await observe('list-reentry-shown') + await compareOrRefreshGolden( + fileURLToPath(new URL('./expected/session-row-hover/grouped.expected.json', import.meta.url)), + JSON.stringify(observations, null, 2), MODE, + ) + expect(consoleWatch.pageErrors).toEqual([]) + } finally { + await ui.close() + } + } + } finally { + await hoverScaffold.close() + } + }) + it('keeps an open row menu up while the pointer moves between trigger and list', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-row-menu')) const sessionRow = await seededSessionRow() @@ -586,7 +822,7 @@ describe('web e2e: workspace management (create / rename / grouping / hover affo await page.waitForTimeout(POINTER_HOLD_MS) expect(await page.getByRole('menuitem', { name: 'Rename' }).count()).toBe(1) // Pointer-leave dismissal still applies once the pointer genuinely leaves. - await page.getByRole('button', { name: 'Settings' }).hover() + await page.getByRole('button', { name: 'Account menu', exact: true }).hover() await expect.poll(() => page.getByRole('menuitem', { name: 'Rename' }).count(), { timeout: 5_000 }).toBe(0) expect(tripwire.pageErrors).toEqual([]) }, 60_000) @@ -779,11 +1015,10 @@ describe('web e2e: New Session after an outdated blank cache', () => { undefined, { createdAt: now - 60_000 }) const stored = await scaffold.ctx.sessionPersistence.stat(id) if (stored === undefined) throw new Error('seeded Session is missing') - // A durable log may advance after its last blank projection checkpoint. - // An unregistered Session writes only the checkpoint, leaving the persisted log intact. - const blank = scaffold.ctx.sessions.prepare(id, { - eventState: 'detached', seed: [], meta: stored.header, inheritedEventCount: SessionLogOffset(0), - }) + // A durable log may advance after its last blank projection checkpoint, + // which then records the cut before the first turn. An unregistered + // Session writes only the checkpoint, leaving the persisted log intact. + const blank = scaffold.ctx.sessions.prepare(id, { meta: stored.header }) await scaffold.ctx.sessionProjectionCache.write(blank) const workspace = await scaffold.ctx.workspaceRegistry.create(scaffold.workspaceCwd, 'New session regression') await workspace.attachSession(id) @@ -794,8 +1029,14 @@ describe('web e2e: New Session after an outdated blank cache', () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-new-session-stale-blank')) await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) - await page.getByRole('tab', { name: 'Chat', exact: true }).waitFor() const selected = page.locator('[role="treeitem"][aria-selected="true"]') + // Startup asks for a new conversation with the stale blank as its reuse + // candidate; the Host declines the started Session and creates a blank one. + await expect.poll(() => selected.innerText()).toBe('New Session') + await expect.poll(() => workspace.sessionIds.length).toBe(2) + // The declined Session's projections are reloaded, so its stale blank row reappears. + await page.getByRole('treeitem', { name: /^Use the read tool twice/ }).click() + await page.getByRole('tab', { name: 'Chat', exact: true }).waitFor() await expect.poll(() => selected.innerText()).not.toBe('New Session') const previous = (await selected.innerText()).split('\n')[0]! const before = await captureStableAria(page, '[role="tree"]', scaffold.workspaceCwd) @@ -803,7 +1044,8 @@ describe('web e2e: New Session after an outdated blank cache', () => { await page.getByRole('button', { name: 'New session', exact: true }).last().click() await expect.poll(() => selected.innerText()).toBe('New Session') await page.getByRole('tab', { name: 'Chat', exact: true }).waitFor({ state: 'hidden' }) - await expect.poll(() => workspace.sessionIds.length).toBe(2) + // New Session creates a fresh Session; the blank startup created stays a member. + await expect.poll(() => workspace.sessionIds.length).toBe(3) expect(workspace.sessionIds).toContain(id) await page.getByRole('treeitem').filter({ hasText: previous }).waitFor() const after = await captureStableAria(page, '[role="tree"]', scaffold.workspaceCwd) diff --git a/apps/web/tests/workspace-new-session-folding.e2e.ts b/apps/web/tests/workspace-new-session-folding.e2e.ts index 176af733b0..4a24a46e59 100644 --- a/apps/web/tests/workspace-new-session-folding.e2e.ts +++ b/apps/web/tests/workspace-new-session-folding.e2e.ts @@ -1,4 +1,4 @@ -/** Blank New Session folding through the shipped Web composition. */ +/** Blank New Session folding and the session watermark through the shipped Web composition. */ import { readFile } from 'node:fs/promises' import { basename, join } from 'node:path' @@ -107,7 +107,42 @@ describe('web e2e: blank New Session folding quota', () => { await expect.poll(() => sidebar.getByRole('treeitem').count()).toBe(18) await sidebar.getByRole('button', { name: 'Show less' }).click() await expect.poll(() => sidebar.getByRole('treeitem').count()).toBe(7) - await assertFixtureInventory(EXPECTED_DIR, ['sidebar.expected.md', 'first-batch.expected.md']) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }) + + it('raises the watermark to the open session, keeps it after leaving, and resets it on fold', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-workspace-session-watermark')) + const sidebar = page.getByRole('tree', { name: 'Sessions' }) + const workspaceTitle = basename(scaffold.workspaceCwd) + const sessions = () => sidebar.getByText('Untitled', { exact: true }).count() + await sidebar.getByRole('button', { name: 'Show 11 more sessions' }).click() + await expect.poll(sessions).toBe(10) + await sidebar.getByText('Untitled', { exact: true }).nth(8).click() + await sidebar.getByRole('button', { name: 'Show 6 more sessions' }).click() + await sidebar.getByRole('button', { name: 'Show 1 more sessions' }).click() + await expect.poll(sessions).toBe(16) + await sidebar.getByRole('button', { name: 'Show less' }).click() + await expect.poll(sessions).toBe(9) + await sidebar.getByRole('button', { name: 'Show 7 more sessions' }).waitFor() + + const workspaceRow = sidebar.getByRole('treeitem').first() + await workspaceRow.hover() + await page.getByRole('button', { name: `New session in ${workspaceTitle}` }).click() + await sidebar.getByText('New Session', { exact: true }).waitFor() + expect(await sessions()).toBe(9) + await workspaceRow.hover() + await compareOrRefreshGolden( + join(EXPECTED_DIR, 'watermark-kept.expected.md'), + await captureStableAria(page, '[role="tree"][aria-label="Sessions"]', scaffold.workspaceCwd), + MODE, + ) + + await workspaceRow.click() + await workspaceRow.click() + await expect.poll(sessions).toBe(5) + await sidebar.getByRole('button', { name: 'Show 11 more sessions' }).waitFor() + await assertFixtureInventory(EXPECTED_DIR, ['sidebar.expected.md', 'first-batch.expected.md', 'watermark-kept.expected.md']) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) }) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index f9269cd333..9103d289a5 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -23,6 +23,8 @@ // cannot see both sides of the cordis Context merges). "exclude": [ "tests/collapse-timing.e2e.ts", + "tests/notice-wake-tail.e2e.ts", + "tests/fold-blank-matrix.e2e.ts", "tests/chat-flow-layout.e2e.ts", "tests/layout-bottom.e2e.ts", "tests/shortcuts-desktop.e2e.ts", @@ -51,6 +53,7 @@ "tests/minimal-preset.snapshot.ts", "tests/message-feedback-protocol.snapshot.ts", "tests/preset-migration.snapshot.ts", + "tests/subagent-fork-schedule.snapshot.ts", "tests/live-interactions.e2e.ts", "tests/question-composer.e2e.ts", "tests/approval-composer.e2e.ts", @@ -74,9 +77,11 @@ "tests/excel-opc.e2e.ts", "tests/office-fixture.ts", "tests/sidebar-browser.e2e.ts", + "tests/jobs-menu-layering.e2e.ts", "tests/plugin-config.e2e.ts", "tests/input-plugin-extensions.e2e.ts", "tests/plugin-manager.e2e.ts", + "tests/schedule-header.e2e.ts", "tests/worktree-opt-in.e2e.ts", "tests/session-inspector.e2e.ts", "tests/shared-inspector.e2e.ts", @@ -85,6 +90,7 @@ "tests/inspector-source-build.e2e.ts", "tests/plugin-install-cancel.e2e.ts", "tests/plugin-install-approve.e2e.ts", + "tests/plugin-hmr-lifecycle.e2e.ts", "tests/plugin-install-registry.e2e.ts", "tests/plugin-install-github.e2e.ts", "tests/client-plugin-live.e2e.ts", @@ -113,6 +119,7 @@ "tests/onboarding-native.e2e.ts", "tests/desktop-onboarding.e2e.ts", "tests/bonus-notice.e2e.ts", + "tests/account-login.e2e.ts", "tests/onboarding-usable-provider.e2e.ts", "tests/remote-settings.e2e.ts", "tests/public-mount.e2e.ts", @@ -147,6 +154,7 @@ "tests/message-feedback.e2e.ts", "tests/markdown-images.e2e.ts", "tests/markdown-file-links.e2e.ts", + "tests/user-text-secret-mask.e2e.ts", "tests/reference-composer.e2e.ts", "tests/markdown-wide-table.e2e.ts", "tests/thinking-markdown.e2e.ts", @@ -158,6 +166,7 @@ "tests/idle-submission-handoff.e2e.ts", "tests/cold-blank-session.e2e.ts", "tests/blank-writer-reuse.e2e.ts", + "tests/blank-preset-reuse.e2e.ts", "tests/queue-image.e2e.ts", "tests/skill-invocation-policy.e2e.ts", "tests/skill-user-invoke.e2e.ts", diff --git a/benchmarks/agent-continuation/agent-continuation.worker.ts b/benchmarks/agent-continuation/agent-continuation.worker.ts index 91faa5f347..b19cc2c43c 100644 --- a/benchmarks/agent-continuation/agent-continuation.worker.ts +++ b/benchmarks/agent-continuation/agent-continuation.worker.ts @@ -9,7 +9,7 @@ import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-test import { createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm' import { SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session' -import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' +import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import { assertBuiltBenchmarkRuntime } from '../support/built-worker.ts' import { PARENT_ID, response, resultText, syntheticHistory, TIME_ZERO, WORKLOAD } from './workload.ts' diff --git a/benchmarks/agent-continuation/child-catalog.worker.ts b/benchmarks/agent-continuation/child-catalog.worker.ts index 52e64fabf5..aedb279937 100644 --- a/benchmarks/agent-continuation/child-catalog.worker.ts +++ b/benchmarks/agent-continuation/child-catalog.worker.ts @@ -5,7 +5,7 @@ import { Context } from '@deepseek-ai/cordis' import { provideWorkingDirectoryFixture } from '@deepseek-ai/dsh-agent-loop-testkit' import SessionStore, { SESSION_FORMAT_VERSION, SessionId, SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' -import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' +import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SessionQueryEngine from '@deepseek-ai/dsh-session-query' import SubagentRuntime, { SUBAGENT_DESCRIPTOR_VERSION } from '@deepseek-ai/dsh-subagent' diff --git a/benchmarks/package.json b/benchmarks/package.json index 68ce69fa23..d36d79bfdb 100644 --- a/benchmarks/package.json +++ b/benchmarks/package.json @@ -15,10 +15,12 @@ "@deepseek-ai/dsh-agent-loop": "workspace:*", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:*", "@deepseek-ai/dsh-agent-preset-registry": "workspace:*", + "@deepseek-ai/dsh-api-remote-access": "workspace:*", "@deepseek-ai/dsh-api-session-controller": "workspace:*", "@deepseek-ai/dsh-brand": "workspace:*", "@deepseek-ai/dsh-client-store": "workspace:*", "@deepseek-ai/dsh-client-ui-chat": "workspace:*", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:*", "@deepseek-ai/dsh-deque": "workspace:*", "@deepseek-ai/dsh-lazy-require": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*", diff --git a/benchmarks/session-corpus/projection-list.worker.ts b/benchmarks/session-corpus/projection-list.worker.ts index 78a60b5c06..5e95356c12 100644 --- a/benchmarks/session-corpus/projection-list.worker.ts +++ b/benchmarks/session-corpus/projection-list.worker.ts @@ -7,9 +7,10 @@ import { monitorEventLoopDelay, performance } from 'node:perf_hooks' import { scheduler, setTimeout as delay } from 'node:timers/promises' import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' +import RemoteAccessService from '@deepseek-ai/dsh-api-remote-access' import SessionController from '@deepseek-ai/dsh-api-session-controller' import SessionStore, { SESSION_FORMAT_VERSION, SessionId, SessionLogOffset, type SessionHeader } from '@deepseek-ai/dsh-session' -import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' +import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SessionProjectionCache, { projectionCacheDomainSpec } from '@deepseek-ai/dsh-session-projection-cache' import SqliteSessionQueryEngine from '@deepseek-ai/dsh-session-query-sqlite' @@ -196,10 +197,15 @@ async function mountHost(ctx: Context, root: string, workload: typeof WORKLOADS[ await ctx.plugin(SqliteSessionQueryEngine, { path: ':memory:', openAt: 'never' }) // No Agents, uploads, model calls, or workspace actions occur on this list path. const dispose = (): void => {} - ctx.provide('typert', { lookups: { configure: () => dispose }, contexts: { configureHost: () => dispose } } as never) + ctx.provide('typert', { + lookups: { configure: () => dispose }, + contexts: { configureHost: () => dispose }, + resources: { register: () => dispose }, + } as never) ctx.provide('fileUploads', { registerAgentResolver: () => dispose } as never) ctx.provide('agentDefaultModel', { currentSelection: () => ({ provider: 'synthetic', model: 'generated-model' }) } as never) ctx.provide('workspaceRegistry', { list: () => [], archivedSessionIds: [] } as never) + new RemoteAccessService(ctx) return new SessionController(ctx, { nativeOpen: false, ...(listWorkSliceMs === undefined ? {} : { listWorkSliceMs }) }, { canOpenPath: () => false }) } diff --git a/benchmarks/session-corpus/session-corpus.worker.ts b/benchmarks/session-corpus/session-corpus.worker.ts index 8dabac10ca..2d1acd56ba 100644 --- a/benchmarks/session-corpus/session-corpus.worker.ts +++ b/benchmarks/session-corpus/session-corpus.worker.ts @@ -8,11 +8,12 @@ import { Context } from '@deepseek-ai/cordis' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-preset-registry' +import RemoteAccessService from '@deepseek-ai/dsh-api-remote-access' import SessionController from '@deepseek-ai/dsh-api-session-controller' import { currentSessionMessageProjections } from '@deepseek-ai/dsh-session-format-catalog/message-projections' import { Session, SessionLogOffset } from '@deepseek-ai/dsh-session' import type { SessionId } from '@deepseek-ai/dsh-session' -import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' +import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import SessionProjectionCache, { checkpointRecord, projectionCacheDomainSpec } from '@deepseek-ai/dsh-session-projection-cache' import type { CheckpointRecord } from '@deepseek-ai/dsh-session-projection-cache' import SqliteSessionQueryEngine from '@deepseek-ai/dsh-session-query-sqlite' @@ -148,10 +149,15 @@ async function mountHost(root: string): Promise<{ readonly ctx: Context; readonl await ctx.plugin(AgentLoop, { agents: [] }) // Transport, upload, model-default, and Workspace services are outside every measured path. const dispose = (): void => {} - ctx.provide('typert', { lookups: { configure: () => dispose }, contexts: { configureHost: () => dispose } } as never) + ctx.provide('typert', { + lookups: { configure: () => dispose }, + contexts: { configureHost: () => dispose }, + resources: { register: () => dispose }, + } as never) ctx.provide('fileUploads', { registerAgentResolver: () => dispose } as never) ctx.provide('agentDefaultModel', { currentSelection: () => BENCH_MODEL } as never) ctx.provide('workspaceRegistry', { list: () => [], archivedSessionIds: [] } as never) + new RemoteAccessService(ctx) const controller = new SessionController(ctx, { nativeOpen: false }, { canOpenPath: () => false }) return { ctx, controller } } diff --git a/benchmarks/session-open/session-open.bench.ts b/benchmarks/session-open/session-open.bench.ts index 9868384c0a..9595ab6c89 100644 --- a/benchmarks/session-open/session-open.bench.ts +++ b/benchmarks/session-open/session-open.bench.ts @@ -6,7 +6,7 @@ import { join } from 'node:path' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { Session, SessionId, SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session' -import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' +import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import { runBuiltBenchmarkWorker, type BuiltBenchmarkWorkerRun, diff --git a/benchmarks/session-open/session-open.worker.ts b/benchmarks/session-open/session-open.worker.ts index b1e2bfec1d..2b6d644960 100644 --- a/benchmarks/session-open/session-open.worker.ts +++ b/benchmarks/session-open/session-open.worker.ts @@ -13,7 +13,7 @@ import SessionStore, { SessionPreparation, } from '@deepseek-ai/dsh-session' import type { AgentHandle } from '@deepseek-ai/dsh-agent' -import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' +import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SessionQueryEngine from '@deepseek-ai/dsh-session-query' import type { diff --git a/benchmarks/support/built-worker.ts b/benchmarks/support/built-worker.ts index 6e91fde0df..8167cc9a4f 100644 --- a/benchmarks/support/built-worker.ts +++ b/benchmarks/support/built-worker.ts @@ -18,6 +18,8 @@ export interface BuiltBenchmarkWorkerOptions { readonly timeoutMs: number readonly exposeGc?: boolean readonly heapLimitMb?: number + /** Fixed semi-space size, so an allocation interval smaller than it runs without a scavenge. */ + readonly semiSpaceMb?: number } /** @@ -40,6 +42,9 @@ export function runBuiltBenchmarkWorker( ...options.heapLimitMb === undefined ? [] : [`--max-old-space-size=${String(options.heapLimitMb)}`], + ...options.semiSpaceMb === undefined + ? [] + : [`--min-semi-space-size=${String(options.semiSpaceMb)}`, `--max-semi-space-size=${String(options.semiSpaceMb)}`], options.worker, ...options.args ?? [], ], { diff --git a/benchmarks/tsdown.config.ts b/benchmarks/tsdown.config.ts index 36c4386865..cb91a1a830 100644 --- a/benchmarks/tsdown.config.ts +++ b/benchmarks/tsdown.config.ts @@ -66,4 +66,11 @@ export default defineConfig([ clean: true, tsconfig: 'tsconfig.client.json', }, + { + ...shared, + entry: { 'user-text.worker': 'user-text/user-text.worker.client.ts' }, + outDir: '.dsh-build/user-text', + clean: true, + tsconfig: 'tsconfig.client.json', + }, ]) diff --git a/benchmarks/user-text/user-text.bench.client.ts b/benchmarks/user-text/user-text.bench.client.ts new file mode 100644 index 0000000000..b7235609ce --- /dev/null +++ b/benchmarks/user-text/user-text.bench.client.ts @@ -0,0 +1,164 @@ +/** Required time and allocation budgets for sent-user-text evaluation with the credential matcher. */ + +import { join } from 'node:path' +import { beforeAll, describe, expect, it } from 'vitest' +import { runBuiltBenchmarkWorker } from '../support/built-worker.ts' +import { ciTimeBudget, PERFORMANCE_BUDGET_HEADROOM } from '../support/calibration.ts' +import { recordTimings, type BenchmarkCase } from '../support/scaling-report.ts' +import type { + UserTextAllocationReport, UserTextTimeReport, UserTextWorkloadAllocation, UserTextWorkloadTiming, +} from './user-text.worker.client.ts' + +/** Fresh registries and documents after warm-up; independent minima of adjacent pairs limit scheduler delay. */ +const ATTEMPTS = 5 +/** A stuck worker is reaped before the outer benchmark deadline. */ +const WORKER_TIMEOUT_MS = 180_000 +/** Larger than every workload's allocation, so the allocation interval runs without a scavenge. */ +const ALLOCATION_SEMI_SPACE_MB = 256 +const WORKER = join(import.meta.dirname, '..', '.dsh-build', 'user-text', 'user-text.worker.js') + +/** + * Documents without credentials: reference-machine milliseconds with the matcher, and the + * ratio to the shipped rules without it. Ratios do not depend on machine speed. The matcher + * adds one native literal scan, about 2 ns per character, plus format checks where a literal + * occurs (`://`, `sk-` in identifiers); 5,000 messages also pay the scheduler's cost for one + * more matcher. Running sixteen format patterns from every query offset measures 1.6–4.9×. + */ +const UNMATCHED_EXPECTED = { + 'typical-messages': { ms: 28, ratio: 1.25 }, + 'prose-256k': { ms: 1.25, ratio: 1.8 }, + 'code-256k': { ms: 1.7, ratio: 2.3 }, + 'base64-256k': { ms: 1.4, ratio: 2.05 }, + 'markdown-256k': { ms: 1.6, ratio: 2.15 }, + 'log-256k': { ms: 1.75, ratio: 2.05 }, + 'json-256k': { ms: 2.05, ratio: 2.75 }, + 'shell-256k': { ms: 3.8, ratio: 1.3 }, +} as const +/** Reference-machine milliseconds with the matcher for documents whose credentials it masks. */ +const MATCHED_EXPECTED_MS = { + 'messages-with-model-key': 28, + 'messages-with-github-token': 28, + 'messages-with-cloud-key-id': 28, + 'messages-with-json-web-token': 29, + 'messages-with-address-password': 31, + 'messages-with-private-key-block': 45, + 'env-keys-2000': 2.1, +} as const +/** + * 128 KiB shaped against one format's prefix, length, or separator rules. Format patterns + * that backtrack, or a per-query memo whose digit search reaches the end of the text from + * every query, measure 60–720 ms and up to 4× per doubling. + */ +const ADVERSARIAL_EXPECTED_MS = 3.25 +/** Doubling the input doubles a linear scan. */ +const MAX_DOUBLING_SCALING = 2 * PERFORMANCE_BUDGET_HEADROOM + +/** Measurement floor: heap statistics objects created while reading the young generation. */ +const ALLOCATION_FLOOR_BYTES = 16 * 1024 +/** + * One credential node: the node and data objects, the visible-prefix slice, and the + * concatenation with a slice of a shared mark string; measured at about 180 bytes. + */ +const ALLOCATION_PER_CREDENTIAL_BYTES = 256 +/** A multi-line mask adds one slice of a shared mark string and one concatenation per line; measured at about 60 bytes. */ +const ALLOCATION_PER_MASKED_LINE_BREAK_BYTES = 64 +/** A slice shorter than a sliced string copies its characters: at most two bytes per masked character. */ +const ALLOCATION_PER_MASKED_CHARACTER_BYTES = 2 + +const ADVERSARIAL = [ + 'digitless-sk', 'jwt-header-runs', 'jwt-two-segments', 'unterminated-key-blocks', 'address-without-at', + 'colon-run', 'long-scheme-user', 'short-token-runs', 'tokens-between-digitless-keys', 'closed-one-line-key-blocks', +] as const + +function benchmarkCase(id: string, measures: string): BenchmarkCase { + return { id: `user-text/${id}`, measures, affects: 'Rendering sent user messages, queued messages, and goal input in the Web GUI.' } +} + +async function worker(args: readonly string[], semiSpaceMb?: number): Promise { + const run = await runBuiltBenchmarkWorker({ + worker: WORKER, args, timeoutMs: WORKER_TIMEOUT_MS, exposeGc: true, ...semiSpaceMb === undefined ? {} : { semiSpaceMb }, + }) + if (run.report === undefined) { + throw new Error(`user-text worker failed: exit=${String(run.exitCode)}, signal=${String(run.signal)}, ` + + `timedOut=${String(run.timedOut)}\n${run.stderr.trim().split('\n').slice(-10).join('\n')}`) + } + return run.report +} + +describe('sent user text with the credential matcher', () => { + let timings: ReadonlyMap + let allocations: ReadonlyMap + + beforeAll(async () => { + const time = await worker(['time', String(ATTEMPTS)]) + const allocation = await worker(['allocation'], ALLOCATION_SEMI_SPACE_MB) + console.log(JSON.stringify({ benchmark: 'user-text', ...time, allocation: allocation.workloads })) + timings = new Map(time.workloads.map(workload => [workload.id, workload])) + allocations = new Map(allocation.workloads.map(workload => [workload.id, workload])) + }, 2 * WORKER_TIMEOUT_MS) + + const timing = (id: string): UserTextWorkloadTiming => { + const found = timings.get(id) + if (found === undefined) throw new Error(`user-text worker reported no ${id} workload`) + return found + } + const record = (benchmark: BenchmarkCase, measured: UserTextWorkloadTiming, budgetMs: number): void => { + recordTimings({ + ...benchmark, + measures: `${benchmark.measures} Minimum of ${ATTEMPTS} per-sample means of ${measured.evaluationsPerSample} fresh evaluations after warm-up.`, + }, { withSecretMs: { ms: measured.withSecretMs, cpuMs: measured.withSecretCpuMs } }, { withSecretMs: budgetMs }) + } + + it.each(Object.entries(UNMATCHED_EXPECTED))('evaluates %s without credentials within its budget and ratio', (id, expected) => { + const measured = timing(id) + const budgetMs = ciTimeBudget(expected.ms) + record(benchmarkCase(id, `Fresh ${id} documents without credentials through every shipped matcher.`), measured, budgetMs) + expect(measured.group).toBe('unmatched') + expect(measured.secrets).toBe(0) + expect(measured.withSecretMs).toBeLessThanOrEqual(budgetMs) + expect(measured.ratio).toBeLessThanOrEqual(expected.ratio * PERFORMANCE_BUDGET_HEADROOM) + }) + + it.each(Object.entries(MATCHED_EXPECTED_MS))('masks every credential of %s within its budget', (id, expectedMs) => { + const measured = timing(id) + const budgetMs = ciTimeBudget(expectedMs) + record(benchmarkCase(id, `Fresh ${id} documents, each credential masked, through every shipped matcher.`), measured, budgetMs) + expect(measured.group).toBe('matched') + expect(measured.secrets).toBe(id === 'env-keys-2000' ? 2_000 : 5_000) + expect(measured.withSecretMs).toBeLessThanOrEqual(budgetMs) + }) + + it('masks 2,000 pasted keys in time linear in their number', () => { + const half = timing('env-keys-1000') + const full = timing('env-keys-2000') + expect([half.secrets, full.secrets]).toEqual([1_000, 2_000]) + expect(full.withSecretMs / half.withSecretMs).toBeLessThanOrEqual(MAX_DOUBLING_SCALING) + }) + + it.each(ADVERSARIAL)('scans adversarial %s input linearly without backtracking', (name) => { + const half = timing(`adversarial-${name}-64k`) + const full = timing(`adversarial-${name}-128k`) + const budgetMs = ciTimeBudget(ADVERSARIAL_EXPECTED_MS) + record(benchmarkCase(`adversarial-${name}`, `One 128 KiB document repeating a ${name} unit.`), full, budgetMs) + expect(full.withSecretMs).toBeLessThanOrEqual(budgetMs) + expect(full.withSecretMs / half.withSecretMs).toBeLessThanOrEqual(MAX_DOUBLING_SCALING) + }) + + it.each(workloadIds())('allocates only result nodes and masks for %s', (id) => { + const allocation = allocations.get(id) + if (allocation === undefined) throw new Error(`user-text worker reported no ${id} allocation`) + expect(allocation.secrets).toBe(timing(id).secrets) + const budgetBytes = ALLOCATION_FLOOR_BYTES + ALLOCATION_PER_CREDENTIAL_BYTES * allocation.secrets + + ALLOCATION_PER_MASKED_LINE_BREAK_BYTES * allocation.maskedLineBreaks + + ALLOCATION_PER_MASKED_CHARACTER_BYTES * allocation.maskedCharacters + expect(allocation.ruleAllocatedBytes).toBeLessThanOrEqual(budgetBytes) + }) +}) + +/** Every workload id, in the worker's order, so each has an allocation case. */ +function workloadIds(): readonly string[] { + return [ + ...Object.keys(UNMATCHED_EXPECTED), ...Object.keys(MATCHED_EXPECTED_MS), 'env-keys-1000', + ...ADVERSARIAL.flatMap(name => [`adversarial-${name}-64k`, `adversarial-${name}-128k`]), + ] +} diff --git a/benchmarks/user-text/user-text.worker.client.ts b/benchmarks/user-text/user-text.worker.client.ts new file mode 100644 index 0000000000..354f9b12bb --- /dev/null +++ b/benchmarks/user-text/user-text.worker.client.ts @@ -0,0 +1,395 @@ +/** Compiled worker for sent-user-text evaluation cost and allocation with and without the credential matcher. */ + +import { PerformanceObserver, performance } from 'node:perf_hooks' +import { getHeapSpaceStatistics } from 'node:v8' +import { Context } from '@deepseek-ai/cordis' +import type { UserTextFacts, UserTextRule, UserTextTransformContext } from '@deepseek-ai/dsh-client-ui-conversation/client' +// These Client-only UserText modules have no plain-Node package export and are compiled into this worker. +import { UserTextRegistry } from '../../packages/client/ui-conversation/src/client/conversation/user-text-registry.ts' +import { sessionReferenceRule } from '../../packages/client/ui-conversation/src/client/user-text/session-reference-rule.ts' +import { collapseWhitespaceRule, trimWhitespaceRule } from '../../packages/client/ui-conversation/src/client/user-text/whitespace-rules.ts' +import { fileReferenceRule, folderReferenceRule } from '../../packages/client/ui-reference/src/client/user-text-rules.ts' +import { skillReferenceRule } from '../../packages/client/ui-skill/src/client/user-text-rules.ts' +import { goalCommandReferenceRule } from '../../packages/client/ui-goal/src/client/user-text-rules.ts' +import { createSecretRule } from '../../packages/client/ui-secret-mask/src/client/secret-rule.ts' +import { assertBuiltBenchmarkRuntime } from '../support/built-worker.ts' + +/** What a workload contains: no credential, real credentials, or input shaped against one format. */ +export type UserTextWorkloadGroup = 'unmatched' | 'matched' | 'adversarial' + +/** Adjacent baseline and credential measurements; even-indexed pairs measure the baseline first. */ +export interface UserTextTimingPair { + readonly baseMs: number + readonly baseCpuMs: number + readonly withSecretMs: number + readonly withSecretCpuMs: number +} + +/** Timing of one workload evaluated by the shipped rule set with and without the credential matcher. */ +export interface UserTextWorkloadTiming { + readonly id: string + readonly group: UserTextWorkloadGroup + readonly documents: number + readonly characters: number + /** Fresh evaluations averaged into each timed sample; counts and characters describe one evaluation. */ + readonly evaluationsPerSample: number + readonly secrets: number + /** Characters inside credential ranges, whose masks the matcher builds. */ + readonly maskedCharacters: number + readonly baseMs: number + /** Mean process CPU time per evaluation in the fastest baseline sample. */ + readonly baseCpuMs: number + readonly withSecretMs: number + /** Mean process CPU time per evaluation in the fastest with-credential sample. */ + readonly withSecretCpuMs: number + readonly ratio: number + /** All measured pairs in execution order, excluding warm-up. */ + readonly samples: readonly UserTextTimingPair[] +} + +/** Heap allocation of the credential matcher over one workload, with the size of what it returns. */ +export interface UserTextWorkloadAllocation { + readonly id: string + /** Young-generation bytes the matcher allocates answering every query of every document, nodes included; no GC ran meanwhile. */ + readonly ruleAllocatedBytes: number + readonly secrets: number + /** Characters inside credential ranges. */ + readonly maskedCharacters: number + /** Line breaks inside credential ranges, each of which ends one piece of a multi-line mask. */ + readonly maskedLineBreaks: number +} + +/** Result of `time `: warmed code, fresh instances, and independent minima from adjacent sample pairs. */ +export interface UserTextTimeReport { + readonly warmupPairs: number + readonly attempts: number + readonly workloads: readonly UserTextWorkloadTiming[] +} + +/** Result of `allocation`, which needs `--expose-gc` and a young generation larger than one evaluation. */ +export interface UserTextAllocationReport { + readonly workloads: readonly UserTextWorkloadAllocation[] +} + +/** Matchers the Web composition registers besides the credential matcher. */ +const BASE_RULES: readonly UserTextRule[] = [ + sessionReferenceRule, fileReferenceRule, folderReferenceRule, skillReferenceRule, + goalCommandReferenceRule, collapseWhitespaceRule, trimWhitespaceRule, +] +/** Facts a sent message bubble supplies. */ +const FACTS: Readonly> = { sessionLabels: ['会话一'], skillNames: ['plan', 'review'] } + +const KIB = 1024 +/** Exercise each workload's code paths before measuring sub-millisecond baseline evaluations. */ +const WARMUP_PAIRS = 10 +/** Batch every single-document workload to limit noise in its shortest evaluation intervals. */ +const SINGLE_DOCUMENT_EVALUATIONS = 20 +const PROSE = '帮我看看这个函数为什么慢,顺便把 README 也更新一下。The quick brown fox jumps over the lazy dog, 1234 times. ' +const CODE = 'const value = await fetch(`https://api.example.com/v1/items?id=${id}`); if (!value.ok) throw new Error("bad: " + value.status);\n' +const BASE64 = 'QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVphYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ejAxMjM0NTY3ODkrLw' +const MARKDOWN = [ + '## 安装与配置', '', '- 运行 `pnpm install`,然后执行 `pnpm run build`。', + '- 详见 [文档](https://example.com/docs/setup?ref=readme#install)。', '', + '| 选项 | 默认值 | 说明 |', '|---|---|---|', '| `timeout` | 30s | 请求超时 |', '| `retries` | 3 | 失败重试次数 |', '', + '```ts', "const client = createClient({ baseUrl: 'https://api.example.com/v1', timeout: 30_000 })", '```', '', '', +].join('\n') +const LOG = '2026-10-11T12:00:00.123Z INFO [worker-3] GET https://api.example.com/v1/items?id=1234&page=2 200 12ms ' + + 'trace=4bf92f3577b34da6a3ce929d0e0e4736 user=alice@example.com\n' +const JSON_LINES = '{"id":"123e4567-e89b-12d3-a456-426614174000","url":"https://cdn.example.com/assets/img_0001.png",' + + '"name":"task-runner","tags":["sk-button","api_key_name"],"created_at":"2026-10-11T12:00:00Z","size":10240},\n' +const SHELL = 'pnpm --filter @deepseek-ai/dsh-client-ui-secret-mask run test -- --coverage --reporter=verbose' + + ' && git push origin feat/user-text-secret-mask\n' +/** Ordinary message fragments: prose, references, a slash skill, a URL without credentials, and code. */ +const MESSAGE_PARTS = [ + PROSE, '参照 @src/app/main.ts 修复一下', ' /plan 先列计划', ' 见 https://example.com/docs/a?b=c#d ', + '`const total = items.reduce((a, b) => a + b, 0)`', ' 然后跑 pnpm run test -- --watch=false。', +] as const + +const fill = (unit: string, characters: number): string => unit.repeat(Math.ceil(characters / unit.length)).slice(0, characters) +const MIXED = 'a1B2c3D4e5F6g7H8i9J0' +// Samples are assembled at run time so the repository holds no literal in a credential format. +const key = (index: number): string => ['sk', '-', `${index.toString(16).padStart(8, '0')}0123456789abcdef01234567`].join('') +const CREDENTIALS = { + 'model-key': key(7), + 'github-token': ['gh', 'p_', fill(MIXED, 36)].join(''), + 'cloud-key-id': ['AK', 'IA', 'ABCDEFGHIJ234567'].join(''), + 'json-web-token': ['ey', 'J', fill(MIXED, 17), '.', 'ey', 'J', fill(MIXED, 30), '.', fill(MIXED, 43)].join(''), + 'address-password': ['postgres://admin:', 'p4ss-W0rd', '@db.internal:5432/app'].join(''), + 'private-key-block': [ + ['-----BEGIN', ' RSA PRIVATE KEY-----'].join(''), ...Array.from({ length: 10 }, () => fill(BASE64, 64)), + ['-----END', ' RSA PRIVATE KEY-----'].join(''), + ].join('\n'), +} as const + +function messages(count: number): string[] { + return Array.from({ length: count }, (_, index) => { + const parts = Array.from({ length: 4 }, (_unused, part) => MESSAGE_PARTS[(index + part * 2) % MESSAGE_PARTS.length]!) + return `${String(index)} ${parts.join('')}`.slice(0, 200) + }) +} + +/** Ordinary 200-character messages, each carrying one credential whole. */ +function credentialMessages(count: number, credential: string): string[] { + return Array.from({ length: count }, (_, index) => { + const head = `${String(index)} 帮我看看这个配置能不能用:` + const tail = `,${MESSAGE_PARTS[index % MESSAGE_PARTS.length]!}${PROSE}` + return head + credential + tail.slice(0, Math.max(0, 200 - head.length - credential.length)) + }) +} + +const envFile = (count: number): string => + Array.from({ length: count }, (_, index) => `SERVICE_${String(index)}_API_KEY=${key(index)}`).join('\n') + +/** Inputs shaped against each credential format's prefix, length, and separator rules. */ +const ADVERSARIAL_UNITS = { + 'digitless-sk': 'sk-', + 'jwt-header-runs': 'eyJabcdefgh-', + 'jwt-two-segments': 'eyJabcdefgh.eyJabcdefgh-', + 'unterminated-key-blocks': ['-----BEGIN', ' RSA PRIVATE KEY-----\nabc\n'].join(''), + 'address-without-at': 'a://u:', + 'colon-run': ':', + 'long-scheme-user': `abcdefghijklmnop://${'u'.repeat(250)}:`, + 'short-token-runs': `ghp_${fill('a1B2c3', 35)} `, + // Closed one-line blocks, each its own credential: mask searches confined to each block keep them linear. + 'closed-one-line-key-blocks': ['-----BEGIN', ' PRIVATE KEY-----abcd', '-----END', ' PRIVATE KEY----- '].join(''), + // Each query starts after an accepted digit-free token and meets a digit-free `sk-` whose digit search reaches the end. + 'tokens-between-digitless-keys': ['-', 'sk', '-', 'gh', 'p_', fill('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ', 36)].join(''), +} as const + +interface Workload { + readonly id: string + readonly group: UserTextWorkloadGroup + readonly texts: readonly string[] +} + +/** Named fixed workloads; adversarial inputs appear at two sizes so their scaling is observable. */ +function workloads(): readonly Workload[] { + return [ + { id: 'typical-messages', group: 'unmatched', texts: messages(5_000) }, + ...Object.entries({ prose: PROSE, code: CODE, base64: BASE64, markdown: MARKDOWN, log: LOG, json: JSON_LINES, shell: SHELL }) + .map(([name, unit]): Workload => ({ id: `${name}-256k`, group: 'unmatched', texts: [fill(unit, 256 * KIB)] })), + ...Object.entries(CREDENTIALS).map(([name, credential]): Workload => ({ + id: `messages-with-${name}`, group: 'matched', texts: credentialMessages(5_000, credential), + })), + { id: 'env-keys-1000', group: 'matched', texts: [envFile(1_000)] }, + { id: 'env-keys-2000', group: 'matched', texts: [envFile(2_000)] }, + ...Object.entries(ADVERSARIAL_UNITS).flatMap(([name, unit]): Workload[] => [ + { id: `adversarial-${name}-64k`, group: 'adversarial', texts: [fill(unit, 64 * KIB)] }, + { id: `adversarial-${name}-128k`, group: 'adversarial', texts: [fill(unit, 128 * KIB)] }, + ]), + ] +} + +interface Sample { + readonly ms: number + readonly cpuMs: number + readonly secrets: number + readonly maskedCharacters: number +} + +interface SamplePair { + readonly base: Sample + readonly secret: Sample +} + +/** Rules for one sample; a fresh credential matcher per sample carries no state from an earlier sample. */ +type RuleSet = () => readonly UserTextRule[] +const withoutSecret: RuleSet = () => BASE_RULES +const withSecret: RuleSet = () => [...BASE_RULES, createSecretRule()] + +/** + * Evaluate fresh documents; await registry disposal outside the measured interval before returning. + * @returns elapsed and CPU milliseconds, credential nodes produced, and their characters. + */ +async function evaluateAll(rules: RuleSet, texts: readonly string[]): Promise { + const scope = new Context() + try { + const registry = new UserTextRegistry(scope) + for (const rule of rules()) registry.register(rule) + let secrets = 0 + let maskedCharacters = 0 + const cpuStart = process.cpuUsage() + const start = performance.now() + for (const text of texts) { + for (const node of registry.createEvaluator()({ text, facts: FACTS })) { + if (node.id !== 'secret') continue + secrets++ + maskedCharacters += node.end - node.start + } + } + const ms = performance.now() - start + const cpu = process.cpuUsage(cpuStart) + return { ms, cpuMs: (cpu.user + cpu.system) / 1_000, secrets, maskedCharacters } + } finally { + await scope.fiber.dispose() + } +} + +async function sample(rules: RuleSet, texts: readonly string[], evaluations: number): Promise { + const first = await evaluateAll(rules, texts) + let ms = first.ms + let cpuMs = first.cpuMs + for (let index = 1; index < evaluations; index++) { + const next = await evaluateAll(rules, texts) + ms += next.ms + cpuMs += next.cpuMs + } + return { ...first, ms: ms / evaluations, cpuMs: cpuMs / evaluations } +} + +async function evaluatePair(texts: readonly string[], index: number, evaluations: number): Promise { + if (index % 2 === 0) { + const base = await sample(withoutSecret, texts, evaluations) + return { base, secret: await sample(withSecret, texts, evaluations) } + } + const secret = await sample(withSecret, texts, evaluations) + return { base: await sample(withoutSecret, texts, evaluations), secret } +} + +async function measure(texts: readonly string[], attempts: number, evaluations: number): Promise { + for (let warm = 0; warm < WARMUP_PAIRS; warm++) await evaluatePair(texts, warm, evaluations) + const pairs: SamplePair[] = [] + for (let attempt = 0; attempt < attempts; attempt++) { + pairs.push(await evaluatePair(texts, attempt, evaluations)) + } + const best = pairs.reduce((min, pair) => ({ + base: pair.base.ms < min.base.ms ? pair.base : min.base, + secret: pair.secret.ms < min.secret.ms ? pair.secret : min.secret, + })) + return { + ...best, + samples: pairs.map(pair => ({ + baseMs: pair.base.ms, baseCpuMs: pair.base.cpuMs, + withSecretMs: pair.secret.ms, withSecretCpuMs: pair.secret.cpuMs, + })), + } +} + +declare const gc: (() => void) | undefined + +/** + * Young-generation bytes in use. Code and feedback that compilation allocates during a + * measurement live in other spaces, so they do not count as allocation by the evaluation. + */ +function youngBytes(): number { + let total = 0 + for (const space of getHeapSpaceStatistics()) { + if (space.space_name === 'new_space' || space.space_name === 'new_large_object_space') total += space.space_used_size + } + return total +} + +/** The credential matcher reads no Facts. */ +const NO_FACTS: UserTextTransformContext = { read: () => undefined } + +/** + * Query the credential matcher over every document as the scheduler does when no other + * matcher produces a node; an index loop, so the loop itself allocates nothing. + */ +function queryAll(rule: UserTextRule, texts: readonly string[]): void { + for (let index = 0; index < texts.length; index++) { + const text = texts[index]! + for (let from = 0; ;) { + const node = rule.match(text, from, NO_FACTS) + if (node === undefined) break + from = node.end + } + } +} + +/** Credential nodes the matcher returns over every document, counted outside any measured interval. */ +function returned(texts: readonly string[]): Omit { + const rule = createSecretRule() + let secrets = 0 + let maskedCharacters = 0 + let maskedLineBreaks = 0 + for (const text of texts) { + for (let node = rule.match(text, 0, NO_FACTS); node !== undefined; node = rule.match(text, node.end, NO_FACTS)) { + secrets++ + maskedCharacters += node.end - node.start + for (let index = node.start; index < node.end; index++) { + const code = text.charCodeAt(index) + if (code === 10 || code === 13) maskedLineBreaks++ + } + } + } + return { secrets, maskedCharacters, maskedLineBreaks } +} + +/** + * Bytes one warmed pass of every query allocates, measured across an interval without GC. + * @returns young-generation growth over the interval. + */ +async function allocatedBytes(texts: readonly string[], collect: () => void): Promise { + const collections: number[] = [] + const observer = new PerformanceObserver((list) => { for (const entry of list.getEntries()) collections.push(entry.startTime) }) + observer.observe({ entryTypes: ['gc'] }) + try { + const rule = createSecretRule() + for (let warm = 0; warm < 10; warm++) queryAll(rule, texts) + collect() + collect() + const start = performance.now() + const used = youngBytes() + queryAll(rule, texts) + const grown = youngBytes() - used + const end = performance.now() + // GC entries arrive on a later turn. + await new Promise(resolve => { setTimeout(resolve, 20) }) + const inside = collections.filter(time => time >= start && time <= end).length + if (inside > 0) throw new Error(`${String(inside)} garbage collections ran inside the allocation interval; enlarge the young generation`) + return grown + } finally { + observer.disconnect() + } +} + +function positiveInteger(value: string | undefined, name: string): number { + const parsed = Number(value) + if (!Number.isSafeInteger(parsed) || parsed <= 0) throw new Error(`${name} must be a positive integer`) + return parsed +} + +async function allocations(): Promise { + if (gc === undefined) throw new Error('user-text allocation mode needs --expose-gc') + const collect = gc + const results: UserTextWorkloadAllocation[] = [] + for (const { id, texts } of workloads()) { + results.push({ id, ruleAllocatedBytes: await allocatedBytes(texts, collect), ...returned(texts) }) + } + return { workloads: results } +} + +async function timings(attempts: number): Promise { + const results: UserTextWorkloadTiming[] = [] + for (const { id, group, texts } of workloads()) { + const evaluationsPerSample = texts.length === 1 ? SINGLE_DOCUMENT_EVALUATIONS : 1 + const { base, secret, samples } = await measure(texts, attempts, evaluationsPerSample) + results.push({ + id, + group, + documents: texts.length, + characters: texts.reduce((sum, text) => sum + text.length, 0), + evaluationsPerSample, + secrets: secret.secrets, + maskedCharacters: secret.maskedCharacters, + baseMs: base.ms, + baseCpuMs: base.cpuMs, + withSecretMs: secret.ms, + withSecretCpuMs: secret.cpuMs, + ratio: secret.ms / base.ms, + samples, + }) + } + return { warmupPairs: WARMUP_PAIRS, attempts, workloads: results } +} + +assertBuiltBenchmarkRuntime(import.meta.url, { + '@deepseek-ai/cordis': import.meta.resolve('@deepseek-ai/cordis'), + '@deepseek-ai/dsh-client-store': import.meta.resolve('@deepseek-ai/dsh-client-store'), +}) +const [mode, count] = process.argv.slice(2) +if (mode !== 'time' && mode !== 'allocation') throw new Error('usage: user-text.worker.js time | allocation') +console.log(JSON.stringify(mode === 'time' ? await timings(positiveInteger(count, 'attempts')) : await allocations())) diff --git a/docs/api-gateway.i18n.yaml b/docs/api-gateway.i18n.yaml index d85a74ff21..65b0c3e196 100644 --- a/docs/api-gateway.i18n.yaml +++ b/docs/api-gateway.i18n.yaml @@ -6,23 +6,23 @@ en: 735eb4f22d5f6f71 zh: 697eef269b133039 /api-gateway/programming-model: - en: ea12b64211f0d377 - zh: 032e5c0d799c7529 + en: 78eb70d6c2ce6608 + zh: 826f0ccc206b7299 /api-gateway/component-responsibilities: - en: e29f1d7026291075 - zh: 15e361e797d44fce + en: fe382a9bcc768cc2 + zh: 78e3d50d59780c3d /api-gateway/strict-generation-pipeline: - en: cb8da81a92acfa52 - zh: be5f3c268f8571df + en: 9c4a98eb0539ec7f + zh: ad2a145abd805fdd /api-gateway/runtime-invocation: - en: 2a2fed340aaa926c - zh: 46bb1f500c870a76 + en: 37b4858fa48a61b6 + zh: 9a296a2171f1ab7d /api-gateway/src-development-fallback: en: 9570ffe7a348e5ac zh: 2b70b61b1ff1fbb0 /api-gateway/development-mode: - en: ba090d76471dd663 - zh: edf362f71b73eaf0 + en: 23ae422bd65300da + zh: 67dd9c02f4ffec53 /api-gateway/boundaries: en: e6befc5f8c90f97b zh: 4ace4a493fd2b41f diff --git a/docs/api-gateway.md b/docs/api-gateway.md index 0fb3edcb79..484b866e95 100644 --- a/docs/api-gateway.md +++ b/docs/api-gateway.md @@ -12,6 +12,8 @@ Business services use `@Remote` or `@RemoteScope` to select the methods exposed `@RemoteScope(key)` first resolves an identity to a scoped Context through `ctx.typert.contexts`, then obtains the service from that Context and invokes the method. It applies when the method itself depends on scoped composition and does not need to receive objects such as `Agent` explicitly. +`@Remote` accepts an export name or `{ name?, mode?, resources? }`, and `@RemoteScope(key, option?)` accepts an export name, as in `@RemoteScope('agent', 'current')` below, or `{ name?, resources? }` as its second argument. `resources` holds per-kind readers that return the identities of the resources one call touches for access control; each reader receives the method's wire arguments as its own parameters, with types inferred from the decorated method. Most methods declare no readers, because the Gateway also reads identities from lookup parameters and scoped Contexts that declare a `resource`, and from the default fields in `typert.resources`; the [Typert subsystem](subsystems/typert.md) owns that order. + Services normally extend `TypertRemoteService` so the constructor explicitly binds the Cordis service key and default Remote namespace. A service that already has another base class can instead declare `readonly typertRemote = bindTypertRemote(this, serviceKey)`; both forms leave an inspectable public binding and do not depend on the compiler injecting a symbol into the constructor. ```ts @@ -88,6 +90,7 @@ The `api-remotes` assembly and the `ctx.remote` contract are React-independent; | Host | `@deepseek-ai/dsh-typert-registry` and Loader | Places generated Host descriptors, schemas, and business-package registrations in `ctx.typert`, and holds lookup and Context providers | | Host | `@deepseek-ai/dsh-api-session-controller` | Owns the application Agent/Session identity policy and configures the corresponding Typert lookups | | Host | `@deepseek-ai/dsh-api-gateway` | Provides `ctx.typertGateway`, claims Remote endpoints, validates request values, resolves objects or Contexts, and invokes live Cordis services | +| Host | `@deepseek-ai/dsh-api-remote-access` | Decides which Remote methods a non-owner Peer may call through business-registered role-based policies, filters list results, and streams each Peer's access snapshot; the Gateway requires it | | Client | `@deepseek-ai/dsh-api-gateway/client` | Provides `ctx.remote` and `remote.` child Services, mounts generated descriptors as concrete methods, and initiates and cancels calls through the Connection | | Client | `@deepseek-ai/dsh-api-remotes/client` | Explicitly selects and mounts the `/remote` contributions allowed by the application and brings the corresponding declaration merges into business code | | Both | `@deepseek-ai/dsh-client-connection` | Provides the RPC carrier, request correlation, trust boundary, cancellation, response envelope, and the `/api` HTTP bridge | @@ -96,7 +99,7 @@ The API Gateway package owns the Host dispatcher and Client Remote endpoint as p ## Strict generation pipeline -The root build runs `build:lib:host`, `build:lib:client`, and `build:web` in order. The Host lib phase first runs `tsc -b tsconfig.host.json`, then `tsdown --env.DSH_BUILD_FACE host`; the normal Host Project Reference graph compiles the Typert generator, which runs during this tsdown pass with the Host aggregate as its only `ts.Program` seed. The Client lib phase then runs `tsc -b tsconfig.client.json` and `tsdown --env.DSH_BUILD_FACE client`, consuming the newly generated Remote Client declarations and runtime contributions without starting Typert again. +The root build compiles and bundles the Host face, then the Client face, then builds the Web shell ([step order](development.md#typescript-project-layout)). The Host phase first compiles the Host package references, then runs `tsdown --env.DSH_BUILD_FACE host`; the normal Host Project Reference graph compiles the Typert generator, which runs during this tsdown pass with the Host aggregate as its only `ts.Program` seed. The Client phase then compiles the Client package references and runs `tsdown --env.DSH_BUILD_FACE client`, consuming the newly generated Remote Client declarations and runtime contributions without starting Typert again. Both tsdown passes match `vendor/*`, `packages/*/*`, and `apps/cli`, the Host pass also `apps/desktop-host` ([build order](development.md#typescript-project-layout)), and both bundle only JavaScript emitted to `lib/types` by the corresponding tsc phase. The root config does not scan Client artifacts, classify package names, or pass a maintained filter to tsdown; package-local configs return entries for the current phase based on `DSH_BUILD_FACE`. An ordinary Client plugin produces both its Node loader entry and browser bundle during the Client phase. @@ -124,7 +127,7 @@ Remote calls use the Connection's `/api` route. The Client Remote calls `connect The Connection performs the unified trust check for `/api` before the HTTP bridge, then dispatches inside the shared FetchHandler. The Typert Gateway claims only two-segment endpoints that have a strict descriptor or active SRC marker; it projects binary Remote fields into JSON-compatible metadata and result-relative byte attachments, which Connection frames as multipart responses. Feature-owned exact Fetch routes handle responses outside the RPC envelope, and other requests return 404. Connection owns transport, RPC ids, response envelopes, and request cancellation, while Gateway owns the Remote data protocol and business dispatch. Replacing the Connection carrier does not require changes to Remote descriptors or the Client programming interface. -For every call, the Gateway resolves the descriptor and live service from the current registries instead of caching business objects. It requires the fields in `args` to match the descriptor exactly, validates wire values with codecs, resolves objects or receivers through registered lookup or Context providers, and invokes the service method targeted by the binding. The Client Gateway validates decoded binary results with the generated result codec; ordinary JSON results retain their existing handling. A missing provider, unknown identity, binding mismatch, missing or extra argument, schema failure, or missing method fails before entering or after leaving business code. +For every call, the Gateway resolves the descriptor and live service from the current registries instead of caching business objects. Every call names the Peer it speaks for, and a disposed Peer is refused with `gateway/peer-disposed`. The Gateway requires the fields in `args` to match the descriptor exactly; for a call whose Peer principal is not the owner it then resolves the resources the call touches and asks `ctx.remoteAccess.decide()`, so a refused call resolves no lookup and activates no Agent. It validates wire values with codecs, resolves objects or receivers through registered lookup or Context providers, and invokes the service method targeted by the binding. The Client Gateway validates decoded binary results with the generated result codec; ordinary JSON results retain their existing handling. A missing provider, unknown identity, binding mismatch, missing or extra argument, schema failure, or missing method fails before entering or after leaving business code. The lookup provider's `register()` supplies both the stable declaration and the default resolver; `configure()` supplies a resolver owned by Host composition that may execute asynchronously and is scoped to an effect lifetime. Configuration may precede provider mounting; without a provider, invocation still fails with `gateway/lookup-unavailable`, and unloading the configuration restores the provider's default policy. The Session Controller owns the standard resolver semantics for `agent` and `session`: it reuses a live Agent, automatically resumes ordinary cold sessions, deduplicates concurrent resumes, and rejects identities owned by subagent routing; the `session` lookup returns that Agent's Session. A resume failure and an ownership fence raise a `RemoteError` carrying their own code, `session/not-found` or `session/agent-busy`, which the Gateway encodes onto the wire unchanged; only an unclassified throw folds into `gateway/internal`. @@ -154,7 +157,7 @@ Changing only a Remote method's implementation body without changing its contrac pnpm run build:lib ``` -The running Client watcher consumes these generated files when it rebundles. If `pnpm run build:lib:host` has already refreshed the Host contract, `pnpm run build:lib:client` can complete the Client side; a clean worktree cannot skip the Host phase. Recompiling only the frontend source cannot infer new types from Host decorators. `pnpm run typecheck` runs the Host lib phase before Client tsc, and CI and release builds use the same order. +The running Client watcher consumes these generated files when it rebundles. If `pnpm run build:lib:host` has already refreshed the Host contract, `pnpm run build:lib:client` can complete the Client side; a clean worktree cannot skip the Host phase. Recompiling only the frontend source cannot infer new types from Host decorators. `pnpm run typecheck` generates the Host contract with the bundle-free Typert pass before Client tsc, and the CI build lanes and release builds generate it in the Host bundle phase before Client tsc. ## Boundaries diff --git a/docs/api-gateway.zh.md b/docs/api-gateway.zh.md index 81b504685e..c44d6f403f 100644 --- a/docs/api-gateway.zh.md +++ b/docs/api-gateway.zh.md @@ -14,6 +14,8 @@ `@RemoteScope(key)` 表示先通过 `ctx.typert.contexts` 把 identity 解析为一个作用域 Context,再从该 Context 取得服务并调用方法。它适用于方法本身依赖作用域组合、而不需要显式接收 `Agent` 等对象的情形。 +`@Remote` 接受导出名或 `{ name?, mode?, resources? }`;`@RemoteScope(key, option?)` 的第二个参数接受导出名(如下文的 `@RemoteScope('agent', 'current')`)或 `{ name?, resources? }`。`resources` 按资源种类给出读取函数,返回一次调用所访问资源的身份,供访问控制使用;每个读取函数以方法的 wire 参数作为自身参数,类型从被装饰方法推断。多数方法不声明读取函数,因为 Gateway 还会从声明了 `resource` 的 lookup 参数与作用域 Context 以及 `typert.resources` 中的默认字段读取身份;[Typert 子系统](subsystems/typert.zh.md)负责该顺序。 + 服务通常继承 `TypertRemoteService`,让 Cordis 服务 key 与默认 Remote namespace 在构造器中显式绑定。已有其他基类的服务可以改为声明 `readonly typertRemote = bindTypertRemote(this, serviceKey)`;两种方式都会留下可检查的公开 binding,不依赖编译器向构造函数注入 symbol。 ```ts @@ -90,6 +92,7 @@ Client 应用只装配 `@deepseek-ai/dsh-api-remotes`。该包以运行时值导 | Host | `@deepseek-ai/dsh-typert-registry` 与 Loader | 把生成的 Host 描述符、schema 及业务包注册项放入 `ctx.typert`,并持有 lookup 与 Context 提供方 | | Host | `@deepseek-ai/dsh-api-session-controller` | 负责应用的 Agent/Session 身份策略,并配置对应的 Typert lookup | | Host | `@deepseek-ai/dsh-api-gateway` | 提供 `ctx.typertGateway`,认领 Remote endpoint,校验请求值,解析对象或 Context,并调用实时 Cordis 服务 | +| Host | `@deepseek-ai/dsh-api-remote-access` | 按业务方注册的基于 role 的 policy 判定非 owner Peer 可调用哪些 Remote 方法,过滤列表结果,并为每个 Peer 推送访问快照;Gateway 依赖它 | | Client | `@deepseek-ai/dsh-api-gateway/client` | 提供 `ctx.remote` 与 `remote.` 子服务,把生成的描述符挂成具体方法,并通过 Connection 发起和取消调用 | | Client | `@deepseek-ai/dsh-api-remotes/client` | 显式选择并挂载本应用允许使用的 `/remote` 贡献,向业务代码带入对应的声明合并 | | 双侧 | `@deepseek-ai/dsh-client-connection` | 提供 RPC carrier、请求关联、信任边界、取消、响应 envelope 与 `/api` HTTP bridge | @@ -98,7 +101,7 @@ API Gateway 包同时拥有 Host dispatcher 与 Client Remote endpoint 两个对 ## 严格生成流水线 -根构建依次执行 `build:lib:host`、`build:lib:client` 与 `build:web`。Host lib 阶段先运行 `tsc -b tsconfig.host.json`,再运行 `tsdown --env.DSH_BUILD_FACE host`;Typert generator 由正常 Host Project Reference 图编译,并在这次 tsdown 中以 Host aggregate 为唯一 `ts.Program` 种子运行。Client lib 阶段随后运行 `tsc -b tsconfig.client.json` 与 `tsdown --env.DSH_BUILD_FACE client`,使用刚生成的 Remote Client 声明和运行时贡献,但不再次启动 Typert。 +根构建依次编译并打包 Host face、Client face,再构建 Web 壳层([步骤顺序](development.zh.md#typescript-project-layout))。Host 阶段先编译 Host 包 reference,再运行 `tsdown --env.DSH_BUILD_FACE host`;Typert generator 由正常 Host Project Reference 图编译,并在这次 tsdown 中以 Host aggregate 为唯一 `ts.Program` 种子运行。Client 阶段随后编译 Client 包 reference 并运行 `tsdown --env.DSH_BUILD_FACE client`,使用刚生成的 Remote Client 声明和运行时贡献,但不再次启动 Typert。 两次 tsdown 都匹配 `vendor/*`、`packages/*/*` 与 `apps/cli`,Host 阶段另外匹配 `apps/desktop-host`([构建顺序](development.zh.md#typescript-project-layout)),且都只打包 `lib/types` 中由对应 tsc 阶段发射的 JavaScript。根配置不扫描 Client 产物、不按包名分类,也不向 tsdown 传维护式 filter;各包的本地配置根据 `DSH_BUILD_FACE` 返回当前阶段的入口。普通 Client 插件在 Client 阶段一起生成 Node loader 入口与 browser bundle。 @@ -126,7 +129,7 @@ Remote 调用使用 Connection 的 `/api` 路由。Client Remote 调用 `connect Connection 在 HTTP bridge 之前执行 `/api` 的统一信任检查,再在共享 FetchHandler 内分发。Typert Gateway 只认领存在严格描述符或活跃 SRC marker 的两段式 endpoint;它把二进制 Remote 字段投影为 JSON 兼容元数据及相对于结果的字节附件,再由 Connection 封装为 multipart 响应。功能自有的精确 Fetch 路由处理 RPC 信封之外的响应,其他请求返回 404。Connection 拥有传输、RPC id、响应 envelope 和请求取消,Gateway 拥有 Remote 数据协议和业务分发。替换 Connection carrier 不要求改变 Remote 描述符或 Client 编程接口。 -Gateway 每次调用都从当前注册表解析描述符和实时服务,不缓存业务对象。它要求 `args` 的字段集合与描述符完全一致,先用 codec 校验 wire 值,再通过注册的 lookup 或 Context 提供方解析对象或接收者,最后调用 binding 指向的服务方法。Client Gateway 使用生成的结果 codec 校验解码后的二进制结果;普通 JSON 结果保持原有处理方式。缺少提供方、identity 未命中、binding 不一致、参数缺失或多余、schema 失败和方法不存在都会在进入业务代码前或离开业务代码后失败。 +Gateway 每次调用都从当前注册表解析描述符和实时服务,不缓存业务对象。每次调用都给出其代表的 Peer,已 dispose 的 Peer 以 `gateway/peer-disposed` 被拒。Gateway 要求 `args` 的字段集合与描述符完全一致;对 Peer principal 不是 owner 的调用,随后解析调用访问的资源并询问 `ctx.remoteAccess.decide()`,被拒的调用不解析任何 lookup,也不激活 Agent。之后它先用 codec 校验 wire 值,再通过注册的 lookup 或 Context 提供方解析对象或接收者,最后调用 binding 指向的服务方法。Client Gateway 使用生成的结果 codec 校验解码后的二进制结果;普通 JSON 结果保持原有处理方式。缺少提供方、identity 未命中、binding 不一致、参数缺失或多余、schema 失败和方法不存在都会在进入业务代码前或离开业务代码后失败。 lookup 提供方的 `register()` 同时提供稳定声明和默认 resolver;`configure()` 提供由 Host 组合拥有、可异步执行且受 effect 生命周期约束的 resolver。配置可以先于提供方挂载;没有提供方时调用仍以 `gateway/lookup-unavailable` 失败,配置卸载后则恢复提供方默认策略。Session Controller 负责 `agent` 与 `session` 的标准 resolver 语义:复用 live Agent,自动恢复普通冷会话,对并发恢复去重,并拒绝由 subagent routing 拥有的 identity;`session` lookup 返回该 Agent 的 Session。恢复失败与 ownership fence 抛出携带自有码的 `RemoteError`(`session/not-found` 或 `session/agent-busy`),Gateway 原样编码上 wire;只有未归类的 throw 才折成 `gateway/internal`。 @@ -156,7 +159,7 @@ pnpm run dev:web pnpm run build:lib ``` -运行中的 Client watcher 会在重新打包时消费这些生成文件。若已单独运行 `pnpm run build:lib:host` 刷新 Host 约定,也可再运行 `pnpm run build:lib:client` 完成 Client 侧;干净工作树不能跳过 Host 阶段。仅重新编译前端源码不能从 Host decorator 推导新类型。`pnpm run typecheck` 会执行 Host lib 阶段后再运行 Client tsc,CI 与发布构建也使用同一顺序。 +运行中的 Client watcher 会在重新打包时消费这些生成文件。若已单独运行 `pnpm run build:lib:host` 刷新 Host 约定,也可再运行 `pnpm run build:lib:client` 完成 Client 侧;干净工作树不能跳过 Host 阶段。仅重新编译前端源码不能从 Host decorator 推导新类型。`pnpm run typecheck` 先以不打包的 Typert 生成刷新 Host 约定,再运行 Client tsc;CI 的构建 lane 与发布构建则在 Client tsc 之前的 Host bundle 阶段生成它。 diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index 182bb9349e..49d05c0722 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.i18n.yaml @@ -6,5 +6,5 @@ en: 41d772192075e133 zh: 5ef3da69b4de1d8a /capability-seams-and-core-services: - en: 912784044de3337d - zh: 6a03f65751cda74a + en: d7b1a50795bcac28 + zh: 68386bdbf1869342 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 7619be90f4..333f570d5e 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -101,6 +101,8 @@ flowchart LR svc_typert["ctx.typert
Runtime type registry"] pkg_typert_loader["typert-loader"] svc_typertGateway["ctx.typertGateway
Typert Host invocation gateway"] + pkg_api_remote_access["api-remote-access"] + svc_remoteAccess["ctx.remoteAccess
Remote method access control"] svc_sessionPersistence["ctx.sessionPersistence
Durable session persistence seam"] pkg_session_persistence_jsonl["session-persistence-jsonl"] pkg_hooks_claude_code["hooks-claude-code"] @@ -272,6 +274,9 @@ flowchart LR svc_webServer["ctx.webServer
HTTP route registration"] pkg_client_modules["client-modules"] pkg_client_hmr["client-hmr"] + pkg_web_app["web-app"] + svc_webApp["ctx.webApp
Served Web GUI"] + pkg_harness_dev_context["harness-dev-context"] svc_clientModules["ctx.clientModules
Client plugin graph host"] pkg_workflow["workflow"] svc_workflowEngine["ctx.workflowEngine
Workflow script engine"] @@ -291,6 +296,7 @@ flowchart LR pkg_agent_preset_registry --> svc_agentPresets pkg_api_gateway --> svc_typertGateway pkg_api_job_controller --> svc_jobController + pkg_api_remote_access --> svc_remoteAccess pkg_api_session_controller --> svc_sessionController pkg_api_session_controller --> svc_sessionFileReferences pkg_api_session_controller --> svc_sessionSkillCatalog @@ -428,6 +434,7 @@ flowchart LR pkg_user_approval --> svc_approval pkg_user_questions --> svc_userQuestions pkg_web --> svc_web + pkg_web_app --> svc_webApp pkg_web_fetch_http --> svc_web pkg_web_search_deepseek --> svc_web pkg_web_search_exa --> svc_web @@ -496,6 +503,9 @@ flowchart LR svc_profileContext --> pkg_plugin_manager svc_ptcRuntime --> pkg_tools svc_ptcRuntime --> pkg_workflow_ptc + svc_remoteAccess --> pkg_api_gateway + svc_remoteAccess --> pkg_api_session_controller + svc_remoteAccess --> pkg_api_workspace_controller svc_sandbox --> pkg_bash_sandbox svc_sandbox --> pkg_terminal_bash svc_sandboxPolicy --> pkg_bash_sandbox @@ -572,6 +582,7 @@ flowchart LR svc_typert --> pkg_typert_loader svc_userQuestions --> pkg_tool_ask_user svc_web --> pkg_tool_web + svc_webApp --> pkg_harness_dev_context svc_webServer --> pkg_client_connection svc_webServer --> pkg_client_hmr svc_webServer --> pkg_client_modules @@ -626,6 +637,7 @@ flowchart LR | `ctx.directoryPickerController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | Carries the picking seam onto the wire: capability gating, cancellation, and the seam-coded failures a browser directory flow discriminates on. | | `ctx.typert` | `core` | [`typert-registry`](../packages/typert/registry) | - | [`typert-loader`](../packages/typert/loader), [`api-gateway`](../packages/api/gateway) | - | Plugins register live zod contributions directly or through dsh-typert-loader; the API gateway consumes invocation descriptors and providers, while other runtime consumers query schemas and reflection metadata at their own edges. | | `ctx.typertGateway` | `core` | [`api-gateway`](../packages/api/gateway) | - | - | - | Associates generated Remote descriptors with live Cordis services, resolves registered identities, and exposes unary calls through the shared Connection RPC carrier. | +| `ctx.remoteAccess` | `core` | [`api-remote-access`](../packages/api/remote-access) | - | [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller) | - | Decides which Remote methods a non-owner Peer may call through business-registered role-based policies, filters list results, and streams each Peer access snapshot. | | `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session/session-persistence) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl) | [`agent-loop`](../packages/core/agent-loop), [`tool-bash`](../packages/shell/tool-bash), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`message-feedback`](../packages/feedback/message-feedback) | - | The JSONL backend persists the SessionEvent vocabulary as one artifact per Session. | | `ctx.configEditor` | `core` | [`config-editor`](../packages/boot/config-editor) | - | [`settings`](../packages/settings/settings), [`agent-default-model`](../packages/core/agent-default-model) | - | Persists profile config patches under the application file lock and HMR queue, then reconciles Loader entries. | | `ctx.settings` | `core` | [`settings`](../packages/settings/settings) | - | [`api-settings-controller`](../packages/api/settings-controller) | - | Forms project volatile Config fields from active profile entries and delegate validated edits to config-editor. Plugins consume their own Config references. | @@ -682,6 +694,7 @@ flowchart LR | `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | The backend saves oversized tool text and returns a model-facing locator plus retrieval hint; spill-policy is the tools/post-execute consumer that decides when to spill. | | `ctx.directoryPicker` | `seam` | [`host-directory-picker`](../packages/host/directory-picker) | [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | [`api-workspace-controller`](../packages/api/workspace-controller) | - | Discriminated interaction capability: the native backend opens one OS chooser on the host display, the browse backend serves listing/creation primitives for the in-app browser; dual-face backends fill ui-workspace directory-flow slots from their browser halves (no wire advertisement). | | `ctx.webServer` | `core` | [`host-webserver`](../packages/host/webserver) | - | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`client-hmr`](../packages/client/hmr) | - | Plain node:http carrier: named-route registry, index transform taps, and the static dist fallback; web-transport plugins register their own routes. | +| `ctx.webApp` | `core` | [`web-app`](../packages/bundle/web-app) | - | [`harness-dev-context`](../packages/context/harness-dev-context) | - | The web-runtime row advertises one URL for its Web GUI: the configured publicUrl, otherwise the listener origin; DSH_WEB_URL, the startup URL, and harness-dev-context read the same value. | | `ctx.clientModules` | `core` | [`client-modules`](../packages/client/modules) | - | [`client-hmr`](../packages/client/hmr) | - | Composes the __DSH_BOOT__ entry graph from an incremental dsh.client scan, serves plugin bundles, and notifies rebuilt/graph-changed subscribers. | | `ctx.workflowEngine` | `seam` | [`workflow`](../packages/workflow/workflow) | [`workflow-ptc`](../packages/workflow/workflow-ptc) | [`tool-workflow`](../packages/workflow/tool-workflow), [`tool-ralph`](../packages/experimental/tool-ralph) | - | One engine per context, as in bash, with no named-provider registry; the general workflow and fixed Ralph consumers start runs whose agent() calls fan out through ctx.subagents. | | `ctx.webhookRuntime` | `core` | [`webhook`](../packages/experimental/webhook) | - | [`webhook-github`](../packages/experimental/webhook-github) | - | Provider adapters dispatch authenticated deliveries; trusted plugins register independent process-local rules, and the runtime turns non-null results into ordinary Workspace-backed Sessions without delivery or completion state. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index b02ec2e41c..d810b4e900 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -103,6 +103,8 @@ flowchart LR svc_typert["ctx.typert
Runtime type registry"] pkg_typert_loader["typert-loader"] svc_typertGateway["ctx.typertGateway
Typert Host invocation gateway"] + pkg_api_remote_access["api-remote-access"] + svc_remoteAccess["ctx.remoteAccess
Remote method access control"] svc_sessionPersistence["ctx.sessionPersistence
Durable session persistence seam"] pkg_session_persistence_jsonl["session-persistence-jsonl"] pkg_hooks_claude_code["hooks-claude-code"] @@ -274,6 +276,9 @@ flowchart LR svc_webServer["ctx.webServer
HTTP route registration"] pkg_client_modules["client-modules"] pkg_client_hmr["client-hmr"] + pkg_web_app["web-app"] + svc_webApp["ctx.webApp
Served Web GUI"] + pkg_harness_dev_context["harness-dev-context"] svc_clientModules["ctx.clientModules
Client plugin graph host"] pkg_workflow["workflow"] svc_workflowEngine["ctx.workflowEngine
Workflow script engine"] @@ -293,6 +298,7 @@ flowchart LR pkg_agent_preset_registry --> svc_agentPresets pkg_api_gateway --> svc_typertGateway pkg_api_job_controller --> svc_jobController + pkg_api_remote_access --> svc_remoteAccess pkg_api_session_controller --> svc_sessionController pkg_api_session_controller --> svc_sessionFileReferences pkg_api_session_controller --> svc_sessionSkillCatalog @@ -430,6 +436,7 @@ flowchart LR pkg_user_approval --> svc_approval pkg_user_questions --> svc_userQuestions pkg_web --> svc_web + pkg_web_app --> svc_webApp pkg_web_fetch_http --> svc_web pkg_web_search_deepseek --> svc_web pkg_web_search_exa --> svc_web @@ -498,6 +505,9 @@ flowchart LR svc_profileContext --> pkg_plugin_manager svc_ptcRuntime --> pkg_tools svc_ptcRuntime --> pkg_workflow_ptc + svc_remoteAccess --> pkg_api_gateway + svc_remoteAccess --> pkg_api_session_controller + svc_remoteAccess --> pkg_api_workspace_controller svc_sandbox --> pkg_bash_sandbox svc_sandbox --> pkg_terminal_bash svc_sandboxPolicy --> pkg_bash_sandbox @@ -574,6 +584,7 @@ flowchart LR svc_typert --> pkg_typert_loader svc_userQuestions --> pkg_tool_ask_user svc_web --> pkg_tool_web + svc_webApp --> pkg_harness_dev_context svc_webServer --> pkg_client_connection svc_webServer --> pkg_client_hmr svc_webServer --> pkg_client_modules @@ -628,6 +639,7 @@ flowchart LR | `ctx.directoryPickerController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | 把选目录 seam 送上线:能力门禁、取消传播,以及浏览器目录流程用于分支判断的 seam 错误码。 | | `ctx.typert` | `core` | [`typert-registry`](../packages/typert/registry) | - | [`typert-loader`](../packages/typert/loader), [`api-gateway`](../packages/api/gateway) | - | 插件直接或通过 dsh-typert-loader 注册实时 zod 贡献;API 网关消费调用描述符和提供方,其他运行时消费方则在各自边界查询 schema 与反射元数据。 | | `ctx.typertGateway` | `core` | [`api-gateway`](../packages/api/gateway) | - | - | - | 将生成的 Remote 描述符与实时 Cordis 服务关联,解析已注册的身份,并通过共享的 Connection RPC 载体提供一元调用。 | +| `ctx.remoteAccess` | `core` | [`api-remote-access`](../packages/api/remote-access) | - | [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller) | - | 通过业务方注册的基于 role 的 policy 判定非 owner Peer 可调用哪些 Remote 方法,过滤列表结果,并为每个 Peer 推送访问快照。 | | `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session/session-persistence) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl) | [`agent-loop`](../packages/core/agent-loop), [`tool-bash`](../packages/shell/tool-bash), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`message-feedback`](../packages/feedback/message-feedback) | - | JSONL backend 把 SessionEvent 词汇持久化为每个 Session 一份产物。 | | `ctx.configEditor` | `core` | [`config-editor`](../packages/boot/config-editor) | - | [`settings`](../packages/settings/settings), [`agent-default-model`](../packages/core/agent-default-model) | - | Persists profile config patches under the application file lock and HMR queue, then reconciles Loader entries. | | `ctx.settings` | `core` | [`settings`](../packages/settings/settings) | - | [`api-settings-controller`](../packages/api/settings-controller) | - | Forms project volatile Config fields from active profile entries and delegate validated edits to config-editor. Plugins consume their own Config references. | @@ -684,6 +696,7 @@ flowchart LR | `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | 后端保存过大的工具文本,并返回面向模型的定位信息和取回提示;spill-policy 是 tools/post-execute 消费方,负责决定何时 spill。 | | `ctx.directoryPicker` | `seam` | [`host-directory-picker`](../packages/host/directory-picker) | [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | [`api-workspace-controller`](../packages/api/workspace-controller) | - | 带判别标记的交互能力:原生后端在 Host 显示设备上打开一个操作系统选择器,浏览后端为应用内浏览器提供列表与创建原语;双端后端通过其浏览器侧填充 ui-workspace 目录流程的 slot(不通过协议发布)。 | | `ctx.webServer` | `core` | [`host-webserver`](../packages/host/webserver) | - | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`client-hmr`](../packages/client/hmr) | - | 普通的 node:http 载体:具名路由注册表、索引转换 tap,以及静态 dist 回退;Web 传输插件注册自己的路由。 | +| `ctx.webApp` | `core` | [`web-app`](../packages/bundle/web-app) | - | [`harness-dev-context`](../packages/context/harness-dev-context) | - | web-runtime 行为其 Web GUI 公告一个 URL:配置了 publicUrl 时用它,否则用监听器 origin;DSH_WEB_URL、启动 URL 与 harness-dev-context 读取同一个值。 | | `ctx.clientModules` | `core` | [`client-modules`](../packages/client/modules) | - | [`client-hmr`](../packages/client/hmr) | - | 通过增量 `dsh.client` 扫描组合 __DSH_BOOT__ 入口图,提供插件组合包,并通知重建/图变更订阅方。 | | `ctx.workflowEngine` | `seam` | [`workflow`](../packages/workflow/workflow) | [`workflow-ptc`](../packages/workflow/workflow-ptc) | [`tool-workflow`](../packages/workflow/tool-workflow), [`tool-ralph`](../packages/experimental/tool-ralph) | - | 每个上下文使用一个引擎,与 bash 相同,且没有具名提供方注册表;通用工作流与固定 Ralph 消费方启动运行,其中的 agent() 调用通过 ctx.subagents 扇出。 | | `ctx.webhookRuntime` | `core` | [`webhook`](../packages/experimental/webhook) | - | [`webhook-github`](../packages/experimental/webhook-github) | - | 提供方适配器分派已认证交付;可信插件注册独立的进程本地规则,runtime 把非 null 结果转换为普通的 Workspace-backed Session,不保留交付或完成状态。 | diff --git a/docs/cli-help.md b/docs/cli-help.md index 9796709587..c14b06ac73 100644 --- a/docs/cli-help.md +++ b/docs/cli-help.md @@ -180,7 +180,7 @@ Options: --port listen port; pass 0 to let the OS pick a free one --public-url advertise this HTTP(S) root in the printed, - opened, web-surface, and DSH_WEB_URL forms; + opened, runtime-context, and DSH_WEB_URL forms; grants no trust --trusted-host extra authority the /api browser-trust fence accepts (host or host:port; repeatable) diff --git a/docs/cli-help.zh.md b/docs/cli-help.zh.md index 54b10f9d16..f646eac073 100644 --- a/docs/cli-help.zh.md +++ b/docs/cli-help.zh.md @@ -182,7 +182,7 @@ Options: --port listen port; pass 0 to let the OS pick a free one --public-url advertise this HTTP(S) root in the printed, - opened, web-surface, and DSH_WEB_URL forms; + opened, runtime-context, and DSH_WEB_URL forms; grants no trust --trusted-host extra authority the /api browser-trust fence accepts (host or host:port; repeatable) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 02f1c6a468..8bfae862ef 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -181,8 +181,8 @@ export interface Config { ## `@deepseek-ai/dsh-api-gateway` -- `inject`: `typert` -- `source`: [`packages/api/gateway/src/index.ts:146`](../packages/api/gateway/src/index.ts) +- `inject`: `typert` · `remoteAccess` +- `source`: [`packages/api/gateway/src/index.ts:155`](../packages/api/gateway/src/index.ts) ```ts config-catalog /** Gateway transport configuration. */ @@ -219,8 +219,8 @@ export interface Config { ## `@deepseek-ai/dsh-api-session-controller` -- `inject`: `agentDefaultModel` · `agents` · `attachments` · `fileUploads` · `fs` · `llm` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry` -- `source`: [`packages/api/session-controller/src/index.ts:80`](../packages/api/session-controller/src/index.ts) +- `inject`: `agentDefaultModel` · `agents` · `attachments` · `fileUploads` · `fs` · `llm` · `remoteAccess` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry` +- `source`: [`packages/api/session-controller/src/index.ts:94`](../packages/api/session-controller/src/index.ts) ```ts config-catalog /** Session Controller deployment policy. */ @@ -279,9 +279,9 @@ export interface Config { readonly maxRows: number /** Screen history rows retained for reconnecting clients. */ readonly scrollback: number - /** Maximum queued UTF-8 frame bytes per output follower before disconnection. */ + /** Maximum queued UTF-8 frame bytes per output follower and in the Client input buffer. */ readonly maxBufferedBytes: number - /** Maximum UTF-8 bytes in one input request. */ + /** Maximum raw UTF-8 bytes per input command and across unacknowledged Client input. */ readonly maxInputBytes: number /** Provider process-termination grace period in milliseconds. */ readonly disposeGraceMs: number @@ -300,8 +300,8 @@ export interface Config { ## `@deepseek-ai/dsh-api-workspace-controller` -- `inject`: `typert` · `workspaceRegistry` -- `source`: [`packages/api/workspace-controller/src/index.ts:33`](../packages/api/workspace-controller/src/index.ts) +- `inject`: `remoteAccess` · `typert` · `workspaceRegistry` +- `source`: [`packages/api/workspace-controller/src/index.ts:37`](../packages/api/workspace-controller/src/index.ts) ```ts config-catalog /** First-use directory policy for the Host account. */ @@ -435,7 +435,7 @@ export type Config = LocalConfig ## `@deepseek-ai/dsh-client-connection` - `inject`: `credentials` -- `source`: [`packages/client/connection/src/index.ts:92`](../packages/client/connection/src/index.ts) +- `source`: [`packages/client/connection/src/index.ts:93`](../packages/client/connection/src/index.ts) ```ts config-catalog /** Browser authentication, request limits, and connection recovery configuration. */ @@ -1253,6 +1253,8 @@ export interface Config { ```ts config-catalog /** Tool routing configuration. */ export interface Config { + /** Register `interrupt_agent` and its policy guidance. Defaults to true; false leaves the domain API available. */ + readonly interrupt?: boolean /** Continuable-subagent provider used for fresh teammates. */ readonly freshProvider?: string /** Continuable-subagent provider used for completed-prefix fork teammates. */ @@ -1435,7 +1437,7 @@ export interface Config { ## `@deepseek-ai/dsh-hmr` - `refs`: `ChokidarOptions` (`chokidar`) -- `source`: [`packages/boot/hmr/src/index.ts:53`](../packages/boot/hmr/src/index.ts) +- `source`: [`packages/boot/hmr/src/index.ts:56`](../packages/boot/hmr/src/index.ts) ```ts config-catalog /** Module roots and watcher timing, with Chokidar deployment options. */ @@ -1679,7 +1681,7 @@ export interface TlsConfig { ## `@deepseek-ai/dsh-jobs-local` -- `source`: [`packages/jobs/jobs-local/src/index.ts:45`](../packages/jobs/jobs-local/src/index.ts) +- `source`: [`packages/jobs/jobs-local/src/index.ts:46`](../packages/jobs/jobs-local/src/index.ts) ```ts config-catalog /** Configuration for the process-local job registry. */ @@ -1709,12 +1711,25 @@ export interface Config { ## `@deepseek-ai/dsh-llm-deepseek-account` - `inject`: `llm` -- `refs`: [`ProtocolConfig`](../packages/llm/llm-deepseek/src/index.ts) -- `source`: [`packages/llm/llm-deepseek-account/src/config.ts:5`](../packages/llm/llm-deepseek-account/src/config.ts) +- `refs`: [`DeepSeekCatalogModel`](../packages/llm/llm-deepseek/src/index.ts) · [`ProtocolConfig`](../packages/llm/llm-deepseek/src/index.ts) · `Volatile` (`@deepseek-ai/cordis`) +- `source`: [`packages/llm/llm-deepseek-account/src/config.ts:19`](../packages/llm/llm-deepseek-account/src/config.ts) ```ts config-catalog /** Account route configuration; authentication comes exclusively from the account service. */ -export type Config = ProtocolConfig +export interface Config extends Omit { + /** Deadline of one listing request, which also bounds a first-use wait (default five seconds). */ + modelsTimeoutMs: Volatile + /** + * Listing age after which the next access refreshes it in the background; + * a failed load retries after the same age (default ten minutes). + */ + modelsMaxAgeMs: Volatile + /** Field overrides by model id, layered over the account listing and the built-in catalog. */ + modelOverrides: Volatile> +} + +/** Field overrides for one model; unset fields and empty lists inherit the underlying entry. */ +export type ModelOverride = Omit ``` @@ -2754,10 +2769,10 @@ export type SessionLogCompressionLevel = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 ## `@deepseek-ai/dsh-session-persistence-jsonl` -- `source`: [`packages/session/session-persistence-jsonl/src/index.ts:90`](../packages/session/session-persistence-jsonl/src/index.ts) +- `source`: [`packages/session/session-persistence-jsonl/src/index.ts:94`](../packages/session/session-persistence-jsonl/src/index.ts) ```ts config-catalog -/** Plugin config for the JSONL backend's root and physical encoding. */ +/** Plugin config for the JSONL backend's root, physical encoding, and listing concurrency. */ export interface Config { /** * Root directory for all session files. Required (no default): a default of @@ -2769,6 +2784,12 @@ export interface Config { root: string /** Physical encoding; defaults to checksummed Zstandard frames. */ compression?: JsonlCompression + /** + * Maximum directory, header, and stat reads in flight across all concurrent + * listings of this backend (`list()`, the root encoding check, and historical + * fingerprints). Defaults to 3. + */ + listConcurrency?: number } /** Physical encoding selected for JSONL session artifacts. */ @@ -3062,7 +3083,7 @@ export interface Config { ```ts config-catalog /** Office skill resource location. */ export interface Config { - /** Absolute assets directory containing the three skill folders and shared scripts; defaults to packaged assets. */ + /** Absolute assets directory containing skill folders; defaults to packaged assets. */ assetRoot?: string /** Standalone Node executable; defaults to the current executable outside Electron and SEA. */ node?: string @@ -3261,7 +3282,7 @@ export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' - `inject`: `workingDirectory` - `refs`: `Volatile` (`@deepseek-ai/cordis`) -- `source`: [`packages/subagent/subagent/src/index.ts:184`](../packages/subagent/subagent/src/index.ts) +- `source`: [`packages/subagent/subagent/src/index.ts:186`](../packages/subagent/subagent/src/index.ts) ```ts config-catalog /** Host configuration for continuable subagent capacity. */ @@ -3477,7 +3498,7 @@ export interface Config { ## `@deepseek-ai/dsh-system-prompt` -- `source`: [`packages/core/system-prompt/src/index.ts:254`](../packages/core/system-prompt/src/index.ts) +- `source`: [`packages/core/system-prompt/src/index.ts:258`](../packages/core/system-prompt/src/index.ts) ```ts config-catalog /** Plugin config: the deployment-authored fragment of the system prompt (see {@link Config.personaPrefix} for its contract). */ @@ -3699,6 +3720,14 @@ export interface Config { ```ts config-catalog /** Plugin config (all optional — `Config` supplies the defaults). */ export interface Config { + /** Register `read` and its guidance. Defaults to true. */ + read?: boolean + /** Register `read_image` while an attachment store is mounted. Defaults to true. */ + readImage?: boolean + /** Register `write` and its guidance. Defaults to true. */ + write?: boolean + /** Register `edit` and its guidance. Defaults to true. */ + edit?: boolean /** Default and maximum number of lines returned by one `read` call. */ readLimit?: number /** Maximum characters returned for a single line before truncation. */ @@ -3977,11 +4006,11 @@ export interface Config { - `inject`: `tools` · `subagents` · `systemPrompt` · `sessionProjections` - `refs`: [`AgentOptions`](subsystems/core.md) -- `source`: [`packages/subagent/tool-subagent/src/index.ts:48`](../packages/subagent/tool-subagent/src/index.ts) +- `source`: [`packages/subagent/tool-subagent/src/index.ts:50`](../packages/subagent/tool-subagent/src/index.ts) ```ts config-catalog /** Config: which registered provider this tool delegates to, plus child defaults. */ -export interface Config { +export interface Config extends PresetSelectionConfig { /** The `ctx.subagents` provider name to start runs on (e.g. `spawn`, `acp`). */ provider: string /** @@ -3998,22 +4027,6 @@ export interface Config { * Agent options applied to every child; omitted fields use child-loop defaults. */ agentOptions?: AgentOptions - /** - * Per-child persona that shadows `deployment:persona-prefix`. Requires the - * provider's `persona` capability; omission preserves the deployment persona. - */ - persona?: string - /** - * Tool filter applied to every child. Filtered tools disappear from its - * prompt and reject execution. Requires the provider's `toolFilter` - * capability; unknown names fail startup. - */ - toolFilter?: { - /** Global tool names the child keeps; everything else is removed. */ - allow?: string[] - /** Global tool names removed from the child. */ - deny?: string[] - } /** * Maximum child depth: a non-negative safe integer (`0` forbids delegation), * or `'provider-managed'` to send no cap. A numeric cap @@ -4026,6 +4039,14 @@ export interface Config { */ maxDepth?: number | 'provider-managed' } + +/** Optional default and model-selectable preset ids for a spawn tool. */ +export interface PresetSelectionConfig { + /** Default independent preset; omission inherits the parent when the model makes no selection. */ + agentPreset?: string + /** Non-empty allowlist exposing agent_preset to the model; omission disables model selection. */ + allowedAgentPresets?: string[] | undefined +} ``` @@ -4266,7 +4287,7 @@ export interface WebRuntimeConfig { ## `@deepseek-ai/dsh-web-app` - `inject`: `webServer` -- `source`: [`packages/bundle/web-app/src/index.ts:43`](../packages/bundle/web-app/src/index.ts) +- `source`: [`packages/bundle/web-app/src/index.ts:55`](../packages/bundle/web-app/src/index.ts) ```ts config-catalog /** Plugin config: composed deployment settings plus per-invocation command-line values. */ @@ -4276,16 +4297,14 @@ export interface Config { /** Print the URL line on activation; a non-interactive layer can turn it off. */ printUrl: boolean /** - * Register the model-visible surface context (the `app:web-surface` prompt - * section and the `DSH_WEB_URL` bash variable). A one-shot non-interactive - * layer can turn it off when its user is not in the GUI, so the - * orientation text would be false. + * Register the `DSH_WEB_URL` bash variable naming this GUI. A one-shot + * non-interactive layer can turn it off when its user is not in the GUI. */ surfaceContext: boolean /** * Canonical HTTP(S) root to advertise in the printed and opened URL, - * `DSH_WEB_URL`, and the web-surface orientation, e.g. - * `https://app.example/ui/`, normalized to end in `/`. Configures no routing + * `DSH_WEB_URL`, and `webApp.getAdvertisedUrl()`, which supplies the Web GUI + * URL in agent runtime context, e.g. `https://app.example/ui/`, normalized to end in `/`. Configures no routing * or authentication; see [public deployments](../README.md#public-deployments). * Absent or YAML `null` advertises the bind-address URL. */ @@ -4502,6 +4521,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co | `@deepseek-ai/dsh-acp-app` | `cmdlineArgs` | [`packages/bundle/acp-app/src/index.ts`](../packages/bundle/acp-app/src/index.ts) | | `@deepseek-ai/dsh-agent` | — | [`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts) | | `@deepseek-ai/dsh-api-account-controller` | `deepseekAccount` · `agents` | [`packages/api/account-controller/src/index.ts`](../packages/api/account-controller/src/index.ts) | +| `@deepseek-ai/dsh-api-remote-access` | — | [`packages/api/remote-access/src/index.ts`](../packages/api/remote-access/src/index.ts) | | `@deepseek-ai/dsh-api-remotes` | `typertGateway` | [`packages/api/remotes/src/index.ts`](../packages/api/remotes/src/index.ts) | | `@deepseek-ai/dsh-authorization` | `credentials` | [`packages/credentials/authorization/src/index.ts`](../packages/credentials/authorization/src/index.ts) | | `@deepseek-ai/dsh-browser-use` | — | [`packages/browser-use/browser-use/src/index.ts`](../packages/browser-use/browser-use/src/index.ts) | @@ -4532,6 +4552,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co | `@deepseek-ai/dsh-client-ui-reference` | — | [`packages/client/ui-reference/src/index.ts`](../packages/client/ui-reference/src/index.ts) | | `@deepseek-ai/dsh-client-ui-renderer` | — | [`packages/client/ui-renderer/src/index.ts`](../packages/client/ui-renderer/src/index.ts) | | `@deepseek-ai/dsh-client-ui-schedule` | — | [`packages/client/ui-schedule/src/index.ts`](../packages/client/ui-schedule/src/index.ts) | +| `@deepseek-ai/dsh-client-ui-secret-mask` | — | [`packages/client/ui-secret-mask/src/index.ts`](../packages/client/ui-secret-mask/src/index.ts) | | `@deepseek-ai/dsh-client-ui-session` | — | [`packages/client/ui-session/src/index.ts`](../packages/client/ui-session/src/index.ts) | | `@deepseek-ai/dsh-client-ui-settings` | — | [`packages/client/ui-settings/src/index.ts`](../packages/client/ui-settings/src/index.ts) | | `@deepseek-ai/dsh-client-ui-settings-agent-loop` | — | [`packages/client/ui-settings-agent-loop/src/index.ts`](../packages/client/ui-settings-agent-loop/src/index.ts) | @@ -4574,6 +4595,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co | `@deepseek-ai/dsh-fs-observation-policy` | — | [`packages/fs/fs-observation-policy/src/index.ts`](../packages/fs/fs-observation-policy/src/index.ts) | | `@deepseek-ai/dsh-fs-ssh` | `ssh` · `sandboxPolicy` | [`packages/ssh/fs-ssh/src/index.ts`](../packages/ssh/fs-ssh/src/index.ts) | | `@deepseek-ai/dsh-goal-round-driver` | `agents` · `goals` · `sessions` | [`packages/goal/goal-round-driver/src/index.ts`](../packages/goal/goal-round-driver/src/index.ts) | +| `@deepseek-ai/dsh-harness-dev-context` | `systemPrompt` · `sessionProjections` · `webApp` | [`packages/context/harness-dev-context/src/index.ts`](../packages/context/harness-dev-context/src/index.ts) | | `@deepseek-ai/dsh-host-directory-picker-auto` | `webServer` · `connection` · `loader` | [`packages/host/directory-picker-auto/src/index.ts`](../packages/host/directory-picker-auto/src/index.ts) | | `@deepseek-ai/dsh-host-directory-picker-native` | — | [`packages/host/directory-picker-native/src/index.ts`](../packages/host/directory-picker-native/src/index.ts) | | `@deepseek-ai/dsh-host-plugin-inventory` | `loader` | [`packages/host/plugin-inventory/src/index.ts`](../packages/host/plugin-inventory/src/index.ts) | diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 330783e10a..68dfbf1852 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -183,8 +183,8 @@ export interface Config { ## `@deepseek-ai/dsh-api-gateway` -- `inject`: `typert` -- `source`: [`packages/api/gateway/src/index.ts:146`](../packages/api/gateway/src/index.ts) +- `inject`: `typert` · `remoteAccess` +- `source`: [`packages/api/gateway/src/index.ts:155`](../packages/api/gateway/src/index.ts) ```ts config-catalog /** Gateway transport configuration. */ @@ -221,8 +221,8 @@ export interface Config { ## `@deepseek-ai/dsh-api-session-controller` -- `inject`: `agentDefaultModel` · `agents` · `attachments` · `fileUploads` · `fs` · `llm` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry` -- `source`: [`packages/api/session-controller/src/index.ts:80`](../packages/api/session-controller/src/index.ts) +- `inject`: `agentDefaultModel` · `agents` · `attachments` · `fileUploads` · `fs` · `llm` · `remoteAccess` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry` +- `source`: [`packages/api/session-controller/src/index.ts:94`](../packages/api/session-controller/src/index.ts) ```ts config-catalog /** Session Controller deployment policy. */ @@ -281,9 +281,9 @@ export interface Config { readonly maxRows: number /** Screen history rows retained for reconnecting clients. */ readonly scrollback: number - /** Maximum queued UTF-8 frame bytes per output follower before disconnection. */ + /** Maximum queued UTF-8 frame bytes per output follower and in the Client input buffer. */ readonly maxBufferedBytes: number - /** Maximum UTF-8 bytes in one input request. */ + /** Maximum raw UTF-8 bytes per input command and across unacknowledged Client input. */ readonly maxInputBytes: number /** Provider process-termination grace period in milliseconds. */ readonly disposeGraceMs: number @@ -302,8 +302,8 @@ export interface Config { ## `@deepseek-ai/dsh-api-workspace-controller` -- `inject`: `typert` · `workspaceRegistry` -- `source`: [`packages/api/workspace-controller/src/index.ts:33`](../packages/api/workspace-controller/src/index.ts) +- `inject`: `remoteAccess` · `typert` · `workspaceRegistry` +- `source`: [`packages/api/workspace-controller/src/index.ts:37`](../packages/api/workspace-controller/src/index.ts) ```ts config-catalog /** First-use directory policy for the Host account. */ @@ -437,7 +437,7 @@ export type Config = LocalConfig ## `@deepseek-ai/dsh-client-connection` - `inject`: `credentials` -- `source`: [`packages/client/connection/src/index.ts:92`](../packages/client/connection/src/index.ts) +- `source`: [`packages/client/connection/src/index.ts:93`](../packages/client/connection/src/index.ts) ```ts config-catalog /** Browser authentication, request limits, and connection recovery configuration. */ @@ -1255,6 +1255,8 @@ export interface Config { ```ts config-catalog /** Tool routing configuration. */ export interface Config { + /** Register `interrupt_agent` and its policy guidance. Defaults to true; false leaves the domain API available. */ + readonly interrupt?: boolean /** Continuable-subagent provider used for fresh teammates. */ readonly freshProvider?: string /** Continuable-subagent provider used for completed-prefix fork teammates. */ @@ -1437,7 +1439,7 @@ export interface Config { ## `@deepseek-ai/dsh-hmr` - `refs`: `ChokidarOptions` (`chokidar`) -- `source`: [`packages/boot/hmr/src/index.ts:53`](../packages/boot/hmr/src/index.ts) +- `source`: [`packages/boot/hmr/src/index.ts:56`](../packages/boot/hmr/src/index.ts) ```ts config-catalog /** Module roots and watcher timing, with Chokidar deployment options. */ @@ -1681,7 +1683,7 @@ export interface TlsConfig { ## `@deepseek-ai/dsh-jobs-local` -- `source`: [`packages/jobs/jobs-local/src/index.ts:45`](../packages/jobs/jobs-local/src/index.ts) +- `source`: [`packages/jobs/jobs-local/src/index.ts:46`](../packages/jobs/jobs-local/src/index.ts) ```ts config-catalog /** Configuration for the process-local job registry. */ @@ -1711,12 +1713,25 @@ export interface Config { ## `@deepseek-ai/dsh-llm-deepseek-account` - `inject`: `llm` -- `refs`: [`ProtocolConfig`](../packages/llm/llm-deepseek/src/index.ts) -- `source`: [`packages/llm/llm-deepseek-account/src/config.ts:5`](../packages/llm/llm-deepseek-account/src/config.ts) +- `refs`: [`DeepSeekCatalogModel`](../packages/llm/llm-deepseek/src/index.ts) · [`ProtocolConfig`](../packages/llm/llm-deepseek/src/index.ts) · `Volatile` (`@deepseek-ai/cordis`) +- `source`: [`packages/llm/llm-deepseek-account/src/config.ts:19`](../packages/llm/llm-deepseek-account/src/config.ts) ```ts config-catalog /** Account route configuration; authentication comes exclusively from the account service. */ -export type Config = ProtocolConfig +export interface Config extends Omit { + /** Deadline of one listing request, which also bounds a first-use wait (default five seconds). */ + modelsTimeoutMs: Volatile + /** + * Listing age after which the next access refreshes it in the background; + * a failed load retries after the same age (default ten minutes). + */ + modelsMaxAgeMs: Volatile + /** Field overrides by model id, layered over the account listing and the built-in catalog. */ + modelOverrides: Volatile> +} + +/** Field overrides for one model; unset fields and empty lists inherit the underlying entry. */ +export type ModelOverride = Omit ``` @@ -2756,10 +2771,10 @@ export type SessionLogCompressionLevel = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 ## `@deepseek-ai/dsh-session-persistence-jsonl` -- `source`: [`packages/session/session-persistence-jsonl/src/index.ts:90`](../packages/session/session-persistence-jsonl/src/index.ts) +- `source`: [`packages/session/session-persistence-jsonl/src/index.ts:94`](../packages/session/session-persistence-jsonl/src/index.ts) ```ts config-catalog -/** Plugin config for the JSONL backend's root and physical encoding. */ +/** Plugin config for the JSONL backend's root, physical encoding, and listing concurrency. */ export interface Config { /** * Root directory for all session files. Required (no default): a default of @@ -2771,6 +2786,12 @@ export interface Config { root: string /** Physical encoding; defaults to checksummed Zstandard frames. */ compression?: JsonlCompression + /** + * Maximum directory, header, and stat reads in flight across all concurrent + * listings of this backend (`list()`, the root encoding check, and historical + * fingerprints). Defaults to 3. + */ + listConcurrency?: number } /** Physical encoding selected for JSONL session artifacts. */ @@ -3064,7 +3085,7 @@ export interface Config { ```ts config-catalog /** Office skill resource location. */ export interface Config { - /** Absolute assets directory containing the three skill folders and shared scripts; defaults to packaged assets. */ + /** Absolute assets directory containing skill folders; defaults to packaged assets. */ assetRoot?: string /** Standalone Node executable; defaults to the current executable outside Electron and SEA. */ node?: string @@ -3263,7 +3284,7 @@ export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' - `inject`: `workingDirectory` - `refs`: `Volatile` (`@deepseek-ai/cordis`) -- `source`: [`packages/subagent/subagent/src/index.ts:184`](../packages/subagent/subagent/src/index.ts) +- `source`: [`packages/subagent/subagent/src/index.ts:186`](../packages/subagent/subagent/src/index.ts) ```ts config-catalog /** Host configuration for continuable subagent capacity. */ @@ -3479,7 +3500,7 @@ export interface Config { ## `@deepseek-ai/dsh-system-prompt` -- `source`: [`packages/core/system-prompt/src/index.ts:254`](../packages/core/system-prompt/src/index.ts) +- `source`: [`packages/core/system-prompt/src/index.ts:258`](../packages/core/system-prompt/src/index.ts) ```ts config-catalog /** Plugin config: the deployment-authored fragment of the system prompt (see {@link Config.personaPrefix} for its contract). */ @@ -3701,6 +3722,14 @@ export interface Config { ```ts config-catalog /** Plugin config (all optional — `Config` supplies the defaults). */ export interface Config { + /** Register `read` and its guidance. Defaults to true. */ + read?: boolean + /** Register `read_image` while an attachment store is mounted. Defaults to true. */ + readImage?: boolean + /** Register `write` and its guidance. Defaults to true. */ + write?: boolean + /** Register `edit` and its guidance. Defaults to true. */ + edit?: boolean /** Default and maximum number of lines returned by one `read` call. */ readLimit?: number /** Maximum characters returned for a single line before truncation. */ @@ -3979,11 +4008,11 @@ export interface Config { - `inject`: `tools` · `subagents` · `systemPrompt` · `sessionProjections` - `refs`: [`AgentOptions`](subsystems/core.zh.md) -- `source`: [`packages/subagent/tool-subagent/src/index.ts:48`](../packages/subagent/tool-subagent/src/index.ts) +- `source`: [`packages/subagent/tool-subagent/src/index.ts:50`](../packages/subagent/tool-subagent/src/index.ts) ```ts config-catalog /** Config: which registered provider this tool delegates to, plus child defaults. */ -export interface Config { +export interface Config extends PresetSelectionConfig { /** The `ctx.subagents` provider name to start runs on (e.g. `spawn`, `acp`). */ provider: string /** @@ -4000,22 +4029,6 @@ export interface Config { * Agent options applied to every child; omitted fields use child-loop defaults. */ agentOptions?: AgentOptions - /** - * Per-child persona that shadows `deployment:persona-prefix`. Requires the - * provider's `persona` capability; omission preserves the deployment persona. - */ - persona?: string - /** - * Tool filter applied to every child. Filtered tools disappear from its - * prompt and reject execution. Requires the provider's `toolFilter` - * capability; unknown names fail startup. - */ - toolFilter?: { - /** Global tool names the child keeps; everything else is removed. */ - allow?: string[] - /** Global tool names removed from the child. */ - deny?: string[] - } /** * Maximum child depth: a non-negative safe integer (`0` forbids delegation), * or `'provider-managed'` to send no cap. A numeric cap @@ -4028,6 +4041,14 @@ export interface Config { */ maxDepth?: number | 'provider-managed' } + +/** Optional default and model-selectable preset ids for a spawn tool. */ +export interface PresetSelectionConfig { + /** Default independent preset; omission inherits the parent when the model makes no selection. */ + agentPreset?: string + /** Non-empty allowlist exposing agent_preset to the model; omission disables model selection. */ + allowedAgentPresets?: string[] | undefined +} ``` @@ -4268,7 +4289,7 @@ export interface WebRuntimeConfig { ## `@deepseek-ai/dsh-web-app` - `inject`: `webServer` -- `source`: [`packages/bundle/web-app/src/index.ts:43`](../packages/bundle/web-app/src/index.ts) +- `source`: [`packages/bundle/web-app/src/index.ts:55`](../packages/bundle/web-app/src/index.ts) ```ts config-catalog /** Plugin config: composed deployment settings plus per-invocation command-line values. */ @@ -4278,16 +4299,14 @@ export interface Config { /** Print the URL line on activation; a non-interactive layer can turn it off. */ printUrl: boolean /** - * Register the model-visible surface context (the `app:web-surface` prompt - * section and the `DSH_WEB_URL` bash variable). A one-shot non-interactive - * layer can turn it off when its user is not in the GUI, so the - * orientation text would be false. + * Register the `DSH_WEB_URL` bash variable naming this GUI. A one-shot + * non-interactive layer can turn it off when its user is not in the GUI. */ surfaceContext: boolean /** * Canonical HTTP(S) root to advertise in the printed and opened URL, - * `DSH_WEB_URL`, and the web-surface orientation, e.g. - * `https://app.example/ui/`, normalized to end in `/`. Configures no routing + * `DSH_WEB_URL`, and `webApp.getAdvertisedUrl()`, which supplies the Web GUI + * URL in agent runtime context, e.g. `https://app.example/ui/`, normalized to end in `/`. Configures no routing * or authentication; see [public deployments](../README.md#public-deployments). * Absent or YAML `null` advertises the bind-address URL. */ @@ -4504,6 +4523,7 @@ export interface Config { | `@deepseek-ai/dsh-acp-app` | `cmdlineArgs` | [`packages/bundle/acp-app/src/index.ts`](../packages/bundle/acp-app/src/index.ts) | | `@deepseek-ai/dsh-agent` | — | [`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts) | | `@deepseek-ai/dsh-api-account-controller` | `deepseekAccount` · `agents` | [`packages/api/account-controller/src/index.ts`](../packages/api/account-controller/src/index.ts) | +| `@deepseek-ai/dsh-api-remote-access` | — | [`packages/api/remote-access/src/index.ts`](../packages/api/remote-access/src/index.ts) | | `@deepseek-ai/dsh-api-remotes` | `typertGateway` | [`packages/api/remotes/src/index.ts`](../packages/api/remotes/src/index.ts) | | `@deepseek-ai/dsh-authorization` | `credentials` | [`packages/credentials/authorization/src/index.ts`](../packages/credentials/authorization/src/index.ts) | | `@deepseek-ai/dsh-browser-use` | — | [`packages/browser-use/browser-use/src/index.ts`](../packages/browser-use/browser-use/src/index.ts) | @@ -4534,6 +4554,7 @@ export interface Config { | `@deepseek-ai/dsh-client-ui-reference` | — | [`packages/client/ui-reference/src/index.ts`](../packages/client/ui-reference/src/index.ts) | | `@deepseek-ai/dsh-client-ui-renderer` | — | [`packages/client/ui-renderer/src/index.ts`](../packages/client/ui-renderer/src/index.ts) | | `@deepseek-ai/dsh-client-ui-schedule` | — | [`packages/client/ui-schedule/src/index.ts`](../packages/client/ui-schedule/src/index.ts) | +| `@deepseek-ai/dsh-client-ui-secret-mask` | — | [`packages/client/ui-secret-mask/src/index.ts`](../packages/client/ui-secret-mask/src/index.ts) | | `@deepseek-ai/dsh-client-ui-session` | — | [`packages/client/ui-session/src/index.ts`](../packages/client/ui-session/src/index.ts) | | `@deepseek-ai/dsh-client-ui-settings` | — | [`packages/client/ui-settings/src/index.ts`](../packages/client/ui-settings/src/index.ts) | | `@deepseek-ai/dsh-client-ui-settings-agent-loop` | — | [`packages/client/ui-settings-agent-loop/src/index.ts`](../packages/client/ui-settings-agent-loop/src/index.ts) | @@ -4576,6 +4597,7 @@ export interface Config { | `@deepseek-ai/dsh-fs-observation-policy` | — | [`packages/fs/fs-observation-policy/src/index.ts`](../packages/fs/fs-observation-policy/src/index.ts) | | `@deepseek-ai/dsh-fs-ssh` | `ssh` · `sandboxPolicy` | [`packages/ssh/fs-ssh/src/index.ts`](../packages/ssh/fs-ssh/src/index.ts) | | `@deepseek-ai/dsh-goal-round-driver` | `agents` · `goals` · `sessions` | [`packages/goal/goal-round-driver/src/index.ts`](../packages/goal/goal-round-driver/src/index.ts) | +| `@deepseek-ai/dsh-harness-dev-context` | `systemPrompt` · `sessionProjections` · `webApp` | [`packages/context/harness-dev-context/src/index.ts`](../packages/context/harness-dev-context/src/index.ts) | | `@deepseek-ai/dsh-host-directory-picker-auto` | `webServer` · `connection` · `loader` | [`packages/host/directory-picker-auto/src/index.ts`](../packages/host/directory-picker-auto/src/index.ts) | | `@deepseek-ai/dsh-host-directory-picker-native` | — | [`packages/host/directory-picker-native/src/index.ts`](../packages/host/directory-picker-native/src/index.ts) | | `@deepseek-ai/dsh-host-plugin-inventory` | `loader` | [`packages/host/plugin-inventory/src/index.ts`](../packages/host/plugin-inventory/src/index.ts) | diff --git a/docs/defensive-patterns/remote-and-api.i18n.yaml b/docs/defensive-patterns/remote-and-api.i18n.yaml index e36bfb5699..c16a005c98 100644 --- a/docs/defensive-patterns/remote-and-api.i18n.yaml +++ b/docs/defensive-patterns/remote-and-api.i18n.yaml @@ -9,8 +9,8 @@ en: 5759a7b0f96ea4bc zh: 62465fbe91a4d2af /remote-and-api-rules/remote-marks-capability-methods: - en: e5613ec07023429d - zh: 9272c9ae5718060a + en: 86422ee69c63f339 + zh: 4a5e8d6f5d9a6ae3 /remote-and-api-rules/packages-api-carries-generic-orchestration-only: en: 12a964b9c04fccf9 zh: cf804f8044535727 diff --git a/docs/defensive-patterns/remote-and-api.md b/docs/defensive-patterns/remote-and-api.md index 25281e96fb..7f2d0646c9 100644 --- a/docs/defensive-patterns/remote-and-api.md +++ b/docs/defensive-patterns/remote-and-api.md @@ -44,13 +44,14 @@ One object then answers for both domains, so a change to either capability's act ## `@Remote` marks capability methods -`@Remote` carries four disciplines that a reviewer checks separately: it marks the concrete method that performs a capability; client-facing types come from the existing definitions through the generator instead of a hand-written copy; the payload is JSON and the remote method performs no output-side decode; and existing method and field names are additive. Each discipline removes a different failure — a wrapper hides which operation is exposed, a second hand-written type set drifts from the generated one, an output-side decode adds a second result path beside the codec the Gateway already runs, and a rename breaks every caller in the same change. +`@Remote` carries five disciplines that a reviewer checks separately: it marks the concrete method that performs a capability; client-facing types come from the existing definitions through the generator instead of a hand-written copy; the payload is JSON and the remote method performs no output-side decode; existing method and field names are additive; and a method names every resource it touches and reads its caller only from the invocation. Each discipline removes a different failure — a wrapper hides which operation is exposed, a second hand-written type set drifts from the generated one, an output-side decode adds a second result path beside the codec the Gateway already runs, a rename breaks every caller in the same change, and an unnamed resource, an unfiltered aggregate, or an argument-supplied caller lets a non-owner reach what no binding grants it. - **Capability methods.** The annotation goes on the concrete method that performs the operation (`list`, `prompt`, `interruptByParent`), never on a wrapper that forwards to the real implementation. Judged by the decorated method's body: a body that only delegates means the annotation is on the wrong method. - **Types from inference.** Client-facing types are aliased from what the generator produces — `type SessionRemote = TypertClientRemote['session']` — instead of being written a second time. Judged by a hand-written declaration whose fields repeat a generated Client type; [typert.md](../subsystems/typert.md) owns the generated maps and their declaration merges. - **JSON on the wire, no output-side decode.** The **wire payload** of a Remote method is JSON, with binary content expressed field-level. The Host signature keeps what the Gateway already supports — lookup arguments resolved to Host objects, a trailing `AbortSignal`, a stream declared with `@Remote({ mode: 'stream' })` — and `@Remote` performs no decode on the way out; the generated codec and the Gateway validate the result. Judged by a decode, a stream reassembly, or a whole non-JSON value in the payload of a method carrying the decorator; [programming model](../api-gateway.md#programming-model) owns what may cross a Remote method. - **Existing names are additive.** A migration does not rename a remote method or field that a Client already calls; a new semantic gets a new entry, and a rename is its own change with its own review. Judged by a diff that renames or removes a name already exposed through `ctx.remote.`. -- **Not covered.** A method without the decorator stays an ordinary service method with in-process callers; the four disciplines govern what becomes a Remote method, not what a service may implement. +- **Caller identity and access.** Every Remote method is under access control: the `@Remote({ resources })` / `@RemoteScope(key, { resources })` readers and automatic resolution must name every resource the method touches, because access control never checks a resource they omit, and a method returning several resources filters each item through `this.ctx.remoteAccess.allows(resource)`. The caller is read only from `this.ctx.invocation.peer.principal`, never from arguments; a Remote method that builds a user-message source records it through `principalOf(this.ctx.invocation)` and passes the built source down instead of passing the principal as a function argument. Every call path names its Peer explicitly. Judged by a method whose resolved resources omit one it touches, an unfiltered aggregate, a principal read from wire data or threaded through helper parameters, or a user-message source built in a Remote method without `principalOf`; the [Peer principals Agent Note](../../.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md) owns the design. +- **Not covered.** A method without the decorator stays an ordinary service method with in-process callers; the five disciplines govern what becomes a Remote method, not what a service may implement. - **Exception.** A rename or a new decode path states its benefit and the impact on existing callers first and needs the domain owner's confirmation; the author never self-approves. The generated map is the only source for Client-facing types, the decorator sits on the concrete method, and the payload stays JSON: diff --git a/docs/defensive-patterns/remote-and-api.zh.md b/docs/defensive-patterns/remote-and-api.zh.md index 1a9c291d9c..c621109913 100644 --- a/docs/defensive-patterns/remote-and-api.zh.md +++ b/docs/defensive-patterns/remote-and-api.zh.md @@ -44,13 +44,14 @@ class ApiProxy { ## `@Remote` 标在能力方法上 -`@Remote` 有四项纪律,审查者分别核对:标注执行能力的具体方法;面向 Client 的类型经生成器来自既有定义,而不是手写第二份;载荷只承载 JSON,远程方法不做输出侧 decode;既有的方法与字段名只增不改。每项纪律消除一类不同的缺陷——壳方法藏起真正暴露的是哪个操作,手写的第二份类型会与生成结果漂移,输出侧 decode 在 Gateway 已有的 codec 之外再造一条结果路径,改名则让所有调用方在同一次变更里一起断掉。 +`@Remote` 有五项纪律,审查者分别核对:标注执行能力的具体方法;面向 Client 的类型经生成器来自既有定义,而不是手写第二份;载荷只承载 JSON,远程方法不做输出侧 decode;既有的方法与字段名只增不改;方法指明其访问的全部资源,且只从 invocation 读取调用方。每项纪律消除一类不同的缺陷——壳方法藏起真正暴露的是哪个操作,手写的第二份类型会与生成结果漂移,输出侧 decode 在 Gateway 已有的 codec 之外再造一条结果路径,改名让所有调用方在同一次变更里一起断掉,遗漏资源、未过滤的聚合结果或按参数判定调用方则让非 owner 访问没有 binding 授予它的内容。 - **标能力方法。** 标注落在执行该操作的具体方法上(`list`、`prompt`、`interruptByParent`),绝不落在转发给真正实现的壳方法上。判定看被标注方法的函数体:函数体只做转发,标注就标错了方法。 - **类型靠推断。** 面向 Client 的类型从生成结果取别名——`type SessionRemote = TypertClientRemote['session']`——而不是重写一份。判定看是否出现字段与生成的 Client 类型重复的手写声明;生成映射及其声明合并的正家是 [typert.zh.md](../subsystems/typert.zh.md)。 - **线上载荷只承载 JSON、不做输出侧 decode。** Remote 方法的**线上载荷**是 JSON,二进制在字段级表达。Host 签名保留 Gateway 已支持的形式——经解析变成 Host 对象的 lookup 参数、末尾的 `AbortSignal`、以 `@Remote({ mode: 'stream' })` 声明的流式返回;`@Remote` 不做输出侧 decode,结果由生成的 codec 与 Gateway 校验。判定看带该标注的方法上是否出现 decode、流重组或整体非 JSON 值;可跨越 Remote 方法的内容正家是 [编程模型](../api-gateway.zh.md#programming-model)。 - **存量名只增不改。** 迁移不改 Client 已在调用的远程方法名或字段名;新语义新增条目,改名是独立的一刀、单独评审。判定看 diff 是否改名或删除了已经经 `ctx.remote.` 暴露的名字。 -- **不覆盖。** 不带该标注的方法仍是有进程内调用者的普通 service 方法;这四项纪律约束的是什么成为 Remote 方法,而不是 service 可以实现什么。 +- **调用方身份与访问。** 每个 Remote 方法都受访问控制:`@Remote({ resources })` / `@RemoteScope(key, { resources })` 的读取函数与自动解析必须指明方法访问的全部资源,因为访问控制从不检查它们遗漏的资源;返回多个资源的方法对每一项经 `this.ctx.remoteAccess.allows(resource)` 过滤。调用方只从 `this.ctx.invocation.peer.principal` 读取,绝不从参数读取;在 Remote 方法内构造用户消息 source 时经 `principalOf(this.ctx.invocation)` 记录调用方,并向下传递已构造的 source,而不是把 principal 当函数参数传递。每条调用路径都显式给出 Peer。判定看方法解析出的资源是否遗漏其访问的资源、聚合结果是否未过滤、principal 是否从 wire 数据读取或经辅助函数参数逐层传递、Remote 方法内构造的用户消息 source 是否未经 `principalOf`;设计的正家是 [Peer 身份属性 Agent Note](../../.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md)。 +- **不覆盖。** 不带该标注的方法仍是有进程内调用者的普通 service 方法;这五项纪律约束的是什么成为 Remote 方法,而不是 service 可以实现什么。 - **例外。** 确需改名或新增 decode 路径,须先说明收益与对既有调用方的影响,并取得对应领域专家的确认;作者不得自行批准。 生成的映射是面向 Client 类型的唯一来源,标注落在具体方法上,载荷保持 JSON: diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index 2a08605a85..297872610b 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.i18n.yaml @@ -21,14 +21,14 @@ en: 3221d1076554001f zh: 29f166be6a5c04d9 /development-guide/contributor-reference/typescript-project-layout: - en: e3327c5492a593cb - zh: d43a5edcb11523ae + en: f0582a3a145a4d93 + zh: 9cab4abec6935b3c /development-guide/contributor-reference/environment-variables: en: dba2c72ee6ffa930 zh: b65c27fbb9129ec8 /development-guide/contributor-reference/git-integrations: - en: dc98eda4f4021feb - zh: b08766708803cf10 + en: 4e782ec2ca27dd1f + zh: 7a60e8c0d60de3c8 /development-guide/contributor-reference/ci-gates: en: b64a914e41b1d285 zh: 6c031b507c3c0e84 @@ -39,8 +39,8 @@ en: 7ce6264a6a06dc4a zh: e12b684f2bef942b /development-guide/contributor-reference/application-commands: - en: 28a029c8768f0880 - zh: 67c17256e046624e + en: 98828fc2b972bf39 + zh: 1dc889a3d766bf35 /development-guide/contributor-reference/todo-markers: en: 85b38f78d87fc88f zh: 00d5eb625e7dfb61 diff --git a/docs/development.md b/docs/development.md index 668beec326..39e05c25ef 100644 --- a/docs/development.md +++ b/docs/development.md @@ -74,24 +74,38 @@ Host and Client stay two aggregate programs because both sides declaration-merge Six packages split Host and Client tsconfigs: `api/remotes`, `api/gateway`, `api/session-controller`, `api/workspace-controller`, `client/connection`, and `session-query/session-log-export`. `api/remotes`' Host entry participates in the Host Typert graph while its Client entry imports generated `/remote` declarations; `session-log-export` keeps Node archive production out of its browser controller. Each split package-root `tsconfig.json` is therefore only a solution, and the two aggregates and direct consumers reference `tsconfig.host.json` or `tsconfig.client.json` respectively. The workspace `constraints` gate walks the reachable Project Reference graph and checks each referencing project's own compiler face: a single-config target remains valid from either face, while a split target must name the matching leaf rather than its solution root or opposite leaf; it discovers split packages from the presence of both leaf configs, so a new split joins the gate automatically. The [`api-remotes` README](../packages/api/remotes/README.md) and [`session-log-export` README](../packages/session-query/session-log-export/README.md) explain their splits. -The root build follows the generated dependency order: +`pnpm run build:artifacts` emits every artifact in the generated dependency order, checking and compiling only the package references of each compiler face: ```sh -tsc -b tsconfig.host.json +pnpm run build:native-system +tsc -b tsdown --env.DSH_BUILD_FACE host pnpm --filter @deepseek-ai/dsh-desktop run bundle -tsc -b tsconfig.client.json +tsc -b tsdown --env.DSH_BUILD_FACE client pnpm run build:web ``` -`pnpm run build --artifacts-only` emits the same project and application artifacts while checking and compiling only the existing package references of each compiler face. Required Linux and Windows normal builds and public `typecheck` also check the repository-wide test and script programs. CI benchmark preparation additionally uses `--noCheck` for its disposable library outputs, relying on those required builds for diagnostics; other artifact builders keep package checking ([rationale](../.agents/notes/implemented/process/2026-10-05-pr-artifact-typechecks.md)). +`pnpm run build:check` runs only `tsc -p tsconfig.host.json` and `tsc -p tsconfig.client.json` concurrently. Project mode checks each aggregate's repository-wide test and script program against the package declarations already on disk and never compiles a reference, so `build:check` neither detects nor rebuilds missing or stale `build:artifacts` output; missing output surfaces as compiler errors. `pnpm run build` runs both step sets as one [scheduled](../scripts/run-gates.ts) dependency graph: each aggregate check starts once its own face's package compilation finishes and overlaps the remaining artifact steps. In all three commands `DSH_GATE_CONCURRENCY` bounds concurrent steps (`1` runs them in dependency order) and `DSH_GATE_FAIL_FAST=1` stops the remaining steps at the first failure; otherwise a failure skips only its dependents, so independent errors are all reported. A failed step prints its own output, and any failure exits non-zero without writing the client build record. + +Choose the command by what the change needs: + +| Situation | Command | +|---|---| +| Iterating on package sources and needing runnable artifacts | `pnpm run build:artifacts`; `dev:web` and `dev:desktop` run it before launching | +| Type-checking tests, scripts, benchmarks, or website code | `pnpm run build:check` after the latest `build:artifacts`; rerun `build:artifacts` first when package sources changed | +| Before declaring TypeScript work complete | `pnpm run build:check` after the latest `build:artifacts`, or `pnpm run build` | +| Complete verification, as the required CI build lanes run it | `pnpm run build` | +| Type checks without the Web and Desktop artifacts | `pnpm run typecheck` | +| Lint or documentation code-block checks | `pnpm run lint` or `pnpm run doc-typecheck`; each prepares `host-contracts` itself | + +PR artifact builders run `build:artifacts`; required Linux and Windows normal builds and public `typecheck` also check the repository-wide test and script programs. CI benchmark preparation additionally uses `--noCheck` for its disposable library outputs, relying on those required builds for diagnostics; other artifact builders keep package checking ([rationale](../.agents/notes/implemented/process/2026-10-05-pr-artifact-typechecks.md)). Both tsdown passes match `vendor/*`, `packages/*/*`, and `apps/cli`; the Host pass also matches `apps/desktop-host`. They neither scan build artifacts to discover Client packages nor maintain a Host/Client package filter list. Package-local tsdown configs select entries for the current phase through `DSH_BUILD_FACE`: an ordinary Client plugin produces both its Node loader and browser bundle during the Client phase; `api-remotes` uses `hostPhase: true` to produce its Host entry early and only its browser bundle during the Client phase. Tsdown consumes only the JavaScript emitted to `lib/types` by the preceding tsc phase. Tsdown builds the matched workspace members concurrently, so `apps/desktop`, whose main bundle inlines workspace devDependencies from their `lib/` output, bundles in its own step after the Host pass ([Desktop README](../apps/desktop/README.md#bundled-workspace-dependencies)). -Typert runs only during Host tsdown, seeded by `tsconfig.host.json`. It analyzes Host types and generates both Host reflection artifacts and the Host-for-Client Remote projection; Client tsdown does not start Typert. Consequently, `pnpm run typecheck` runs the complete Host lib phase before Client tsc, while `pnpm run build` continues through Client tsdown and the Web build. +Typert analyzes Host types, seeded by `tsconfig.host.json`, and generates both Host reflection artifacts and the Host-for-Client Remote projection; Client tsdown does not start Typert. Host tsdown runs it as a plugin, and [`generate-typert.ts`](../scripts/generate-typert.ts) runs the same generation without bundling and writes byte-identical files. The bundle-free pass reads workspace packages only from source and fails when the Host program would load a package's `lib/` output, naming the import and the `paths` alias to add, so it can run beside Host package compilation. Client package compilation always follows one of the two passes because Client sources import the generated Remote declarations. `pnpm run typecheck` runs the `build.ts --scope typecheck` graph: Host package compilation beside the bundle-free Typert pass, then Client package compilation, with each aggregate checked in build mode (`tsc -b`) once its own face's packages compile; it skips the native addon, every bundle, Web, and the client build record. `lint`, `lint:fix`, and `doc-typecheck` read the generated declarations but not the Host aggregate check, so they prepare only Host package compilation and the bundle-free Typert pass (`compile-referenced-projects.ts host-contracts`). None of these commands writes bundles: the runnable `lib/` output stays as the last `build:artifacts` left it, so run `build:artifacts` before launching or testing built output. -`pnpm run build` embeds the root package version, the seven-character source commit, and a dirty marker when Git reports local changes; it also inherits other caller-supplied `DSH_CLIENT_*` values. `pnpm run build:official` is the cross-platform local equivalent of the CI and release artifact build and omits the local dirty marker. Each successful complete build writes a gitignored record that binds the exact public values to the Vite output and dynamic client bundles; release packing and built Web tests reject a missing record or artifacts changed by a later partial build. `pnpm run dev:web` runs that complete build first (`--skip-build` reuses an existing artifact tree instead), then samples the current version and Git state once and shares that environment across every watcher stage for the session; it does not validate the complete-build record because the watcher stages rewrite its recorded artifacts. +`pnpm run build` and `build:artifacts` embed the root package version, the seven-character source commit, and a dirty marker when Git reports local changes; they also inherit other caller-supplied `DSH_CLIENT_*` values. `pnpm run build:official` is the cross-platform local equivalent of the CI and release artifact build and omits the local dirty marker. Each successful `build` or `build:artifacts` writes a gitignored record that binds the exact public values to the Vite output and dynamic client bundles; release packing and built Web tests reject a missing record or artifacts changed by a later partial build. `pnpm run dev:web` runs `build:artifacts` first (`--skip-build` reuses an existing artifact tree instead), then samples the current version and Git state once and shares that environment across every watcher stage for the session; it does not validate the complete-build record because the watcher stages rewrite its recorded artifacts. Static analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Generated Host-for-Client Remote declarations are the deliberate exception: the public `typecheck`, `lint`, and `doc-typecheck` commands generate them first, while internal `*:contracts-ready` scripts assume that an invoking public command or scheduler gate already depends on the Typert contract-generation pass or the complete build. See the [Typert Remote note](../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md) for the gate-preparation contract. @@ -122,9 +136,9 @@ DEEPSEEK_BASE_URL=https://... # optional lefthook is configured in `lefthook.yml` as a fast local checkpoint: -- `pre-commit` verifies staged pairing records against the staged owner blobs, validates staged files with the project-free `.oxlintrc.staged.json` profile and applies Oxlint fixes with one bounded retry, regenerates `THIRD_PARTY_NOTICES.md` when a staged file is one of its inputs, checks the staged diff for whitespace errors, and runs the vendor manifest guard. +- `pre-commit` verifies staged pairing records against the staged owner blobs, validates staged files with the project-free `.oxlintrc.staged.json` profile and applies Oxlint fixes with one bounded retry, checks the staged diff for whitespace errors, and runs the vendor manifest guard. - `pre-merge-commit` performs the same index-backed pairing check before Git creates an automatic merge commit. -- `pre-push` runs `pnpm run typecheck`, which completes the Host lib phase, including generated Typert contracts, before the Client TypeScript check. +- `pre-push` runs `pnpm run typecheck`, which generates Typert contracts without bundling before the Client TypeScript check and checks both aggregates. The vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code. @@ -132,6 +146,8 @@ Apart from the scoped staged-record verification, the hooks intentionally do not Contributors can opt into the comprehensive local gate set with `pnpm run check:all`. The command is independent of the Git hooks and is not an agent instruction. +Outside CI (`CI` other than `true`), default worker counts follow the [local worker budget](../scripts/parallelism.ts): 12 for gate schedulers, `build`, `typecheck`, Oxlint, and publint, and 48 for Vitest forks and local coverage. `doc-sync`, `test:docs`, `hygiene`, and `check:all` run at most 8 gates at once, in CI as well. `os.availableParallelism()` reports host CPUs rather than a container's CPU quota, so uncapped defaults oversubscribe shared hosts. CI keeps the runner's CPU count. `DSH_GATE_CONCURRENCY`, `DSH_OXLINT_THREADS`, `DSH_COVERAGE_MAX_WORKERS`, and Vitest `--maxWorkers` override the defaults. + ### CI gates The keyless [CI workflow](../.github/workflows/ci.yml) groups independent gates into broad lanes and runs a smaller compatibility signal across supported Node versions. Artifact consumers wait for one build within their lane. Coverage partitions reuse file timings scoped to their platform and runner pool to balance work within the configured worker limit. Required benchmarks run separately on standard GitHub-hosted Linux; the [benchmark runner reference](../benchmarks/AGENTS.md) owns routing and the job timeout. The separate real-API workflow runs `pnpm run test:e2e` with its configured worker bound. See [scripts/run-gates.ts](../scripts/run-gates.ts) and the workflow files for the current gate and job inventory. @@ -164,7 +180,7 @@ pnpm run demo:ptc -- "summarize this workspace" ### Application commands -Web and Desktop share one command pair. `start:*` launches the artifacts of a prior `pnpm run build`; `dev:*` runs that build first and then launches. Web additionally keeps client bundles rebuilt on source edits, because its Host runs from source while the browser loads built bundles: +Web and Desktop share one command pair. `start:*` launches the artifacts of a prior `pnpm run build` or `build:artifacts`; `dev:*` runs `build:artifacts` first and then launches. Web additionally keeps client bundles rebuilt on source edits, because its Host runs from source while the browser loads built bundles: ```sh pnpm run start:web # serve built Web artifacts through the source launcher (the same launch as pnpm dsh web) diff --git a/docs/development.zh.md b/docs/development.zh.md index 5446ee3a89..c8b50343fd 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -78,24 +78,38 @@ Host 与 Client 保持两个 aggregate program,是因为两侧在相同键下 拆分 Host/Client tsconfig 的包有六个:`api/remotes`、`api/gateway`、`api/session-controller`、`api/workspace-controller`、`client/connection` 与 `session-query/session-log-export`。`api/remotes` 的 Host 入口进入 Host Typert 图,而 Client 入口导入生成的 `/remote` 声明;`session-log-export` 则让 Node archive 生产代码不进入浏览器 controller。每个拆分包根 `tsconfig.json` 因此只作为 solution,两个 aggregate 和直接消费方分别引用 `tsconfig.host.json` 或 `tsconfig.client.json`。workspace `constraints` 门禁遍历可达的 Project Reference 图,并按各引用 project 自身的 compiler face 检查:只有单一配置的目标可由任一 face 引用,拆分配置的目标则必须引用匹配的 leaf,不得引用 solution 根或另一侧 leaf;该门禁按「两个 leaf 配置同时存在」自动发现拆分包,所以新拆分的包会自动纳入管辖。[`api-remotes` README](../packages/api/remotes/README.zh.md) 与 [`session-log-export` README](../packages/session-query/session-log-export/README.zh.md)分别说明其拆分。 -根构建按生成依赖排序: +`pnpm run build:artifacts` 按生成依赖顺序产出全部产物,只检查并编译各 compiler face 的包 reference: ```sh -tsc -b tsconfig.host.json +pnpm run build:native-system +tsc -b tsdown --env.DSH_BUILD_FACE host pnpm --filter @deepseek-ai/dsh-desktop run bundle -tsc -b tsconfig.client.json +tsc -b tsdown --env.DSH_BUILD_FACE client pnpm run build:web ``` -`pnpm run build --artifacts-only` 产出相同的 project 和应用产物,但只检查并编译各 compiler face 的现有包 reference。必需的 Linux 与 Windows 普通构建以及公开 `typecheck` 还检查仓库级测试和脚本 program。CI benchmark 准备阶段还对其临时 library 输出使用 `--noCheck`,由这些必需构建负责诊断;其他产物 builder 保留包检查([理由](../.agents/notes/implemented/process/2026-10-05-pr-artifact-typechecks.zh.md))。 +`pnpm run build:check` 只并发运行 `tsc -p tsconfig.host.json` 与 `tsc -p tsconfig.client.json`。project mode 依据磁盘上已有的包声明检查各 aggregate 的仓库级测试和脚本 program,从不编译 reference,因此 `build:check` 既不检测也不重建缺失或过期的 `build:artifacts` 产物;产物缺失时表现为编译器错误。`pnpm run build` 把两组步骤作为一张[调度](../scripts/run-gates.ts)依赖图运行:每个 aggregate 检查在本 face 的包编译完成后立即开始,与其余产物步骤重叠执行。三个命令中,`DSH_GATE_CONCURRENCY` 限制并发步骤数(`1` 按依赖顺序执行),`DSH_GATE_FAIL_FAST=1` 在第一个失败时停止其余步骤;否则失败只跳过其依赖方,因此会报告所有相互独立的错误。失败步骤打印自身输出,任一失败都以非零状态退出且不写 client build record。 + +按改动需要选择命令: + +| 场景 | 命令 | +|---|---| +| 迭代包源码并需要可运行的产物 | `pnpm run build:artifacts`;`dev:web` 与 `dev:desktop` 启动前会先执行它 | +| 检查测试、脚本、benchmarks 或 website 代码的类型 | 在最近一次 `build:artifacts` 之后运行 `pnpm run build:check`;包源码有改动时先重新运行 `build:artifacts` | +| 声明 TypeScript 工作完成之前 | 在最近一次 `build:artifacts` 之后运行 `pnpm run build:check`,或运行 `pnpm run build` | +| 完整验证(与 CI 必需的构建 lane 相同) | `pnpm run build` | +| 只做类型检查、不需要 Web 与 Desktop 产物 | `pnpm run typecheck` | +| lint 或文档代码块检查 | `pnpm run lint` 或 `pnpm run doc-typecheck`;它们各自先准备 `host-contracts` | + +PR 产物 builder 运行 `build:artifacts`;必需的 Linux 与 Windows 普通构建以及公开 `typecheck` 还检查仓库级测试和脚本 program。CI benchmark 准备阶段还对其临时 library 输出使用 `--noCheck`,由这些必需构建负责诊断;其他产物 builder 保留包检查([理由](../.agents/notes/implemented/process/2026-10-05-pr-artifact-typechecks.zh.md))。 两次 tsdown 都匹配 `vendor/*`、`packages/*/*` 与 `apps/cli`,Host 阶段另外匹配 `apps/desktop-host`;两者都不扫描构建产物来发现 Client 包,也不维护 Host/Client 包过滤表。包内 tsdown 配置根据 `DSH_BUILD_FACE` 决定当前阶段的入口:普通 Client 插件在 Client 阶段同时生成 Node loader 与 browser bundle;`api-remotes` 通过 `hostPhase: true` 提前生成 Host 入口,再在 Client 阶段只生成 browser bundle。tsdown 只消费 `lib/types` 中由前置 tsc 发射的 JavaScript。tsdown 并发构建匹配到的 workspace 成员;`apps/desktop` 的主进程 bundle 需要从工作区 devDependencies 的 `lib/` 产物内联这些包,因此在 Host 阶段之后单独一步打 bundle([Desktop README](../apps/desktop/README.zh.md#bundled-workspace-dependencies))。 -Typert 只在 Host tsdown 中以 `tsconfig.host.json` 为种子运行。它分析 Host 类型并生成 Host 反射产物及 Host-for-Client Remote 投影;Client tsdown 不启动 Typert。`pnpm run typecheck` 因此先执行完整 Host lib 阶段,再运行 Client tsc;`pnpm run build` 继续执行 Client tsdown 和 Web 构建。 +Typert 以 `tsconfig.host.json` 为种子分析 Host 类型,生成 Host 反射产物及 Host-for-Client Remote 投影;Client tsdown 不启动 Typert。Host tsdown 以插件形式运行它,[`generate-typert.ts`](../scripts/generate-typert.ts) 则不打包地执行同一生成,写出逐字节相同的文件。不打包的这一轮只从源码读取工作区包;若 Host program 会载入某个包的 `lib/` 产物,它就失败并指出该 import 及应补的 `paths` 别名,因此可以与 Host 包编译并行。Client 源码 import 生成的 Remote 声明,所以 Client 包编译总在这两种生成之一之后。`pnpm run typecheck` 运行 `build.ts --scope typecheck` 图:Host 包编译与不打包的 Typert 生成并行,之后是 Client 包编译,每个 aggregate 在本 face 的包编译完成后以 build mode(`tsc -b`)检查;它跳过 native addon、所有 bundle、Web 以及 client build record。`lint`、`lint:fix` 与 `doc-typecheck` 读取生成的声明但不依赖 Host aggregate 检查,因此只准备 Host 包编译与不打包的 Typert 生成(`compile-referenced-projects.ts host-contracts`)。这些命令都不写 bundle:可运行的 `lib/` 产物保持上一次 `build:artifacts` 的状态,因此启动或测试构建产物之前先运行 `build:artifacts`。 -`pnpm run build` 会内联根包版本、七位源码 commit,并在 Git 报告本地变化时内联 dirty 标记;调用方提供的其他 `DSH_CLIENT_*` 值也会被继承。`pnpm run build:official` 是与 CI 和 release 产物构建等价的跨平台本地命令,并省略本地 dirty 标记。每次完整构建成功后都会写入一份被 gitignore 的记录,把精确公开值与 Vite 输出及动态 client bundle 绑定;release 打包和 built Web 测试会拒绝缺少记录或被后续局部构建改动的产物。`pnpm run dev:web` 会先执行这次完整构建(`--skip-build` 则复用现有产物树),再读取一次当前版本和 Git 状态,并在本次会话的所有 watcher stage 之间共享该环境;它不会校验完整构建记录,因为 watcher stage 会重写记录覆盖的产物。 +`pnpm run build` 与 `build:artifacts` 会内联根包版本、七位源码 commit,并在 Git 报告本地变化时内联 dirty 标记;调用方提供的其他 `DSH_CLIENT_*` 值也会被继承。`pnpm run build:official` 是与 CI 和 release 产物构建等价的跨平台本地命令,并省略本地 dirty 标记。每次 `build` 或 `build:artifacts` 成功后都会写入一份被 gitignore 的记录,把精确公开值与 Vite 输出及动态 client bundle 绑定;release 打包和 built Web 测试会拒绝缺少记录或被后续局部构建改动的产物。`pnpm run dev:web` 会先执行 `build:artifacts`(`--skip-build` 则复用现有产物树),再读取一次当前版本和 Git 状态,并在本次会话的所有 watcher stage 之间共享该环境;它不会校验完整构建记录,因为 watcher stage 会重写记录覆盖的产物。 静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。生成的 Host-for-Client Remote 声明是有意设置的例外:公共 `typecheck`、`lint` 和 `doc-typecheck` 命令会先生成这些声明,而内部 `*:contracts-ready` 脚本假定调用它的公共命令或调度器门禁已经依赖 Typert 约定生成阶段或完整构建。门禁准备约定见 [Typert Remote Agent Note](../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md)。 @@ -127,9 +141,9 @@ DEEPSEEK_BASE_URL=https://... # optional lefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点: -- `pre-commit` 对照暂存的配对文档 blob 校验暂存的配对记录,使用不加载项目的 `.oxlintrc.staged.json` 配置验证暂存文件,并通过一次有界重试应用 Oxlint 修复,在暂存文件属于 `THIRD_PARTY_NOTICES.md` 的输入时重新生成该文件,然后检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫; +- `pre-commit` 对照暂存的配对文档 blob 校验暂存的配对记录,使用不加载项目的 `.oxlintrc.staged.json` 配置验证暂存文件,并通过一次有界重试应用 Oxlint 修复,然后检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫; - `pre-merge-commit` 在 Git 创建自动合并提交前执行同样以索引为准的配对检查; -- `pre-push` 运行 `pnpm run typecheck`;该命令会先完成包含 Typert 约定生成的完整 Host lib 阶段,再运行 Client TypeScript 检查。 +- `pre-push` 运行 `pnpm run typecheck`;该命令先不打包地生成 Typert 约定,再运行 Client TypeScript 检查,并检查两个 aggregate。 vendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。 @@ -137,6 +151,8 @@ vendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `v 贡献者可以选择运行 `pnpm run check:all`,执行全面的本地门禁集。该命令独立于 Git 钩子,也不是对 agent 的指令。 +在 CI 之外(`CI` 不为 `true`),默认 worker 数遵循[本地 worker 预算](../scripts/parallelism.ts):门禁调度器、`build`、`typecheck`、Oxlint 与 publint 为 12,Vitest fork 与本地覆盖率为 48。`doc-sync`、`test:docs`、`hygiene` 与 `check:all` 最多同时运行 8 个门禁,CI 中同样如此。`os.availableParallelism()` 报告的是宿主 CPU 数而不是容器的 CPU 配额,因此不设上限的默认值会在共享主机上超额订阅。CI 仍使用 runner 的 CPU 数。`DSH_GATE_CONCURRENCY`、`DSH_OXLINT_THREADS`、`DSH_COVERAGE_MAX_WORKERS` 与 Vitest 的 `--maxWorkers` 可覆盖这些默认值。 + ### CI 门禁 keyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若干宽粒度 lane,并在受支持的 Node 版本上运行一组较小的兼容性检查。产物消费方在各自 lane 内等待一次 build。覆盖率分区复用按平台和运行器池隔离的文件耗时,在配置的 worker 上限内均衡工作量。必需 benchmark 在标准 GitHub 托管 Linux 上独立运行;[benchmark 运行器说明](../benchmarks/AGENTS.md)拥有路由及 job 超时。单独的真实 API 工作流按其配置的 worker 上限运行 `pnpm run test:e2e`。当前门禁和 job 清单以 [scripts/run-gates.ts](../scripts/run-gates.ts) 和工作流文件为准。 @@ -169,7 +185,7 @@ pnpm run demo:ptc -- "summarize this workspace" ### 应用命令 -Web 与 Desktop 共用同一对命令。`start:*` 启动上一次 `pnpm run build` 的产物;`dev:*` 先执行该构建再启动。Web 还会在源码修改时持续重建 client bundle,因为它的 Host 从源码运行,而浏览器加载的是构建产物: +Web 与 Desktop 共用同一对命令。`start:*` 启动上一次 `pnpm run build` 或 `build:artifacts` 的产物;`dev:*` 先执行 `build:artifacts` 再启动。Web 还会在源码修改时持续重建 client bundle,因为它的 Host 从源码运行,而浏览器加载的是构建产物: ```sh pnpm run start:web # serve built Web artifacts through the source launcher (the same launch as pnpm dsh web) diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 7c6cf014bd..d727ee4ec8 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -22,17 +22,17 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:381`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:619`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:599`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:626`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:605`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:612`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:53`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:629`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:609`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:636`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:615`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:622`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:54`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:87`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | - | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | | `compaction/summary-error` | `waterfall` | [`packages/compaction/compaction/src/index.ts:106`](../packages/compaction/compaction/src/index.ts) | [`compaction-basic`](../packages/compaction/compaction-basic) (`waterfall`) | [`compaction-image-offload`](../packages/compaction/compaction-image-offload) | -| `connection/request` | `waterfall` | [`packages/client/connection/src/index.ts:67`](../packages/client/connection/src/index.ts) | `connection` (`waterfall`) | - | +| `connection/request` | `waterfall` | [`packages/client/connection/src/index.ts:68`](../packages/client/connection/src/index.ts) | `connection` (`waterfall`) | - | | `cordis/dynamic-package` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:383`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | | `cordis/dynamic-retract` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:389`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | | `cordis/inspect-query` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:395`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | @@ -51,14 +51,14 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:59`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`workspace-changes`](../packages/deliverables/workspace-changes) | | `goal/activation-changed` | `emit` | [`packages/goal/goal/src/types.ts:150`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | `remotes` | | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | -| `hmr/change` | `emit` | [`packages/boot/hmr/src/index.ts:32`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - | -| `hmr/reload` | `emit` | [`packages/boot/hmr/src/index.ts:37`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - | +| `hmr/change` | `emit` | [`packages/boot/hmr/src/index.ts:35`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - | +| `hmr/reload` | `emit` | [`packages/boot/hmr/src/index.ts:40`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - | | `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), `remotes` | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:75`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`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:258`](../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:264`](../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:271`](../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:269`](../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:275`](../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:282`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | | `schedule/changed` | `emit` | [`packages/schedule/schedule/src/types.ts:478`](../packages/schedule/schedule/src/types.ts) | [`schedule`](../packages/schedule/schedule) (`emit`) | `remotes` | | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:44`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`permission-presets`](../packages/interaction/permission-presets), [`schedule`](../packages/schedule/schedule), `server`, `session-controller`, [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry) | @@ -80,7 +80,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:191`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | | `tools/result` | `emit` | [`packages/core/tools/src/index.ts:199`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`subagent`](../packages/subagent/subagent), [`tool-present`](../packages/deliverables/tool-present) | | `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:157`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes` | -| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:39`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` | +| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:39`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `remote-access`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` | | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) | | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) | | `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index aed165abe7..fccfeea698 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -24,17 +24,17 @@ | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:381`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:619`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:599`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:626`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:605`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:612`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:53`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:629`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:609`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:636`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:615`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:622`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:54`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:87`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | - | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | | `compaction/summary-error` | `waterfall` | [`packages/compaction/compaction/src/index.ts:106`](../packages/compaction/compaction/src/index.ts) | [`compaction-basic`](../packages/compaction/compaction-basic) (`waterfall`) | [`compaction-image-offload`](../packages/compaction/compaction-image-offload) | -| `connection/request` | `waterfall` | [`packages/client/connection/src/index.ts:67`](../packages/client/connection/src/index.ts) | `connection` (`waterfall`) | - | +| `connection/request` | `waterfall` | [`packages/client/connection/src/index.ts:68`](../packages/client/connection/src/index.ts) | `connection` (`waterfall`) | - | | `cordis/dynamic-package` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:383`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | | `cordis/dynamic-retract` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:389`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | | `cordis/inspect-query` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:395`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | @@ -53,14 +53,14 @@ | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:59`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`workspace-changes`](../packages/deliverables/workspace-changes) | | `goal/activation-changed` | `emit` | [`packages/goal/goal/src/types.ts:150`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | `remotes` | | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | -| `hmr/change` | `emit` | [`packages/boot/hmr/src/index.ts:32`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - | -| `hmr/reload` | `emit` | [`packages/boot/hmr/src/index.ts:37`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - | +| `hmr/change` | `emit` | [`packages/boot/hmr/src/index.ts:35`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - | +| `hmr/reload` | `emit` | [`packages/boot/hmr/src/index.ts:40`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - | | `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), `remotes` | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:75`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`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:258`](../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:264`](../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:271`](../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:269`](../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:275`](../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:282`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | | `schedule/changed` | `emit` | [`packages/schedule/schedule/src/types.ts:478`](../packages/schedule/schedule/src/types.ts) | [`schedule`](../packages/schedule/schedule) (`emit`) | `remotes` | | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:44`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`permission-presets`](../packages/interaction/permission-presets), [`schedule`](../packages/schedule/schedule), `server`, `session-controller`, [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry) | @@ -82,7 +82,7 @@ | `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:191`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | | `tools/result` | `emit` | [`packages/core/tools/src/index.ts:199`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`subagent`](../packages/subagent/subagent), [`tool-present`](../packages/deliverables/tool-present) | | `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:157`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes` | -| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:39`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` | +| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:39`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `remote-access`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` | | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) | | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) | | `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | diff --git a/docs/glossary.i18n.yaml b/docs/glossary.i18n.yaml index e2e49e31f5..532c84896c 100644 --- a/docs/glossary.i18n.yaml +++ b/docs/glossary.i18n.yaml @@ -18,8 +18,8 @@ en: 38a55fc61b359ace zh: 567cae201b3a1bdf /glossary/loop-hierarchy: - en: 343a7cb31b8191c2 - zh: 6831415a7fe1726a + en: 6bf1ad7dd53f79cc + zh: 6231e68c769fdefa /glossary/ralph: en: 5059f8a377980b9d zh: 39bf11c10e5c0a84 diff --git a/docs/glossary.md b/docs/glossary.md index 1a9ac39a73..e30e4e2122 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -37,6 +37,7 @@ Domain vocabulary for DeepSeek Harness uses one canonical term per concept. Term - **turn** — one drain of admitted input in a session, ending after the model and its tools stop or a terminal policy intervenes. - **step** — one model request plus the tool executions caused by its response; a turn contains zero or more steps. - **round** — an outer policy iteration containing a turn, such as a [goal round](#goal-round) or one fresh-agent Ralph attempt. Round counters belong to that policy and do not count every turn in a session. +- **exchange** — consecutive [turns](#turn) from a turn whose first [step](#step) admits human input until the next such turn; turns woken by notices, such as settled subagents, goal rounds, or schedules, stay in the preceding exchange. Chat's next-message folding keeps the latest exchange expanded. ## Ralph diff --git a/docs/glossary.zh.md b/docs/glossary.zh.md index cd41876d41..68f5e4bf8c 100644 --- a/docs/glossary.zh.md +++ b/docs/glossary.zh.md @@ -37,6 +37,7 @@ DeepSeek Harness 的领域词汇为每个概念规定一个规范术语。各术 - **轮次**:会话中一次对已接纳输入的排空过程,在模型及其工具停止工作或终止策略介入后结束。 - **步骤**:一次模型请求,以及由模型响应引发的工具执行;一个轮次包含零个或多个步骤。 - **Round**:承载一个轮次的外层策略迭代,例如一个 [Goal Round](#goal-round) 或一次使用全新 agent 的 Ralph 尝试。Round 计数器归该策略所有,并不统计会话中的每个轮次。 +- **交互段**:连续的[轮次](#turn),从首个[步骤](#step)接纳真人输入的轮次起,到下一个这样的轮次为止;由通知(子 agent 完成、Goal Round、定时任务等)唤醒的轮次归入前面的交互段。Chat 的「下次发送时收起」保持最新交互段展开。 ## Ralph diff --git a/docs/i18n/terminology.md b/docs/i18n/terminology.md index cf7009ff7f..5971f88600 100644 --- a/docs/i18n/terminology.md +++ b/docs/i18n/terminology.md @@ -127,6 +127,7 @@ | event log | 事件日志 | | | | | event stream | 事件流 | | | | | event-sourced | 事件溯源 | | | 沿用 DDD 社区通行译法 | +| exchange | 交互段 | | 回合、交换 | Chat 中从真人输入开启的轮次起、到下一个这样的轮次为止的连续轮次;见术语表 | | Executive summary | 摘要 | | | 事故复盘标题用语 | | executor | 执行器 | | | | | expected output | 预期输出 | | 金标 | 指 snapshot 比较产物;翻译语料的人工校准样例不在此列 | diff --git a/docs/module-graph.md b/docs/module-graph.md index 12a6608993..b6df4b5e7b 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -111,6 +111,7 @@ flowchart TD pkg_api_account_controller["api-account-controller"] pkg_api_gateway["api-gateway"] pkg_api_job_controller["api-job-controller"] + pkg_api_remote_access["api-remote-access"] pkg_api_remotes["api-remotes"] pkg_api_session_controller["api-session-controller"] pkg_api_settings_controller["api-settings-controller"] @@ -175,6 +176,7 @@ flowchart TD pkg_client_ui_reference["client-ui-reference"] pkg_client_ui_renderer["client-ui-renderer"] pkg_client_ui_schedule["client-ui-schedule"] + pkg_client_ui_secret_mask["client-ui-secret-mask"] pkg_client_ui_session["client-ui-session"] pkg_client_ui_settings["client-ui-settings"] pkg_client_ui_settings_account["client-ui-settings-account"] @@ -219,6 +221,7 @@ flowchart TD pkg_agent_instructions["agent-instructions"] pkg_file_reference["file-reference"] pkg_file_reference_local["file-reference-local"] + pkg_harness_dev_context["harness-dev-context"] pkg_session_reference["session-reference"] pkg_time_context["time-context"] pkg_tmux_context["tmux-context"] @@ -450,6 +453,8 @@ flowchart TD pkg_web --> pkg_llm pkg_api_remotes --> pkg_scope pkg_attachment --> pkg_brand + pkg_app_boot --> pkg_home_paths + pkg_app_boot --> pkg_launch_environment pkg_browser_use --> pkg_brand pkg_client_connection --> pkg_scope pkg_computer_use --> pkg_brand @@ -480,13 +485,11 @@ flowchart TD pkg_spill --> pkg_brand pkg_spill --> pkg_llm pkg_spill --> pkg_session - pkg_api_gateway --> pkg_client_connection pkg_api_terminal_controller --> pkg_subprocess pkg_attachment_local --> pkg_attachment pkg_attachment_local --> pkg_home_paths - pkg_app_boot --> pkg_home_paths - pkg_app_boot --> pkg_launch_environment - pkg_app_boot --> pkg_system_prompt + pkg_hmr --> pkg_app_boot + pkg_hmr --> pkg_cmdline pkg_experimental_computer_use_cua_driver_mcp --> pkg_computer_use pkg_experimental_inspector --> pkg_client_connection pkg_experimental_inspector --> pkg_client_modules @@ -528,8 +531,8 @@ flowchart TD pkg_spill_local --> pkg_spill pkg_session_log_export --> pkg_session pkg_session_log_export --> pkg_session_persistence - pkg_hmr --> pkg_app_boot - pkg_hmr --> pkg_cmdline + pkg_config_editor --> pkg_app_boot + pkg_config_editor --> pkg_hmr pkg_experimental_speech_to_text --> pkg_settings pkg_experimental_translator --> pkg_credentials pkg_experimental_translator --> pkg_launch_environment @@ -597,8 +600,6 @@ flowchart TD pkg_api_workspace_controller --> pkg_storage_domain pkg_api_workspace_controller --> pkg_typert_protocol pkg_api_workspace_controller --> pkg_workspace - pkg_config_editor --> pkg_app_boot - pkg_config_editor --> pkg_hmr pkg_experimental_api_speech_to_text --> pkg_experimental_speech_to_text pkg_experimental_api_speech_to_text --> pkg_typert_protocol pkg_experimental_speech_to_text_sensevoice --> pkg_experimental_speech_to_text @@ -637,6 +638,10 @@ flowchart TD pkg_goal --> pkg_session_projection pkg_goal --> pkg_typert_protocol pkg_file_reference --> pkg_agent + pkg_harness_dev_context --> pkg_agent + pkg_harness_dev_context --> pkg_session + pkg_harness_dev_context --> pkg_session_projection + pkg_harness_dev_context --> pkg_system_prompt pkg_time_context --> pkg_agent pkg_time_context --> pkg_llm pkg_time_context --> pkg_session @@ -703,6 +708,7 @@ flowchart TD pkg_llm_deepseek_account --> pkg_launch_environment pkg_llm_deepseek_account --> pkg_llm pkg_llm_deepseek_account --> pkg_llm_deepseek + pkg_llm_deepseek_account --> pkg_timeout pkg_tools --> pkg_agent pkg_tools --> pkg_llm pkg_tools --> pkg_ptc_runtime @@ -716,10 +722,6 @@ flowchart TD pkg_command_goal --> pkg_commands pkg_command_goal --> pkg_goal pkg_command_goal --> pkg_llm - pkg_goal_round_driver --> pkg_agent - pkg_goal_round_driver --> pkg_goal - pkg_goal_round_driver --> pkg_llm - pkg_goal_round_driver --> pkg_session pkg_fs_sandbox --> pkg_fs pkg_fs_sandbox --> pkg_fs_local pkg_fs_sandbox --> pkg_sandbox @@ -1045,7 +1047,6 @@ flowchart TD pkg_api_settings_controller --> pkg_settings pkg_api_settings_controller --> pkg_typert_protocol pkg_web_app --> pkg_shell_env - pkg_web_app --> pkg_system_prompt pkg_experimental_auto_review --> pkg_agent pkg_experimental_auto_review --> pkg_agent_instructions pkg_experimental_auto_review --> pkg_llm @@ -1208,6 +1209,11 @@ flowchart TD pkg_webhook_github --> pkg_host_webserver pkg_webhook_github --> pkg_session pkg_webhook_github --> pkg_webhook + pkg_goal_round_driver --> pkg_agent + pkg_goal_round_driver --> pkg_goal + pkg_goal_round_driver --> pkg_llm + pkg_goal_round_driver --> pkg_session + pkg_goal_round_driver --> pkg_subagent pkg_subagent_acp --> pkg_agent pkg_subagent_acp --> pkg_llm pkg_subagent_acp --> pkg_session @@ -1229,6 +1235,7 @@ flowchart TD pkg_subagent_fork_in_process --> pkg_subagent pkg_subagent_spawn_in_process --> pkg_subagent pkg_tool_subagent --> pkg_agent + pkg_tool_subagent --> pkg_agent_preset_registry pkg_tool_subagent --> pkg_llm pkg_tool_subagent --> pkg_scope pkg_tool_subagent --> pkg_session @@ -1454,7 +1461,9 @@ flowchart TD | [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions) | `llm` | — | | [`llm`](../packages/llm/llm) | `llm` | — | | [`scope`](../packages/core/scope) | `core` | — | +| [`api-gateway`](../packages/api/gateway) | `api` | — | | [`api-job-controller`](../packages/api/job-controller) | `api` | — | +| [`api-remote-access`](../packages/api/remote-access) | `api` | — | | [`api-workspace-files`](../packages/api/workspace-files) | `api` | — | | [`cmdline`](../packages/boot/cmdline) | `boot` | — | | [`acp-app`](../packages/bundle/acp-app) | `bundle` | — | @@ -1494,6 +1503,7 @@ flowchart TD | [`client-ui-reference`](../packages/client/ui-reference) | `client` | — | | [`client-ui-renderer`](../packages/client/ui-renderer) | `client` | — | | [`client-ui-schedule`](../packages/client/ui-schedule) | `client` | — | +| [`client-ui-secret-mask`](../packages/client/ui-secret-mask) | `client` | — | | [`client-ui-session`](../packages/client/ui-session) | `client` | — | | [`client-ui-settings`](../packages/client/ui-settings) | `client` | — | | [`client-ui-settings-account`](../packages/client/ui-settings-account) | `client` | — | @@ -1561,6 +1571,7 @@ flowchart TD | [`web`](../packages/web/web) | `web` | [`llm`](../packages/llm/llm) | | [`api-remotes`](../packages/api/remotes) | `api` | [`scope`](../packages/core/scope) | | [`attachment`](../packages/attachment/attachment) | `attachment` | [`brand`](../packages/util/brand) | +| [`app-boot`](../packages/boot/app-boot) | `boot` | [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment) | | [`browser-use`](../packages/browser-use/browser-use) | `browser-use` | [`brand`](../packages/util/brand) | | [`client-connection`](../packages/client/connection) | `client` | [`scope`](../packages/core/scope) | | [`computer-use`](../packages/computer-use/computer-use) | `computer-use` | [`brand`](../packages/util/brand) | @@ -1579,10 +1590,9 @@ flowchart TD | [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) | | [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) | | [`spill`](../packages/spill/spill) | `spill` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`api-gateway`](../packages/api/gateway) | `api` | [`client-connection`](../packages/client/connection) | | [`api-terminal-controller`](../packages/api/terminal-controller) | `api` | [`subprocess`](../packages/subprocess/subprocess) | | [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`home-paths`](../packages/util/home-paths) | -| [`app-boot`](../packages/boot/app-boot) | `boot` | [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment), [`system-prompt`](../packages/core/system-prompt) | +| [`hmr`](../packages/boot/hmr) | `boot` | [`app-boot`](../packages/boot/app-boot), [`cmdline`](../packages/boot/cmdline) | | [`experimental-computer-use-cua-driver-mcp`](../packages/experimental/computer-use-cua-driver-mcp) | `experimental` | [`computer-use`](../packages/computer-use/computer-use) | | [`experimental-inspector`](../packages/experimental/inspector) | `experimental` | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver) | | [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver) | @@ -1603,7 +1613,7 @@ flowchart TD | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`spill-local`](../packages/spill/spill-local) | `spill` | [`spill`](../packages/spill/spill) | | [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) | -| [`hmr`](../packages/boot/hmr) | `boot` | [`app-boot`](../packages/boot/app-boot), [`cmdline`](../packages/boot/cmdline) | +| [`config-editor`](../packages/boot/config-editor) | `boot` | [`app-boot`](../packages/boot/app-boot), [`hmr`](../packages/boot/hmr) | | [`experimental-speech-to-text`](../packages/experimental/speech-to-text) | `experimental` | [`settings`](../packages/settings/settings) | | [`experimental-translator`](../packages/experimental/translator) | `experimental` | [`credentials`](../packages/credentials/credentials), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`timeout`](../packages/util/timeout), [`util-values`](../packages/util/values) | | [`host-product-telemetry-otel`](../packages/host/product-telemetry-otel) | `host` | [`otel`](../packages/telemetry/otel) | @@ -1622,7 +1632,6 @@ flowchart TD | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | `fs` | [`fs`](../packages/fs/fs) | | [`skill-filesystem`](../packages/skill/skill-filesystem) | `skill` | [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`skill`](../packages/skill/skill) | | [`api-workspace-controller`](../packages/api/workspace-controller) | `api` | [`api-gateway`](../packages/api/gateway), [`client-connection`](../packages/client/connection), [`host-directory-picker`](../packages/host/directory-picker), [`session`](../packages/core/session), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) | -| [`config-editor`](../packages/boot/config-editor) | `boot` | [`app-boot`](../packages/boot/app-boot), [`hmr`](../packages/boot/hmr) | | [`experimental-api-speech-to-text`](../packages/experimental/api-speech-to-text) | `experimental` | [`experimental-speech-to-text`](../packages/experimental/speech-to-text), [`typert-protocol`](../packages/typert/protocol) | | [`experimental-speech-to-text-sensevoice`](../packages/experimental/speech-to-text-sensevoice) | `experimental` | [`experimental-speech-to-text`](../packages/experimental/speech-to-text), [`subprocess`](../packages/subprocess/subprocess) | | [`hook-protocol`](../packages/experimental/hook-protocol) | `experimental` | [`session`](../packages/core/session), [`shell`](../packages/shell/shell) | @@ -1634,6 +1643,7 @@ flowchart TD | [`agent-default-model`](../packages/core/agent-default-model) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm) | | [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`typert-protocol`](../packages/typert/protocol) | | [`file-reference`](../packages/context/file-reference) | `context` | [`agent`](../packages/core/agent) | +| [`harness-dev-context`](../packages/context/harness-dev-context) | `context` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`shell`](../packages/shell/shell) | | [`deepseek-account`](../packages/credentials/deepseek-account) | `credentials` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) | @@ -1648,10 +1658,9 @@ flowchart TD | [`terminal`](../packages/terminal/terminal) | `terminal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand) | | [`loader-smoke`](../packages/test-support/loader-smoke) | `test-support` | [`agent`](../packages/core/agent), [`http-proxy`](../packages/util/http-proxy), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`llm-deepseek-account`](../packages/llm/llm-deepseek-account) | `llm` | [`deepseek-account`](../packages/credentials/deepseek-account), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek) | +| [`llm-deepseek-account`](../packages/llm/llm-deepseek-account) | `llm` | [`deepseek-account`](../packages/credentials/deepseek-account), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`timeout`](../packages/util/timeout) | | [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/interaction/user-approval), [`working-directory`](../packages/session/working-directory) | | [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm) | -| [`goal-round-driver`](../packages/goal/goal-round-driver) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | | [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`deepseek-account`](../packages/credentials/deepseek-account), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`web`](../packages/web/web) | | [`api-account-controller`](../packages/api/account-controller) | `api` | [`agent`](../packages/core/agent), [`deepseek-account`](../packages/credentials/deepseek-account), [`typert-protocol`](../packages/typert/protocol) | @@ -1714,7 +1723,7 @@ flowchart TD | [`token-meter`](../packages/llm/token-meter) | `llm` | [`compaction`](../packages/compaction/compaction), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-title`](../packages/session/session-title), [`tool-todo`](../packages/todo/tool-todo) | | [`api-settings-controller`](../packages/api/settings-controller) | `api` | [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`credentials`](../packages/credentials/credentials), [`native-command`](../packages/util/native-command), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`typert-protocol`](../packages/typert/protocol) | -| [`web-app`](../packages/bundle/web-app) | `bundle` | [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt) | +| [`web-app`](../packages/bundle/web-app) | `bundle` | [`shell-env`](../packages/shell/shell-env) | | [`experimental-auto-review`](../packages/experimental/auto-review) | `experimental` | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`webhook`](../packages/experimental/webhook) | `experimental` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) | | [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`app-boot`](../packages/boot/app-boot), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`tools`](../packages/core/tools) | @@ -1738,12 +1747,13 @@ flowchart TD | [`experimental-claude-code-mods`](../packages/experimental/claude-code-mods) | `experimental` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain), [`subprocess`](../packages/subprocess/subprocess), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-questions`](../packages/interaction/user-questions), [`working-directory`](../packages/session/working-directory) | | [`tool-session-query`](../packages/experimental/tool-session-query) | `experimental` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`webhook-github`](../packages/experimental/webhook-github) | `experimental` | [`credentials`](../packages/credentials/credentials), [`host-webserver`](../packages/host/webserver), [`session`](../packages/core/session), [`webhook`](../packages/experimental/webhook) | +| [`goal-round-driver`](../packages/goal/goal-round-driver) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | `subagent` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-codex`](../packages/subagent/subagent-codex) | `subagent` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process) | `subagent` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process) | `subagent` | [`subagent`](../packages/subagent/subagent) | -| [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`settings`](../packages/settings/settings), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`working-directory`](../packages/session/working-directory), [`workspace`](../packages/workspace/workspace) | | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`subagent`](../packages/subagent/subagent) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index b9a744b112..4c4e5e23d9 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -113,6 +113,7 @@ flowchart TD pkg_api_account_controller["api-account-controller"] pkg_api_gateway["api-gateway"] pkg_api_job_controller["api-job-controller"] + pkg_api_remote_access["api-remote-access"] pkg_api_remotes["api-remotes"] pkg_api_session_controller["api-session-controller"] pkg_api_settings_controller["api-settings-controller"] @@ -177,6 +178,7 @@ flowchart TD pkg_client_ui_reference["client-ui-reference"] pkg_client_ui_renderer["client-ui-renderer"] pkg_client_ui_schedule["client-ui-schedule"] + pkg_client_ui_secret_mask["client-ui-secret-mask"] pkg_client_ui_session["client-ui-session"] pkg_client_ui_settings["client-ui-settings"] pkg_client_ui_settings_account["client-ui-settings-account"] @@ -221,6 +223,7 @@ flowchart TD pkg_agent_instructions["agent-instructions"] pkg_file_reference["file-reference"] pkg_file_reference_local["file-reference-local"] + pkg_harness_dev_context["harness-dev-context"] pkg_session_reference["session-reference"] pkg_time_context["time-context"] pkg_tmux_context["tmux-context"] @@ -452,6 +455,8 @@ flowchart TD pkg_web --> pkg_llm pkg_api_remotes --> pkg_scope pkg_attachment --> pkg_brand + pkg_app_boot --> pkg_home_paths + pkg_app_boot --> pkg_launch_environment pkg_browser_use --> pkg_brand pkg_client_connection --> pkg_scope pkg_computer_use --> pkg_brand @@ -482,13 +487,11 @@ flowchart TD pkg_spill --> pkg_brand pkg_spill --> pkg_llm pkg_spill --> pkg_session - pkg_api_gateway --> pkg_client_connection pkg_api_terminal_controller --> pkg_subprocess pkg_attachment_local --> pkg_attachment pkg_attachment_local --> pkg_home_paths - pkg_app_boot --> pkg_home_paths - pkg_app_boot --> pkg_launch_environment - pkg_app_boot --> pkg_system_prompt + pkg_hmr --> pkg_app_boot + pkg_hmr --> pkg_cmdline pkg_experimental_computer_use_cua_driver_mcp --> pkg_computer_use pkg_experimental_inspector --> pkg_client_connection pkg_experimental_inspector --> pkg_client_modules @@ -530,8 +533,8 @@ flowchart TD pkg_spill_local --> pkg_spill pkg_session_log_export --> pkg_session pkg_session_log_export --> pkg_session_persistence - pkg_hmr --> pkg_app_boot - pkg_hmr --> pkg_cmdline + pkg_config_editor --> pkg_app_boot + pkg_config_editor --> pkg_hmr pkg_experimental_speech_to_text --> pkg_settings pkg_experimental_translator --> pkg_credentials pkg_experimental_translator --> pkg_launch_environment @@ -599,8 +602,6 @@ flowchart TD pkg_api_workspace_controller --> pkg_storage_domain pkg_api_workspace_controller --> pkg_typert_protocol pkg_api_workspace_controller --> pkg_workspace - pkg_config_editor --> pkg_app_boot - pkg_config_editor --> pkg_hmr pkg_experimental_api_speech_to_text --> pkg_experimental_speech_to_text pkg_experimental_api_speech_to_text --> pkg_typert_protocol pkg_experimental_speech_to_text_sensevoice --> pkg_experimental_speech_to_text @@ -639,6 +640,10 @@ flowchart TD pkg_goal --> pkg_session_projection pkg_goal --> pkg_typert_protocol pkg_file_reference --> pkg_agent + pkg_harness_dev_context --> pkg_agent + pkg_harness_dev_context --> pkg_session + pkg_harness_dev_context --> pkg_session_projection + pkg_harness_dev_context --> pkg_system_prompt pkg_time_context --> pkg_agent pkg_time_context --> pkg_llm pkg_time_context --> pkg_session @@ -705,6 +710,7 @@ flowchart TD pkg_llm_deepseek_account --> pkg_launch_environment pkg_llm_deepseek_account --> pkg_llm pkg_llm_deepseek_account --> pkg_llm_deepseek + pkg_llm_deepseek_account --> pkg_timeout pkg_tools --> pkg_agent pkg_tools --> pkg_llm pkg_tools --> pkg_ptc_runtime @@ -718,10 +724,6 @@ flowchart TD pkg_command_goal --> pkg_commands pkg_command_goal --> pkg_goal pkg_command_goal --> pkg_llm - pkg_goal_round_driver --> pkg_agent - pkg_goal_round_driver --> pkg_goal - pkg_goal_round_driver --> pkg_llm - pkg_goal_round_driver --> pkg_session pkg_fs_sandbox --> pkg_fs pkg_fs_sandbox --> pkg_fs_local pkg_fs_sandbox --> pkg_sandbox @@ -1047,7 +1049,6 @@ flowchart TD pkg_api_settings_controller --> pkg_settings pkg_api_settings_controller --> pkg_typert_protocol pkg_web_app --> pkg_shell_env - pkg_web_app --> pkg_system_prompt pkg_experimental_auto_review --> pkg_agent pkg_experimental_auto_review --> pkg_agent_instructions pkg_experimental_auto_review --> pkg_llm @@ -1210,6 +1211,11 @@ flowchart TD pkg_webhook_github --> pkg_host_webserver pkg_webhook_github --> pkg_session pkg_webhook_github --> pkg_webhook + pkg_goal_round_driver --> pkg_agent + pkg_goal_round_driver --> pkg_goal + pkg_goal_round_driver --> pkg_llm + pkg_goal_round_driver --> pkg_session + pkg_goal_round_driver --> pkg_subagent pkg_subagent_acp --> pkg_agent pkg_subagent_acp --> pkg_llm pkg_subagent_acp --> pkg_session @@ -1231,6 +1237,7 @@ flowchart TD pkg_subagent_fork_in_process --> pkg_subagent pkg_subagent_spawn_in_process --> pkg_subagent pkg_tool_subagent --> pkg_agent + pkg_tool_subagent --> pkg_agent_preset_registry pkg_tool_subagent --> pkg_llm pkg_tool_subagent --> pkg_scope pkg_tool_subagent --> pkg_session @@ -1456,7 +1463,9 @@ flowchart TD | [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions) | `llm` | — | | [`llm`](../packages/llm/llm) | `llm` | — | | [`scope`](../packages/core/scope) | `core` | — | +| [`api-gateway`](../packages/api/gateway) | `api` | — | | [`api-job-controller`](../packages/api/job-controller) | `api` | — | +| [`api-remote-access`](../packages/api/remote-access) | `api` | — | | [`api-workspace-files`](../packages/api/workspace-files) | `api` | — | | [`cmdline`](../packages/boot/cmdline) | `boot` | — | | [`acp-app`](../packages/bundle/acp-app) | `bundle` | — | @@ -1496,6 +1505,7 @@ flowchart TD | [`client-ui-reference`](../packages/client/ui-reference) | `client` | — | | [`client-ui-renderer`](../packages/client/ui-renderer) | `client` | — | | [`client-ui-schedule`](../packages/client/ui-schedule) | `client` | — | +| [`client-ui-secret-mask`](../packages/client/ui-secret-mask) | `client` | — | | [`client-ui-session`](../packages/client/ui-session) | `client` | — | | [`client-ui-settings`](../packages/client/ui-settings) | `client` | — | | [`client-ui-settings-account`](../packages/client/ui-settings-account) | `client` | — | @@ -1563,6 +1573,7 @@ flowchart TD | [`web`](../packages/web/web) | `web` | [`llm`](../packages/llm/llm) | | [`api-remotes`](../packages/api/remotes) | `api` | [`scope`](../packages/core/scope) | | [`attachment`](../packages/attachment/attachment) | `attachment` | [`brand`](../packages/util/brand) | +| [`app-boot`](../packages/boot/app-boot) | `boot` | [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment) | | [`browser-use`](../packages/browser-use/browser-use) | `browser-use` | [`brand`](../packages/util/brand) | | [`client-connection`](../packages/client/connection) | `client` | [`scope`](../packages/core/scope) | | [`computer-use`](../packages/computer-use/computer-use) | `computer-use` | [`brand`](../packages/util/brand) | @@ -1581,10 +1592,9 @@ flowchart TD | [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) | | [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) | | [`spill`](../packages/spill/spill) | `spill` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`api-gateway`](../packages/api/gateway) | `api` | [`client-connection`](../packages/client/connection) | | [`api-terminal-controller`](../packages/api/terminal-controller) | `api` | [`subprocess`](../packages/subprocess/subprocess) | | [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`home-paths`](../packages/util/home-paths) | -| [`app-boot`](../packages/boot/app-boot) | `boot` | [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment), [`system-prompt`](../packages/core/system-prompt) | +| [`hmr`](../packages/boot/hmr) | `boot` | [`app-boot`](../packages/boot/app-boot), [`cmdline`](../packages/boot/cmdline) | | [`experimental-computer-use-cua-driver-mcp`](../packages/experimental/computer-use-cua-driver-mcp) | `experimental` | [`computer-use`](../packages/computer-use/computer-use) | | [`experimental-inspector`](../packages/experimental/inspector) | `experimental` | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver) | | [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver) | @@ -1605,7 +1615,7 @@ flowchart TD | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`spill-local`](../packages/spill/spill-local) | `spill` | [`spill`](../packages/spill/spill) | | [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) | -| [`hmr`](../packages/boot/hmr) | `boot` | [`app-boot`](../packages/boot/app-boot), [`cmdline`](../packages/boot/cmdline) | +| [`config-editor`](../packages/boot/config-editor) | `boot` | [`app-boot`](../packages/boot/app-boot), [`hmr`](../packages/boot/hmr) | | [`experimental-speech-to-text`](../packages/experimental/speech-to-text) | `experimental` | [`settings`](../packages/settings/settings) | | [`experimental-translator`](../packages/experimental/translator) | `experimental` | [`credentials`](../packages/credentials/credentials), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`timeout`](../packages/util/timeout), [`util-values`](../packages/util/values) | | [`host-product-telemetry-otel`](../packages/host/product-telemetry-otel) | `host` | [`otel`](../packages/telemetry/otel) | @@ -1624,7 +1634,6 @@ flowchart TD | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | `fs` | [`fs`](../packages/fs/fs) | | [`skill-filesystem`](../packages/skill/skill-filesystem) | `skill` | [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`skill`](../packages/skill/skill) | | [`api-workspace-controller`](../packages/api/workspace-controller) | `api` | [`api-gateway`](../packages/api/gateway), [`client-connection`](../packages/client/connection), [`host-directory-picker`](../packages/host/directory-picker), [`session`](../packages/core/session), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) | -| [`config-editor`](../packages/boot/config-editor) | `boot` | [`app-boot`](../packages/boot/app-boot), [`hmr`](../packages/boot/hmr) | | [`experimental-api-speech-to-text`](../packages/experimental/api-speech-to-text) | `experimental` | [`experimental-speech-to-text`](../packages/experimental/speech-to-text), [`typert-protocol`](../packages/typert/protocol) | | [`experimental-speech-to-text-sensevoice`](../packages/experimental/speech-to-text-sensevoice) | `experimental` | [`experimental-speech-to-text`](../packages/experimental/speech-to-text), [`subprocess`](../packages/subprocess/subprocess) | | [`hook-protocol`](../packages/experimental/hook-protocol) | `experimental` | [`session`](../packages/core/session), [`shell`](../packages/shell/shell) | @@ -1636,6 +1645,7 @@ flowchart TD | [`agent-default-model`](../packages/core/agent-default-model) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm) | | [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`typert-protocol`](../packages/typert/protocol) | | [`file-reference`](../packages/context/file-reference) | `context` | [`agent`](../packages/core/agent) | +| [`harness-dev-context`](../packages/context/harness-dev-context) | `context` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`shell`](../packages/shell/shell) | | [`deepseek-account`](../packages/credentials/deepseek-account) | `credentials` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) | @@ -1650,10 +1660,9 @@ flowchart TD | [`terminal`](../packages/terminal/terminal) | `terminal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand) | | [`loader-smoke`](../packages/test-support/loader-smoke) | `test-support` | [`agent`](../packages/core/agent), [`http-proxy`](../packages/util/http-proxy), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`llm-deepseek-account`](../packages/llm/llm-deepseek-account) | `llm` | [`deepseek-account`](../packages/credentials/deepseek-account), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek) | +| [`llm-deepseek-account`](../packages/llm/llm-deepseek-account) | `llm` | [`deepseek-account`](../packages/credentials/deepseek-account), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`timeout`](../packages/util/timeout) | | [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/interaction/user-approval), [`working-directory`](../packages/session/working-directory) | | [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm) | -| [`goal-round-driver`](../packages/goal/goal-round-driver) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | | [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`deepseek-account`](../packages/credentials/deepseek-account), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`web`](../packages/web/web) | | [`api-account-controller`](../packages/api/account-controller) | `api` | [`agent`](../packages/core/agent), [`deepseek-account`](../packages/credentials/deepseek-account), [`typert-protocol`](../packages/typert/protocol) | @@ -1716,7 +1725,7 @@ flowchart TD | [`token-meter`](../packages/llm/token-meter) | `llm` | [`compaction`](../packages/compaction/compaction), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-title`](../packages/session/session-title), [`tool-todo`](../packages/todo/tool-todo) | | [`api-settings-controller`](../packages/api/settings-controller) | `api` | [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`credentials`](../packages/credentials/credentials), [`native-command`](../packages/util/native-command), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`typert-protocol`](../packages/typert/protocol) | -| [`web-app`](../packages/bundle/web-app) | `bundle` | [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt) | +| [`web-app`](../packages/bundle/web-app) | `bundle` | [`shell-env`](../packages/shell/shell-env) | | [`experimental-auto-review`](../packages/experimental/auto-review) | `experimental` | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`webhook`](../packages/experimental/webhook) | `experimental` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) | | [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`app-boot`](../packages/boot/app-boot), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`tools`](../packages/core/tools) | @@ -1740,12 +1749,13 @@ flowchart TD | [`experimental-claude-code-mods`](../packages/experimental/claude-code-mods) | `experimental` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain), [`subprocess`](../packages/subprocess/subprocess), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-questions`](../packages/interaction/user-questions), [`working-directory`](../packages/session/working-directory) | | [`tool-session-query`](../packages/experimental/tool-session-query) | `experimental` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`webhook-github`](../packages/experimental/webhook-github) | `experimental` | [`credentials`](../packages/credentials/credentials), [`host-webserver`](../packages/host/webserver), [`session`](../packages/core/session), [`webhook`](../packages/experimental/webhook) | +| [`goal-round-driver`](../packages/goal/goal-round-driver) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | `subagent` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-codex`](../packages/subagent/subagent-codex) | `subagent` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process) | `subagent` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process) | `subagent` | [`subagent`](../packages/subagent/subagent) | -| [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`settings`](../packages/settings/settings), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`working-directory`](../packages/session/working-directory), [`workspace`](../packages/workspace/workspace) | | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`subagent`](../packages/subagent/subagent) | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index e61666b542..4f88b2f226 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -9,11 +9,11 @@ en: 1387c4505dde2faf zh: 48570fa1c13d6910 /session-persistence-event-catalog/persistence-type-fingerprints: - en: d8542cb8987022e0 - zh: 38e135021cb21f20 + en: a696c43a89d5fbe8 + zh: 6855eb0239b8dcb3 /session-persistence-event-catalog/event-envelope: - en: 7ebb1a2e0dd1f916 - zh: 8946f415dda5c1b8 + en: 689f781d95689e20 + zh: 602f6a7f0835d698 /session-persistence-event-catalog/events: en: f4776ba3d0fd53ce zh: e86769f50a69055a @@ -27,8 +27,8 @@ en: 49891ca9be5ac123 zh: 49891ca9be5ac123 /session-persistence-event-catalog/events/agent-preset/agent-preset-selected-log-only: - en: 0cb4079706a5395b - zh: 9f1a0d8564c54d98 + en: 9eb4e8ec6b503d83 + zh: 6d96288a30d92b81 /session-persistence-event-catalog/events/approval: en: 9d2ec4c4c3664b9f zh: 9d2ec4c4c3664b9f @@ -45,11 +45,11 @@ en: 44426b6cd0dc01cf zh: 44426b6cd0dc01cf /session-persistence-event-catalog/events/assistant/assistant-attempt-log-only: - en: 6baf474f8c4a905e - zh: b92f1c041441ba98 + en: 5b002a8dcefbfc71 + zh: e44f53ed4bf1f929 /session-persistence-event-catalog/events/assistant/assistant-message-surface: - en: 70721b64d98f892e - zh: ad5091727753c698 + en: a0f8289f97125e4f + zh: 1ff54770086eb016 /session-persistence-event-catalog/events/command: en: 2cecb5b0ee955732 zh: 2cecb5b0ee955732 @@ -84,8 +84,8 @@ en: 3344204b1858edb1 zh: 3344204b1858edb1 /session-persistence-event-catalog/events/developer/developer-message-surface: - en: d1ef5d25c01a725e - zh: eea5a11e0c4516ee + en: cd036bde54c62690 + zh: 5a21179b33551ed5 /session-persistence-event-catalog/events/feedback: en: f50dbac6867fa64d zh: f50dbac6867fa64d @@ -119,6 +119,15 @@ /session-persistence-event-catalog/events/image/image-offload-log-only: en: 5b3bba4fc4261208 zh: 6d5f536c3ca92710 +/session-persistence-event-catalog/events/job: + en: 941358a5fef6a5b4 + zh: 941358a5fef6a5b4 +/session-persistence-event-catalog/events/job/job-registered-log-only: + en: 37ab7f962b199555 + zh: af05a3274adbe530 +/session-persistence-event-catalog/events/job/job-settled-log-only: + en: dde416e1c0d32dfb + zh: fa08197cad8acb9d /session-persistence-event-catalog/events/llm: en: 8a5c09e48ec0a6ae zh: 8a5c09e48ec0a6ae @@ -132,8 +141,8 @@ en: 9edf4ef31e60e880 zh: 9edf4ef31e60e880 /session-persistence-event-catalog/events/model/model-selection-log-only: - en: ea963e9ec025c667 - zh: f6eea77d22442cd8 + en: 0d9e20c2357ae7ac + zh: 23401d59e97d6011 /session-persistence-event-catalog/events/permission: en: 19917bc5438e60af zh: 19917bc5438e60af @@ -150,11 +159,11 @@ en: a8df82440ebdf5f8 zh: a8df82440ebdf5f8 /session-persistence-event-catalog/events/request/request-context-log-only: - en: e1a46fbd7273e666 - zh: e3390b27a540e353 + en: 799784e7cd019889 + zh: db6c59d673b4acff /session-persistence-event-catalog/events/request/request-header-log-only: - en: 897d315d9a0489d2 - zh: 98726636aa3c4ae1 + en: fb095eddad22b5f5 + zh: 49f1a3ac0a9ec57c /session-persistence-event-catalog/events/sandbox: en: ec841ab8439335b0 zh: ec841ab8439335b0 @@ -171,8 +180,8 @@ en: a1ac8f2660a4f61e zh: a1ac8f2660a4f61e /session-persistence-event-catalog/events/session/session-end-seed-log-only: - en: 53b95ea8a0fd3bd5 - zh: c5a0d2f068637efa + en: 70c3c0d6614f2d30 + zh: bb4dd52f5bcf351c /session-persistence-event-catalog/events/session/session-title-log-only: en: 6c6a27578dff5828 zh: 9a5add2bc0df2d0a @@ -189,11 +198,11 @@ en: bd0dcbbc352b771a zh: bd0dcbbc352b771a /session-persistence-event-catalog/events/step/step-end-log-only: - en: e24b5dd8b2b806aa - zh: a02bc5c3d7b07168 + en: 2de61d81867d485c + zh: 21025a396ef7c88f /session-persistence-event-catalog/events/step/step-start-log-only: - en: 71b35276d634c512 - zh: b1b7f063cffac1de + en: cb8f05bf3b59adef + zh: 686b33b689c4a841 /session-persistence-event-catalog/events/subagent: en: 9630752e97ddbfbc zh: 9630752e97ddbfbc @@ -210,8 +219,8 @@ en: 8cac41d2fb575975 zh: 8cac41d2fb575975 /session-persistence-event-catalog/events/system/system-message-surface: - en: 59abc66d6a56e861 - zh: cc2d67c879079151 + en: 92b1f61ac638da71 + zh: 2d60ba9d38abda05 /session-persistence-event-catalog/events/team: en: a640800a38b3c874 zh: a640800a38b3c874 @@ -237,8 +246,8 @@ en: 28a24430609494b2 zh: 28a24430609494b2 /session-persistence-event-catalog/events/tool/tool-call-log-only: - en: 96cc0c05d3a2d01b - zh: f6f86d5bd29db6a5 + en: 03f36237cbf9e267 + zh: 83fe2e69bbbfd3c1 /session-persistence-event-catalog/events/tool/tool-ptc-dispatch-log-only: en: 3d5d72b99e702a8c zh: e8c3b82cd5361c4a @@ -246,8 +255,8 @@ en: de38cf70d81ad7c3 zh: dd0a2a8be099271d /session-persistence-event-catalog/events/tool/tool-result-surface: - en: 2857bd718f6e7264 - zh: 61aac43cff6451b6 + en: 283e3cf1fdd57f05 + zh: 0bc3a15ae4f47aaf /session-persistence-event-catalog/events/tool-workflow: en: 1eac7deac6af15f3 zh: 1eac7deac6af15f3 @@ -267,17 +276,17 @@ en: d7e8c999a0851a0c zh: d7e8c999a0851a0c /session-persistence-event-catalog/events/turn/turn-end-log-only: - en: db0b56bc4d2959d1 - zh: 4587a42273da90d0 + en: ce5ef6916c95ea16 + zh: 9652c8d40b5dfb6d /session-persistence-event-catalog/events/turn/turn-start-log-only: - en: 09ef649bb3c92015 - zh: 69244b81c8c1f614 + en: 999ffb7ad4e600aa + zh: a8720bf8578431ae /session-persistence-event-catalog/events/user: en: d864a88c03e8971c zh: d864a88c03e8971c /session-persistence-event-catalog/events/user/user-message-surface: - en: 6d302c03787ebb1f - zh: d53f597a8b651309 + en: 55128b5458eae10f + zh: 4198ea89093484f6 /session-persistence-event-catalog/events/web: en: d278748103cf1640 zh: d278748103cf1640 @@ -540,8 +549,8 @@ en: 028791baa24a6a0a zh: 028791baa24a6a0a /session-persistence-event-catalog/resolved-persistence-types/in-history: - en: 7ee8e3e9b3947c22 - zh: 035ceca2d5031800 + en: 8bda2ac68f50953b + zh: 190a69ddd097c445 /session-persistence-event-catalog/resolved-persistence-types/in-progress: en: 61de2b0c9de8c15b zh: 61de2b0c9de8c15b @@ -555,8 +564,20 @@ en: 759b0bea643a3f2b zh: 759b0bea643a3f2b /session-persistence-event-catalog/resolved-persistence-types/interrupted: - en: 69a7a30a4e43ff35 - zh: 69a7a30a4e43ff35 + en: 9f4bd316342f65ef + zh: 9f4bd316342f65ef +/session-persistence-event-catalog/resolved-persistence-types/job-registered: + en: 19aa7833c2187c48 + zh: 19aa7833c2187c48 +/session-persistence-event-catalog/resolved-persistence-types/job-settled: + en: 0a0b96a87bf8ad25 + zh: 0a0b96a87bf8ad25 +/session-persistence-event-catalog/resolved-persistence-types/kill: + en: 0401f4463ce25ee0 + zh: 0401f4463ce25ee0 +/session-persistence-event-catalog/resolved-persistence-types/killed: + en: cf74df062715315c + zh: cf74df062715315c /session-persistence-event-catalog/resolved-persistence-types/legacy: en: 1fc6c6420d6eb307 zh: 1fc6c6420d6eb307 @@ -600,8 +621,11 @@ en: afd1613dd3d4da80 zh: afd1613dd3d4da80 /session-persistence-event-catalog/resolved-persistence-types/other: - en: 588a8f46adda6707 - zh: 588a8f46adda6707 + en: d4ae27f96b0df108 + zh: d4ae27f96b0df108 +/session-persistence-event-catalog/resolved-persistence-types/owner: + en: 5ae91798cfeb7895 + zh: 5ae91798cfeb7895 /session-persistence-event-catalog/resolved-persistence-types/parent: en: 83af3e4fc9243790 zh: 83af3e4fc9243790 @@ -624,8 +648,11 @@ en: de774562ec890a6c zh: de774562ec890a6c /session-persistence-event-catalog/resolved-persistence-types/positive: - en: 2a01e347d9604ac6 - zh: 2a01e347d9604ac6 + en: e048b2b8f8165011 + zh: e048b2b8f8165011 +/session-persistence-event-catalog/resolved-persistence-types/producer: + en: b829f105842b826c + zh: b829f105842b826c /session-persistence-event-catalog/resolved-persistence-types/product-interaction: en: 05fe435810456683 zh: 05fe435810456683 @@ -783,8 +810,11 @@ en: d683235390472024 zh: d683235390472024 /session-persistence-event-catalog/resolved-persistence-types/team-task: - en: f8a5fe2380e1a4c4 - zh: f8a5fe2380e1a4c4 + en: 7aa9286372341561 + zh: 7aa9286372341561 +/session-persistence-event-catalog/resolved-persistence-types/teardown: + en: 301cd78f24fe13c5 + zh: 301cd78f24fe13c5 /session-persistence-event-catalog/resolved-persistence-types/text: en: 20b696e15c9cf906 zh: 20b696e15c9cf906 @@ -948,8 +978,8 @@ en: e393deed01de3665 zh: 0fa98ad93c22ce79 /session-persistence-event-catalog/resolved-persistence-types/commandsource: - en: 86c7e2b4ae099661 - zh: 2ffe1bd3f25b96fd + en: 53feeeb70547e7df + zh: 99d9e901d9b4afe5 /session-persistence-event-catalog/resolved-persistence-types/compactioncheckpointsource: en: 87f2aaf2b23a5249 zh: 3d6c5ed6603bee31 @@ -972,14 +1002,14 @@ en: 586743dfeab78c55 zh: 26ccef4a02dd7a58 /session-persistence-event-catalog/resolved-persistence-types/deepseeksearchllmrequest: - en: 8bb149f81f7c2544 - zh: 7e9c410f530b7c82 + en: db71ecccc1e5409e + zh: 7b55744e022dc102 /session-persistence-event-catalog/resolved-persistence-types/developermessage: - en: da88473da1299be0 - zh: fe756703e34fcc42 + en: 997b5db27df00446 + zh: 07e5197ddd7bd59b /session-persistence-event-catalog/resolved-persistence-types/epochheader: - en: d31020fa1fda6fa4 - zh: 77984c4bf09443fb + en: 3786becd1ca9c697 + zh: ea8e6fd4f6590567 /session-persistence-event-catalog/resolved-persistence-types/everyscheduledispatchchange: en: 54c487394ce0db1d zh: 48d7c9affbe841cb @@ -1041,8 +1071,20 @@ en: a3087ab7bebe9371 zh: abf6afc2f9f5b692 /session-persistence-event-catalog/resolved-persistence-types/inboxtarget: - en: 49855060f429f597 - zh: 19d1db4ab892ba44 + en: 938113c1b01efad1 + zh: d6829dbe979e6819 +/session-persistence-event-catalog/resolved-persistence-types/jobregistereddata: + en: f604eebb973fe2cf + zh: 0c0aba1e17ecf66e +/session-persistence-event-catalog/resolved-persistence-types/jobsettlecause: + en: 748a4269d86e007a + zh: 4ce04c4451bdcfe8 +/session-persistence-event-catalog/resolved-persistence-types/jobsettleddata: + en: fc8caf270a7581f7 + zh: b467e1d4191106eb +/session-persistence-event-catalog/resolved-persistence-types/jobtoolcall: + en: 7ffee3cc96604a88 + zh: 026abfab0e46c21a /session-persistence-event-catalog/resolved-persistence-types/jsonvalue: en: c239aa9c90226c35 zh: 3561e77832c7c8b5 @@ -1074,11 +1116,11 @@ en: 6dbe099c62f7c226 zh: 6fd788f63c038c91 /session-persistence-event-catalog/resolved-persistence-types/llmretrystartedeventdata: - en: cc63fb8b8fad7d63 - zh: 2026e3da98cad54e + en: c2d9edd8d9f492b7 + zh: f59c687ac691f7ce /session-persistence-event-catalog/resolved-persistence-types/message: - en: e478c640a9c17a4f - zh: b4c6a4930a3789a1 + en: 594707e4894819a8 + zh: 268c4bfd5c4c28f2 /session-persistence-event-catalog/resolved-persistence-types/messagefeedbackdelete: en: c582953270d4ca44 zh: 966ccfe2e7660a2a @@ -1089,20 +1131,20 @@ en: f58575a1fc96df42 zh: 9f0c6ed9033e5c9d /session-persistence-event-catalog/resolved-persistence-types/messagefeedbackrating: - en: 46f4cbb41e7c053a - zh: 511e10d1be567bc5 + en: 674296d54c1914d0 + zh: 3d71ab319514be25 /session-persistence-event-catalog/resolved-persistence-types/messagesource: - en: c4ed18665fbc1e15 - zh: 6db2b051fc329408 + en: 3fab179fe8d58c31 + zh: 5328c9161ff908a9 /session-persistence-event-catalog/resolved-persistence-types/message~2: - en: 2c79aaf7cec7d9d6 - zh: 1403dd3cf9ed4a82 + en: 8bd12543fcdcda5f + zh: d4c6b17083948856 /session-persistence-event-catalog/resolved-persistence-types/modelmessagesource: en: 3b677f021d7dd4d3 zh: 2a875742b22879a2 /session-persistence-event-catalog/resolved-persistence-types/modelselection: - en: 771e9ab3e64076bc - zh: 30d5095a93549d55 + en: 6d66ffc1b393f2f2 + zh: ef6cb30b71f287e7 /session-persistence-event-catalog/resolved-persistence-types/oneshotscheduledispatchchange: en: a3300babe57b6b67 zh: 4aaf06a149b399c3 @@ -1110,14 +1152,17 @@ en: 9ba0cfd5ee9d6a38 zh: 1414fd7468c42651 /session-persistence-event-catalog/resolved-persistence-types/optionalsessionseq: - en: 7baa520fa984480e - zh: 6f809a83a720416d + en: 6d4afd6c237b50fd + zh: ec07f774c853f27b /session-persistence-event-catalog/resolved-persistence-types/presentedfile: en: 3e36e2f29ff1b506 zh: 3dcc1a1dced59d9f /session-persistence-event-catalog/resolved-persistence-types/presentedfile~2: - en: 807fde1cf42ddef3 - zh: 2ea131667b990d1e + en: bb11552744e477b8 + zh: afb881f3ebf97e68 +/session-persistence-event-catalog/resolved-persistence-types/principal: + en: d1e45e03f161dc7c + zh: 4f80af59ca4967c5 /session-persistence-event-catalog/resolved-persistence-types/ptcdispatcheventdata: en: 81e66c5a07e17dd6 zh: 0ecf3cfceba14d2b @@ -1128,14 +1173,14 @@ en: 07e04c3e336f880f zh: 6f8986ede9994fa1 /session-persistence-event-catalog/resolved-persistence-types/replayenvelope: - en: 9a80b6f5eb08eaea - zh: 061c632b8f3e4d87 + en: 3460f0c910a247fd + zh: 3990587ab01a1907 /session-persistence-event-catalog/resolved-persistence-types/requestcontext: - en: cc8bb7075d546941 - zh: 77209abf30744be0 + en: b1ade5ed2c42b1ad + zh: f1a6126bec25657b /session-persistence-event-catalog/resolved-persistence-types/requestheaderreason: - en: 93c4403f94ecdc14 - zh: 2b2eb0c400881d71 + en: 0db3aa2b122a4454 + zh: 15b6cfd8896935b8 /session-persistence-event-catalog/resolved-persistence-types/sandboxmode: en: c9fa5343ea99733b zh: d6de41351c57a73f @@ -1149,17 +1194,17 @@ en: 35c0b15c285d045e zh: 8ccce6ea60dd5742 /session-persistence-event-catalog/resolved-persistence-types/sessionheader: - en: 0f60d736cd9d6c94 - zh: 205b8833a4efc5e5 + en: 0536dab0d2eed6ad + zh: 54396338c9917818 /session-persistence-event-catalog/resolved-persistence-types/sessionreferencesource: en: 9a54c4478ad3b620 zh: 02c4c1fd7e252770 /session-persistence-event-catalog/resolved-persistence-types/sessiontitleeventdata: - en: 4ff17eae0b30056d - zh: 6642afbaa87da3ab + en: bf7325029c9338d6 + zh: e5f98271511e5318 /session-persistence-event-catalog/resolved-persistence-types/sessiontitlellmrequesteventdata: - en: d7491d51e36cd1b6 - zh: 2fa7d1c33bace7fc + en: 89dcb9a9b957c126 + zh: 132aeb7dfd435290 /session-persistence-event-catalog/resolved-persistence-types/sessiontitlemodelidentity: en: 9531468264499498 zh: 9da23c0ad2776563 @@ -1176,8 +1221,8 @@ en: ecc15ebadb77afdf zh: 97ac27d6a4175f6f /session-persistence-event-catalog/resolved-persistence-types/streamchunk: - en: 0d571899222eb04f - zh: de28c6d67d1763c1 + en: 12fe93a89508739b + zh: 03306c0f7b124f0d /session-persistence-event-catalog/resolved-persistence-types/subagentcatalogevent: en: 725539dcd5c335ed zh: 5ca08d3667f84ff9 @@ -1188,8 +1233,8 @@ en: d34acab659e74957 zh: 25cabc6c9fb44a7e /session-persistence-event-catalog/resolved-persistence-types/surfaceop: - en: 4f3349f21317ba18 - zh: 508c26670085953a + en: 95f8ef2fb8a7c154 + zh: 04cdad161fb88cc3 /session-persistence-event-catalog/resolved-persistence-types/systemmessage: en: 94876079d6696df9 zh: d25c7ad9f5054eb7 @@ -1245,8 +1290,8 @@ en: 2258683c3cfa4ce3 zh: 5663aa76c911761b /session-persistence-event-catalog/resolved-persistence-types/toolschema: - en: 6e1e6ebc7c1c38c8 - zh: e47a3ca80c4c3b71 + en: eb76c5bd050ffccd + zh: cb24780d4b5f79ac /session-persistence-event-catalog/resolved-persistence-types/toolschema~2: en: 12c14727bb7834e2 zh: b0f3c877873c36db @@ -1263,17 +1308,17 @@ en: 54090e169899c573 zh: 530ea8d0d0f8cf68 /session-persistence-event-catalog/resolved-persistence-types/turnendcancelcause: - en: 544292eddeff5faa - zh: 4e36e575a5030fb2 + en: 4dc89c072fe5d0d1 + zh: 4ed2c9e9e8b4f7e2 /session-persistence-event-catalog/resolved-persistence-types/turnendreason: - en: cdf0bbf6399a3677 - zh: f4422bf67b216bae + en: eec92deb07c000fa + zh: 666682b6f3efcfa3 /session-persistence-event-catalog/resolved-persistence-types/usermessage: - en: b1c19f6d8f6f31fc - zh: 3843640f47897934 + en: bd3aedd570ded4d2 + zh: f020d0816d3a6c1a /session-persistence-event-catalog/resolved-persistence-types/usermessage~2: - en: 2ee253b3743ee6a2 - zh: 5ee8b08850d93d8e + en: b99d66ffe277f0bc + zh: eacc9c1f62ae66c1 /session-persistence-event-catalog/resolved-persistence-types/workflowagentoutcome: en: d0a3f9aab18c3de5 zh: 76ac69a835ca4e29 @@ -1293,14 +1338,14 @@ en: bccefcfce00a211f zh: bccefcfce00a211f /session-persistence-event-catalog/resolved-persistence-types/number: - en: 5e7d82a493a1d4d8 - zh: d2d785891dd766fc + en: 0223d7ffb67ac272 + zh: c2a8221caab6d350 /session-persistence-event-catalog/resolved-persistence-types/number~2: - en: 386989934d1bb62e - zh: 69b8bc182a5acbd3 + en: 2786fef560851bb4 + zh: 5d787475356b2606 /session-persistence-event-catalog/resolved-persistence-types/string: - en: 90d3fd7202322ca4 - zh: 88cce532aed9f6d4 + en: 009c7ecb8c7d3bb5 + zh: 7f56d672f376b22f /session-persistence-event-catalog/resolved-persistence-types/string~2: en: 0089620bf4638263 zh: 1922bef1713094bb @@ -1329,9 +1374,12 @@ en: ab07ee7c506483d4 zh: 299e31a106d93dfe /session-persistence-event-catalog/resolved-persistence-types/union-3-variants~2: - en: faa507a889e1c476 - zh: b5f13f6f0590152f + en: 5095d352cbd90e21 + zh: e0bfa5c90e3917b6 /session-persistence-event-catalog/resolved-persistence-types/union-3-variants~3: + en: 9e296094df7462ba + zh: e7e3255a3ada9279 +/session-persistence-event-catalog/resolved-persistence-types/union-3-variants~4: en: 5fc89a7ec1cecd78 zh: dc66aa8e4aae1847 /session-persistence-event-catalog/resolved-persistence-types/union-6-variants: @@ -1350,8 +1398,8 @@ en: a3de6a6fe9672c7a zh: a357481e27b3b81f /session-persistence-event-catalog/resolved-persistence-types/agentpreset: - en: b8a0f8aac9708056 - zh: f5f004bd205b41e1 + en: 1afbe8196f161601 + zh: a320a36a8928817c /session-persistence-event-catalog/resolved-persistence-types/allowedmodels: en: 6178321110ceb69d zh: 0a9d94cab9d57487 @@ -1359,8 +1407,8 @@ en: a117573a5d58cb3c zh: d5ad59e17a3a815d /session-persistence-event-catalog/resolved-persistence-types/arguments-callid-name-step: - en: bbb8322870e4695e - zh: dc90a029bf156604 + en: 99e67b046fa9cf40 + zh: 88060ee577d02d20 /session-persistence-event-catalog/resolved-persistence-types/callid-files-turn: en: 5f4d3f66ed86a14d zh: 7c729360fa1279e6 @@ -1392,8 +1440,8 @@ en: a1f188c9058cf621 zh: eb43c0f045fb9afb /session-persistence-event-catalog/resolved-persistence-types/code-name-reason: - en: 5b7790906008fcba - zh: 3d1a6bdb82d82fde + en: 404a76066a59f3c6 + zh: ec9d510bdbac9786 /session-persistence-event-catalog/resolved-persistence-types/commandid-kind-sourceeventseq-text: en: 87b03e1c73d2a326 zh: d7b9f06863cad891 @@ -1434,14 +1482,14 @@ en: 4b7a8793663d3fef zh: 249459c825d330c3 /session-persistence-event-catalog/resolved-persistence-types/endseq-op-startseq: - en: ba27e6f100b73e81 - zh: 82a12774bf5611f8 + en: 318404925fed44c8 + zh: 6cc8b0fcd8d11540 /session-persistence-event-catalog/resolved-persistence-types/error-kind-error: - en: b24398b25f46a94f - zh: 40640447d21ba296 + en: 72572b3d4f2f0c01 + zh: 4c4234325c1713fc /session-persistence-event-catalog/resolved-persistence-types/error-message-meta-step: - en: 423077da59c080a5 - zh: ea8a92a786603279 + en: 8cc3e4d05fa25012 + zh: 2c2d1c1936dd99ce /session-persistence-event-catalog/resolved-persistence-types/failure-kind-aborted: en: 349cd90f2598fd12 zh: 1eda02d520ad5d76 @@ -1449,11 +1497,11 @@ en: 51af619ed8532a43 zh: a5ad878c63d0e046 /session-persistence-event-catalog/resolved-persistence-types/header-reason-startsseries: - en: 562fcce1a6c17ade - zh: 15828c9bf4dcbe6e + en: fe0f6163c5202da6 + zh: 98feea5f03b725e1 /session-persistence-event-catalog/resolved-persistence-types/headerseq-message-step-turn: - en: 884dda8acdf28a83 - zh: 53ef06336629743c + en: 05903e55f510fd79 + zh: 90a17b10f682abcb /session-persistence-event-catalog/resolved-persistence-types/height-width: en: a5df50710083ac25 zh: aad3ccf6f6de70e4 @@ -1470,29 +1518,29 @@ en: 41f73c58a77dfa28 zh: af77f4aee3cb4a40 /session-persistence-event-catalog/resolved-persistence-types/inherited: - en: f46de4c8d413b567 - zh: af0283688492ac43 + en: 8a9199b1784c70f5 + zh: 76c98bc74686fda0 /session-persistence-event-catalog/resolved-persistence-types/inserted-outcome-removedcount-start: - en: 62e1cb96abf41fd5 - zh: c24c03766f6790d8 + en: e77d5d524dd8b4ea + zh: 4dac28dda78a510f /session-persistence-event-catalog/resolved-persistence-types/interrupted-message-step-stream: - en: f460572284c92755 - zh: bae7561ce887b040 + en: ec10cd8e9c202a81 + zh: e08f859bcb13f0d4 /session-persistence-event-catalog/resolved-persistence-types/kind-aborted-reason: - en: c7755cbd343d67ee - zh: e00a7477cd664cb5 + en: 48424cbed3fbc269 + zh: 2ce88c04ad002104 /session-persistence-event-catalog/resolved-persistence-types/kind-blocked: - en: 46465e6fa6292ec3 - zh: b8ed143600bf51d2 + en: 2fad818b19823c48 + zh: a6b33dee21c3fdc9 /session-persistence-event-catalog/resolved-persistence-types/kind-completed: - en: 2f3ffb879c568b64 - zh: ffc7d0f2b64494fc + en: e8f76b61a39ebeb0 + zh: e61a304be00b4490 /session-persistence-event-catalog/resolved-persistence-types/kind-cordis-host-runner: en: fb7b8207a6ef3b81 zh: 9d6f112181d90701 /session-persistence-event-catalog/resolved-persistence-types/kind-disposed: - en: 70d5bdb48ced0ea9 - zh: fc950fee2c166ff0 + en: e3c9527803bc6de3 + zh: 375c82ce2ac5282b /session-persistence-event-catalog/resolved-persistence-types/kind-dsh-session-title-llm: en: 428529e5dedc761d zh: 3ca0b54c9c521ea0 @@ -1518,11 +1566,11 @@ en: 78e34e6d0d70339e zh: 072127e40cfb9686 /session-persistence-event-catalog/resolved-persistence-types/kind-forked: - en: 4b299abd1cf27c78 - zh: dce75fa13b690720 + en: dc750041126fe853 + zh: 736505cd1e57db62 /session-persistence-event-catalog/resolved-persistence-types/kind-hook: - en: 965c99cedd28afdf - zh: 15d8f9991883bd5c + en: 78278a869370695f + zh: 6cdc47f1876f3b23 /session-persistence-event-catalog/resolved-persistence-types/kind-hooks-claude-code: en: 5eb383a67f11ac4d zh: 1943747cb4995dbc @@ -1566,14 +1614,14 @@ en: a8a338be3b91008f zh: c7632c42c869c2f6 /session-persistence-event-catalog/resolved-persistence-types/kind-interrupted: - en: 9e87f168b322c617 - zh: bf81e0aa69f46c08 + en: b2dea1e08facaa40 + zh: 71c9b8d4e3d1d2b8 /session-persistence-event-catalog/resolved-persistence-types/kind-legacy: - en: 7c000bffbf0c567e - zh: b3210306a125820b + en: c6f68741f8631d4f + zh: 090b58f2b86c02d0 /session-persistence-event-catalog/resolved-persistence-types/kind-max-tokens: - en: 20433ac79d2cf7a7 - zh: 8907d87c5179fca8 + en: 4746f91df74cc1dd + zh: d0427e9191359299 /session-persistence-event-catalog/resolved-persistence-types/kind-model-selection: en: a5165486dca91116 zh: b7051ca8167806ef @@ -1596,8 +1644,8 @@ en: 509f9a91aedb2da1 zh: db1071678bfb98d1 /session-persistence-event-catalog/resolved-persistence-types/kind-parent: - en: 96dec53b9a43815a - zh: 4163af6bc636ecc9 + en: 7a3ac2238405913d + zh: b249a4d6d0508d40 /session-persistence-event-catalog/resolved-persistence-types/kind-plan-mode: en: 9758304800b717df zh: dccf14f3a5d0d861 @@ -1779,11 +1827,11 @@ en: 08be4349548406a9 zh: 8e7cf9966198ef78 /session-persistence-event-catalog/resolved-persistence-types/kind-tool-registry: - en: 79db83dd2c430faf - zh: 173f568241f5ce86 + en: 809a23c25ded8497 + zh: e9cd23e281f8d31f /session-persistence-event-catalog/resolved-persistence-types/kind-user: - en: cb5d3fbab4debac6 - zh: 72add55254356149 + en: 9b6318ab4d7eba12 + zh: f475439ef40b2681 /session-persistence-event-catalog/resolved-persistence-types/kind-user-approval: en: a52d2dfbaf844127 zh: 4c5306ca40f20873 @@ -1821,8 +1869,8 @@ en: 82b0989dad6bbfc0 zh: 0789b63f1d59b2ed /session-persistence-event-catalog/resolved-persistence-types/message-step-turn: - en: 7cc30865737643f2 - zh: 512c6c81ed10a9b6 + en: dcaa676f106cfdfd + zh: e3941fea97c943fb /session-persistence-event-catalog/resolved-persistence-types/message-teamid-version: en: f778769acb264dd5 zh: eba9b81d04e9f57b @@ -1839,8 +1887,8 @@ en: f60ecbd156c6f063 zh: c9149679bbacff21 /session-persistence-event-catalog/resolved-persistence-types/reason-turn: - en: 168c2ac7db7371b4 - zh: f851223c95582eb9 + en: a4ba4d0ee1396381 + zh: b6f595f8a696d512 /session-persistence-event-catalog/resolved-persistence-types/role-user: en: 04b34b8ffe7e7764 zh: a792d5371516fd9d @@ -1851,11 +1899,11 @@ en: 7a2ab7b62244f43b zh: 1084e3f8876feebc /session-persistence-event-catalog/resolved-persistence-types/step-stream-turn: - en: 9d436c515695f35d - zh: 247a7c0bb24337d3 + en: 84004f9b9c6bf24c + zh: ce1abe27b0bc43e2 /session-persistence-event-catalog/resolved-persistence-types/step-turn: - en: 6c9cd916657e052e - zh: ed60789608cee181 + en: 989fad01cdd866ba + zh: 4f08dafda68f0c77 /session-persistence-event-catalog/resolved-persistence-types/targets: en: 5360af614fe753e3 zh: 96427c489cc71de6 @@ -1866,14 +1914,14 @@ en: 06e6c1837e2b5d50 zh: 37cebfe3e41294ec /session-persistence-event-catalog/resolved-persistence-types/turn: - en: a79b2ab16b9ed215 - zh: 764cd1400ee8db63 + en: c58b89cec274f956 + zh: 056a29ed1ada9690 /session-persistence-event-catalog/resolved-persistence-types/type-agent-preset-selected: - en: 52112a8633ba49e4 - zh: 95318f8c8b2d19ee + en: c8c731ee23bed85a + zh: f4ffc80f7828701d /session-persistence-event-catalog/resolved-persistence-types/type-agent-inbox-spliced: - en: 35219c983f8168c9 - zh: 5bdf2bc555cc5e1a + en: 62dfe9447efaceb1 + zh: 076454de19b5d7d7 /session-persistence-event-catalog/resolved-persistence-types/type-approval-asked: en: 9c77f18b0dad22f0 zh: 8894cf2878fe90db @@ -1890,11 +1938,11 @@ en: bb507524bcc39c53 zh: 8314898562437473 /session-persistence-event-catalog/resolved-persistence-types/type-block-end: - en: 8c5a05fa6adf3e70 - zh: 66768b25d6fb3e07 + en: c5acb63761d0cbb8 + zh: 6caad01b80c6eb0d /session-persistence-event-catalog/resolved-persistence-types/type-block-start: - en: 5caed426cab416ee - zh: 86f47f90f8c440f2 + en: c52a22b7878bde2f + zh: 42f3fae52cf09ebb /session-persistence-event-catalog/resolved-persistence-types/type-chunk: en: 2186ede3e9917aea zh: 0cfef64045dce95b @@ -1917,11 +1965,11 @@ en: ba73fb10b7e2ffc9 zh: 8d5c97e63e5f16da /session-persistence-event-catalog/resolved-persistence-types/type-deliverables-presented: - en: d69ad6b38df667d3 - zh: 72fb317a507a0b3b + en: a4d9eab2928e9b30 + zh: b36702c866518273 /session-persistence-event-catalog/resolved-persistence-types/type-developer-message: - en: 1d16681802c679ce - zh: 01c03831212dd260 + en: ed8c78af3a89ba8f + zh: 4f859687aa13c1a3 /session-persistence-event-catalog/resolved-persistence-types/type-feedback-message-delete: en: 204561e2311db97e zh: 77264396bd5e25c4 @@ -1932,8 +1980,8 @@ en: 14e4b48d856bcf57 zh: 72ba176535dc7f3f /session-persistence-event-catalog/resolved-persistence-types/type-finish: - en: 21627e72a24990ff - zh: d71375de325ee9f8 + en: 359385a004565fda + zh: 313f78d5906a3128 /session-persistence-event-catalog/resolved-persistence-types/type-goal-change: en: aad2516b62d3ee83 zh: e7204c4dc4e8e408 @@ -1944,8 +1992,14 @@ en: 8b523e0be5327519 zh: 86ec891087b8bf24 /session-persistence-event-catalog/resolved-persistence-types/type-image-offload: - en: 06230b6290fa9826 - zh: 11930bfa355b70fa + en: bf512693267d4262 + zh: 805710898b4e913a +/session-persistence-event-catalog/resolved-persistence-types/type-job-registered: + en: 734d3bb283cc6f66 + zh: 75bcb55f71673e34 +/session-persistence-event-catalog/resolved-persistence-types/type-job-settled: + en: 742c3a1543414ecb + zh: e256fb92d72b8184 /session-persistence-event-catalog/resolved-persistence-types/type-llm-retry: en: 6d026baeac30a0f1 zh: 55131683e3d5ed27 @@ -1965,8 +2019,8 @@ en: 34e5591d75322649 zh: a216839f305c9796 /session-persistence-event-catalog/resolved-persistence-types/type-reasoning-delta: - en: a18feb76be195fa0 - zh: 3db7147ee4c5c963 + en: 8b5e884e0c0ab11b + zh: 3b291d80028b6583 /session-persistence-event-catalog/resolved-persistence-types/type-request-context: en: f8addb1a01da9ca1 zh: 4ef11ebddd302c05 @@ -1986,11 +2040,11 @@ en: acf7e21e4d21ebff zh: fb83a853634d204a /session-persistence-event-catalog/resolved-persistence-types/type-session-title: - en: 203f5661ca1850b1 - zh: b3d9ec1019188df4 + en: b99fbacda0db1abd + zh: 9e75ce6b1d174d48 /session-persistence-event-catalog/resolved-persistence-types/type-session-title-llm-request: - en: 246920f06c3a2175 - zh: 56deee3b86499174 + en: 7ba30b98d8e3f653 + zh: ca68b01ab4b6ba3d /session-persistence-event-catalog/resolved-persistence-types/type-step-end: en: 7fc233c4a569dae2 zh: bc2b3fed4a397c00 @@ -2025,8 +2079,8 @@ en: 1484738718de5312 zh: 3e6fbe9c64068225 /session-persistence-event-catalog/resolved-persistence-types/type-text-delta: - en: 47ae2d57be710450 - zh: b072753349ac505e + en: bac4775a1255bbbb + zh: 834b81d203e21b7c /session-persistence-event-catalog/resolved-persistence-types/type-todo-write: en: 2fa7ba77598a95b4 zh: 50972b51d77e9155 @@ -2034,8 +2088,8 @@ en: 32f732b97739e4d2 zh: f288e48e356969c0 /session-persistence-event-catalog/resolved-persistence-types/type-tool-call-delta: - en: 1df89f427ccac815 - zh: 1bec3d025ea1f7e3 + en: a172400ce8d6dbe7 + zh: 1d6cba18564d0230 /session-persistence-event-catalog/resolved-persistence-types/type-tool-workflow-agent-end: en: 8eb5e1eb95722bc7 zh: 8dd5980029600648 @@ -2067,11 +2121,11 @@ en: 1b045de96172be4b zh: 8a15c6f35b576b37 /session-persistence-event-catalog/resolved-persistence-types/type-usage: - en: fa542ccb91698e56 - zh: 7b725c813d69588d + en: 1eabcbd128f86602 + zh: c6a2067a05f4e692 /session-persistence-event-catalog/resolved-persistence-types/type-user-message: - en: fb49c375aec5952c - zh: 19b80920c1068b86 + en: afab869c0592b46c + zh: f15874a1e728ab0d /session-persistence-event-catalog/resolved-persistence-types/type-web-deepseek-search-llm-request: en: f109997e295af37d zh: b2dcfd599f73b61c diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 3d8b0b77db..67f829b9d8 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -23,7 +23,7 @@ The [format references](persistence-changes/historical-formats/README.md) cover | `JsonlHeaderLine` | header | `18ee62b8900a4c3d046700f05d7a4d49d6cab2a660a020c87481d1603dd8bd4f` | [`HeaderLine`](#persistence-type-sha256-18ee62b8900a4c3d046700f05d7a4d49d6cab2a660a020c87481d1603dd8bd4f) | | `SessionEventEnvelope` | envelope | `5776e5553ff2dfe3f5bc202dbb1e7c9f93e35a531aebb7764c23b2b6153b2ccc` | [`union (3 variants)`](#persistence-type-sha256-5776e5553ff2dfe3f5bc202dbb1e7c9f93e35a531aebb7764c23b2b6153b2ccc) | | `event:agent-preset/selected` | event | `a10c17474eaf2ddab7095a099e0fe3d046fc18e56c3e344fc8894c05ff9ef97b` | [`{ type: "agent-preset/selected" }`](#persistence-type-sha256-a10c17474eaf2ddab7095a099e0fe3d046fc18e56c3e344fc8894c05ff9ef97b) | -| `event:agent/inbox/spliced` | event | `9a36727716d0e73f681f3b725e7f28f89417c68174c8af0ff2b1ef52b08326a0` | [`{ type: "agent/inbox/spliced" }`](#persistence-type-sha256-9a36727716d0e73f681f3b725e7f28f89417c68174c8af0ff2b1ef52b08326a0) | +| `event:agent/inbox/spliced` | event | `0755b7d1128319ba2e389619cbbf2c961ad648882b4e91da7f3e9582cc8a2034` | [`{ type: "agent/inbox/spliced" }`](#persistence-type-sha256-0755b7d1128319ba2e389619cbbf2c961ad648882b4e91da7f3e9582cc8a2034) | | `event:approval/asked` | event | `3bfeb47b58606f4661904bc723da612782214c463d01e6d61cd6d6193d7374e1` | [`{ type: "approval/asked" }`](#persistence-type-sha256-3bfeb47b58606f4661904bc723da612782214c463d01e6d61cd6d6193d7374e1) | | `event:approval/decided` | event | `bb1ab3d08f49a9f3b265f844cd78d5c49813062a7b34b54904b426f85d0ff6e3` | [`{ type: "approval/decided" }`](#persistence-type-sha256-bb1ab3d08f49a9f3b265f844cd78d5c49813062a7b34b54904b426f85d0ff6e3) | | `event:approval/policy` | event | `26718e15e7e395bce9642dba5bbe09b3b1a4ce2213d20d566cd9207d7fc5fb78` | [`{ type: "approval/policy" }`](#persistence-type-sha256-26718e15e7e395bce9642dba5bbe09b3b1a4ce2213d20d566cd9207d7fc5fb78) | @@ -36,7 +36,7 @@ The [format references](persistence-changes/historical-formats/README.md) cover | `event:compaction/start` | event | `db874d463b0fdec77e9da1c4568f37cb70bd6596781eb93800db44fb8a116965` | [`{ type: "compaction/start" }`](#persistence-type-sha256-db874d463b0fdec77e9da1c4568f37cb70bd6596781eb93800db44fb8a116965) | | `event:compaction/summary` | event | `e2f9a41e0989f54ed8cee80f8db2bcf9d60a5c810dc9d45b83fa050b9dce7602` | [`{ type: "compaction/summary" }`](#persistence-type-sha256-e2f9a41e0989f54ed8cee80f8db2bcf9d60a5c810dc9d45b83fa050b9dce7602) | | `event:deliverables/presented` | event | `13d3d180f977bf78081d487ffa0ecb75857349bcab29a5a3fb48189fca2a6176` | [`{ type: "deliverables/presented" }`](#persistence-type-sha256-13d3d180f977bf78081d487ffa0ecb75857349bcab29a5a3fb48189fca2a6176) | -| `event:developer/message` | event | `ba5b10ea77d68f4a29439a9bd2a14f6a560acf55731749d57237757cc72fbc06` | [`{ type: "developer/message" }`](#persistence-type-sha256-ba5b10ea77d68f4a29439a9bd2a14f6a560acf55731749d57237757cc72fbc06) | +| `event:developer/message` | event | `97e0162f983c27456ec71269fb4976b1851588b8c1aeac5d071d43e11f95e03c` | [`{ type: "developer/message" }`](#persistence-type-sha256-97e0162f983c27456ec71269fb4976b1851588b8c1aeac5d071d43e11f95e03c) | | `event:feedback/message-delete` | event | `3ee93b06f3a125850337602bcdf155d2538c43a5c944ec55b1b3c365152d6796` | [`{ type: "feedback/message-delete" }`](#persistence-type-sha256-3ee93b06f3a125850337602bcdf155d2538c43a5c944ec55b1b3c365152d6796) | | `event:feedback/message-put` | event | `b5086d249e8502e9ead1d39156bb8d559bde7951cac0f14ce150345b4e42a2bf` | [`{ type: "feedback/message-put" }`](#persistence-type-sha256-b5086d249e8502e9ead1d39156bb8d559bde7951cac0f14ce150345b4e42a2bf) | | `event:feedback/record` | event | `b54940ff095c17e874c5be03815f4c2145a256cf3a1d34dae4ab2f7769dfffe8` | [`{ type: "feedback/record" }`](#persistence-type-sha256-b54940ff095c17e874c5be03815f4c2145a256cf3a1d34dae4ab2f7769dfffe8) | @@ -44,6 +44,8 @@ The [format references](persistence-changes/historical-formats/README.md) cover | `event:hook/invoked` | event | `8a6e1ec9e8db346b0e02f027db73c07a94f067a26d40c1aef1abd09c47ce7ba0` | [`{ type: "hook/invoked" }`](#persistence-type-sha256-8a6e1ec9e8db346b0e02f027db73c07a94f067a26d40c1aef1abd09c47ce7ba0) | | `event:hook/result` | event | `e75916628f3f10c2d50658bd143052a46285fbf1a9a700ba54947614603d26b4` | [`{ type: "hook/result" }`](#persistence-type-sha256-e75916628f3f10c2d50658bd143052a46285fbf1a9a700ba54947614603d26b4) | | `event:image/offload` | event | `b222069eea2d768065161c1147b1f2c78c1b54328c84b3586ae5c8f91b8ed35e` | [`{ type: "image/offload" }`](#persistence-type-sha256-b222069eea2d768065161c1147b1f2c78c1b54328c84b3586ae5c8f91b8ed35e) | +| `event:job/registered` | event | `f5520c1d261ca86ea03735799c311a0a9ede8babb588cf0a65422576d2ee0ad1` | [`{ type: "job/registered" }`](#persistence-type-sha256-f5520c1d261ca86ea03735799c311a0a9ede8babb588cf0a65422576d2ee0ad1) | +| `event:job/settled` | event | `edd37b1e4974af648633014435572089b1e8fb71910468a0767a7e28fdd22abf` | [`{ type: "job/settled" }`](#persistence-type-sha256-edd37b1e4974af648633014435572089b1e8fb71910468a0767a7e28fdd22abf) | | `event:llm/retry` | event | `525254db03b1d1e6b74cf55aced52817568331ac1f96c0818728910b6692e336` | [`{ type: "llm/retry" }`](#persistence-type-sha256-525254db03b1d1e6b74cf55aced52817568331ac1f96c0818728910b6692e336) | | `event:llm/retry-started` | event | `48e5c9861f16ac07e78cb7b5ae9dabdf7bb85c58baed5a51b4ad275050ea58e3` | [`{ type: "llm/retry-started" }`](#persistence-type-sha256-48e5c9861f16ac07e78cb7b5ae9dabdf7bb85c58baed5a51b4ad275050ea58e3) | | `event:model/selection` | event | `35203ba7ad5ef6f97d556b85df20ae98f04f09c65748cecdf8eefdb8b6405ffc` | [`{ type: "model/selection" }`](#persistence-type-sha256-35203ba7ad5ef6f97d556b85df20ae98f04f09c65748cecdf8eefdb8b6405ffc) | @@ -56,7 +58,7 @@ The [format references](persistence-changes/historical-formats/README.md) cover | `event:session-log-deepseek/delivery-accepted` | event | `d63b8b8ffad9c02fd80c43a17df4f240c1fe8118ecca9de34f9d5871838ab5b9` | [`{ type: "session-log-deepseek/delivery-accepted" }`](#persistence-type-sha256-d63b8b8ffad9c02fd80c43a17df4f240c1fe8118ecca9de34f9d5871838ab5b9) | | `event:session/end-seed` | event | `5e6db6e24948d4a853c71cb9fabd252ad051ce93d4672c1266cf837c1c17b84e` | [`{ type: "session/end-seed" }`](#persistence-type-sha256-5e6db6e24948d4a853c71cb9fabd252ad051ce93d4672c1266cf837c1c17b84e) | | `event:session/title` | event | `1b912703e2d64f91c99c675b8f805b01076c8325b905c1218ad81ef0b24909d5` | [`{ type: "session/title" }`](#persistence-type-sha256-1b912703e2d64f91c99c675b8f805b01076c8325b905c1218ad81ef0b24909d5) | -| `event:session/title-llm-request` | event | `b33545b3244861bcbf73b407d24b53402710f5acacab7ae939537e5504a68dc6` | [`{ type: "session/title-llm-request" }`](#persistence-type-sha256-b33545b3244861bcbf73b407d24b53402710f5acacab7ae939537e5504a68dc6) | +| `event:session/title-llm-request` | event | `79a4c87f8bfb4d6d05cd9952b99210d6a7db3b478c4a01d94b4c50602308c056` | [`{ type: "session/title-llm-request" }`](#persistence-type-sha256-79a4c87f8bfb4d6d05cd9952b99210d6a7db3b478c4a01d94b4c50602308c056) | | `event:step/end` | event | `e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05` | [`{ type: "step/end" }`](#persistence-type-sha256-e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05) | | `event:step/start` | event | `4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986` | [`{ type: "step/start" }`](#persistence-type-sha256-4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986) | | `event:subagent/catalog` | event | `53750b9abf61efb7992b0aadc3e247ec927449c63266df28230fd1139f5590ac` | [`{ type: "subagent/catalog" }`](#persistence-type-sha256-53750b9abf61efb7992b0aadc3e247ec927449c63266df28230fd1139f5590ac) | @@ -78,7 +80,7 @@ The [format references](persistence-changes/historical-formats/README.md) cover | `event:tool/result` | event | `7c9f44e90a0058f4cc532ae20dad0c10afa6eba22e70a6c79fc79490bad64397` | [`{ type: "tool/result" }`](#persistence-type-sha256-7c9f44e90a0058f4cc532ae20dad0c10afa6eba22e70a6c79fc79490bad64397) | | `event:turn/end` | event | `0f8512903d94f57a4748fa1a2092e64342856796684e6b8343db685b192745ce` | [`{ type: "turn/end" }`](#persistence-type-sha256-0f8512903d94f57a4748fa1a2092e64342856796684e6b8343db685b192745ce) | | `event:turn/start` | event | `aa0957eca50aeb28bcd2e6930b95809926edacb550c8c340ba526ba6b861b3d8` | [`{ type: "turn/start" }`](#persistence-type-sha256-aa0957eca50aeb28bcd2e6930b95809926edacb550c8c340ba526ba6b861b3d8) | -| `event:user/message` | event | `2ace3b8059b82b6d9837fed518ca97a99eb37c4482df7aad0e71c85489428ade` | [`{ type: "user/message" }`](#persistence-type-sha256-2ace3b8059b82b6d9837fed518ca97a99eb37c4482df7aad0e71c85489428ade) | +| `event:user/message` | event | `2ae8594a998c9862a597129e782277bcc518266712a703b5c3161bdf552d13b8` | [`{ type: "user/message" }`](#persistence-type-sha256-2ae8594a998c9862a597129e782277bcc518266712a703b5c3161bdf552d13b8) | | `event:web/deepseek-search-llm-request` | event | `cf6e3aaf1e2de6480aa0157730a41b9a492108a55304100b0f7e112711dd4331` | [`{ type: "web/deepseek-search-llm-request" }`](#persistence-type-sha256-cf6e3aaf1e2de6480aa0157730a41b9a492108a55304100b0f7e112711dd4331) | | `event:working-directory/change` | event | `124aa62078ca3311353b39cc3bf6c75da281338be6bbf672f8b361d828cddca3` | [`{ type: "working-directory/change" }`](#persistence-type-sha256-124aa62078ca3311353b39cc3bf6c75da281338be6bbf672f8b361d828cddca3) | | `event:workspace/changes` | event | `e308ccf867a5398e316e0af8cb6ce238a8d33a63b9b384c8250a686786285f72` | [`{ type: "workspace/changes" }`](#persistence-type-sha256-e308ccf867a5398e316e0af8cb6ce238a8d33a63b9b384c8250a686786285f72) | @@ -158,7 +160,7 @@ export type SessionEvent = { }[T] ``` -Sources: [`packages/core/session/src/types.ts:431`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:439`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:462`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:493`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:432`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:440`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:463`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:494`](../packages/core/session/src/types.ts) ## Events @@ -196,12 +198,15 @@ Source: [`packages/core/agent/src/types.ts:96`](../packages/core/agent/src/types * The session's agent preset was chosen after creation, while the session * was still blank. Log-only: it records the composition later turns ran * under, so a resumed or forked session rebuilds the same one instead of - * the header's creation-time value. + * the header's creation-time value. In a child Session, an occurrence + * before its first own `subagent/descriptor` marks an independent spawn + * selection that cold resume restores instead of joining the parent's + * composition (`dsh-subagent` README, "Restoring child composition"). */ 'agent-preset/selected': { agentPreset: string } ``` -Source: [`packages/preset/agent-preset-registry/src/session.ts:28`](../packages/preset/agent-preset-registry/src/session.ts) +Source: [`packages/preset/agent-preset-registry/src/session.ts:31`](../packages/preset/agent-preset-registry/src/session.ts) ### `approval/*` @@ -285,7 +290,7 @@ Source: [`packages/interaction/user-approval/src/index.ts:40`](../packages/inter 'assistant/attempt': { turn: number; step: number; stream: AssistantStreamRecord[] } ``` -Source: [`packages/core/session/src/types.ts:355`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:356`](../packages/core/session/src/types.ts) @@ -315,7 +320,7 @@ Source: [`packages/core/session/src/types.ts:355`](../packages/core/session/src/ Types: [TokenUsage](subsystems/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:341`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:342`](../packages/core/session/src/types.ts) ### `command/*` @@ -503,7 +508,7 @@ Source: [`packages/deliverables/tool-present/src/types.ts:15`](../packages/deliv } ``` -Source: [`packages/core/session/src/types.ts:311`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:312`](../packages/core/session/src/types.ts) ### `feedback/*` @@ -627,6 +632,36 @@ Source: [`packages/experimental/hook-protocol/src/types.ts:31`](../packages/expe Source: [`packages/compaction/compaction-image-offload/src/projection.ts:25`](../packages/compaction/compaction-image-offload/src/projection.ts) +### `job/*` + + + +#### `job/registered` — log-only + +```ts persistence-catalog +/** + * Log-only job registration; model output collection is not required. + * @param data - Execution identity, original owner, registration time, and creating tool call. + */ +'job/registered': JobRegisteredData +``` + +Source: [`packages/jobs/jobs/src/types.ts:59`](../packages/jobs/jobs/src/types.ts) + + + +#### `job/settled` — log-only + +```ts persistence-catalog +/** + * Log-only terminal record, paired by the same unique jobId used for live control. + * @param data - Registration facts plus settlement time, outcome, and producer acknowledgement. + */ +'job/settled': JobSettledData +``` + +Source: [`packages/jobs/jobs/src/types.ts:64`](../packages/jobs/jobs/src/types.ts) + ### `llm/*` @@ -665,7 +700,7 @@ Source: [`packages/llm/llm-retry/src/types.ts:11`](../packages/llm/llm-retry/src 'model/selection': ModelSelection ``` -Source: [`packages/api/session-controller/src/types.ts:40`](../packages/api/session-controller/src/types.ts) +Source: [`packages/api/session-controller/src/types.ts:41`](../packages/api/session-controller/src/types.ts) ### `permission/*` @@ -718,7 +753,7 @@ Source: [`packages/plan/plan-mode/src/index.ts:52`](../packages/plan/plan-mode/s 'request/context': RequestContext ``` -Source: [`packages/core/session/src/types.ts:402`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:403`](../packages/core/session/src/types.ts) @@ -737,7 +772,7 @@ Source: [`packages/core/session/src/types.ts:402`](../packages/core/session/src/ } ``` -Source: [`packages/core/session/src/types.ts:390`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:391`](../packages/core/session/src/types.ts) ### `sandbox/*` @@ -814,7 +849,7 @@ Source: [`packages/schedule/schedule/src/types.ts:365`](../packages/schedule/sch 'session/end-seed': { inherited?: true } ``` -Source: [`packages/core/session/src/types.ts:427`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:428`](../packages/core/session/src/types.ts) @@ -876,7 +911,7 @@ Source: [`packages/session/session-log-deepseek/src/types.ts:81`](../packages/se 'step/end': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:301`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:302`](../packages/core/session/src/types.ts) @@ -887,7 +922,7 @@ Source: [`packages/core/session/src/types.ts:301`](../packages/core/session/src/ 'step/start': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:299`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:300`](../packages/core/session/src/types.ts) ### `subagent/*` @@ -963,7 +998,7 @@ Source: [`packages/subagent/tool-subagent/src/model-selection-state.ts:17`](../p 'system/message': { turn: number; step: number; message: SystemMessage } ``` -Source: [`packages/core/session/src/types.ts:330`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:331`](../packages/core/session/src/types.ts) ### `team/*` @@ -1056,7 +1091,7 @@ Source: [`packages/todo/tool-todo/src/types.ts:31`](../packages/todo/tool-todo/s Types: [ToolCallId](subsystems/core.md) -Source: [`packages/core/session/src/types.ts:361`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:362`](../packages/core/session/src/types.ts) @@ -1137,7 +1172,7 @@ Source: [`packages/core/tools/src/types.ts:45`](../packages/core/tools/src/types } ``` -Source: [`packages/core/session/src/types.ts:375`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:376`](../packages/core/session/src/types.ts) ### `tool-workflow/*` @@ -1217,7 +1252,7 @@ Source: [`packages/workflow/tool-workflow/src/types.ts:47`](../packages/workflow Types: [TurnEndReason](subsystems/session.md) -Source: [`packages/core/session/src/types.ts:297`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:298`](../packages/core/session/src/types.ts) @@ -1233,7 +1268,7 @@ Source: [`packages/core/session/src/types.ts:297`](../packages/core/session/src/ 'turn/start': { turn: number } ``` -Source: [`packages/core/session/src/types.ts:288`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:289`](../packages/core/session/src/types.ts) ### `user/*` @@ -1252,7 +1287,7 @@ Source: [`packages/core/session/src/types.ts:288`](../packages/core/session/src/ 'user/message': UserMessage ``` -Source: [`packages/core/session/src/types.ts:309`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:310`](../packages/core/session/src/types.ts) ### `web/*` @@ -1954,7 +1989,7 @@ SHA-256: `ebb768d85ec87fb68e42fc14fd7d8535b5ac11791b14d04c6770180f0a719bf8` SHA-256: `f311671e07bc7fa645a0b6597de9966b97147956565296ba76a36543801b66c5` -Sources: [`packages/llm/llm/src/types.ts:396`](../packages/llm/llm/src/types.ts) +Sources: [`packages/llm/llm/src/types.ts:412`](../packages/llm/llm/src/types.ts) `"in-history"` @@ -1998,6 +2033,38 @@ SHA-256: `69d238a6e9b08d67f601b1825962963d8d3523cb69ebf6208c697dc5d058c199` `"interrupted"` + + +### `"job/registered"` + +SHA-256: `24a8188c4d61b70a01af8cc576748765d36f132632ec55c0064f6fba482369fc` + +`"job/registered"` + + + +### `"job/settled"` + +SHA-256: `347de3380f2da308d0c74e08821f9e85559f55af4c5010795b110d00f7fc9dbd` + +`"job/settled"` + + + +### `"kill"` + +SHA-256: `bab68657940075483f086fffb711682a45bcc99c43d659fbe1c36af21ac05dd7` + +`"kill"` + + + +### `"killed"` + +SHA-256: `ef226a90ddac6cd8e631a3a337a3a3e27f9494be92befb7c718b404681d75e21` + +`"killed"` + ### `"legacy"` @@ -2118,6 +2185,14 @@ SHA-256: `9d80129e13edfb6feb03ed327e6085c6ef60b97ad074244b2f5fd4667424a6a4` `"other"` + + +### `"owner"` + +SHA-256: `95a3ffccbf3c8afcf9a2f3b355f724da8e07e98dc47ee741166dde7f9ea058bd` + +`"owner"` + ### `"parent"` @@ -2182,6 +2257,14 @@ SHA-256: `df5d9709b7db2a72510dc52052a23f0c38f39730af1504bab8bccabc14fae528` `"positive"` + + +### `"producer"` + +SHA-256: `6cf082d0fb6034dea61636787ff0ddf43b6e59aad71e644435a479bc9b83ad65` + +`"producer"` + ### `"product-interaction"` @@ -2606,6 +2689,14 @@ SHA-256: `b57ae1b37e52bee0a21cbdace168dc6a15582bcda8c306dad962c5dcc8a8e28b` `"team/task"` + + +### `"teardown"` + +SHA-256: `5cb63db9fe57dc09c3a1c2b84c3edf73ff5b62960155d8436a2377a5c43ce32c` + +`"teardown"` + ### `"text"` @@ -3123,7 +3214,7 @@ Array of [`AssistantStreamRecord`](#persistence-type-sha256-33341aac09312300de5a SHA-256: `ada310bf0bdb8fed51f3b56ea63f6ea6b18bbd587f04fccb63a14ab0b2a24e05` -Sources: [`packages/core/session/src/types.ts:190`](../packages/core/session/src/types.ts) · [`packages/interaction/commands/src/types.ts:75`](../packages/interaction/commands/src/types.ts) · [`packages/interaction/commands/src/types.ts:79`](../packages/interaction/commands/src/types.ts) · [`packages/llm/llm/src/message.ts:111`](../packages/llm/llm/src/message.ts) · [`packages/session/session-title/src/types.ts:35`](../packages/session/session-title/src/types.ts) +Sources: [`packages/core/session/src/types.ts:191`](../packages/core/session/src/types.ts) · [`packages/interaction/commands/src/types.ts:75`](../packages/interaction/commands/src/types.ts) · [`packages/interaction/commands/src/types.ts:79`](../packages/interaction/commands/src/types.ts) · [`packages/llm/llm/src/message.ts:111`](../packages/llm/llm/src/message.ts) · [`packages/session/session-title/src/types.ts:35`](../packages/session/session-title/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -3266,7 +3357,7 @@ Sources: [`packages/web/web-search-deepseek/src/provider.ts:55`](../packages/web | `body` | required | [`{ max_tokens, messages, model, tools }`](#persistence-type-sha256-930a6567a10bb62ddd157bd8abdf4b182810c8c3b49d91309b3b009a5fed9731) | | `endpoint` | required | `string` | - + @@ -3274,7 +3365,7 @@ Sources: [`packages/web/web-search-deepseek/src/provider.ts:55`](../packages/web ### `DeveloperMessage` -SHA-256: `af99c95d46bed802870f1ebefe0ea11f92a03cb37b8e0cbe3f94b7aaaf509b08` +SHA-256: `0f6265188dae7ba6faa26830a180bb4deb0760561f97ee0c9d399ccf6c9cf1f7` Sources: [`packages/llm/llm/src/message.ts:157`](../packages/llm/llm/src/message.ts) @@ -3283,7 +3374,7 @@ Sources: [`packages/llm/llm/src/message.ts:157`](../packages/llm/llm/src/message | `content` | required | [`ContentBlock[]`](#persistence-type-sha256-b52f215a6cd691058a3413c12f161fa3b563a2f4895adb3df6f744ec4b0a6ef1) | | `id` | required | `string` | | `role` | required | `"developer"` | -| `source` | required | [`MessageSource`](#persistence-type-sha256-1fa156c85450ca31063d0e0d2853028e6dac9687f2d3191fa811648210118154) | +| `source` | required | [`MessageSource`](#persistence-type-sha256-5bc2d7f02503fc6b3e1bb7e0b7fa22c0b9e4b9841fa3175b2ad8e607ae3525a8) | Source compatibility: `source` — `session-source-attribution` v1; `session.developer-message.source`; `kind`; `preserve`. @@ -3299,7 +3390,7 @@ Attribution-only additions: `tmux-context`, `user-question-reply`, `working-dire SHA-256: `9a930283b91d493d6347146f2475bb107294b379415ca02162354f46a64164f7` -Sources: [`packages/core/session/src/types.ts:240`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:241`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -3696,6 +3787,88 @@ One of: - `"next-step"` - `"next-turn"` + + + + + + +### `JobRegisteredData` + +SHA-256: `e3056e0dda3a379e895386e368c3ce42131460ea65e410e1d091cca98a347305` + +Sources: [`packages/jobs/jobs/src/types.ts:24`](../packages/jobs/jobs/src/types.ts) + +| Property | Presence | Type | +|---|---|---| +| `jobId` | required | `string` | +| `kind` | required | `string` | +| `label` | required | `string` | +| `owner` | required | `string` | +| `startedAt` | required | `number` | +| `toolCall` | optional | [`JobToolCall`](#persistence-type-sha256-39cf2d3ff8bac81c62d49ac2ba38a095b18e80f3ad700819fa1f650a4e60197f) | + + + + + + + +### `JobSettleCause` + +SHA-256: `750a2b129d67704df675c06823c20c80b65cbae89d5aaa914fd5a72e3df395b5` + +Sources: [`packages/jobs/jobs/src/types.ts:247`](../packages/jobs/jobs/src/types.ts) + +One of: + +- `"kill"` +- `"producer"` +- `"teardown"` + + + + + + + +### `JobSettledData` + +SHA-256: `611d82d2dc50df706cf08a0fc04bc8eaf78083391fe6f10313de943e615dd2da` + +Sources: [`packages/jobs/jobs/src/types.ts:40`](../packages/jobs/jobs/src/types.ts) + +| Property | Presence | Type | +|---|---|---| +| `cause` | required | [`JobSettleCause`](#persistence-type-sha256-750a2b129d67704df675c06823c20c80b65cbae89d5aaa914fd5a72e3df395b5) | +| `detail` | optional | `string` | +| `finishedAt` | required | `number` | +| `jobId` | required | `string` | +| `kind` | required | `string` | +| `label` | required | `string` | +| `owner` | required | `string` | +| `producerReported` | required | `boolean` | +| `startedAt` | required | `number` | +| `status` | required | [`union (3 variants)`](#persistence-type-sha256-c2f49004d9ae38a964e80850c87bc6849d6a872091a1d43e99eedf00c641e2a3) | +| `toolCall` | optional | [`JobToolCall`](#persistence-type-sha256-39cf2d3ff8bac81c62d49ac2ba38a095b18e80f3ad700819fa1f650a4e60197f) | + + + + + + + +### `JobToolCall` + +SHA-256: `39cf2d3ff8bac81c62d49ac2ba38a095b18e80f3ad700819fa1f650a4e60197f` + +Sources: [`packages/jobs/jobs/src/types.ts:16`](../packages/jobs/jobs/src/types.ts) + +| Property | Presence | Type | +|---|---|---| +| `callId` | required | `string` | +| `rootCallId` | required | `string` | + @@ -3900,7 +4073,7 @@ Sources: [`packages/llm/llm-retry/src/types.ts:43`](../packages/llm/llm-retry/sr | `step` | required | `number` | | `turn` | required | `number` | - + @@ -3908,17 +4081,17 @@ Sources: [`packages/llm/llm-retry/src/types.ts:43`](../packages/llm/llm-retry/sr ### `Message` -SHA-256: `d901c4f5e856591c3217af983caa15bba568d580700f0958bab56b88004eced9` +SHA-256: `040cd65423bd64264d225504f1c572d239eeb1793d0c2856ad8e42bb6e1e148b` Sources: [`packages/llm/llm/src/message.ts:196`](../packages/llm/llm/src/message.ts) One of: - [`ToolResultMessage`](#persistence-type-sha256-4b259f7f8f0a44ad5e90b70a73553ced1df27012d126921d397ff403b6c86731) -- [`DeveloperMessage`](#persistence-type-sha256-af99c95d46bed802870f1ebefe0ea11f92a03cb37b8e0cbe3f94b7aaaf509b08) -- [`UserMessage`](#persistence-type-sha256-89b2edff547be1ecfcd78ef18aeeb3cf507fcbf7d6093ec04fb111ef4c8e8a97) -- [`SystemMessage`](#persistence-type-sha256-09f3f8dc417f278257202714132f98ba7b52957d7b558c4a04456603890eb363) +- [`DeveloperMessage`](#persistence-type-sha256-0f6265188dae7ba6faa26830a180bb4deb0760561f97ee0c9d399ccf6c9cf1f7) +- [`UserMessage`](#persistence-type-sha256-2cf075b013d363ad743b5a4982900678d108499c721bc11255137402003db711) - [`AssistantMessage`](#persistence-type-sha256-fa87537f4a9c1e25602990a8bf686b383e9e00352ec8946e16ae24ba7c1625be) +- [`SystemMessage`](#persistence-type-sha256-09f3f8dc417f278257202714132f98ba7b52957d7b558c4a04456603890eb363) @@ -3993,7 +4166,7 @@ One of: - `"negative"` - `"positive"` - + @@ -4001,7 +4174,7 @@ One of: ### `MessageSource` -SHA-256: `1fa156c85450ca31063d0e0d2853028e6dac9687f2d3191fa811648210118154` +SHA-256: `5bc2d7f02503fc6b3e1bb7e0b7fa22c0b9e4b9841fa3175b2ad8e607ae3525a8` Sources: [`packages/llm/llm/src/message.ts:136`](../packages/llm/llm/src/message.ts) @@ -4106,7 +4279,7 @@ Sources: [`packages/llm/llm/src/message.ts:136`](../packages/llm/llm/src/message | `tool-jobs` | `"snapshot"` | `sections`: [`ContextSnapshotSection[]`](#persistence-type-sha256-c748fb42c80a120038a8c587af80ec39924803c401364db11c8974d5bf6d0262) | [`{ kind: "tool-jobs", form: "snapshot" }`](#persistence-type-sha256-c5228b7a55498d84f529591744a0ab26e0b93ade18a70a26db1377a1ecfa6b49) | | `tool-jobs` | not declared | none | [`{ kind: "tool-jobs" }`](#persistence-type-sha256-24ce31aa528272e59f21cd26da9eca08968bcc97529756a749bfdab71746d9c8) | | `tool-registry` | not declared | none | [`{ kind: "tool-registry" }`](#persistence-type-sha256-8fcb0b44a575e998665c053643d1ab04c82f39253ebd190ac0c975b78e40b543) | -| `user` | not declared | `rpcId`: `string` | [`{ kind: "user" }`](#persistence-type-sha256-6a4f72e2e179e17b922f2a9392c0e1c8f8c707f32494372454850a3eb184a6e7) | +| `user` | not declared | `rpcId`: `string` | [`{ kind: "user" }`](#persistence-type-sha256-6dacb1584f2bfbe209dc6b6f578d51da6d55595f64d0e63c727c98f359278def) | | `user` | not declared | none | [`CommandSource`](#persistence-type-sha256-ada310bf0bdb8fed51f3b56ea63f6ea6b18bbd587f04fccb63a14ab0b2a24e05) | | `user-approval` | `"catalog"` | none | [`{ kind: "user-approval", form: "catalog" }`](#persistence-type-sha256-825ca23416b1ea9d81442afead5cf9229a6fa05842f042135cc3aa9710a6a2d8) | | `user-approval` | `"instructions"` | none | [`{ kind: "user-approval", form: "instructions" }`](#persistence-type-sha256-aa2bf9e82b9fdeb94a42af20031fffa30e4e23f01f4ac39757427d4c64c407f0) | @@ -4119,13 +4292,13 @@ Sources: [`packages/llm/llm/src/message.ts:136`](../packages/llm/llm/src/message | `webhook` | `"notice"` | `deliveryId`: `string`; `provider`: `string`; `ruleId`: `string`; `source`: `string`; `summary`: `string` | [`{ kind: "webhook", form: "notice" }`](#persistence-type-sha256-9edc162949abf93bd5ff049191b345454ba104f583937ce5580fb50f067d2b94) | | `working-directory` | not declared | none | [`{ kind: "working-directory" }`](#persistence-type-sha256-2235d957bf4b7c6cf959231d480f0ba1f3a6d72d82981493fb6c7c963021d3d5) | - + ### `Message[]` -SHA-256: `4be8fd28fefa5534675ea9aabd0a24efc2838b989bbc74da20a1efbd0daede8c` +SHA-256: `a927d444c928ac0efd9dd8b2e7d7d04d711c264cb71578d81e0e2fd143ff669f` -Array of [`Message`](#persistence-type-sha256-d901c4f5e856591c3217af983caa15bba568d580700f0958bab56b88004eced9). +Array of [`Message`](#persistence-type-sha256-040cd65423bd64264d225504f1c572d239eeb1793d0c2856ad8e42bb6e1e148b). @@ -4156,7 +4329,7 @@ Sources: [`packages/llm/llm/src/message.ts:24`](../packages/llm/llm/src/message. SHA-256: `f09e5e7acf5ff5ccd193ff2eeec015fa2ff7c29d98bc465f8c3302058d47e29b` -Sources: [`packages/api/session-controller/src/types.ts:100`](../packages/api/session-controller/src/types.ts) +Sources: [`packages/api/session-controller/src/types.ts:101`](../packages/api/session-controller/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -4211,7 +4384,7 @@ Sources: [`packages/subagent/subagent/src/descriptor.ts:61`](../packages/subagen SHA-256: `3bd652ebfa8726b3ce3937a4f1bd2759e02f86b3a08a20f9e41a8513656fbc5f` -Sources: [`packages/core/session/src/types.ts:65`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:66`](../packages/core/session/src/types.ts) One of: @@ -4243,6 +4416,22 @@ SHA-256: `1d9cb3caa96100b18b1911fa3ff8c751ef6992971a03c5f4d9aefc1a0004a345` Array of [`PresentedFile`](#persistence-type-sha256-b8fc636a2121df9d8423c242e03c9d23b7ab7c6fdce00e45746932c60b730f97). + + + + + + +### `Principal` + +SHA-256: `deebfc7c62177ea840f23c6362b8615bc78ffa93377dd5d243c04e541c413af5` + +Sources: [`packages/typert/protocol/src/types.ts:457`](../packages/typert/protocol/src/types.ts) · [`packages/typert/protocol/src/types.ts:461`](../packages/typert/protocol/src/types.ts) + +| Property | Presence | Type | +|---|---|---| +| `kind` | required | `"owner"` | + @@ -4314,7 +4503,7 @@ Sources: [`packages/llm/llm/src/types.ts:68`](../packages/llm/llm/src/types.ts) SHA-256: `0acd94fe8794574c9ae72c7855ee16d29c546096d4af760d16d1225385764555` -Sources: [`packages/llm/llm/src/types.ts:430`](../packages/llm/llm/src/types.ts) +Sources: [`packages/llm/llm/src/types.ts:446`](../packages/llm/llm/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -4331,7 +4520,7 @@ Sources: [`packages/llm/llm/src/types.ts:430`](../packages/llm/llm/src/types.ts) SHA-256: `b158f3ff6923714807c0d73882f0b05dc87f8fa85c9e90814852d48bbc866081` -Sources: [`packages/core/session/src/types.ts:254`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:255`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -4350,7 +4539,7 @@ Sources: [`packages/core/session/src/types.ts:254`](../packages/core/session/src SHA-256: `530da6c0267813bb4511af72883cc518834e624e3b3220a7062955fd57a788a6` -Sources: [`packages/core/session/src/types.ts:273`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:274`](../packages/core/session/src/types.ts) One of: @@ -4442,7 +4631,7 @@ Sources: [`packages/schedule/schedule/src/types.ts:230`](../packages/schedule/sc SHA-256: `1a3440e3577382704d42a6263aa463504eb74c566734a55e9503a63efcd02445` -Sources: [`packages/core/session/src/types.ts:94`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:95`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -4493,7 +4682,7 @@ Sources: [`packages/session/session-title/src/types.ts:41`](../packages/session/ | `source` | required | [`SessionTitleSource`](#persistence-type-sha256-30d5a537b296685770b1241c4b4bbde19d5a31ff81aa23df73fab5e90b8fc65f) | | `title` | required | `string` | - + @@ -4501,7 +4690,7 @@ Sources: [`packages/session/session-title/src/types.ts:41`](../packages/session/ ### `SessionTitleLlmRequestEventData` -SHA-256: `045926475a46f625f84ae2be292b384817059f16927c69a9adab93717923ac19` +SHA-256: `00c054d3018c198d92f459563331edd1385e58345c677bb499791df2da0ade9f` Sources: [`packages/session/session-title-llm/src/index.ts:37`](../packages/session/session-title-llm/src/index.ts) @@ -4509,7 +4698,7 @@ Sources: [`packages/session/session-title-llm/src/index.ts:37`](../packages/sess |---|---|---| | `maxTokens` | required | `number` | | `messageSeqs` | required | [`number[]`](#persistence-type-sha256-5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78) | -| `messages` | required | [`Message[]`](#persistence-type-sha256-4be8fd28fefa5534675ea9aabd0a24efc2838b989bbc74da20a1efbd0daede8c) | +| `messages` | required | [`Message[]`](#persistence-type-sha256-a927d444c928ac0efd9dd8b2e7d7d04d711c264cb71578d81e0e2fd143ff669f) | | `reasoningEffort` | optional | `string` | | `route` | required | [`SessionTitleModelIdentity`](#persistence-type-sha256-07e1a58c58b593f507b8e7f25723bb0aa42baae6e00f5ecfddf28cb1e64d3235) | | `system` | required | `string` | @@ -4609,7 +4798,7 @@ Sources: [`packages/skill/skill/src/index.ts:146`](../packages/skill/skill/src/i SHA-256: `47e9095591ae36e7c39ee62553097772f2bfa16304536a72dbbc3f691f605180` -Sources: [`packages/llm/llm/src/types.ts:452`](../packages/llm/llm/src/types.ts) +Sources: [`packages/llm/llm/src/types.ts:468`](../packages/llm/llm/src/types.ts) One of: @@ -4688,7 +4877,7 @@ Sources: [`packages/subagent/subagent/src/continuation-messages.ts:29`](../packa SHA-256: `335e242de1fcc17b6ca920fc420d71bec2d76e53e37955c00948b65ab77f05c5` -Sources: [`packages/core/session/src/types.ts:462`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:463`](../packages/core/session/src/types.ts) One of: @@ -5034,7 +5223,7 @@ Sources: [`packages/llm/llm/src/message.ts:173`](../packages/llm/llm/src/message SHA-256: `66ba70f6f068da443f969a0b947a013c3addd776b9b60a0a393759c19825e9a2` -Sources: [`packages/llm/llm/src/types.ts:473`](../packages/llm/llm/src/types.ts) +Sources: [`packages/llm/llm/src/types.ts:489`](../packages/llm/llm/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -5133,7 +5322,7 @@ Sources: [`packages/workflow/tool-workflow/src/types.ts:14`](../packages/workflo SHA-256: `1f8c126cfab3df812cd115cfc23e3256f0fa2383400f4ce91ecad313577e59b0` -Sources: [`packages/core/session/src/types.ts:196`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:197`](../packages/core/session/src/types.ts) | kind | Form property | Other required fields | Full definition | |---|---|---|---| @@ -5153,7 +5342,7 @@ Sources: [`packages/core/session/src/types.ts:196`](../packages/core/session/src SHA-256: `f1687f982fa3d41ef29e159feed154ecd13af4dd209acaee16038f223f2e9402` -Sources: [`packages/core/session/src/types.ts:232`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:233`](../packages/core/session/src/types.ts) | kind | Form property | Other required fields | Full definition | |---|---|---|---| @@ -5165,7 +5354,7 @@ Sources: [`packages/core/session/src/types.ts:232`](../packages/core/session/src | `interrupted` | not declared | none | [`{ kind: "interrupted" }`](#persistence-type-sha256-56b7c047694875a843ba020dfea3c8380d54ae4ff88ab349e72004dc21e07c23) | | `max-tokens` | not declared | none | [`{ kind: "max-tokens" }`](#persistence-type-sha256-ab7ec5f9eb2c6dfacca5da4c37cf7ba85d04172053ee6b1ebf1709ffcd99c1a7) | - + @@ -5173,7 +5362,7 @@ Sources: [`packages/core/session/src/types.ts:232`](../packages/core/session/src ### `UserMessage` -SHA-256: `89b2edff547be1ecfcd78ef18aeeb3cf507fcbf7d6093ec04fb111ef4c8e8a97` +SHA-256: `2cf075b013d363ad743b5a4982900678d108499c721bc11255137402003db711` Sources: [`packages/llm/llm/src/message.ts:162`](../packages/llm/llm/src/message.ts) @@ -5182,19 +5371,19 @@ Sources: [`packages/llm/llm/src/message.ts:162`](../packages/llm/llm/src/message | `content` | required | [`ContentBlock[]`](#persistence-type-sha256-b52f215a6cd691058a3413c12f161fa3b563a2f4895adb3df6f744ec4b0a6ef1) | | `id` | required | `string` | | `role` | required | `"user"` | -| `source` | required | [`MessageSource`](#persistence-type-sha256-1fa156c85450ca31063d0e0d2853028e6dac9687f2d3191fa811648210118154) | +| `source` | required | [`MessageSource`](#persistence-type-sha256-5bc2d7f02503fc6b3e1bb7e0b7fa22c0b9e4b9841fa3175b2ad8e607ae3525a8) | Source compatibility: `source` — `session-source-attribution` v1; `session.user-message.source`; `kind`; `preserve`. Attribution-only additions: `tmux-context`, `user-question-reply`, `working-directory`. - + ### `UserMessage[]` -SHA-256: `2fef90def36aa758ca096c5f6ad0e00dbb2101943e19ca54b05618801974894a` +SHA-256: `454a0fa879e38076bdcdbd87ef561cd67d7e43e58a287cf7dbaa7b6cf7847020` -Array of [`UserMessage`](#persistence-type-sha256-89b2edff547be1ecfcd78ef18aeeb3cf507fcbf7d6093ec04fb111ef4c8e8a97). +Array of [`UserMessage`](#persistence-type-sha256-2cf075b013d363ad743b5a4982900678d108499c721bc11255137402003db711). @@ -5274,7 +5463,7 @@ SHA-256: `938f7c7357bc403ea3b281a9dd016939df7a0880fc8db12612585be574d0d615` SHA-256: `0657d3ca5ac4e7bf79b00bcf860b1c7159d01a9da137497221ec2f1776e365f7` -Sources: [`packages/core/session/src/types.ts:32`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:33`](../packages/core/session/src/types.ts) `number` @@ -5298,6 +5487,8 @@ Array of `number`. + + @@ -5330,6 +5521,8 @@ Array of `number`. + + @@ -5382,7 +5575,7 @@ Array of `number`. SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b` -Sources: [`packages/api/session-controller/src/types.ts:403`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) +Sources: [`packages/api/session-controller/src/types.ts:409`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:21`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/jobs/jobs/src/brand.ts:19`](../packages/jobs/jobs/src/brand.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) `string` @@ -5491,6 +5684,18 @@ One of: - `"replace"` - `"set"` + + +### `union (3 variants)` + +SHA-256: `c2f49004d9ae38a964e80850c87bc6849d6a872091a1d43e99eedf00c641e2a3` + +One of: + +- `"completed"` +- `"failed"` +- `"killed"` + ### `union (3 variants)` @@ -5568,7 +5773,7 @@ Sources: [`packages/plan/plan-mode/src/index.ts:52`](../packages/plan/plan-mode/ SHA-256: `6e52dd55a1d48954ed5464ec46fe34d9eb65f709baabc9561e7a279910b0c406` -Sources: [`packages/preset/agent-preset-registry/src/session.ts:28`](../packages/preset/agent-preset-registry/src/session.ts) +Sources: [`packages/preset/agent-preset-registry/src/session.ts:31`](../packages/preset/agent-preset-registry/src/session.ts) | Property | Presence | Type | |---|---|---| @@ -5607,7 +5812,7 @@ Sources: [`packages/interaction/commands/src/types.ts:105`](../packages/interact SHA-256: `e4c18e294232c3ba6c9f1999f168263cc55956147cfe4121720899be8e52edd1` -Sources: [`packages/core/session/src/types.ts:361`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:362`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -5766,7 +5971,7 @@ SHA-256: `cbbe8377ee744964537dbafd83607219e58ba79a13f6db0d0645c7ad3d5e8a09` SHA-256: `a81059ca06999da1e712414520c2c65e491d052fcf9640fae20aa38f0a5dc735` -Sources: [`packages/core/session/src/types.ts:383`](../packages/core/session/src/types.ts) · [`packages/core/tools/src/types.ts:27`](../packages/core/tools/src/types.ts) +Sources: [`packages/core/session/src/types.ts:384`](../packages/core/session/src/types.ts) · [`packages/core/tools/src/types.ts:27`](../packages/core/tools/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -5986,7 +6191,7 @@ Sources: [`packages/compaction/compaction/src/types.ts:38`](../packages/compacti SHA-256: `bcf0caf62d964b2fcf5404bd5c909cfa9a21c3f3c96e6b7e36d9e33565223825` -Sources: [`packages/core/session/src/types.ts:464`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:465`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -6000,7 +6205,7 @@ Sources: [`packages/core/session/src/types.ts:464`](../packages/core/session/src SHA-256: `79650cfa10aa3c52440328d85e8a25f0ffadc8d7efd784e3c0819d8bb74a1985` -Sources: [`packages/core/session/src/types.ts:212`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:213`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -6013,7 +6218,7 @@ Sources: [`packages/core/session/src/types.ts:212`](../packages/core/session/src SHA-256: `5302e763151277614b97e77f00e3b326694c6c1247b916a20503c344dc970777` -Sources: [`packages/core/session/src/types.ts:375`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:376`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -6055,7 +6260,7 @@ Sources: [`packages/llm/llm/src/types.ts:161`](../packages/llm/llm/src/types.ts) SHA-256: `ad0c38a0adb82a94c925456df41586d9b5f9202fca18593eae2213d4445b4585` -Sources: [`packages/core/session/src/types.ts:390`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:391`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -6063,18 +6268,18 @@ Sources: [`packages/core/session/src/types.ts:390`](../packages/core/session/src | `reason` | required | [`RequestHeaderReason`](#persistence-type-sha256-530da6c0267813bb4511af72883cc518834e624e3b3220a7062955fd57a788a6) | | `startsSeries` | optional | `true` | - + ### `{ headerSeq?, message, step, turn }` -SHA-256: `5b55817a403569fbb2d379496df2e3f3f71107f6c577a218028ce187c60b61e6` +SHA-256: `f4ed4717b4c259e933b550a8dcd90dc93628cda8a3c7affcaa53b9c13c1d53eb` -Sources: [`packages/core/session/src/types.ts:311`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:312`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| | `headerSeq` | optional | `number` | -| `message` | required | [`DeveloperMessage`](#persistence-type-sha256-af99c95d46bed802870f1ebefe0ea11f92a03cb37b8e0cbe3f94b7aaaf509b08) | +| `message` | required | [`DeveloperMessage`](#persistence-type-sha256-0f6265188dae7ba6faa26830a180bb4deb0760561f97ee0c9d399ccf6c9cf1f7) | | `step` | required | `number` | | `turn` | required | `number` | @@ -6152,23 +6357,23 @@ SHA-256: `e3e77b26f0148755a505f5b8ea843a3be117827d4703bb5754d9dc8ddbef521f` SHA-256: `17d1afb770d9941936130996da00dc86782cfef731d8d6526162c301256a4ac3` -Sources: [`packages/core/session/src/types.ts:427`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:428`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| | `inherited` | optional | `true` | - + ### `{ inserted, outcome?, removedCount?, start, … }` -SHA-256: `83a665476a9ee9af05cb1fdded12dd647d1b97b491200d7f12ef1af7cdac4428` +SHA-256: `fa106c5b33b657e8ef16e8cbf39f3b7d44e5cbcdf4f9303d9db6410f28951e42` Sources: [`packages/core/agent/src/types.ts:96`](../packages/core/agent/src/types.ts) | Property | Presence | Type | |---|---|---| -| `inserted` | required | [`UserMessage[]`](#persistence-type-sha256-2fef90def36aa758ca096c5f6ad0e00dbb2101943e19ca54b05618801974894a) | +| `inserted` | required | [`UserMessage[]`](#persistence-type-sha256-454a0fa879e38076bdcdbd87ef561cd67d7e43e58a287cf7dbaa7b6cf7847020) | | `outcome` | optional | `"canceled"` | | `removedCount` | optional | `number` | | `start` | required | `number` | @@ -6180,7 +6385,7 @@ Sources: [`packages/core/agent/src/types.ts:96`](../packages/core/agent/src/type SHA-256: `1ccb56061942f5ccb0d30c9e4f2883b9d115ff906560029de637b61d3cde896d` -Sources: [`packages/core/session/src/types.ts:341`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:342`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -6197,7 +6402,7 @@ Sources: [`packages/core/session/src/types.ts:341`](../packages/core/session/src SHA-256: `0a30c3be336cdd4f8f34e66543a3d46d611c43986edd9b3ce6cbb9b721d3b5d3` -Sources: [`packages/core/session/src/types.ts:204`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:205`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -6210,7 +6415,7 @@ Sources: [`packages/core/session/src/types.ts:204`](../packages/core/session/src SHA-256: `023a355b2be0d688fb6e4df8388dadb942761d46cfe96d0f499aae9631a159d4` -Sources: [`packages/core/session/src/types.ts:206`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:207`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -6222,7 +6427,7 @@ Sources: [`packages/core/session/src/types.ts:206`](../packages/core/session/src SHA-256: `b6ef9c6210be8350cd465f485cf520031fffa6bf3809c61a1e713e834c5982d0` -Sources: [`packages/core/session/src/types.ts:202`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:203`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -6246,7 +6451,7 @@ Sources: [`packages/extensions/cordis-host-runner/src/index.ts:14`](../packages/ SHA-256: `4bb451330ff699f36b45539ceb0eb2606ead72084e2c5bb637c36cc80e2252db` -Sources: [`packages/core/session/src/types.ts:193`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:194`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -6348,7 +6553,7 @@ Sources: [`packages/session/session-title/src/types.ts:29`](../packages/session/ SHA-256: `ec8b5ff7c77ca6893ac7ba5a0c5b2aefeb52f12c64450b7359660c2a7e8a53d7` -Sources: [`packages/core/session/src/types.ts:228`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:229`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -6360,7 +6565,7 @@ Sources: [`packages/core/session/src/types.ts:228`](../packages/core/session/src SHA-256: `6c5a37d42ebdb12db4410699259335f1e149e3a0260425684c00b153afc8990d` -Sources: [`packages/core/session/src/types.ts:192`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:193`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -6529,7 +6734,7 @@ SHA-256: `88841202942c7683b3b7e5ea6a796047ec326b6701ac23e58cc236d85613b9fa` SHA-256: `56b7c047694875a843ba020dfea3c8380d54ae4ff88ab349e72004dc21e07c23` -Sources: [`packages/core/session/src/types.ts:221`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:222`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -6541,7 +6746,7 @@ Sources: [`packages/core/session/src/types.ts:221`](../packages/core/session/src SHA-256: `eaa71be0fbf0f702df8ea6c11d0aa2f9782263d45a10d0fb14eda662f4bfe04c` -Sources: [`packages/core/session/src/types.ts:196`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:197`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -6553,7 +6758,7 @@ Sources: [`packages/core/session/src/types.ts:196`](../packages/core/session/src SHA-256: `ab7ec5f9eb2c6dfacca5da4c37cf7ba85d04172053ee6b1ebf1709ffcd99c1a7` -Sources: [`packages/core/session/src/types.ts:214`](../packages/core/session/src/types.ts) · [`packages/llm/llm/src/types.ts:159`](../packages/llm/llm/src/types.ts) +Sources: [`packages/core/session/src/types.ts:215`](../packages/core/session/src/types.ts) · [`packages/llm/llm/src/types.ts:159`](../packages/llm/llm/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -6643,7 +6848,7 @@ SHA-256: `52c6113cb0e53f345324bf84f01928bea5d5df57ee80bc0dd65407b8bc5a3734` SHA-256: `6c03546143cdf6ae88f1745ac98c5139efa1588d1415249bbc5f3104d32ab800` -Sources: [`packages/core/session/src/types.ts:191`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:192`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -7335,18 +7540,19 @@ Sources: [`packages/core/tools/src/index.ts:35`](../packages/core/tools/src/inde |---|---|---| | `kind` | required | `"tool-registry"` | - + ### `{ kind: "user" }` -SHA-256: `6a4f72e2e179e17b922f2a9392c0e1c8f8c707f32494372454850a3eb184a6e7` +SHA-256: `6dacb1584f2bfbe209dc6b6f578d51da6d55595f64d0e63c727c98f359278def` -Sources: [`packages/api/session-controller/src/types.ts:408`](../packages/api/session-controller/src/types.ts) +Sources: [`packages/api/session-controller/src/types.ts:418`](../packages/api/session-controller/src/types.ts) | Property | Presence | Type | |---|---|---| | `clientTimeZone` | optional | `string` | | `kind` | required | `"user"` | +| `principal` | optional | [`Principal`](#persistence-type-sha256-deebfc7c62177ea840f23c6362b8615bc78ffa93377dd5d243c04e541c413af5) | | `rpcId` | required | `string` | @@ -7506,7 +7712,7 @@ Sources: [`packages/experimental/agent-team/src/types.ts:230`](../packages/exper SHA-256: `6bc8e51124d0874bc89e1d0ceff381a9c3cd0fc240667d69f841ce4f60f65fe5` -Sources: [`packages/core/session/src/types.ts:330`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:331`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -7587,7 +7793,7 @@ Sources: [`packages/interaction/permission-presets/src/index.ts:59`](../packages SHA-256: `e481c46af3813e0010558e93f578b2986acd658404cec2d1679e3ac2a6c48654` -Sources: [`packages/core/session/src/types.ts:297`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:298`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -7641,7 +7847,7 @@ Sources: [`packages/compaction/compaction/src/types.ts:82`](../packages/compacti SHA-256: `54f9276a1617d7c12594722c89cfe864bcbe4517e9a6f9c98bab14f199a135e0` -Sources: [`packages/core/session/src/types.ts:355`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:356`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -7655,7 +7861,7 @@ Sources: [`packages/core/session/src/types.ts:355`](../packages/core/session/src SHA-256: `ef11a3b7fa2033ae4279545ff8a8a2ee4e301b9b73e5b91654c48e6d08283545` -Sources: [`packages/core/session/src/types.ts:299`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:301`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:300`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:302`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -7706,7 +7912,7 @@ Sources: [`packages/todo/tool-todo/src/types.ts:31`](../packages/todo/tool-todo/ SHA-256: `f90eb4ab2d3897bc20c521a038e3119d581f791ec974b1a0004f63a8ab9e232d` -Sources: [`packages/core/session/src/types.ts:288`](../packages/core/session/src/types.ts) · [`packages/deliverables/workspace-changes/src/types.ts:106`](../packages/deliverables/workspace-changes/src/types.ts) +Sources: [`packages/core/session/src/types.ts:289`](../packages/core/session/src/types.ts) · [`packages/deliverables/workspace-changes/src/types.ts:106`](../packages/deliverables/workspace-changes/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -7728,17 +7934,17 @@ SHA-256: `a10c17474eaf2ddab7095a099e0fe3d046fc18e56c3e344fc8894c05ff9ef97b` | `time` | required | `number` | | `type` | required | `"agent-preset/selected"` | - + ### `{ type: "agent/inbox/spliced" }` -SHA-256: `9a36727716d0e73f681f3b725e7f28f89417c68174c8af0ff2b1ef52b08326a0` +SHA-256: `0755b7d1128319ba2e389619cbbf2c961ad648882b4e91da7f3e9582cc8a2034` | Property | Presence | Type | |---|---|---| -| `data` | required | [`{ inserted, outcome?, removedCount?, start, … }`](#persistence-type-sha256-83a665476a9ee9af05cb1fdded12dd647d1b97b491200d7f12ef1af7cdac4428) | +| `data` | required | [`{ inserted, outcome?, removedCount?, start, … }`](#persistence-type-sha256-fa106c5b33b657e8ef16e8cbf39f3b7d44e5cbcdf4f9303d9db6410f28951e42) | | `ignorable` | optional | `true` | | `seq` | required | `number` | | `time` | required | `number` | @@ -7831,7 +8037,7 @@ SHA-256: `1033093edd0db80ff410e00830b523405e00bb0c7684948e531ff65095799625` SHA-256: `d4b739491689cc6bb13d07ffac1a32239875abda364c0d5edd8f74faf5e9c00d` -Sources: [`packages/llm/llm/src/types.ts:457`](../packages/llm/llm/src/types.ts) +Sources: [`packages/llm/llm/src/types.ts:473`](../packages/llm/llm/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -7845,7 +8051,7 @@ Sources: [`packages/llm/llm/src/types.ts:457`](../packages/llm/llm/src/types.ts) SHA-256: `3bec87f9e6ab3da03346f49923d3c51621debc0159df68605e543c3b2a026f90` -Sources: [`packages/llm/llm/src/types.ts:453`](../packages/llm/llm/src/types.ts) +Sources: [`packages/llm/llm/src/types.ts:469`](../packages/llm/llm/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -7979,17 +8185,17 @@ SHA-256: `13d3d180f977bf78081d487ffa0ecb75857349bcab29a5a3fb48189fca2a6176` | `time` | required | `number` | | `type` | required | `"deliverables/presented"` | - + ### `{ type: "developer/message" }` -SHA-256: `ba5b10ea77d68f4a29439a9bd2a14f6a560acf55731749d57237757cc72fbc06` +SHA-256: `97e0162f983c27456ec71269fb4976b1851588b8c1aeac5d071d43e11f95e03c` | Property | Presence | Type | |---|---|---| -| `data` | required | [`{ headerSeq?, message, step, turn }`](#persistence-type-sha256-5b55817a403569fbb2d379496df2e3f3f71107f6c577a218028ce187c60b61e6) | +| `data` | required | [`{ headerSeq?, message, step, turn }`](#persistence-type-sha256-f4ed4717b4c259e933b550a8dcd90dc93628cda8a3c7affcaa53b9c13c1d53eb) | | `ignorable` | optional | `true` | | `seq` | required | `number` | | `sourceEventSeqs` | optional | [`number[]`](#persistence-type-sha256-5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78) | @@ -8051,7 +8257,7 @@ SHA-256: `b54940ff095c17e874c5be03815f4c2145a256cf3a1d34dae4ab2f7769dfffe8` SHA-256: `a2b49331716ba0a7078b026d145306a70eae64b7313c3e6204bd931e75ec3bb1` -Sources: [`packages/llm/llm/src/types.ts:459`](../packages/llm/llm/src/types.ts) +Sources: [`packages/llm/llm/src/types.ts:475`](../packages/llm/llm/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -8123,6 +8329,38 @@ SHA-256: `b222069eea2d768065161c1147b1f2c78c1b54328c84b3586ae5c8f91b8ed35e` | `time` | required | `number` | | `type` | required | `"image/offload"` | + + + + +### `{ type: "job/registered" }` + +SHA-256: `f5520c1d261ca86ea03735799c311a0a9ede8babb588cf0a65422576d2ee0ad1` + +| Property | Presence | Type | +|---|---|---| +| `data` | required | [`JobRegisteredData`](#persistence-type-sha256-e3056e0dda3a379e895386e368c3ce42131460ea65e410e1d091cca98a347305) | +| `ignorable` | optional | `true` | +| `seq` | required | `number` | +| `time` | required | `number` | +| `type` | required | `"job/registered"` | + + + + + +### `{ type: "job/settled" }` + +SHA-256: `edd37b1e4974af648633014435572089b1e8fb71910468a0767a7e28fdd22abf` + +| Property | Presence | Type | +|---|---|---| +| `data` | required | [`JobSettledData`](#persistence-type-sha256-611d82d2dc50df706cf08a0fc04bc8eaf78083391fe6f10313de943e615dd2da) | +| `ignorable` | optional | `true` | +| `seq` | required | `number` | +| `time` | required | `number` | +| `type` | required | `"job/settled"` | + @@ -8225,7 +8463,7 @@ Sources: [`packages/llm/llm/src/assistant-stream.ts:28`](../packages/llm/llm/src SHA-256: `2abb711460e6238d851c30f3e4bdd87a2aa1887ee70575b3d3b7b25610673095` -Sources: [`packages/llm/llm/src/types.ts:455`](../packages/llm/llm/src/types.ts) +Sources: [`packages/llm/llm/src/types.ts:471`](../packages/llm/llm/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -8345,17 +8583,17 @@ SHA-256: `1b912703e2d64f91c99c675b8f805b01076c8325b905c1218ad81ef0b24909d5` | `time` | required | `number` | | `type` | required | `"session/title"` | - + ### `{ type: "session/title-llm-request" }` -SHA-256: `b33545b3244861bcbf73b407d24b53402710f5acacab7ae939537e5504a68dc6` +SHA-256: `79a4c87f8bfb4d6d05cd9952b99210d6a7db3b478c4a01d94b4c50602308c056` | Property | Presence | Type | |---|---|---| -| `data` | required | [`SessionTitleLlmRequestEventData`](#persistence-type-sha256-045926475a46f625f84ae2be292b384817059f16927c69a9adab93717923ac19) | +| `data` | required | [`SessionTitleLlmRequestEventData`](#persistence-type-sha256-00c054d3018c198d92f459563331edd1385e58345c677bb499791df2da0ade9f) | | `ignorable` | optional | `true` | | `seq` | required | `number` | | `time` | required | `number` | @@ -8545,7 +8783,7 @@ Sources: [`packages/llm/llm/src/assistant-stream.ts:21`](../packages/llm/llm/src SHA-256: `95080295115fbd83544520ba247b68a03790131f6a2eac323d388758cb542f65` -Sources: [`packages/llm/llm/src/types.ts:454`](../packages/llm/llm/src/types.ts) +Sources: [`packages/llm/llm/src/types.ts:470`](../packages/llm/llm/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -8593,7 +8831,7 @@ Sources: [`packages/llm/llm/src/assistant-stream.ts:35`](../packages/llm/llm/src SHA-256: `88a610b831be6d92612a464be5c7d0b88ec24d97bd299837bf74bf7672183c9a` -Sources: [`packages/llm/llm/src/types.ts:456`](../packages/llm/llm/src/types.ts) +Sources: [`packages/llm/llm/src/types.ts:472`](../packages/llm/llm/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -8771,24 +9009,24 @@ SHA-256: `aa0957eca50aeb28bcd2e6930b95809926edacb550c8c340ba526ba6b861b3d8` SHA-256: `f1a96afe7503ac1817de769cd0ba13d9b80111c7034c50106b3344ecc14cc515` -Sources: [`packages/llm/llm/src/types.ts:458`](../packages/llm/llm/src/types.ts) +Sources: [`packages/llm/llm/src/types.ts:474`](../packages/llm/llm/src/types.ts) | Property | Presence | Type | |---|---|---| | `type` | required | `"usage"` | | `usage` | required | [`TokenUsage`](#persistence-type-sha256-fec5442b9ee4122e10514d81d5a43648003fe8bfb7ee2ba50c0cdbdd383ed394) | - + ### `{ type: "user/message" }` -SHA-256: `2ace3b8059b82b6d9837fed518ca97a99eb37c4482df7aad0e71c85489428ade` +SHA-256: `2ae8594a998c9862a597129e782277bcc518266712a703b5c3161bdf552d13b8` | Property | Presence | Type | |---|---|---| -| `data` | required | [`UserMessage`](#persistence-type-sha256-89b2edff547be1ecfcd78ef18aeeb3cf507fcbf7d6093ec04fb111ef4c8e8a97) | +| `data` | required | [`UserMessage`](#persistence-type-sha256-2cf075b013d363ad743b5a4982900678d108499c721bc11255137402003db711) | | `ignorable` | optional | `true` | | `seq` | required | `number` | | `sourceEventSeqs` | optional | [`number[]`](#persistence-type-sha256-5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78) | diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 76af401f6b..c1eb47569f 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -25,7 +25,7 @@ | `JsonlHeaderLine` | header | `18ee62b8900a4c3d046700f05d7a4d49d6cab2a660a020c87481d1603dd8bd4f` | [`HeaderLine`](#persistence-type-sha256-18ee62b8900a4c3d046700f05d7a4d49d6cab2a660a020c87481d1603dd8bd4f) | | `SessionEventEnvelope` | envelope | `5776e5553ff2dfe3f5bc202dbb1e7c9f93e35a531aebb7764c23b2b6153b2ccc` | [`union (3 variants)`](#persistence-type-sha256-5776e5553ff2dfe3f5bc202dbb1e7c9f93e35a531aebb7764c23b2b6153b2ccc) | | `event:agent-preset/selected` | event | `a10c17474eaf2ddab7095a099e0fe3d046fc18e56c3e344fc8894c05ff9ef97b` | [`{ type: "agent-preset/selected" }`](#persistence-type-sha256-a10c17474eaf2ddab7095a099e0fe3d046fc18e56c3e344fc8894c05ff9ef97b) | -| `event:agent/inbox/spliced` | event | `9a36727716d0e73f681f3b725e7f28f89417c68174c8af0ff2b1ef52b08326a0` | [`{ type: "agent/inbox/spliced" }`](#persistence-type-sha256-9a36727716d0e73f681f3b725e7f28f89417c68174c8af0ff2b1ef52b08326a0) | +| `event:agent/inbox/spliced` | event | `0755b7d1128319ba2e389619cbbf2c961ad648882b4e91da7f3e9582cc8a2034` | [`{ type: "agent/inbox/spliced" }`](#persistence-type-sha256-0755b7d1128319ba2e389619cbbf2c961ad648882b4e91da7f3e9582cc8a2034) | | `event:approval/asked` | event | `3bfeb47b58606f4661904bc723da612782214c463d01e6d61cd6d6193d7374e1` | [`{ type: "approval/asked" }`](#persistence-type-sha256-3bfeb47b58606f4661904bc723da612782214c463d01e6d61cd6d6193d7374e1) | | `event:approval/decided` | event | `bb1ab3d08f49a9f3b265f844cd78d5c49813062a7b34b54904b426f85d0ff6e3` | [`{ type: "approval/decided" }`](#persistence-type-sha256-bb1ab3d08f49a9f3b265f844cd78d5c49813062a7b34b54904b426f85d0ff6e3) | | `event:approval/policy` | event | `26718e15e7e395bce9642dba5bbe09b3b1a4ce2213d20d566cd9207d7fc5fb78` | [`{ type: "approval/policy" }`](#persistence-type-sha256-26718e15e7e395bce9642dba5bbe09b3b1a4ce2213d20d566cd9207d7fc5fb78) | @@ -38,7 +38,7 @@ | `event:compaction/start` | event | `db874d463b0fdec77e9da1c4568f37cb70bd6596781eb93800db44fb8a116965` | [`{ type: "compaction/start" }`](#persistence-type-sha256-db874d463b0fdec77e9da1c4568f37cb70bd6596781eb93800db44fb8a116965) | | `event:compaction/summary` | event | `e2f9a41e0989f54ed8cee80f8db2bcf9d60a5c810dc9d45b83fa050b9dce7602` | [`{ type: "compaction/summary" }`](#persistence-type-sha256-e2f9a41e0989f54ed8cee80f8db2bcf9d60a5c810dc9d45b83fa050b9dce7602) | | `event:deliverables/presented` | event | `13d3d180f977bf78081d487ffa0ecb75857349bcab29a5a3fb48189fca2a6176` | [`{ type: "deliverables/presented" }`](#persistence-type-sha256-13d3d180f977bf78081d487ffa0ecb75857349bcab29a5a3fb48189fca2a6176) | -| `event:developer/message` | event | `ba5b10ea77d68f4a29439a9bd2a14f6a560acf55731749d57237757cc72fbc06` | [`{ type: "developer/message" }`](#persistence-type-sha256-ba5b10ea77d68f4a29439a9bd2a14f6a560acf55731749d57237757cc72fbc06) | +| `event:developer/message` | event | `97e0162f983c27456ec71269fb4976b1851588b8c1aeac5d071d43e11f95e03c` | [`{ type: "developer/message" }`](#persistence-type-sha256-97e0162f983c27456ec71269fb4976b1851588b8c1aeac5d071d43e11f95e03c) | | `event:feedback/message-delete` | event | `3ee93b06f3a125850337602bcdf155d2538c43a5c944ec55b1b3c365152d6796` | [`{ type: "feedback/message-delete" }`](#persistence-type-sha256-3ee93b06f3a125850337602bcdf155d2538c43a5c944ec55b1b3c365152d6796) | | `event:feedback/message-put` | event | `b5086d249e8502e9ead1d39156bb8d559bde7951cac0f14ce150345b4e42a2bf` | [`{ type: "feedback/message-put" }`](#persistence-type-sha256-b5086d249e8502e9ead1d39156bb8d559bde7951cac0f14ce150345b4e42a2bf) | | `event:feedback/record` | event | `b54940ff095c17e874c5be03815f4c2145a256cf3a1d34dae4ab2f7769dfffe8` | [`{ type: "feedback/record" }`](#persistence-type-sha256-b54940ff095c17e874c5be03815f4c2145a256cf3a1d34dae4ab2f7769dfffe8) | @@ -46,6 +46,8 @@ | `event:hook/invoked` | event | `8a6e1ec9e8db346b0e02f027db73c07a94f067a26d40c1aef1abd09c47ce7ba0` | [`{ type: "hook/invoked" }`](#persistence-type-sha256-8a6e1ec9e8db346b0e02f027db73c07a94f067a26d40c1aef1abd09c47ce7ba0) | | `event:hook/result` | event | `e75916628f3f10c2d50658bd143052a46285fbf1a9a700ba54947614603d26b4` | [`{ type: "hook/result" }`](#persistence-type-sha256-e75916628f3f10c2d50658bd143052a46285fbf1a9a700ba54947614603d26b4) | | `event:image/offload` | event | `b222069eea2d768065161c1147b1f2c78c1b54328c84b3586ae5c8f91b8ed35e` | [`{ type: "image/offload" }`](#persistence-type-sha256-b222069eea2d768065161c1147b1f2c78c1b54328c84b3586ae5c8f91b8ed35e) | +| `event:job/registered` | event | `f5520c1d261ca86ea03735799c311a0a9ede8babb588cf0a65422576d2ee0ad1` | [`{ type: "job/registered" }`](#persistence-type-sha256-f5520c1d261ca86ea03735799c311a0a9ede8babb588cf0a65422576d2ee0ad1) | +| `event:job/settled` | event | `edd37b1e4974af648633014435572089b1e8fb71910468a0767a7e28fdd22abf` | [`{ type: "job/settled" }`](#persistence-type-sha256-edd37b1e4974af648633014435572089b1e8fb71910468a0767a7e28fdd22abf) | | `event:llm/retry` | event | `525254db03b1d1e6b74cf55aced52817568331ac1f96c0818728910b6692e336` | [`{ type: "llm/retry" }`](#persistence-type-sha256-525254db03b1d1e6b74cf55aced52817568331ac1f96c0818728910b6692e336) | | `event:llm/retry-started` | event | `48e5c9861f16ac07e78cb7b5ae9dabdf7bb85c58baed5a51b4ad275050ea58e3` | [`{ type: "llm/retry-started" }`](#persistence-type-sha256-48e5c9861f16ac07e78cb7b5ae9dabdf7bb85c58baed5a51b4ad275050ea58e3) | | `event:model/selection` | event | `35203ba7ad5ef6f97d556b85df20ae98f04f09c65748cecdf8eefdb8b6405ffc` | [`{ type: "model/selection" }`](#persistence-type-sha256-35203ba7ad5ef6f97d556b85df20ae98f04f09c65748cecdf8eefdb8b6405ffc) | @@ -58,7 +60,7 @@ | `event:session-log-deepseek/delivery-accepted` | event | `d63b8b8ffad9c02fd80c43a17df4f240c1fe8118ecca9de34f9d5871838ab5b9` | [`{ type: "session-log-deepseek/delivery-accepted" }`](#persistence-type-sha256-d63b8b8ffad9c02fd80c43a17df4f240c1fe8118ecca9de34f9d5871838ab5b9) | | `event:session/end-seed` | event | `5e6db6e24948d4a853c71cb9fabd252ad051ce93d4672c1266cf837c1c17b84e` | [`{ type: "session/end-seed" }`](#persistence-type-sha256-5e6db6e24948d4a853c71cb9fabd252ad051ce93d4672c1266cf837c1c17b84e) | | `event:session/title` | event | `1b912703e2d64f91c99c675b8f805b01076c8325b905c1218ad81ef0b24909d5` | [`{ type: "session/title" }`](#persistence-type-sha256-1b912703e2d64f91c99c675b8f805b01076c8325b905c1218ad81ef0b24909d5) | -| `event:session/title-llm-request` | event | `b33545b3244861bcbf73b407d24b53402710f5acacab7ae939537e5504a68dc6` | [`{ type: "session/title-llm-request" }`](#persistence-type-sha256-b33545b3244861bcbf73b407d24b53402710f5acacab7ae939537e5504a68dc6) | +| `event:session/title-llm-request` | event | `79a4c87f8bfb4d6d05cd9952b99210d6a7db3b478c4a01d94b4c50602308c056` | [`{ type: "session/title-llm-request" }`](#persistence-type-sha256-79a4c87f8bfb4d6d05cd9952b99210d6a7db3b478c4a01d94b4c50602308c056) | | `event:step/end` | event | `e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05` | [`{ type: "step/end" }`](#persistence-type-sha256-e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05) | | `event:step/start` | event | `4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986` | [`{ type: "step/start" }`](#persistence-type-sha256-4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986) | | `event:subagent/catalog` | event | `53750b9abf61efb7992b0aadc3e247ec927449c63266df28230fd1139f5590ac` | [`{ type: "subagent/catalog" }`](#persistence-type-sha256-53750b9abf61efb7992b0aadc3e247ec927449c63266df28230fd1139f5590ac) | @@ -80,7 +82,7 @@ | `event:tool/result` | event | `7c9f44e90a0058f4cc532ae20dad0c10afa6eba22e70a6c79fc79490bad64397` | [`{ type: "tool/result" }`](#persistence-type-sha256-7c9f44e90a0058f4cc532ae20dad0c10afa6eba22e70a6c79fc79490bad64397) | | `event:turn/end` | event | `0f8512903d94f57a4748fa1a2092e64342856796684e6b8343db685b192745ce` | [`{ type: "turn/end" }`](#persistence-type-sha256-0f8512903d94f57a4748fa1a2092e64342856796684e6b8343db685b192745ce) | | `event:turn/start` | event | `aa0957eca50aeb28bcd2e6930b95809926edacb550c8c340ba526ba6b861b3d8` | [`{ type: "turn/start" }`](#persistence-type-sha256-aa0957eca50aeb28bcd2e6930b95809926edacb550c8c340ba526ba6b861b3d8) | -| `event:user/message` | event | `2ace3b8059b82b6d9837fed518ca97a99eb37c4482df7aad0e71c85489428ade` | [`{ type: "user/message" }`](#persistence-type-sha256-2ace3b8059b82b6d9837fed518ca97a99eb37c4482df7aad0e71c85489428ade) | +| `event:user/message` | event | `2ae8594a998c9862a597129e782277bcc518266712a703b5c3161bdf552d13b8` | [`{ type: "user/message" }`](#persistence-type-sha256-2ae8594a998c9862a597129e782277bcc518266712a703b5c3161bdf552d13b8) | | `event:web/deepseek-search-llm-request` | event | `cf6e3aaf1e2de6480aa0157730a41b9a492108a55304100b0f7e112711dd4331` | [`{ type: "web/deepseek-search-llm-request" }`](#persistence-type-sha256-cf6e3aaf1e2de6480aa0157730a41b9a492108a55304100b0f7e112711dd4331) | | `event:working-directory/change` | event | `124aa62078ca3311353b39cc3bf6c75da281338be6bbf672f8b361d828cddca3` | [`{ type: "working-directory/change" }`](#persistence-type-sha256-124aa62078ca3311353b39cc3bf6c75da281338be6bbf672f8b361d828cddca3) | | `event:workspace/changes` | event | `e308ccf867a5398e316e0af8cb6ce238a8d33a63b9b384c8250a686786285f72` | [`{ type: "workspace/changes" }`](#persistence-type-sha256-e308ccf867a5398e316e0af8cb6ce238a8d33a63b9b384c8250a686786285f72) | @@ -160,7 +162,7 @@ export type SessionEvent = { }[T] ``` -来源:[`packages/core/session/src/types.ts:431`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:439`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:462`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:493`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:432`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:440`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:463`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:494`](../packages/core/session/src/types.ts) ## 事件 @@ -198,12 +200,15 @@ export type SessionEvent = { * The session's agent preset was chosen after creation, while the session * was still blank. Log-only: it records the composition later turns ran * under, so a resumed or forked session rebuilds the same one instead of - * the header's creation-time value. + * the header's creation-time value. In a child Session, an occurrence + * before its first own `subagent/descriptor` marks an independent spawn + * selection that cold resume restores instead of joining the parent's + * composition (`dsh-subagent` README, "Restoring child composition"). */ 'agent-preset/selected': { agentPreset: string } ``` -来源:[`packages/preset/agent-preset-registry/src/session.ts:28`](../packages/preset/agent-preset-registry/src/session.ts) +来源:[`packages/preset/agent-preset-registry/src/session.ts:31`](../packages/preset/agent-preset-registry/src/session.ts) ### `approval/*` @@ -287,7 +292,7 @@ export type SessionEvent = { 'assistant/attempt': { turn: number; step: number; stream: AssistantStreamRecord[] } ``` -来源:[`packages/core/session/src/types.ts:355`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:356`](../packages/core/session/src/types.ts) @@ -317,7 +322,7 @@ export type SessionEvent = { 类型:[TokenUsage](subsystems/llm-streaming.zh.md) -来源:[`packages/core/session/src/types.ts:341`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:342`](../packages/core/session/src/types.ts) ### `command/*` @@ -505,7 +510,7 @@ export type SessionEvent = { } ``` -来源:[`packages/core/session/src/types.ts:311`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:312`](../packages/core/session/src/types.ts) ### `feedback/*` @@ -629,6 +634,36 @@ export type SessionEvent = { 来源:[`packages/compaction/compaction-image-offload/src/projection.ts:25`](../packages/compaction/compaction-image-offload/src/projection.ts) +### `job/*` + + + +#### `job/registered` — log-only + +```ts persistence-catalog +/** + * Log-only job registration; model output collection is not required. + * @param data - Execution identity, original owner, registration time, and creating tool call. + */ +'job/registered': JobRegisteredData +``` + +来源:[`packages/jobs/jobs/src/types.ts:59`](../packages/jobs/jobs/src/types.ts) + + + +#### `job/settled` — log-only + +```ts persistence-catalog +/** + * Log-only terminal record, paired by the same unique jobId used for live control. + * @param data - Registration facts plus settlement time, outcome, and producer acknowledgement. + */ +'job/settled': JobSettledData +``` + +来源:[`packages/jobs/jobs/src/types.ts:64`](../packages/jobs/jobs/src/types.ts) + ### `llm/*` @@ -667,7 +702,7 @@ export type SessionEvent = { 'model/selection': ModelSelection ``` -来源:[`packages/api/session-controller/src/types.ts:40`](../packages/api/session-controller/src/types.ts) +来源:[`packages/api/session-controller/src/types.ts:41`](../packages/api/session-controller/src/types.ts) ### `permission/*` @@ -720,7 +755,7 @@ export type SessionEvent = { 'request/context': RequestContext ``` -来源:[`packages/core/session/src/types.ts:402`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:403`](../packages/core/session/src/types.ts) @@ -739,7 +774,7 @@ export type SessionEvent = { } ``` -来源:[`packages/core/session/src/types.ts:390`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:391`](../packages/core/session/src/types.ts) ### `sandbox/*` @@ -816,7 +851,7 @@ export type SessionEvent = { 'session/end-seed': { inherited?: true } ``` -来源:[`packages/core/session/src/types.ts:427`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:428`](../packages/core/session/src/types.ts) @@ -878,7 +913,7 @@ export type SessionEvent = { 'step/end': { turn: number; step: number } ``` -来源:[`packages/core/session/src/types.ts:301`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:302`](../packages/core/session/src/types.ts) @@ -889,7 +924,7 @@ export type SessionEvent = { 'step/start': { turn: number; step: number } ``` -来源:[`packages/core/session/src/types.ts:299`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:300`](../packages/core/session/src/types.ts) ### `subagent/*` @@ -965,7 +1000,7 @@ export type SessionEvent = { 'system/message': { turn: number; step: number; message: SystemMessage } ``` -来源:[`packages/core/session/src/types.ts:330`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:331`](../packages/core/session/src/types.ts) ### `team/*` @@ -1058,7 +1093,7 @@ export type SessionEvent = { 类型:[ToolCallId](subsystems/core.zh.md) -来源:[`packages/core/session/src/types.ts:361`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:362`](../packages/core/session/src/types.ts) @@ -1139,7 +1174,7 @@ export type SessionEvent = { } ``` -来源:[`packages/core/session/src/types.ts:375`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:376`](../packages/core/session/src/types.ts) ### `tool-workflow/*` @@ -1219,7 +1254,7 @@ export type SessionEvent = { 类型:[TurnEndReason](subsystems/session.zh.md) -来源:[`packages/core/session/src/types.ts:297`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:298`](../packages/core/session/src/types.ts) @@ -1235,7 +1270,7 @@ export type SessionEvent = { 'turn/start': { turn: number } ``` -来源:[`packages/core/session/src/types.ts:288`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:289`](../packages/core/session/src/types.ts) ### `user/*` @@ -1254,7 +1289,7 @@ export type SessionEvent = { 'user/message': UserMessage ``` -来源:[`packages/core/session/src/types.ts:309`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:310`](../packages/core/session/src/types.ts) ### `web/*` @@ -1956,7 +1991,7 @@ SHA-256: `ebb768d85ec87fb68e42fc14fd7d8535b5ac11791b14d04c6770180f0a719bf8` SHA-256: `f311671e07bc7fa645a0b6597de9966b97147956565296ba76a36543801b66c5` -来源:[`packages/llm/llm/src/types.ts:396`](../packages/llm/llm/src/types.ts) +来源:[`packages/llm/llm/src/types.ts:412`](../packages/llm/llm/src/types.ts) `"in-history"` @@ -2000,6 +2035,38 @@ SHA-256: `69d238a6e9b08d67f601b1825962963d8d3523cb69ebf6208c697dc5d058c199` `"interrupted"` + + +### `"job/registered"` + +SHA-256: `24a8188c4d61b70a01af8cc576748765d36f132632ec55c0064f6fba482369fc` + +`"job/registered"` + + + +### `"job/settled"` + +SHA-256: `347de3380f2da308d0c74e08821f9e85559f55af4c5010795b110d00f7fc9dbd` + +`"job/settled"` + + + +### `"kill"` + +SHA-256: `bab68657940075483f086fffb711682a45bcc99c43d659fbe1c36af21ac05dd7` + +`"kill"` + + + +### `"killed"` + +SHA-256: `ef226a90ddac6cd8e631a3a337a3a3e27f9494be92befb7c718b404681d75e21` + +`"killed"` + ### `"legacy"` @@ -2120,6 +2187,14 @@ SHA-256: `9d80129e13edfb6feb03ed327e6085c6ef60b97ad074244b2f5fd4667424a6a4` `"other"` + + +### `"owner"` + +SHA-256: `95a3ffccbf3c8afcf9a2f3b355f724da8e07e98dc47ee741166dde7f9ea058bd` + +`"owner"` + ### `"parent"` @@ -2184,6 +2259,14 @@ SHA-256: `df5d9709b7db2a72510dc52052a23f0c38f39730af1504bab8bccabc14fae528` `"positive"` + + +### `"producer"` + +SHA-256: `6cf082d0fb6034dea61636787ff0ddf43b6e59aad71e644435a479bc9b83ad65` + +`"producer"` + ### `"product-interaction"` @@ -2608,6 +2691,14 @@ SHA-256: `b57ae1b37e52bee0a21cbdace168dc6a15582bcda8c306dad962c5dcc8a8e28b` `"team/task"` + + +### `"teardown"` + +SHA-256: `5cb63db9fe57dc09c3a1c2b84c3edf73ff5b62960155d8436a2377a5c43ce32c` + +`"teardown"` + ### `"text"` @@ -3125,7 +3216,7 @@ SHA-256: `84bbfdce7d2eab5b0c53e72ff7406db856c8bfd621b965b72fd86598ba493bf7` SHA-256: `ada310bf0bdb8fed51f3b56ea63f6ea6b18bbd587f04fccb63a14ab0b2a24e05` -来源:[`packages/core/session/src/types.ts:190`](../packages/core/session/src/types.ts) · [`packages/interaction/commands/src/types.ts:75`](../packages/interaction/commands/src/types.ts) · [`packages/interaction/commands/src/types.ts:79`](../packages/interaction/commands/src/types.ts) · [`packages/llm/llm/src/message.ts:111`](../packages/llm/llm/src/message.ts) · [`packages/session/session-title/src/types.ts:35`](../packages/session/session-title/src/types.ts) +来源:[`packages/core/session/src/types.ts:191`](../packages/core/session/src/types.ts) · [`packages/interaction/commands/src/types.ts:75`](../packages/interaction/commands/src/types.ts) · [`packages/interaction/commands/src/types.ts:79`](../packages/interaction/commands/src/types.ts) · [`packages/llm/llm/src/message.ts:111`](../packages/llm/llm/src/message.ts) · [`packages/session/session-title/src/types.ts:35`](../packages/session/session-title/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -3268,7 +3359,7 @@ SHA-256: `2517ba143a271508d3ca35126d5aca8f7f1facf5aaddce4adf011832042fa2b9` | `body` | 必需 | [`{ max_tokens, messages, model, tools }`](#persistence-type-sha256-930a6567a10bb62ddd157bd8abdf4b182810c8c3b49d91309b3b009a5fed9731) | | `endpoint` | 必需 | `string` | - + @@ -3276,7 +3367,7 @@ SHA-256: `2517ba143a271508d3ca35126d5aca8f7f1facf5aaddce4adf011832042fa2b9` ### `DeveloperMessage` -SHA-256: `af99c95d46bed802870f1ebefe0ea11f92a03cb37b8e0cbe3f94b7aaaf509b08` +SHA-256: `0f6265188dae7ba6faa26830a180bb4deb0760561f97ee0c9d399ccf6c9cf1f7` 来源:[`packages/llm/llm/src/message.ts:157`](../packages/llm/llm/src/message.ts) @@ -3285,7 +3376,7 @@ SHA-256: `af99c95d46bed802870f1ebefe0ea11f92a03cb37b8e0cbe3f94b7aaaf509b08` | `content` | 必需 | [`ContentBlock[]`](#persistence-type-sha256-b52f215a6cd691058a3413c12f161fa3b563a2f4895adb3df6f744ec4b0a6ef1) | | `id` | 必需 | `string` | | `role` | 必需 | `"developer"` | -| `source` | 必需 | [`MessageSource`](#persistence-type-sha256-1fa156c85450ca31063d0e0d2853028e6dac9687f2d3191fa811648210118154) | +| `source` | 必需 | [`MessageSource`](#persistence-type-sha256-5bc2d7f02503fc6b3e1bb7e0b7fa22c0b9e4b9841fa3175b2ad8e607ae3525a8) | 消息源兼容策略:`source` — `session-source-attribution` v1; `session.developer-message.source`; `kind`; `preserve`. @@ -3301,7 +3392,7 @@ SHA-256: `af99c95d46bed802870f1ebefe0ea11f92a03cb37b8e0cbe3f94b7aaaf509b08` SHA-256: `9a930283b91d493d6347146f2475bb107294b379415ca02162354f46a64164f7` -来源:[`packages/core/session/src/types.ts:240`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:241`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -3698,6 +3789,88 @@ SHA-256: `96f4a9c81fc0f940ef71528a8cc66731ae4cf1f40d79680c5fe3c39f96d7723c` - `"next-step"` - `"next-turn"` + + + + + + +### `JobRegisteredData` + +SHA-256: `e3056e0dda3a379e895386e368c3ce42131460ea65e410e1d091cca98a347305` + +来源:[`packages/jobs/jobs/src/types.ts:24`](../packages/jobs/jobs/src/types.ts) + +| 属性 | 存在性 | 类型 | +|---|---|---| +| `jobId` | 必需 | `string` | +| `kind` | 必需 | `string` | +| `label` | 必需 | `string` | +| `owner` | 必需 | `string` | +| `startedAt` | 必需 | `number` | +| `toolCall` | 可选 | [`JobToolCall`](#persistence-type-sha256-39cf2d3ff8bac81c62d49ac2ba38a095b18e80f3ad700819fa1f650a4e60197f) | + + + + + + + +### `JobSettleCause` + +SHA-256: `750a2b129d67704df675c06823c20c80b65cbae89d5aaa914fd5a72e3df395b5` + +来源:[`packages/jobs/jobs/src/types.ts:247`](../packages/jobs/jobs/src/types.ts) + +以下类型之一: + +- `"kill"` +- `"producer"` +- `"teardown"` + + + + + + + +### `JobSettledData` + +SHA-256: `611d82d2dc50df706cf08a0fc04bc8eaf78083391fe6f10313de943e615dd2da` + +来源:[`packages/jobs/jobs/src/types.ts:40`](../packages/jobs/jobs/src/types.ts) + +| 属性 | 存在性 | 类型 | +|---|---|---| +| `cause` | 必需 | [`JobSettleCause`](#persistence-type-sha256-750a2b129d67704df675c06823c20c80b65cbae89d5aaa914fd5a72e3df395b5) | +| `detail` | 可选 | `string` | +| `finishedAt` | 必需 | `number` | +| `jobId` | 必需 | `string` | +| `kind` | 必需 | `string` | +| `label` | 必需 | `string` | +| `owner` | 必需 | `string` | +| `producerReported` | 必需 | `boolean` | +| `startedAt` | 必需 | `number` | +| `status` | 必需 | [`union (3 variants)`](#persistence-type-sha256-c2f49004d9ae38a964e80850c87bc6849d6a872091a1d43e99eedf00c641e2a3) | +| `toolCall` | 可选 | [`JobToolCall`](#persistence-type-sha256-39cf2d3ff8bac81c62d49ac2ba38a095b18e80f3ad700819fa1f650a4e60197f) | + + + + + + + +### `JobToolCall` + +SHA-256: `39cf2d3ff8bac81c62d49ac2ba38a095b18e80f3ad700819fa1f650a4e60197f` + +来源:[`packages/jobs/jobs/src/types.ts:16`](../packages/jobs/jobs/src/types.ts) + +| 属性 | 存在性 | 类型 | +|---|---|---| +| `callId` | 必需 | `string` | +| `rootCallId` | 必需 | `string` | + @@ -3902,7 +4075,7 @@ SHA-256: `6f72ece8f317cd080c4f20a93f1bf709965f5b81ac4669528f5a023191cfcf20` | `step` | 必需 | `number` | | `turn` | 必需 | `number` | - + @@ -3910,17 +4083,17 @@ SHA-256: `6f72ece8f317cd080c4f20a93f1bf709965f5b81ac4669528f5a023191cfcf20` ### `Message` -SHA-256: `d901c4f5e856591c3217af983caa15bba568d580700f0958bab56b88004eced9` +SHA-256: `040cd65423bd64264d225504f1c572d239eeb1793d0c2856ad8e42bb6e1e148b` 来源:[`packages/llm/llm/src/message.ts:196`](../packages/llm/llm/src/message.ts) 以下类型之一: - [`ToolResultMessage`](#persistence-type-sha256-4b259f7f8f0a44ad5e90b70a73553ced1df27012d126921d397ff403b6c86731) -- [`DeveloperMessage`](#persistence-type-sha256-af99c95d46bed802870f1ebefe0ea11f92a03cb37b8e0cbe3f94b7aaaf509b08) -- [`UserMessage`](#persistence-type-sha256-89b2edff547be1ecfcd78ef18aeeb3cf507fcbf7d6093ec04fb111ef4c8e8a97) -- [`SystemMessage`](#persistence-type-sha256-09f3f8dc417f278257202714132f98ba7b52957d7b558c4a04456603890eb363) +- [`DeveloperMessage`](#persistence-type-sha256-0f6265188dae7ba6faa26830a180bb4deb0760561f97ee0c9d399ccf6c9cf1f7) +- [`UserMessage`](#persistence-type-sha256-2cf075b013d363ad743b5a4982900678d108499c721bc11255137402003db711) - [`AssistantMessage`](#persistence-type-sha256-fa87537f4a9c1e25602990a8bf686b383e9e00352ec8946e16ae24ba7c1625be) +- [`SystemMessage`](#persistence-type-sha256-09f3f8dc417f278257202714132f98ba7b52957d7b558c4a04456603890eb363) @@ -3995,7 +4168,7 @@ SHA-256: `390eed1028aba02774f14408647acec10ec5516e97d4f15ecde2b9989d5a69f2` - `"negative"` - `"positive"` - + @@ -4003,7 +4176,7 @@ SHA-256: `390eed1028aba02774f14408647acec10ec5516e97d4f15ecde2b9989d5a69f2` ### `MessageSource` -SHA-256: `1fa156c85450ca31063d0e0d2853028e6dac9687f2d3191fa811648210118154` +SHA-256: `5bc2d7f02503fc6b3e1bb7e0b7fa22c0b9e4b9841fa3175b2ad8e607ae3525a8` 来源:[`packages/llm/llm/src/message.ts:136`](../packages/llm/llm/src/message.ts) @@ -4108,7 +4281,7 @@ SHA-256: `1fa156c85450ca31063d0e0d2853028e6dac9687f2d3191fa811648210118154` | `tool-jobs` | `"snapshot"` | `sections`: [`ContextSnapshotSection[]`](#persistence-type-sha256-c748fb42c80a120038a8c587af80ec39924803c401364db11c8974d5bf6d0262) | [`{ kind: "tool-jobs", form: "snapshot" }`](#persistence-type-sha256-c5228b7a55498d84f529591744a0ab26e0b93ade18a70a26db1377a1ecfa6b49) | | `tool-jobs` | 未声明 | 无 | [`{ kind: "tool-jobs" }`](#persistence-type-sha256-24ce31aa528272e59f21cd26da9eca08968bcc97529756a749bfdab71746d9c8) | | `tool-registry` | 未声明 | 无 | [`{ kind: "tool-registry" }`](#persistence-type-sha256-8fcb0b44a575e998665c053643d1ab04c82f39253ebd190ac0c975b78e40b543) | -| `user` | 未声明 | `rpcId`: `string` | [`{ kind: "user" }`](#persistence-type-sha256-6a4f72e2e179e17b922f2a9392c0e1c8f8c707f32494372454850a3eb184a6e7) | +| `user` | 未声明 | `rpcId`: `string` | [`{ kind: "user" }`](#persistence-type-sha256-6dacb1584f2bfbe209dc6b6f578d51da6d55595f64d0e63c727c98f359278def) | | `user` | 未声明 | 无 | [`CommandSource`](#persistence-type-sha256-ada310bf0bdb8fed51f3b56ea63f6ea6b18bbd587f04fccb63a14ab0b2a24e05) | | `user-approval` | `"catalog"` | 无 | [`{ kind: "user-approval", form: "catalog" }`](#persistence-type-sha256-825ca23416b1ea9d81442afead5cf9229a6fa05842f042135cc3aa9710a6a2d8) | | `user-approval` | `"instructions"` | 无 | [`{ kind: "user-approval", form: "instructions" }`](#persistence-type-sha256-aa2bf9e82b9fdeb94a42af20031fffa30e4e23f01f4ac39757427d4c64c407f0) | @@ -4121,13 +4294,13 @@ SHA-256: `1fa156c85450ca31063d0e0d2853028e6dac9687f2d3191fa811648210118154` | `webhook` | `"notice"` | `deliveryId`: `string`; `provider`: `string`; `ruleId`: `string`; `source`: `string`; `summary`: `string` | [`{ kind: "webhook", form: "notice" }`](#persistence-type-sha256-9edc162949abf93bd5ff049191b345454ba104f583937ce5580fb50f067d2b94) | | `working-directory` | 未声明 | 无 | [`{ kind: "working-directory" }`](#persistence-type-sha256-2235d957bf4b7c6cf959231d480f0ba1f3a6d72d82981493fb6c7c963021d3d5) | - + ### `Message[]` -SHA-256: `4be8fd28fefa5534675ea9aabd0a24efc2838b989bbc74da20a1efbd0daede8c` +SHA-256: `a927d444c928ac0efd9dd8b2e7d7d04d711c264cb71578d81e0e2fd143ff669f` -[`Message`](#persistence-type-sha256-d901c4f5e856591c3217af983caa15bba568d580700f0958bab56b88004eced9) 的数组。 +[`Message`](#persistence-type-sha256-040cd65423bd64264d225504f1c572d239eeb1793d0c2856ad8e42bb6e1e148b) 的数组。 @@ -4158,7 +4331,7 @@ SHA-256: `719c51f67074f97034a6c94297fe1f4b5d3c8d392c57586dab732aa5398c7a91` SHA-256: `f09e5e7acf5ff5ccd193ff2eeec015fa2ff7c29d98bc465f8c3302058d47e29b` -来源:[`packages/api/session-controller/src/types.ts:100`](../packages/api/session-controller/src/types.ts) +来源:[`packages/api/session-controller/src/types.ts:101`](../packages/api/session-controller/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -4213,7 +4386,7 @@ SHA-256: `5f758f3de5f00c18968a7c6d347083aac5c9ae13f6a79c78f6611b23c4973564` SHA-256: `3bd652ebfa8726b3ce3937a4f1bd2759e02f86b3a08a20f9e41a8513656fbc5f` -来源:[`packages/core/session/src/types.ts:65`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:66`](../packages/core/session/src/types.ts) 以下类型之一: @@ -4245,6 +4418,22 @@ SHA-256: `1d9cb3caa96100b18b1911fa3ff8c751ef6992971a03c5f4d9aefc1a0004a345` [`PresentedFile`](#persistence-type-sha256-b8fc636a2121df9d8423c242e03c9d23b7ab7c6fdce00e45746932c60b730f97) 的数组。 + + + + + + +### `Principal` + +SHA-256: `deebfc7c62177ea840f23c6362b8615bc78ffa93377dd5d243c04e541c413af5` + +来源:[`packages/typert/protocol/src/types.ts:457`](../packages/typert/protocol/src/types.ts) · [`packages/typert/protocol/src/types.ts:461`](../packages/typert/protocol/src/types.ts) + +| 属性 | 存在性 | 类型 | +|---|---|---| +| `kind` | 必需 | `"owner"` | + @@ -4316,7 +4505,7 @@ SHA-256: `814d9434e1ddf9078612b3b34322f26e9363b469213335007f7e9ba7fee9464e` SHA-256: `0acd94fe8794574c9ae72c7855ee16d29c546096d4af760d16d1225385764555` -来源:[`packages/llm/llm/src/types.ts:430`](../packages/llm/llm/src/types.ts) +来源:[`packages/llm/llm/src/types.ts:446`](../packages/llm/llm/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -4333,7 +4522,7 @@ SHA-256: `0acd94fe8794574c9ae72c7855ee16d29c546096d4af760d16d1225385764555` SHA-256: `b158f3ff6923714807c0d73882f0b05dc87f8fa85c9e90814852d48bbc866081` -来源:[`packages/core/session/src/types.ts:254`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:255`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -4352,7 +4541,7 @@ SHA-256: `b158f3ff6923714807c0d73882f0b05dc87f8fa85c9e90814852d48bbc866081` SHA-256: `530da6c0267813bb4511af72883cc518834e624e3b3220a7062955fd57a788a6` -来源:[`packages/core/session/src/types.ts:273`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:274`](../packages/core/session/src/types.ts) 以下类型之一: @@ -4444,7 +4633,7 @@ SHA-256: `18dc21f12bbac8ade8377e6fbb54179e20c3d955d73fba3244c57fb6bf001df0` SHA-256: `1a3440e3577382704d42a6263aa463504eb74c566734a55e9503a63efcd02445` -来源:[`packages/core/session/src/types.ts:94`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:95`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -4495,7 +4684,7 @@ SHA-256: `b56f6a885da3dc394c69a3e3cc5c0cfc4da15601b63fda4989eee8f40efc5be4` | `source` | 必需 | [`SessionTitleSource`](#persistence-type-sha256-30d5a537b296685770b1241c4b4bbde19d5a31ff81aa23df73fab5e90b8fc65f) | | `title` | 必需 | `string` | - + @@ -4503,7 +4692,7 @@ SHA-256: `b56f6a885da3dc394c69a3e3cc5c0cfc4da15601b63fda4989eee8f40efc5be4` ### `SessionTitleLlmRequestEventData` -SHA-256: `045926475a46f625f84ae2be292b384817059f16927c69a9adab93717923ac19` +SHA-256: `00c054d3018c198d92f459563331edd1385e58345c677bb499791df2da0ade9f` 来源:[`packages/session/session-title-llm/src/index.ts:37`](../packages/session/session-title-llm/src/index.ts) @@ -4511,7 +4700,7 @@ SHA-256: `045926475a46f625f84ae2be292b384817059f16927c69a9adab93717923ac19` |---|---|---| | `maxTokens` | 必需 | `number` | | `messageSeqs` | 必需 | [`number[]`](#persistence-type-sha256-5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78) | -| `messages` | 必需 | [`Message[]`](#persistence-type-sha256-4be8fd28fefa5534675ea9aabd0a24efc2838b989bbc74da20a1efbd0daede8c) | +| `messages` | 必需 | [`Message[]`](#persistence-type-sha256-a927d444c928ac0efd9dd8b2e7d7d04d711c264cb71578d81e0e2fd143ff669f) | | `reasoningEffort` | 可选 | `string` | | `route` | 必需 | [`SessionTitleModelIdentity`](#persistence-type-sha256-07e1a58c58b593f507b8e7f25723bb0aa42baae6e00f5ecfddf28cb1e64d3235) | | `system` | 必需 | `string` | @@ -4611,7 +4800,7 @@ SHA-256: `672c4aa2a9b736e3d6e5da6346d2e2ed5e3573f2d0baafa18e7407c9f3fee3db` SHA-256: `47e9095591ae36e7c39ee62553097772f2bfa16304536a72dbbc3f691f605180` -来源:[`packages/llm/llm/src/types.ts:452`](../packages/llm/llm/src/types.ts) +来源:[`packages/llm/llm/src/types.ts:468`](../packages/llm/llm/src/types.ts) 以下类型之一: @@ -4690,7 +4879,7 @@ SHA-256: `4e97c3d85c0fc817ee58873c38c25a2474abaaa0af4839311b50b68db3b8cf1a` SHA-256: `335e242de1fcc17b6ca920fc420d71bec2d76e53e37955c00948b65ab77f05c5` -来源:[`packages/core/session/src/types.ts:462`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:463`](../packages/core/session/src/types.ts) 以下类型之一: @@ -5036,7 +5225,7 @@ SHA-256: `4b259f7f8f0a44ad5e90b70a73553ced1df27012d126921d397ff403b6c86731` SHA-256: `66ba70f6f068da443f969a0b947a013c3addd776b9b60a0a393759c19825e9a2` -来源:[`packages/llm/llm/src/types.ts:473`](../packages/llm/llm/src/types.ts) +来源:[`packages/llm/llm/src/types.ts:489`](../packages/llm/llm/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -5135,7 +5324,7 @@ SHA-256: `a6da9ad5d42ae83a6833a8288b076688e8e0801633ea36cbdd8a30a6d9f3160c` SHA-256: `1f8c126cfab3df812cd115cfc23e3256f0fa2383400f4ce91ecad313577e59b0` -来源:[`packages/core/session/src/types.ts:196`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:197`](../packages/core/session/src/types.ts) | kind | form 属性 | 其他必需字段 | 完整定义 | |---|---|---|---| @@ -5155,7 +5344,7 @@ SHA-256: `1f8c126cfab3df812cd115cfc23e3256f0fa2383400f4ce91ecad313577e59b0` SHA-256: `f1687f982fa3d41ef29e159feed154ecd13af4dd209acaee16038f223f2e9402` -来源:[`packages/core/session/src/types.ts:232`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:233`](../packages/core/session/src/types.ts) | kind | form 属性 | 其他必需字段 | 完整定义 | |---|---|---|---| @@ -5167,7 +5356,7 @@ SHA-256: `f1687f982fa3d41ef29e159feed154ecd13af4dd209acaee16038f223f2e9402` | `interrupted` | 未声明 | 无 | [`{ kind: "interrupted" }`](#persistence-type-sha256-56b7c047694875a843ba020dfea3c8380d54ae4ff88ab349e72004dc21e07c23) | | `max-tokens` | 未声明 | 无 | [`{ kind: "max-tokens" }`](#persistence-type-sha256-ab7ec5f9eb2c6dfacca5da4c37cf7ba85d04172053ee6b1ebf1709ffcd99c1a7) | - + @@ -5175,7 +5364,7 @@ SHA-256: `f1687f982fa3d41ef29e159feed154ecd13af4dd209acaee16038f223f2e9402` ### `UserMessage` -SHA-256: `89b2edff547be1ecfcd78ef18aeeb3cf507fcbf7d6093ec04fb111ef4c8e8a97` +SHA-256: `2cf075b013d363ad743b5a4982900678d108499c721bc11255137402003db711` 来源:[`packages/llm/llm/src/message.ts:162`](../packages/llm/llm/src/message.ts) @@ -5184,19 +5373,19 @@ SHA-256: `89b2edff547be1ecfcd78ef18aeeb3cf507fcbf7d6093ec04fb111ef4c8e8a97` | `content` | 必需 | [`ContentBlock[]`](#persistence-type-sha256-b52f215a6cd691058a3413c12f161fa3b563a2f4895adb3df6f744ec4b0a6ef1) | | `id` | 必需 | `string` | | `role` | 必需 | `"user"` | -| `source` | 必需 | [`MessageSource`](#persistence-type-sha256-1fa156c85450ca31063d0e0d2853028e6dac9687f2d3191fa811648210118154) | +| `source` | 必需 | [`MessageSource`](#persistence-type-sha256-5bc2d7f02503fc6b3e1bb7e0b7fa22c0b9e4b9841fa3175b2ad8e607ae3525a8) | 消息源兼容策略:`source` — `session-source-attribution` v1; `session.user-message.source`; `kind`; `preserve`. 仅表示归属的新增 kind:`tmux-context`, `user-question-reply`, `working-directory`. - + ### `UserMessage[]` -SHA-256: `2fef90def36aa758ca096c5f6ad0e00dbb2101943e19ca54b05618801974894a` +SHA-256: `454a0fa879e38076bdcdbd87ef561cd67d7e43e58a287cf7dbaa7b6cf7847020` -[`UserMessage`](#persistence-type-sha256-89b2edff547be1ecfcd78ef18aeeb3cf507fcbf7d6093ec04fb111ef4c8e8a97) 的数组。 +[`UserMessage`](#persistence-type-sha256-2cf075b013d363ad743b5a4982900678d108499c721bc11255137402003db711) 的数组。 @@ -5276,7 +5465,7 @@ SHA-256: `938f7c7357bc403ea3b281a9dd016939df7a0880fc8db12612585be574d0d615` SHA-256: `0657d3ca5ac4e7bf79b00bcf860b1c7159d01a9da137497221ec2f1776e365f7` -来源:[`packages/core/session/src/types.ts:32`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:33`](../packages/core/session/src/types.ts) `number` @@ -5300,6 +5489,8 @@ SHA-256: `5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78` + + @@ -5332,6 +5523,8 @@ SHA-256: `5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78` + + @@ -5384,7 +5577,7 @@ SHA-256: `5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78` SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b` -来源:[`packages/api/session-controller/src/types.ts:403`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) +来源:[`packages/api/session-controller/src/types.ts:409`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:21`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/jobs/jobs/src/brand.ts:19`](../packages/jobs/jobs/src/brand.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) `string` @@ -5493,6 +5686,18 @@ SHA-256: `904bf499d98be4a3a5f836fcac533b6c4899569b018071e6604e1d32657d00fd` - `"replace"` - `"set"` + + +### `union (3 variants)` + +SHA-256: `c2f49004d9ae38a964e80850c87bc6849d6a872091a1d43e99eedf00c641e2a3` + +以下类型之一: + +- `"completed"` +- `"failed"` +- `"killed"` + ### `union (3 variants)` @@ -5570,7 +5775,7 @@ SHA-256: `20a57544bbf204dacd1ae6beb2ab29444365c3c869f2afdf489dcdcbc3d9c8c5` SHA-256: `6e52dd55a1d48954ed5464ec46fe34d9eb65f709baabc9561e7a279910b0c406` -来源:[`packages/preset/agent-preset-registry/src/session.ts:28`](../packages/preset/agent-preset-registry/src/session.ts) +来源:[`packages/preset/agent-preset-registry/src/session.ts:31`](../packages/preset/agent-preset-registry/src/session.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -5609,7 +5814,7 @@ SHA-256: `a853902cfab417b8f08aa51ad6855fea46f34ed4588a74e47403035111d0fb86` SHA-256: `e4c18e294232c3ba6c9f1999f168263cc55956147cfe4121720899be8e52edd1` -来源:[`packages/core/session/src/types.ts:361`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:362`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -5768,7 +5973,7 @@ SHA-256: `cbbe8377ee744964537dbafd83607219e58ba79a13f6db0d0645c7ad3d5e8a09` SHA-256: `a81059ca06999da1e712414520c2c65e491d052fcf9640fae20aa38f0a5dc735` -来源:[`packages/core/session/src/types.ts:383`](../packages/core/session/src/types.ts) · [`packages/core/tools/src/types.ts:27`](../packages/core/tools/src/types.ts) +来源:[`packages/core/session/src/types.ts:384`](../packages/core/session/src/types.ts) · [`packages/core/tools/src/types.ts:27`](../packages/core/tools/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -5988,7 +6193,7 @@ SHA-256: `9e41386b3a0c9572b0d63078492ebb3997da7d3a830d44e0259418bd02f4bcb2` SHA-256: `bcf0caf62d964b2fcf5404bd5c909cfa9a21c3f3c96e6b7e36d9e33565223825` -来源:[`packages/core/session/src/types.ts:464`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:465`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -6002,7 +6207,7 @@ SHA-256: `bcf0caf62d964b2fcf5404bd5c909cfa9a21c3f3c96e6b7e36d9e33565223825` SHA-256: `79650cfa10aa3c52440328d85e8a25f0ffadc8d7efd784e3c0819d8bb74a1985` -来源:[`packages/core/session/src/types.ts:212`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:213`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -6015,7 +6220,7 @@ SHA-256: `79650cfa10aa3c52440328d85e8a25f0ffadc8d7efd784e3c0819d8bb74a1985` SHA-256: `5302e763151277614b97e77f00e3b326694c6c1247b916a20503c344dc970777` -来源:[`packages/core/session/src/types.ts:375`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:376`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -6057,7 +6262,7 @@ SHA-256: `693f4952fb5565da96c06bace8924b098ac2fbc915fde0ff8019701183abc057` SHA-256: `ad0c38a0adb82a94c925456df41586d9b5f9202fca18593eae2213d4445b4585` -来源:[`packages/core/session/src/types.ts:390`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:391`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -6065,18 +6270,18 @@ SHA-256: `ad0c38a0adb82a94c925456df41586d9b5f9202fca18593eae2213d4445b4585` | `reason` | 必需 | [`RequestHeaderReason`](#persistence-type-sha256-530da6c0267813bb4511af72883cc518834e624e3b3220a7062955fd57a788a6) | | `startsSeries` | 可选 | `true` | - + ### `{ headerSeq?, message, step, turn }` -SHA-256: `5b55817a403569fbb2d379496df2e3f3f71107f6c577a218028ce187c60b61e6` +SHA-256: `f4ed4717b4c259e933b550a8dcd90dc93628cda8a3c7affcaa53b9c13c1d53eb` -来源:[`packages/core/session/src/types.ts:311`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:312`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| | `headerSeq` | 可选 | `number` | -| `message` | 必需 | [`DeveloperMessage`](#persistence-type-sha256-af99c95d46bed802870f1ebefe0ea11f92a03cb37b8e0cbe3f94b7aaaf509b08) | +| `message` | 必需 | [`DeveloperMessage`](#persistence-type-sha256-0f6265188dae7ba6faa26830a180bb4deb0760561f97ee0c9d399ccf6c9cf1f7) | | `step` | 必需 | `number` | | `turn` | 必需 | `number` | @@ -6154,23 +6359,23 @@ SHA-256: `e3e77b26f0148755a505f5b8ea843a3be117827d4703bb5754d9dc8ddbef521f` SHA-256: `17d1afb770d9941936130996da00dc86782cfef731d8d6526162c301256a4ac3` -来源:[`packages/core/session/src/types.ts:427`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:428`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| | `inherited` | 可选 | `true` | - + ### `{ inserted, outcome?, removedCount?, start, … }` -SHA-256: `83a665476a9ee9af05cb1fdded12dd647d1b97b491200d7f12ef1af7cdac4428` +SHA-256: `fa106c5b33b657e8ef16e8cbf39f3b7d44e5cbcdf4f9303d9db6410f28951e42` 来源:[`packages/core/agent/src/types.ts:96`](../packages/core/agent/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| -| `inserted` | 必需 | [`UserMessage[]`](#persistence-type-sha256-2fef90def36aa758ca096c5f6ad0e00dbb2101943e19ca54b05618801974894a) | +| `inserted` | 必需 | [`UserMessage[]`](#persistence-type-sha256-454a0fa879e38076bdcdbd87ef561cd67d7e43e58a287cf7dbaa7b6cf7847020) | | `outcome` | 可选 | `"canceled"` | | `removedCount` | 可选 | `number` | | `start` | 必需 | `number` | @@ -6182,7 +6387,7 @@ SHA-256: `83a665476a9ee9af05cb1fdded12dd647d1b97b491200d7f12ef1af7cdac4428` SHA-256: `1ccb56061942f5ccb0d30c9e4f2883b9d115ff906560029de637b61d3cde896d` -来源:[`packages/core/session/src/types.ts:341`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:342`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -6199,7 +6404,7 @@ SHA-256: `1ccb56061942f5ccb0d30c9e4f2883b9d115ff906560029de637b61d3cde896d` SHA-256: `0a30c3be336cdd4f8f34e66543a3d46d611c43986edd9b3ce6cbb9b721d3b5d3` -来源:[`packages/core/session/src/types.ts:204`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:205`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -6212,7 +6417,7 @@ SHA-256: `0a30c3be336cdd4f8f34e66543a3d46d611c43986edd9b3ce6cbb9b721d3b5d3` SHA-256: `023a355b2be0d688fb6e4df8388dadb942761d46cfe96d0f499aae9631a159d4` -来源:[`packages/core/session/src/types.ts:206`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:207`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -6224,7 +6429,7 @@ SHA-256: `023a355b2be0d688fb6e4df8388dadb942761d46cfe96d0f499aae9631a159d4` SHA-256: `b6ef9c6210be8350cd465f485cf520031fffa6bf3809c61a1e713e834c5982d0` -来源:[`packages/core/session/src/types.ts:202`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:203`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -6248,7 +6453,7 @@ SHA-256: `02b2c23f2d94b028c9588a8faace3fce1217405db346da3389368b495457bf79` SHA-256: `4bb451330ff699f36b45539ceb0eb2606ead72084e2c5bb637c36cc80e2252db` -来源:[`packages/core/session/src/types.ts:193`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:194`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -6350,7 +6555,7 @@ SHA-256: `1b5649238876e28811e80f10ec67a00c197ad3babd4ecc61a22b09064b88c597` SHA-256: `ec8b5ff7c77ca6893ac7ba5a0c5b2aefeb52f12c64450b7359660c2a7e8a53d7` -来源:[`packages/core/session/src/types.ts:228`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:229`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -6362,7 +6567,7 @@ SHA-256: `ec8b5ff7c77ca6893ac7ba5a0c5b2aefeb52f12c64450b7359660c2a7e8a53d7` SHA-256: `6c5a37d42ebdb12db4410699259335f1e149e3a0260425684c00b153afc8990d` -来源:[`packages/core/session/src/types.ts:192`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:193`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -6531,7 +6736,7 @@ SHA-256: `88841202942c7683b3b7e5ea6a796047ec326b6701ac23e58cc236d85613b9fa` SHA-256: `56b7c047694875a843ba020dfea3c8380d54ae4ff88ab349e72004dc21e07c23` -来源:[`packages/core/session/src/types.ts:221`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:222`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -6543,7 +6748,7 @@ SHA-256: `56b7c047694875a843ba020dfea3c8380d54ae4ff88ab349e72004dc21e07c23` SHA-256: `eaa71be0fbf0f702df8ea6c11d0aa2f9782263d45a10d0fb14eda662f4bfe04c` -来源:[`packages/core/session/src/types.ts:196`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:197`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -6555,7 +6760,7 @@ SHA-256: `eaa71be0fbf0f702df8ea6c11d0aa2f9782263d45a10d0fb14eda662f4bfe04c` SHA-256: `ab7ec5f9eb2c6dfacca5da4c37cf7ba85d04172053ee6b1ebf1709ffcd99c1a7` -来源:[`packages/core/session/src/types.ts:214`](../packages/core/session/src/types.ts) · [`packages/llm/llm/src/types.ts:159`](../packages/llm/llm/src/types.ts) +来源:[`packages/core/session/src/types.ts:215`](../packages/core/session/src/types.ts) · [`packages/llm/llm/src/types.ts:159`](../packages/llm/llm/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -6645,7 +6850,7 @@ SHA-256: `52c6113cb0e53f345324bf84f01928bea5d5df57ee80bc0dd65407b8bc5a3734` SHA-256: `6c03546143cdf6ae88f1745ac98c5139efa1588d1415249bbc5f3104d32ab800` -来源:[`packages/core/session/src/types.ts:191`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:192`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7337,18 +7542,19 @@ SHA-256: `8fcb0b44a575e998665c053643d1ab04c82f39253ebd190ac0c975b78e40b543` |---|---|---| | `kind` | 必需 | `"tool-registry"` | - + ### `{ kind: "user" }` -SHA-256: `6a4f72e2e179e17b922f2a9392c0e1c8f8c707f32494372454850a3eb184a6e7` +SHA-256: `6dacb1584f2bfbe209dc6b6f578d51da6d55595f64d0e63c727c98f359278def` -来源:[`packages/api/session-controller/src/types.ts:408`](../packages/api/session-controller/src/types.ts) +来源:[`packages/api/session-controller/src/types.ts:418`](../packages/api/session-controller/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| | `clientTimeZone` | 可选 | `string` | | `kind` | 必需 | `"user"` | +| `principal` | 可选 | [`Principal`](#persistence-type-sha256-deebfc7c62177ea840f23c6362b8615bc78ffa93377dd5d243c04e541c413af5) | | `rpcId` | 必需 | `string` | @@ -7508,7 +7714,7 @@ SHA-256: `b1ebd5cda47d90d980bb05bffc3f970c51537b9aff15b3bc3d639811727741b1` SHA-256: `6bc8e51124d0874bc89e1d0ceff381a9c3cd0fc240667d69f841ce4f60f65fe5` -来源:[`packages/core/session/src/types.ts:330`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:331`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7589,7 +7795,7 @@ SHA-256: `8a9c990773f798eac10ca8878e6bcc531ee080bc589cac956730e462cc6a09ad` SHA-256: `e481c46af3813e0010558e93f578b2986acd658404cec2d1679e3ac2a6c48654` -来源:[`packages/core/session/src/types.ts:297`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:298`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7643,7 +7849,7 @@ SHA-256: `5dd766ccd1702a1142ad8b4bd44f2242f8e8a6752669ad3de0a5d224329b1438` SHA-256: `54f9276a1617d7c12594722c89cfe864bcbe4517e9a6f9c98bab14f199a135e0` -来源:[`packages/core/session/src/types.ts:355`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:356`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7657,7 +7863,7 @@ SHA-256: `54f9276a1617d7c12594722c89cfe864bcbe4517e9a6f9c98bab14f199a135e0` SHA-256: `ef11a3b7fa2033ae4279545ff8a8a2ee4e301b9b73e5b91654c48e6d08283545` -来源:[`packages/core/session/src/types.ts:299`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:301`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:300`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:302`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7708,7 +7914,7 @@ SHA-256: `79ddc907312f97cf9553d4841e8b3fc937097bd8d97900a790efad3c05255e99` SHA-256: `f90eb4ab2d3897bc20c521a038e3119d581f791ec974b1a0004f63a8ab9e232d` -来源:[`packages/core/session/src/types.ts:288`](../packages/core/session/src/types.ts) · [`packages/deliverables/workspace-changes/src/types.ts:106`](../packages/deliverables/workspace-changes/src/types.ts) +来源:[`packages/core/session/src/types.ts:289`](../packages/core/session/src/types.ts) · [`packages/deliverables/workspace-changes/src/types.ts:106`](../packages/deliverables/workspace-changes/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7730,17 +7936,17 @@ SHA-256: `a10c17474eaf2ddab7095a099e0fe3d046fc18e56c3e344fc8894c05ff9ef97b` | `time` | 必需 | `number` | | `type` | 必需 | `"agent-preset/selected"` | - + ### `{ type: "agent/inbox/spliced" }` -SHA-256: `9a36727716d0e73f681f3b725e7f28f89417c68174c8af0ff2b1ef52b08326a0` +SHA-256: `0755b7d1128319ba2e389619cbbf2c961ad648882b4e91da7f3e9582cc8a2034` | 属性 | 存在性 | 类型 | |---|---|---| -| `data` | 必需 | [`{ inserted, outcome?, removedCount?, start, … }`](#persistence-type-sha256-83a665476a9ee9af05cb1fdded12dd647d1b97b491200d7f12ef1af7cdac4428) | +| `data` | 必需 | [`{ inserted, outcome?, removedCount?, start, … }`](#persistence-type-sha256-fa106c5b33b657e8ef16e8cbf39f3b7d44e5cbcdf4f9303d9db6410f28951e42) | | `ignorable` | 可选 | `true` | | `seq` | 必需 | `number` | | `time` | 必需 | `number` | @@ -7833,7 +8039,7 @@ SHA-256: `1033093edd0db80ff410e00830b523405e00bb0c7684948e531ff65095799625` SHA-256: `d4b739491689cc6bb13d07ffac1a32239875abda364c0d5edd8f74faf5e9c00d` -来源:[`packages/llm/llm/src/types.ts:457`](../packages/llm/llm/src/types.ts) +来源:[`packages/llm/llm/src/types.ts:473`](../packages/llm/llm/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7847,7 +8053,7 @@ SHA-256: `d4b739491689cc6bb13d07ffac1a32239875abda364c0d5edd8f74faf5e9c00d` SHA-256: `3bec87f9e6ab3da03346f49923d3c51621debc0159df68605e543c3b2a026f90` -来源:[`packages/llm/llm/src/types.ts:453`](../packages/llm/llm/src/types.ts) +来源:[`packages/llm/llm/src/types.ts:469`](../packages/llm/llm/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7981,17 +8187,17 @@ SHA-256: `13d3d180f977bf78081d487ffa0ecb75857349bcab29a5a3fb48189fca2a6176` | `time` | 必需 | `number` | | `type` | 必需 | `"deliverables/presented"` | - + ### `{ type: "developer/message" }` -SHA-256: `ba5b10ea77d68f4a29439a9bd2a14f6a560acf55731749d57237757cc72fbc06` +SHA-256: `97e0162f983c27456ec71269fb4976b1851588b8c1aeac5d071d43e11f95e03c` | 属性 | 存在性 | 类型 | |---|---|---| -| `data` | 必需 | [`{ headerSeq?, message, step, turn }`](#persistence-type-sha256-5b55817a403569fbb2d379496df2e3f3f71107f6c577a218028ce187c60b61e6) | +| `data` | 必需 | [`{ headerSeq?, message, step, turn }`](#persistence-type-sha256-f4ed4717b4c259e933b550a8dcd90dc93628cda8a3c7affcaa53b9c13c1d53eb) | | `ignorable` | 可选 | `true` | | `seq` | 必需 | `number` | | `sourceEventSeqs` | 可选 | [`number[]`](#persistence-type-sha256-5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78) | @@ -8053,7 +8259,7 @@ SHA-256: `b54940ff095c17e874c5be03815f4c2145a256cf3a1d34dae4ab2f7769dfffe8` SHA-256: `a2b49331716ba0a7078b026d145306a70eae64b7313c3e6204bd931e75ec3bb1` -来源:[`packages/llm/llm/src/types.ts:459`](../packages/llm/llm/src/types.ts) +来源:[`packages/llm/llm/src/types.ts:475`](../packages/llm/llm/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -8125,6 +8331,38 @@ SHA-256: `b222069eea2d768065161c1147b1f2c78c1b54328c84b3586ae5c8f91b8ed35e` | `time` | 必需 | `number` | | `type` | 必需 | `"image/offload"` | + + + + +### `{ type: "job/registered" }` + +SHA-256: `f5520c1d261ca86ea03735799c311a0a9ede8babb588cf0a65422576d2ee0ad1` + +| 属性 | 存在性 | 类型 | +|---|---|---| +| `data` | 必需 | [`JobRegisteredData`](#persistence-type-sha256-e3056e0dda3a379e895386e368c3ce42131460ea65e410e1d091cca98a347305) | +| `ignorable` | 可选 | `true` | +| `seq` | 必需 | `number` | +| `time` | 必需 | `number` | +| `type` | 必需 | `"job/registered"` | + + + + + +### `{ type: "job/settled" }` + +SHA-256: `edd37b1e4974af648633014435572089b1e8fb71910468a0767a7e28fdd22abf` + +| 属性 | 存在性 | 类型 | +|---|---|---| +| `data` | 必需 | [`JobSettledData`](#persistence-type-sha256-611d82d2dc50df706cf08a0fc04bc8eaf78083391fe6f10313de943e615dd2da) | +| `ignorable` | 可选 | `true` | +| `seq` | 必需 | `number` | +| `time` | 必需 | `number` | +| `type` | 必需 | `"job/settled"` | + @@ -8227,7 +8465,7 @@ SHA-256: `71bb3254ec8f1e491ce63b762a7b5f18709de23aa4e8abcc8b886f4b6ce051dd` SHA-256: `2abb711460e6238d851c30f3e4bdd87a2aa1887ee70575b3d3b7b25610673095` -来源:[`packages/llm/llm/src/types.ts:455`](../packages/llm/llm/src/types.ts) +来源:[`packages/llm/llm/src/types.ts:471`](../packages/llm/llm/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -8347,17 +8585,17 @@ SHA-256: `1b912703e2d64f91c99c675b8f805b01076c8325b905c1218ad81ef0b24909d5` | `time` | 必需 | `number` | | `type` | 必需 | `"session/title"` | - + ### `{ type: "session/title-llm-request" }` -SHA-256: `b33545b3244861bcbf73b407d24b53402710f5acacab7ae939537e5504a68dc6` +SHA-256: `79a4c87f8bfb4d6d05cd9952b99210d6a7db3b478c4a01d94b4c50602308c056` | 属性 | 存在性 | 类型 | |---|---|---| -| `data` | 必需 | [`SessionTitleLlmRequestEventData`](#persistence-type-sha256-045926475a46f625f84ae2be292b384817059f16927c69a9adab93717923ac19) | +| `data` | 必需 | [`SessionTitleLlmRequestEventData`](#persistence-type-sha256-00c054d3018c198d92f459563331edd1385e58345c677bb499791df2da0ade9f) | | `ignorable` | 可选 | `true` | | `seq` | 必需 | `number` | | `time` | 必需 | `number` | @@ -8547,7 +8785,7 @@ SHA-256: `e72269f931559e901e5e8fe1cfcbd0669fb795f43cc038ff39fba62f60403c33` SHA-256: `95080295115fbd83544520ba247b68a03790131f6a2eac323d388758cb542f65` -来源:[`packages/llm/llm/src/types.ts:454`](../packages/llm/llm/src/types.ts) +来源:[`packages/llm/llm/src/types.ts:470`](../packages/llm/llm/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -8595,7 +8833,7 @@ SHA-256: `3860b4757d512a2869be833acb0faf834335b1b056bf3f83df5474dce95f8fa6` SHA-256: `88a610b831be6d92612a464be5c7d0b88ec24d97bd299837bf74bf7672183c9a` -来源:[`packages/llm/llm/src/types.ts:456`](../packages/llm/llm/src/types.ts) +来源:[`packages/llm/llm/src/types.ts:472`](../packages/llm/llm/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -8773,24 +9011,24 @@ SHA-256: `aa0957eca50aeb28bcd2e6930b95809926edacb550c8c340ba526ba6b861b3d8` SHA-256: `f1a96afe7503ac1817de769cd0ba13d9b80111c7034c50106b3344ecc14cc515` -来源:[`packages/llm/llm/src/types.ts:458`](../packages/llm/llm/src/types.ts) +来源:[`packages/llm/llm/src/types.ts:474`](../packages/llm/llm/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| | `type` | 必需 | `"usage"` | | `usage` | 必需 | [`TokenUsage`](#persistence-type-sha256-fec5442b9ee4122e10514d81d5a43648003fe8bfb7ee2ba50c0cdbdd383ed394) | - + ### `{ type: "user/message" }` -SHA-256: `2ace3b8059b82b6d9837fed518ca97a99eb37c4482df7aad0e71c85489428ade` +SHA-256: `2ae8594a998c9862a597129e782277bcc518266712a703b5c3161bdf552d13b8` | 属性 | 存在性 | 类型 | |---|---|---| -| `data` | 必需 | [`UserMessage`](#persistence-type-sha256-89b2edff547be1ecfcd78ef18aeeb3cf507fcbf7d6093ec04fb111ef4c8e8a97) | +| `data` | 必需 | [`UserMessage`](#persistence-type-sha256-2cf075b013d363ad743b5a4982900678d108499c721bc11255137402003db711) | | `ignorable` | 可选 | `true` | | `seq` | 必需 | `number` | | `sourceEventSeqs` | 可选 | [`number[]`](#persistence-type-sha256-5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78) | diff --git a/docs/persistence-changes/2026-09-30-job-session-lifecycle.i18n.yaml b/docs/persistence-changes/2026-09-30-job-session-lifecycle.i18n.yaml new file mode 100644 index 0000000000..c8b13e6c47 --- /dev/null +++ b/docs/persistence-changes/2026-09-30-job-session-lifecycle.i18n.yaml @@ -0,0 +1,28 @@ +# Bilingual-pair consistency record for 2026-09-30-job-session-lifecycle.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/persistence-changes/2026-09-30-job-session-lifecycle.md +/: + en: 3d5a424ae7d7e46b + zh: c22a8e4a62d8cc1a +/2026-09-30-job-session-lifecycle: + en: 9be8dc8cb5ce646a + zh: 6ce0040813c36e92 +/2026-09-30-job-session-lifecycle/summary: + en: 7d98020db3395e2e + zh: abe692ef42327e49 +/2026-09-30-job-session-lifecycle/table-of-contents: + en: 6cf314a069ceb451 + zh: 9adff678e9ecab3b +/2026-09-30-job-session-lifecycle/declaration: + en: 6907136f28e48c90 + zh: 02f8298485f3d8e7 +/2026-09-30-job-session-lifecycle/compatibility: + en: 546fe003ad5293b6 + zh: 86d78c9704a03718 +/2026-09-30-job-session-lifecycle/verification: + en: e9660a31a43378fc + zh: e97fbe31ff47ea2e +/2026-09-30-job-session-lifecycle/dev-note: + en: b75c8458928b72b9 + zh: 003ba4e17b4b5269 diff --git a/docs/persistence-changes/2026-09-30-job-session-lifecycle.md b/docs/persistence-changes/2026-09-30-job-session-lifecycle.md new file mode 100644 index 0000000000..d77ccc2c87 --- /dev/null +++ b/docs/persistence-changes/2026-09-30-job-session-lifecycle.md @@ -0,0 +1,52 @@ +--- +description: "Records a persistence type transition and its compatibility acknowledgement." +kind: persistence-change +--- + +# 2026-09-30-job-session-lifecycle + +English | [中文](2026-09-30-job-session-lifecycle.zh.md) + +## Summary + +Records owned job registration and settlement with one unique JobId, original timestamps, creating tool calls, and producer completion reports. + +## Table of Contents + +- [Declaration](#declaration) +- [Compatibility](#compatibility) +- [Verification](#verification) +- [Dev Note](#dev-note) + + +## Declaration + +```yaml persistence-change +schemaVersion: 1 +id: 2026-09-30-job-session-lifecycle +baseline: false +changes: + - root: "event:job/registered" + previous: null + after: "f5520c1d261ca86ea03735799c311a0a9ede8babb588cf0a65422576d2ee0ad1" + decision: same-version + - root: "event:job/settled" + previous: null + after: "edd37b1e4974af648633014435572089b1e8fb71910468a0767a7e28fdd22abf" + decision: same-version +``` + + +## Compatibility + +Adds required job event kinds without changing the Session envelope. New JobIds use a kind-prefixed UUID for both live controls and durable pairing; historical sessions receive no fabricated job history. Older builds reject unknown required events. producerReported describes completion-promise fulfillment, not verified resource release. + + +## Verification + +Registry and snapshot-normalizer tests cover causal order and unique JobIds. CLI and SDK snapshots verify registration, settlement, and notices during execution; the [job lifecycle contract](../subsystems/jobs.md#session-lifecycle-records) allows incomplete terminal history during application teardown. + + +## Dev Note + +None. diff --git a/docs/persistence-changes/2026-09-30-job-session-lifecycle.schema.json b/docs/persistence-changes/2026-09-30-job-session-lifecycle.schema.json new file mode 100644 index 0000000000..f31a0696c3 --- /dev/null +++ b/docs/persistence-changes/2026-09-30-job-session-lifecycle.schema.json @@ -0,0 +1,297 @@ +{ + "formatVersion": 2, + "roots": [ + { + "key": "event:job/registered", + "kind": "event", + "event": "job/registered", + "surface": false, + "digest": "f5520c1d261ca86ea03735799c311a0a9ede8babb588cf0a65422576d2ee0ad1", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "object", + "properties": [ + { + "name": "data", + "type": 1, + "optional": false + }, + { + "name": "ignorable", + "type": 5, + "optional": true + }, + { + "name": "seq", + "type": 3, + "optional": false + }, + { + "name": "time", + "type": 3, + "optional": false + }, + { + "name": "type", + "type": 6, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "jobId", + "type": 2, + "optional": false + }, + { + "name": "kind", + "type": 2, + "optional": false + }, + { + "name": "label", + "type": 2, + "optional": false + }, + { + "name": "owner", + "type": 2, + "optional": false + }, + { + "name": "startedAt", + "type": 3, + "optional": false + }, + { + "name": "toolCall", + "type": 4, + "optional": true + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "primitive", + "type": "number" + }, + { + "kind": "object", + "properties": [ + { + "name": "callId", + "type": 2, + "optional": false + }, + { + "name": "rootCallId", + "type": 2, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": true + }, + { + "kind": "literal", + "value": "job/registered" + } + ] + } + }, + { + "key": "event:job/settled", + "kind": "event", + "event": "job/settled", + "surface": false, + "digest": "edd37b1e4974af648633014435572089b1e8fb71910468a0767a7e28fdd22abf", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "object", + "properties": [ + { + "name": "data", + "type": 1, + "optional": false + }, + { + "name": "ignorable", + "type": 14, + "optional": true + }, + { + "name": "seq", + "type": 7, + "optional": false + }, + { + "name": "time", + "type": 7, + "optional": false + }, + { + "name": "type", + "type": 15, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "cause", + "type": 2, + "optional": false + }, + { + "name": "detail", + "type": 6, + "optional": true + }, + { + "name": "finishedAt", + "type": 7, + "optional": false + }, + { + "name": "jobId", + "type": 6, + "optional": false + }, + { + "name": "kind", + "type": 6, + "optional": false + }, + { + "name": "label", + "type": 6, + "optional": false + }, + { + "name": "owner", + "type": 6, + "optional": false + }, + { + "name": "producerReported", + "type": 8, + "optional": false + }, + { + "name": "startedAt", + "type": 7, + "optional": false + }, + { + "name": "status", + "type": 9, + "optional": false + }, + { + "name": "toolCall", + "type": 13, + "optional": true + } + ], + "indices": [] + }, + { + "kind": "union", + "types": [ + 3, + 4, + 5 + ] + }, + { + "kind": "literal", + "value": "producer" + }, + { + "kind": "literal", + "value": "teardown" + }, + { + "kind": "literal", + "value": "kill" + }, + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "primitive", + "type": "number" + }, + { + "kind": "primitive", + "type": "boolean" + }, + { + "kind": "union", + "types": [ + 10, + 11, + 12 + ] + }, + { + "kind": "literal", + "value": "completed" + }, + { + "kind": "literal", + "value": "failed" + }, + { + "kind": "literal", + "value": "killed" + }, + { + "kind": "object", + "properties": [ + { + "name": "callId", + "type": 6, + "optional": false + }, + { + "name": "rootCallId", + "type": 6, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": true + }, + { + "kind": "literal", + "value": "job/settled" + } + ] + } + } + ], + "types": [] +} diff --git a/docs/persistence-changes/2026-09-30-job-session-lifecycle.zh.md b/docs/persistence-changes/2026-09-30-job-session-lifecycle.zh.md new file mode 100644 index 0000000000..b808a0e1d6 --- /dev/null +++ b/docs/persistence-changes/2026-09-30-job-session-lifecycle.zh.md @@ -0,0 +1,52 @@ +--- +description: "记录持久化类型更改及其兼容性确认。" +kind: persistence-change +--- + +# 2026-09-30-job-session-lifecycle + +[English](2026-09-30-job-session-lifecycle.md) | 中文 + +## 概述 + +以唯一 JobId 将有所有者 job 的注册与结算记录为 Session 事件,包含原始时间戳、创建工具调用和生产方完成报告。 + +## 目录 + +- [声明](#declaration) +- [兼容性](#compatibility) +- [验证](#verification) +- [开发备注](#dev-note) + + +## 声明 + +```yaml persistence-change +schemaVersion: 1 +id: 2026-09-30-job-session-lifecycle +baseline: false +changes: + - root: "event:job/registered" + previous: null + after: "f5520c1d261ca86ea03735799c311a0a9ede8babb588cf0a65422576d2ee0ad1" + decision: same-version + - root: "event:job/settled" + previous: null + after: "edd37b1e4974af648633014435572089b1e8fb71910468a0767a7e28fdd22abf" + decision: same-version +``` + + +## 兼容性 + +增加必需的 job 事件类型,不改变 Session 封装。新 JobId 用带 kind 前缀的 UUID 同时进行运行期控制与持久化配对;不为历史会话补造 job 事件。旧构建拒绝未知的必需事件。producerReported 表示完成 promise 兑现,不确认资源已释放。 + + +## 验证 + +注册表和快照归一化测试覆盖因果顺序与唯一 JobId。CLI 和 SDK 快照验证运行中的注册、结算和通知;[job 生命周期约定](../subsystems/jobs.zh.md#session-lifecycle-records)允许应用销毁时留下不完整的终态历史。 + + +## 开发备注 + +无。 diff --git a/docs/persistence-changes/2026-10-13-user-rpc-principal.i18n.yaml b/docs/persistence-changes/2026-10-13-user-rpc-principal.i18n.yaml new file mode 100644 index 0000000000..13e1d9e422 --- /dev/null +++ b/docs/persistence-changes/2026-10-13-user-rpc-principal.i18n.yaml @@ -0,0 +1,28 @@ +# Bilingual-pair consistency record for 2026-10-13-user-rpc-principal.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/persistence-changes/2026-10-13-user-rpc-principal.md +/: + en: 3d5a424ae7d7e46b + zh: c22a8e4a62d8cc1a +/2026-10-13-user-rpc-principal: + en: c8b8c9fe1f1fb295 + zh: 6775ad8687023eb9 +/2026-10-13-user-rpc-principal/summary: + en: 9fd17b8ef1f569d2 + zh: d1e9679296ce47a0 +/2026-10-13-user-rpc-principal/table-of-contents: + en: 6cf314a069ceb451 + zh: 9adff678e9ecab3b +/2026-10-13-user-rpc-principal/declaration: + en: 6907136f28e48c90 + zh: 02f8298485f3d8e7 +/2026-10-13-user-rpc-principal/compatibility: + en: ae44b935893f7eaa + zh: 686893a2a812570d +/2026-10-13-user-rpc-principal/verification: + en: 53e8380b9c0c63a9 + zh: 3adac787e267570d +/2026-10-13-user-rpc-principal/dev-note: + en: b75c8458928b72b9 + zh: 003ba4e17b4b5269 diff --git a/docs/persistence-changes/2026-10-13-user-rpc-principal.md b/docs/persistence-changes/2026-10-13-user-rpc-principal.md new file mode 100644 index 0000000000..cccf5c6a32 --- /dev/null +++ b/docs/persistence-changes/2026-10-13-user-rpc-principal.md @@ -0,0 +1,60 @@ +--- +description: "Records a persistence type transition and its compatibility acknowledgement." +kind: persistence-change +--- + +# 2026-10-13-user-rpc-principal + +English | [中文](2026-10-13-user-rpc-principal.zh.md) + +## Summary + +Adds an optional principal to the user-rpc message source for prompts submitted by a caller other than the owner. + +## Table of Contents + +- [Declaration](#declaration) +- [Compatibility](#compatibility) +- [Verification](#verification) +- [Dev Note](#dev-note) + + +## Declaration + +```yaml persistence-change +schemaVersion: 1 +id: 2026-10-13-user-rpc-principal +baseline: false +changes: + - root: "event:agent/inbox/spliced" + previous: "2026-10-05-working-directory-attribution" + after: "0755b7d1128319ba2e389619cbbf2c961ad648882b4e91da7f3e9582cc8a2034" + decision: same-version + - root: "event:developer/message" + previous: "2026-10-05-working-directory-attribution" + after: "97e0162f983c27456ec71269fb4976b1851588b8c1aeac5d071d43e11f95e03c" + decision: same-version + - root: "event:session/title-llm-request" + previous: "2026-10-07-title-reasoning-effort" + after: "79a4c87f8bfb4d6d05cd9952b99210d6a7db3b478c4a01d94b4c50602308c056" + decision: same-version + - root: "event:user/message" + previous: "2026-10-05-working-directory-attribution" + after: "2ae8594a998c9862a597129e782277bcc518266712a703b5c3161bdf552d13b8" + decision: same-version +``` + + +## Compatibility + +Owner prompts omit the field, so their sources and every existing Session log are unchanged; an absent principal means the owner. A reader that predates the field ignores it and treats the message as an ordinary user message, because the kind stays 'user'. A current reader of an older log finds no principal and treats every message as the owner's. The field is not part of any model request: provider adapters read only the model-visible message content. SESSION_FORMAT_VERSION does not change. + + +## Verification + +pnpm exec vitest run packages/api/session-controller/tests/remote-access.host.spec.ts packages/subagent/subagent/tests/control.spec.ts packages/api/session-controller/tests/session-cold.host.spec.ts: non-owner prompts persist source.principal in agent/inbox/spliced, owner and existing time-zone sources stay byte-identical; all tests passed. + + +## Dev Note + +None. diff --git a/docs/persistence-changes/2026-10-13-user-rpc-principal.schema.json b/docs/persistence-changes/2026-10-13-user-rpc-principal.schema.json new file mode 100644 index 0000000000..82d7ab993e --- /dev/null +++ b/docs/persistence-changes/2026-10-13-user-rpc-principal.schema.json @@ -0,0 +1,11559 @@ +{ + "formatVersion": 2, + "roots": [ + { + "key": "event:agent/inbox/spliced", + "kind": "event", + "event": "agent/inbox/spliced", + "surface": false, + "digest": "0755b7d1128319ba2e389619cbbf2c961ad648882b4e91da7f3e9582cc8a2034", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "object", + "properties": [ + { + "name": "data", + "type": 1, + "optional": false + }, + { + "name": "ignorable", + "type": 27, + "optional": true + }, + { + "name": "seq", + "type": 20, + "optional": false + }, + { + "name": "time", + "type": 20, + "optional": false + }, + { + "name": "type", + "type": 206, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "inserted", + "type": 2, + "optional": false + }, + { + "name": "outcome", + "type": 202, + "optional": true + }, + { + "name": "removedCount", + "type": 20, + "optional": true + }, + { + "name": "start", + "type": 20, + "optional": false + }, + { + "name": "target", + "type": 203, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "array", + "element": 3 + }, + { + "kind": "object", + "properties": [ + { + "name": "content", + "type": 4, + "optional": false + }, + { + "name": "id", + "type": 7, + "optional": false + }, + { + "name": "role", + "type": 32, + "optional": false + }, + { + "name": "source", + "type": 33, + "optional": false, + "compatibility": { + "version": 1, + "policy": "session-source-attribution", + "binding": "session.user-message.source", + "discriminator": "kind", + "unknownKinds": "preserve", + "attributionKinds": [ + "tmux-context", + "user-question-reply", + "working-directory" + ] + } + } + ], + "indices": [] + }, + { + "kind": "array", + "element": 5 + }, + { + "kind": "union", + "types": [ + 6, + 9, + 11, + 14, + 16, + 18, + 29 + ] + }, + { + "kind": "object", + "properties": [ + { + "name": "text", + "type": 7, + "optional": false + }, + { + "name": "type", + "type": 8, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "literal", + "value": "reasoning" + }, + { + "kind": "object", + "properties": [ + { + "name": "text", + "type": 7, + "optional": false + }, + { + "name": "type", + "type": 10, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "text" + }, + { + "kind": "object", + "properties": [ + { + "name": "tool", + "type": 12, + "optional": true + }, + { + "name": "toolName", + "type": 7, + "optional": false + }, + { + "name": "type", + "type": 13, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "never" + }, + { + "kind": "literal", + "value": "tool-addition" + }, + { + "kind": "object", + "properties": [ + { + "name": "toolName", + "type": 7, + "optional": false + }, + { + "name": "type", + "type": 15, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "tool-removal" + }, + { + "kind": "object", + "properties": [ + { + "name": "arguments", + "type": 7, + "optional": false + }, + { + "name": "id", + "type": 7, + "optional": false + }, + { + "name": "name", + "type": 7, + "optional": false + }, + { + "name": "type", + "type": 17, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "tool-call" + }, + { + "kind": "object", + "properties": [ + { + "name": "attachment", + "type": 19, + "optional": false + }, + { + "name": "offloaded", + "type": 27, + "optional": true + }, + { + "name": "type", + "type": 28, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "attachmentId", + "type": 7, + "optional": false + }, + { + "name": "bytes", + "type": 20, + "optional": false + }, + { + "name": "height", + "type": 20, + "optional": false + }, + { + "name": "mediaType", + "type": 21, + "optional": false + }, + { + "name": "name", + "type": 7, + "optional": true + }, + { + "name": "originalDimensions", + "type": 26, + "optional": true + }, + { + "name": "width", + "type": 20, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "number" + }, + { + "kind": "union", + "types": [ + 22, + 23, + 24, + 25 + ] + }, + { + "kind": "literal", + "value": "image/gif" + }, + { + "kind": "literal", + "value": "image/jpeg" + }, + { + "kind": "literal", + "value": "image/png" + }, + { + "kind": "literal", + "value": "image/webp" + }, + { + "kind": "object", + "properties": [ + { + "name": "height", + "type": 20, + "optional": false + }, + { + "name": "width", + "type": 20, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": true + }, + { + "kind": "literal", + "value": "image" + }, + { + "kind": "object", + "properties": [ + { + "name": "attachment", + "type": 30, + "optional": false + }, + { + "name": "type", + "type": 31, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "attachmentId", + "type": 7, + "optional": false + }, + { + "name": "bytes", + "type": 20, + "optional": false + }, + { + "name": "name", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "file" + }, + { + "kind": "literal", + "value": "user" + }, + { + "kind": "union", + "types": [ + 34, + 39, + 41, + 43, + 45, + 48, + 51, + 53, + 55, + 57, + 59, + 61, + 63, + 65, + 67, + 69, + 70, + 71, + 72, + 73, + 75, + 76, + 77, + 78, + 79, + 80, + 81, + 82, + 83, + 84, + 85, + 86, + 87, + 89, + 90, + 91, + 92, + 93, + 94, + 95, + 96, + 97, + 98, + 99, + 100, + 101, + 102, + 103, + 104, + 105, + 106, + 107, + 108, + 109, + 110, + 111, + 112, + 113, + 114, + 116, + 117, + 118, + 119, + 120, + 121, + 122, + 123, + 124, + 125, + 126, + 127, + 128, + 130, + 132, + 135, + 136, + 137, + 138, + 139, + 141, + 142, + 143, + 144, + 146, + 147, + 148, + 149, + 150, + 152, + 153, + 154, + 156, + 158, + 159, + 167, + 170, + 172, + 175, + 177, + 179, + 183, + 185, + 193, + 194, + 195, + 196, + 197, + 198, + 199, + 200, + 201 + ] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 35, + "optional": false + }, + { + "name": "kind", + "type": 36, + "optional": false + }, + { + "name": "sections", + "type": 37, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "snapshot" + }, + { + "kind": "literal", + "value": "tool-goal" + }, + { + "kind": "array", + "element": 38 + }, + { + "kind": "object", + "properties": [ + { + "name": "name", + "type": 7, + "optional": false + }, + { + "name": "text", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 35, + "optional": false + }, + { + "name": "kind", + "type": 40, + "optional": false + }, + { + "name": "sections", + "type": 37, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "tool-jobs" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 35, + "optional": false + }, + { + "name": "kind", + "type": 42, + "optional": false + }, + { + "name": "sections", + "type": 37, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "user-approval" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 35, + "optional": false + }, + { + "name": "kind", + "type": 44, + "optional": false + }, + { + "name": "sections", + "type": 37, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "dsh-session-title-llm" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 46, + "optional": false + }, + { + "name": "kind", + "type": 47, + "optional": false + }, + { + "name": "senderSessionId", + "type": 7, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "notice" + }, + { + "kind": "literal", + "value": "subagent-settled" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 49, + "optional": false + }, + { + "name": "kind", + "type": 50, + "optional": false + }, + { + "name": "senderSessionId", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "relay" + }, + { + "kind": "literal", + "value": "agent-message" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 46, + "optional": false + }, + { + "name": "kind", + "type": 52, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "hooks-claude-code" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 46, + "optional": false + }, + { + "name": "kind", + "type": 54, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "hooks-codex" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 46, + "optional": false + }, + { + "name": "kind", + "type": 56, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "model-selection" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 46, + "optional": false + }, + { + "name": "kind", + "type": 58, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "plan-mode" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 46, + "optional": false + }, + { + "name": "kind", + "type": 60, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "repeat-tool-reminder" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 46, + "optional": false + }, + { + "name": "kind", + "type": 62, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "runtime-context" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 46, + "optional": false + }, + { + "name": "kind", + "type": 64, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "schedule" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 46, + "optional": false + }, + { + "name": "kind", + "type": 66, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "time-context" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 46, + "optional": false + }, + { + "name": "kind", + "type": 68, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "tmux-context" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 46, + "optional": false + }, + { + "name": "kind", + "type": 36, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 46, + "optional": false + }, + { + "name": "kind", + "type": 40, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 46, + "optional": false + }, + { + "name": "kind", + "type": 42, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 46, + "optional": false + }, + { + "name": "kind", + "type": 44, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 74, + "optional": false + }, + { + "name": "kind", + "type": 52, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "instructions" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 74, + "optional": false + }, + { + "name": "kind", + "type": 54, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 74, + "optional": false + }, + { + "name": "kind", + "type": 56, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 74, + "optional": false + }, + { + "name": "kind", + "type": 58, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 74, + "optional": false + }, + { + "name": "kind", + "type": 60, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 74, + "optional": false + }, + { + "name": "kind", + "type": 62, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 74, + "optional": false + }, + { + "name": "kind", + "type": 64, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 74, + "optional": false + }, + { + "name": "kind", + "type": 66, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 74, + "optional": false + }, + { + "name": "kind", + "type": 68, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 74, + "optional": false + }, + { + "name": "kind", + "type": 36, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 74, + "optional": false + }, + { + "name": "kind", + "type": 40, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 74, + "optional": false + }, + { + "name": "kind", + "type": 42, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 74, + "optional": false + }, + { + "name": "kind", + "type": 44, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 88, + "optional": false + }, + { + "name": "kind", + "type": 52, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "recall" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 88, + "optional": false + }, + { + "name": "kind", + "type": 54, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 88, + "optional": false + }, + { + "name": "kind", + "type": 56, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 88, + "optional": false + }, + { + "name": "kind", + "type": 58, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 88, + "optional": false + }, + { + "name": "kind", + "type": 60, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 88, + "optional": false + }, + { + "name": "kind", + "type": 62, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 88, + "optional": false + }, + { + "name": "kind", + "type": 64, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 88, + "optional": false + }, + { + "name": "kind", + "type": 66, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 88, + "optional": false + }, + { + "name": "kind", + "type": 68, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 88, + "optional": false + }, + { + "name": "kind", + "type": 36, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 88, + "optional": false + }, + { + "name": "kind", + "type": 40, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 88, + "optional": false + }, + { + "name": "kind", + "type": 42, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 88, + "optional": false + }, + { + "name": "kind", + "type": 44, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 49, + "optional": false + }, + { + "name": "kind", + "type": 52, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 49, + "optional": false + }, + { + "name": "kind", + "type": 54, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 49, + "optional": false + }, + { + "name": "kind", + "type": 56, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 49, + "optional": false + }, + { + "name": "kind", + "type": 58, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 49, + "optional": false + }, + { + "name": "kind", + "type": 60, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 49, + "optional": false + }, + { + "name": "kind", + "type": 62, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 49, + "optional": false + }, + { + "name": "kind", + "type": 64, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 49, + "optional": false + }, + { + "name": "kind", + "type": 66, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 49, + "optional": false + }, + { + "name": "kind", + "type": 68, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 49, + "optional": false + }, + { + "name": "kind", + "type": 36, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 49, + "optional": false + }, + { + "name": "kind", + "type": 40, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 49, + "optional": false + }, + { + "name": "kind", + "type": 42, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 49, + "optional": false + }, + { + "name": "kind", + "type": 44, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 115, + "optional": false + }, + { + "name": "kind", + "type": 52, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "catalog" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 115, + "optional": false + }, + { + "name": "kind", + "type": 54, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 115, + "optional": false + }, + { + "name": "kind", + "type": 56, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 115, + "optional": false + }, + { + "name": "kind", + "type": 58, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 115, + "optional": false + }, + { + "name": "kind", + "type": 60, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 115, + "optional": false + }, + { + "name": "kind", + "type": 62, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 115, + "optional": false + }, + { + "name": "kind", + "type": 64, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 115, + "optional": false + }, + { + "name": "kind", + "type": 66, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 115, + "optional": false + }, + { + "name": "kind", + "type": 68, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 115, + "optional": false + }, + { + "name": "kind", + "type": 36, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 115, + "optional": false + }, + { + "name": "kind", + "type": 40, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 115, + "optional": false + }, + { + "name": "kind", + "type": 42, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 115, + "optional": false + }, + { + "name": "kind", + "type": 44, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "goalId", + "type": 7, + "optional": false + }, + { + "name": "kind", + "type": 129, + "optional": false + }, + { + "name": "revision", + "type": 20, + "optional": false + }, + { + "name": "round", + "type": 20, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "goal" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 131, + "optional": false + }, + { + "name": "messageId", + "type": 7, + "optional": false + }, + { + "name": "senderId", + "type": 7, + "optional": false + }, + { + "name": "senderName", + "type": 7, + "optional": false + }, + { + "name": "teamId", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "team-message" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 133, + "optional": false + }, + { + "name": "model", + "type": 7, + "optional": false + }, + { + "name": "provider", + "type": 7, + "optional": false + }, + { + "name": "replayState", + "type": 134, + "optional": true + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "model" + }, + { + "kind": "opaque", + "reason": "unknown" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 52, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 54, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 56, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 58, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 140, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "ptc-mode" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 60, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 62, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 64, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 145, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "system-prompt" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 66, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 68, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 36, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 40, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 151, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "tool-registry" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 32, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 42, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 155, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "working-directory" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 157, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "cordis-host-runner" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 44, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "baseline", + "type": 27, + "optional": true + }, + { + "name": "baselineIdentity", + "type": 7, + "optional": true + }, + { + "name": "changes", + "type": 160, + "optional": false + }, + { + "name": "form", + "type": 74, + "optional": false + }, + { + "name": "kind", + "type": 166, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "array", + "element": 161 + }, + { + "kind": "object", + "properties": [ + { + "name": "action", + "type": 162, + "optional": false + }, + { + "name": "digest", + "type": 7, + "optional": true + }, + { + "name": "path", + "type": 7, + "optional": false + }, + { + "name": "scope", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "union", + "types": [ + 163, + 164, + 165 + ] + }, + { + "kind": "literal", + "value": "remove" + }, + { + "kind": "literal", + "value": "replace" + }, + { + "kind": "literal", + "value": "set" + }, + { + "kind": "literal", + "value": "agent-instructions" + }, + { + "kind": "object", + "properties": [ + { + "name": "callId", + "type": 7, + "optional": false + }, + { + "name": "kind", + "type": 168, + "optional": false + }, + { + "name": "outcome", + "type": 169, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "user-question-reply" + }, + { + "kind": "literal", + "value": "answered" + }, + { + "kind": "object", + "properties": [ + { + "name": "callId", + "type": 7, + "optional": false + }, + { + "name": "kind", + "type": 171, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "tool" + }, + { + "kind": "object", + "properties": [ + { + "name": "clientTimeZone", + "type": 7, + "optional": true + }, + { + "name": "kind", + "type": 32, + "optional": false + }, + { + "name": "principal", + "type": 173, + "optional": true + }, + { + "name": "rpcId", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 174, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "owner" + }, + { + "kind": "object", + "properties": [ + { + "name": "compactionId", + "type": 7, + "optional": false + }, + { + "name": "kind", + "type": 176, + "optional": false + }, + { + "name": "sourceCommandId", + "type": 7, + "optional": true + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "compact-checkpoint" + }, + { + "kind": "object", + "properties": [ + { + "name": "deliveryId", + "type": 7, + "optional": false + }, + { + "name": "form", + "type": 46, + "optional": false + }, + { + "name": "kind", + "type": 178, + "optional": false + }, + { + "name": "provider", + "type": 7, + "optional": false + }, + { + "name": "ruleId", + "type": 7, + "optional": false + }, + { + "name": "source", + "type": 7, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "webhook" + }, + { + "kind": "object", + "properties": [ + { + "name": "entries", + "type": 180, + "optional": false + }, + { + "name": "form", + "type": 115, + "optional": false + }, + { + "name": "kind", + "type": 182, + "optional": false + }, + { + "name": "update", + "type": 27, + "optional": true + } + ], + "indices": [] + }, + { + "kind": "array", + "element": 181 + }, + { + "kind": "object", + "properties": [ + { + "name": "description", + "type": 7, + "optional": false + }, + { + "name": "name", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "skill-catalog" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 74, + "optional": false + }, + { + "name": "kind", + "type": 184, + "optional": false + }, + { + "name": "name", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "skill-invocation" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 88, + "optional": false + }, + { + "name": "kind", + "type": 186, + "optional": false + }, + { + "name": "references", + "type": 187, + "optional": false + }, + { + "name": "version", + "type": 192, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "session-reference" + }, + { + "kind": "array", + "element": 188 + }, + { + "kind": "object", + "properties": [ + { + "name": "capturedFormatVersion", + "type": 20, + "optional": true + }, + { + "name": "capturedThroughSeq", + "type": 189, + "optional": false + }, + { + "name": "compacted", + "type": 191, + "optional": false + }, + { + "name": "inputIndex", + "type": 20, + "optional": false + }, + { + "name": "label", + "type": 7, + "optional": false + }, + { + "name": "omittedBytes", + "type": 20, + "optional": false + }, + { + "name": "omittedMessages", + "type": 20, + "optional": false + }, + { + "name": "originalMessages", + "type": 20, + "optional": false + }, + { + "name": "retainedMessages", + "type": 20, + "optional": false + }, + { + "name": "sessionId", + "type": 7, + "optional": false + }, + { + "name": "truncated", + "type": 191, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "union", + "types": [ + 20, + 190 + ] + }, + { + "kind": "primitive", + "type": "null" + }, + { + "kind": "primitive", + "type": "boolean" + }, + { + "kind": "literal", + "value": 1 + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 35, + "optional": false + }, + { + "name": "kind", + "type": 52, + "optional": false + }, + { + "name": "sections", + "type": 37, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 35, + "optional": false + }, + { + "name": "kind", + "type": 54, + "optional": false + }, + { + "name": "sections", + "type": 37, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 35, + "optional": false + }, + { + "name": "kind", + "type": 56, + "optional": false + }, + { + "name": "sections", + "type": 37, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 35, + "optional": false + }, + { + "name": "kind", + "type": 58, + "optional": false + }, + { + "name": "sections", + "type": 37, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 35, + "optional": false + }, + { + "name": "kind", + "type": 60, + "optional": false + }, + { + "name": "sections", + "type": 37, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 35, + "optional": false + }, + { + "name": "kind", + "type": 62, + "optional": false + }, + { + "name": "sections", + "type": 37, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 35, + "optional": false + }, + { + "name": "kind", + "type": 64, + "optional": false + }, + { + "name": "sections", + "type": 37, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 35, + "optional": false + }, + { + "name": "kind", + "type": 66, + "optional": false + }, + { + "name": "sections", + "type": 37, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 35, + "optional": false + }, + { + "name": "kind", + "type": 68, + "optional": false + }, + { + "name": "sections", + "type": 37, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "canceled" + }, + { + "kind": "union", + "types": [ + 204, + 205 + ] + }, + { + "kind": "literal", + "value": "next-step" + }, + { + "kind": "literal", + "value": "next-turn" + }, + { + "kind": "literal", + "value": "agent/inbox/spliced" + } + ] + } + }, + { + "key": "event:developer/message", + "kind": "event", + "event": "developer/message", + "surface": true, + "digest": "97e0162f983c27456ec71269fb4976b1851588b8c1aeac5d071d43e11f95e03c", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "object", + "properties": [ + { + "name": "data", + "type": 1, + "optional": false + }, + { + "name": "ignorable", + "type": 26, + "optional": true + }, + { + "name": "seq", + "type": 2, + "optional": false + }, + { + "name": "sourceEventSeqs", + "type": 202, + "optional": true + }, + { + "name": "surfaceOp", + "type": 203, + "optional": false + }, + { + "name": "time", + "type": 2, + "optional": false + }, + { + "name": "type", + "type": 206, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "headerSeq", + "type": 2, + "optional": true + }, + { + "name": "message", + "type": 3, + "optional": false + }, + { + "name": "step", + "type": 2, + "optional": false + }, + { + "name": "turn", + "type": 2, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "number" + }, + { + "kind": "object", + "properties": [ + { + "name": "content", + "type": 4, + "optional": false + }, + { + "name": "id", + "type": 7, + "optional": false + }, + { + "name": "role", + "type": 31, + "optional": false + }, + { + "name": "source", + "type": 32, + "optional": false, + "compatibility": { + "version": 1, + "policy": "session-source-attribution", + "binding": "session.developer-message.source", + "discriminator": "kind", + "unknownKinds": "preserve", + "attributionKinds": [ + "tmux-context", + "user-question-reply", + "working-directory" + ] + } + } + ], + "indices": [] + }, + { + "kind": "array", + "element": 5 + }, + { + "kind": "union", + "types": [ + 6, + 9, + 11, + 14, + 16, + 18, + 28 + ] + }, + { + "kind": "object", + "properties": [ + { + "name": "text", + "type": 7, + "optional": false + }, + { + "name": "type", + "type": 8, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "literal", + "value": "reasoning" + }, + { + "kind": "object", + "properties": [ + { + "name": "text", + "type": 7, + "optional": false + }, + { + "name": "type", + "type": 10, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "text" + }, + { + "kind": "object", + "properties": [ + { + "name": "tool", + "type": 12, + "optional": true + }, + { + "name": "toolName", + "type": 7, + "optional": false + }, + { + "name": "type", + "type": 13, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "never" + }, + { + "kind": "literal", + "value": "tool-addition" + }, + { + "kind": "object", + "properties": [ + { + "name": "toolName", + "type": 7, + "optional": false + }, + { + "name": "type", + "type": 15, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "tool-removal" + }, + { + "kind": "object", + "properties": [ + { + "name": "arguments", + "type": 7, + "optional": false + }, + { + "name": "id", + "type": 7, + "optional": false + }, + { + "name": "name", + "type": 7, + "optional": false + }, + { + "name": "type", + "type": 17, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "tool-call" + }, + { + "kind": "object", + "properties": [ + { + "name": "attachment", + "type": 19, + "optional": false + }, + { + "name": "offloaded", + "type": 26, + "optional": true + }, + { + "name": "type", + "type": 27, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "attachmentId", + "type": 7, + "optional": false + }, + { + "name": "bytes", + "type": 2, + "optional": false + }, + { + "name": "height", + "type": 2, + "optional": false + }, + { + "name": "mediaType", + "type": 20, + "optional": false + }, + { + "name": "name", + "type": 7, + "optional": true + }, + { + "name": "originalDimensions", + "type": 25, + "optional": true + }, + { + "name": "width", + "type": 2, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "union", + "types": [ + 21, + 22, + 23, + 24 + ] + }, + { + "kind": "literal", + "value": "image/gif" + }, + { + "kind": "literal", + "value": "image/jpeg" + }, + { + "kind": "literal", + "value": "image/png" + }, + { + "kind": "literal", + "value": "image/webp" + }, + { + "kind": "object", + "properties": [ + { + "name": "height", + "type": 2, + "optional": false + }, + { + "name": "width", + "type": 2, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": true + }, + { + "kind": "literal", + "value": "image" + }, + { + "kind": "object", + "properties": [ + { + "name": "attachment", + "type": 29, + "optional": false + }, + { + "name": "type", + "type": 30, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "attachmentId", + "type": 7, + "optional": false + }, + { + "name": "bytes", + "type": 2, + "optional": false + }, + { + "name": "name", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "file" + }, + { + "kind": "literal", + "value": "developer" + }, + { + "kind": "union", + "types": [ + 33, + 38, + 40, + 42, + 44, + 47, + 50, + 52, + 54, + 56, + 58, + 60, + 62, + 64, + 66, + 68, + 69, + 70, + 71, + 72, + 74, + 75, + 76, + 77, + 78, + 79, + 80, + 81, + 82, + 83, + 84, + 85, + 86, + 88, + 89, + 90, + 91, + 92, + 93, + 94, + 95, + 96, + 97, + 98, + 99, + 100, + 101, + 102, + 103, + 104, + 105, + 106, + 107, + 108, + 109, + 110, + 111, + 112, + 113, + 115, + 116, + 117, + 118, + 119, + 120, + 121, + 122, + 123, + 124, + 125, + 126, + 127, + 129, + 131, + 134, + 135, + 136, + 137, + 138, + 139, + 141, + 142, + 143, + 144, + 146, + 147, + 148, + 149, + 150, + 152, + 154, + 155, + 157, + 159, + 167, + 170, + 172, + 175, + 177, + 179, + 183, + 185, + 193, + 194, + 195, + 196, + 197, + 198, + 199, + 200, + 201 + ] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 34, + "optional": false + }, + { + "name": "kind", + "type": 35, + "optional": false + }, + { + "name": "sections", + "type": 36, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "snapshot" + }, + { + "kind": "literal", + "value": "tmux-context" + }, + { + "kind": "array", + "element": 37 + }, + { + "kind": "object", + "properties": [ + { + "name": "name", + "type": 7, + "optional": false + }, + { + "name": "text", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 34, + "optional": false + }, + { + "name": "kind", + "type": 39, + "optional": false + }, + { + "name": "sections", + "type": 36, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "tool-goal" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 34, + "optional": false + }, + { + "name": "kind", + "type": 41, + "optional": false + }, + { + "name": "sections", + "type": 36, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "tool-jobs" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 34, + "optional": false + }, + { + "name": "kind", + "type": 43, + "optional": false + }, + { + "name": "sections", + "type": 36, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "user-approval" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 45, + "optional": false + }, + { + "name": "kind", + "type": 46, + "optional": false + }, + { + "name": "senderSessionId", + "type": 7, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "notice" + }, + { + "kind": "literal", + "value": "subagent-settled" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 48, + "optional": false + }, + { + "name": "kind", + "type": 49, + "optional": false + }, + { + "name": "senderSessionId", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "relay" + }, + { + "kind": "literal", + "value": "agent-message" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 45, + "optional": false + }, + { + "name": "kind", + "type": 51, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "dsh-session-title-llm" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 45, + "optional": false + }, + { + "name": "kind", + "type": 53, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "hooks-claude-code" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 45, + "optional": false + }, + { + "name": "kind", + "type": 55, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "hooks-codex" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 45, + "optional": false + }, + { + "name": "kind", + "type": 57, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "model-selection" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 45, + "optional": false + }, + { + "name": "kind", + "type": 59, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "plan-mode" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 45, + "optional": false + }, + { + "name": "kind", + "type": 61, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "repeat-tool-reminder" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 45, + "optional": false + }, + { + "name": "kind", + "type": 63, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "runtime-context" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 45, + "optional": false + }, + { + "name": "kind", + "type": 65, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "schedule" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 45, + "optional": false + }, + { + "name": "kind", + "type": 67, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "time-context" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 45, + "optional": false + }, + { + "name": "kind", + "type": 35, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 45, + "optional": false + }, + { + "name": "kind", + "type": 39, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 45, + "optional": false + }, + { + "name": "kind", + "type": 41, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 45, + "optional": false + }, + { + "name": "kind", + "type": 43, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 73, + "optional": false + }, + { + "name": "kind", + "type": 51, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "instructions" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 73, + "optional": false + }, + { + "name": "kind", + "type": 53, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 73, + "optional": false + }, + { + "name": "kind", + "type": 55, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 73, + "optional": false + }, + { + "name": "kind", + "type": 57, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 73, + "optional": false + }, + { + "name": "kind", + "type": 59, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 73, + "optional": false + }, + { + "name": "kind", + "type": 61, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 73, + "optional": false + }, + { + "name": "kind", + "type": 63, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 73, + "optional": false + }, + { + "name": "kind", + "type": 65, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 73, + "optional": false + }, + { + "name": "kind", + "type": 67, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 73, + "optional": false + }, + { + "name": "kind", + "type": 35, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 73, + "optional": false + }, + { + "name": "kind", + "type": 39, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 73, + "optional": false + }, + { + "name": "kind", + "type": 41, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 73, + "optional": false + }, + { + "name": "kind", + "type": 43, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 87, + "optional": false + }, + { + "name": "kind", + "type": 51, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "recall" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 87, + "optional": false + }, + { + "name": "kind", + "type": 53, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 87, + "optional": false + }, + { + "name": "kind", + "type": 55, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 87, + "optional": false + }, + { + "name": "kind", + "type": 57, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 87, + "optional": false + }, + { + "name": "kind", + "type": 59, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 87, + "optional": false + }, + { + "name": "kind", + "type": 61, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 87, + "optional": false + }, + { + "name": "kind", + "type": 63, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 87, + "optional": false + }, + { + "name": "kind", + "type": 65, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 87, + "optional": false + }, + { + "name": "kind", + "type": 67, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 87, + "optional": false + }, + { + "name": "kind", + "type": 35, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 87, + "optional": false + }, + { + "name": "kind", + "type": 39, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 87, + "optional": false + }, + { + "name": "kind", + "type": 41, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 87, + "optional": false + }, + { + "name": "kind", + "type": 43, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 48, + "optional": false + }, + { + "name": "kind", + "type": 51, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 48, + "optional": false + }, + { + "name": "kind", + "type": 53, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 48, + "optional": false + }, + { + "name": "kind", + "type": 55, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 48, + "optional": false + }, + { + "name": "kind", + "type": 57, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 48, + "optional": false + }, + { + "name": "kind", + "type": 59, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 48, + "optional": false + }, + { + "name": "kind", + "type": 61, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 48, + "optional": false + }, + { + "name": "kind", + "type": 63, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 48, + "optional": false + }, + { + "name": "kind", + "type": 65, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 48, + "optional": false + }, + { + "name": "kind", + "type": 67, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 48, + "optional": false + }, + { + "name": "kind", + "type": 35, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 48, + "optional": false + }, + { + "name": "kind", + "type": 39, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 48, + "optional": false + }, + { + "name": "kind", + "type": 41, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 48, + "optional": false + }, + { + "name": "kind", + "type": 43, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 114, + "optional": false + }, + { + "name": "kind", + "type": 51, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "catalog" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 114, + "optional": false + }, + { + "name": "kind", + "type": 53, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 114, + "optional": false + }, + { + "name": "kind", + "type": 55, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 114, + "optional": false + }, + { + "name": "kind", + "type": 57, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 114, + "optional": false + }, + { + "name": "kind", + "type": 59, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 114, + "optional": false + }, + { + "name": "kind", + "type": 61, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 114, + "optional": false + }, + { + "name": "kind", + "type": 63, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 114, + "optional": false + }, + { + "name": "kind", + "type": 65, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 114, + "optional": false + }, + { + "name": "kind", + "type": 67, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 114, + "optional": false + }, + { + "name": "kind", + "type": 35, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 114, + "optional": false + }, + { + "name": "kind", + "type": 39, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 114, + "optional": false + }, + { + "name": "kind", + "type": 41, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 114, + "optional": false + }, + { + "name": "kind", + "type": 43, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "goalId", + "type": 7, + "optional": false + }, + { + "name": "kind", + "type": 128, + "optional": false + }, + { + "name": "revision", + "type": 2, + "optional": false + }, + { + "name": "round", + "type": 2, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "goal" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 130, + "optional": false + }, + { + "name": "messageId", + "type": 7, + "optional": false + }, + { + "name": "senderId", + "type": 7, + "optional": false + }, + { + "name": "senderName", + "type": 7, + "optional": false + }, + { + "name": "teamId", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "team-message" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 132, + "optional": false + }, + { + "name": "model", + "type": 7, + "optional": false + }, + { + "name": "provider", + "type": 7, + "optional": false + }, + { + "name": "replayState", + "type": 133, + "optional": true + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "model" + }, + { + "kind": "opaque", + "reason": "unknown" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 51, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 53, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 55, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 57, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 59, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 140, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "ptc-mode" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 61, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 63, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 65, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 145, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "system-prompt" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 67, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 35, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 39, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 41, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 151, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "tool-registry" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 153, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "user" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 43, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 156, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "working-directory" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 158, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "cordis-host-runner" + }, + { + "kind": "object", + "properties": [ + { + "name": "baseline", + "type": 26, + "optional": true + }, + { + "name": "baselineIdentity", + "type": 7, + "optional": true + }, + { + "name": "changes", + "type": 160, + "optional": false + }, + { + "name": "form", + "type": 73, + "optional": false + }, + { + "name": "kind", + "type": 166, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "array", + "element": 161 + }, + { + "kind": "object", + "properties": [ + { + "name": "action", + "type": 162, + "optional": false + }, + { + "name": "digest", + "type": 7, + "optional": true + }, + { + "name": "path", + "type": 7, + "optional": false + }, + { + "name": "scope", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "union", + "types": [ + 163, + 164, + 165 + ] + }, + { + "kind": "literal", + "value": "remove" + }, + { + "kind": "literal", + "value": "replace" + }, + { + "kind": "literal", + "value": "set" + }, + { + "kind": "literal", + "value": "agent-instructions" + }, + { + "kind": "object", + "properties": [ + { + "name": "callId", + "type": 7, + "optional": false + }, + { + "name": "kind", + "type": 168, + "optional": false + }, + { + "name": "outcome", + "type": 169, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "user-question-reply" + }, + { + "kind": "literal", + "value": "answered" + }, + { + "kind": "object", + "properties": [ + { + "name": "callId", + "type": 7, + "optional": false + }, + { + "name": "kind", + "type": 171, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "tool" + }, + { + "kind": "object", + "properties": [ + { + "name": "clientTimeZone", + "type": 7, + "optional": true + }, + { + "name": "kind", + "type": 153, + "optional": false + }, + { + "name": "principal", + "type": 173, + "optional": true + }, + { + "name": "rpcId", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 174, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "owner" + }, + { + "kind": "object", + "properties": [ + { + "name": "compactionId", + "type": 7, + "optional": false + }, + { + "name": "kind", + "type": 176, + "optional": false + }, + { + "name": "sourceCommandId", + "type": 7, + "optional": true + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "compact-checkpoint" + }, + { + "kind": "object", + "properties": [ + { + "name": "deliveryId", + "type": 7, + "optional": false + }, + { + "name": "form", + "type": 45, + "optional": false + }, + { + "name": "kind", + "type": 178, + "optional": false + }, + { + "name": "provider", + "type": 7, + "optional": false + }, + { + "name": "ruleId", + "type": 7, + "optional": false + }, + { + "name": "source", + "type": 7, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "webhook" + }, + { + "kind": "object", + "properties": [ + { + "name": "entries", + "type": 180, + "optional": false + }, + { + "name": "form", + "type": 114, + "optional": false + }, + { + "name": "kind", + "type": 182, + "optional": false + }, + { + "name": "update", + "type": 26, + "optional": true + } + ], + "indices": [] + }, + { + "kind": "array", + "element": 181 + }, + { + "kind": "object", + "properties": [ + { + "name": "description", + "type": 7, + "optional": false + }, + { + "name": "name", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "skill-catalog" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 73, + "optional": false + }, + { + "name": "kind", + "type": 184, + "optional": false + }, + { + "name": "name", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "skill-invocation" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 87, + "optional": false + }, + { + "name": "kind", + "type": 186, + "optional": false + }, + { + "name": "references", + "type": 187, + "optional": false + }, + { + "name": "version", + "type": 192, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "session-reference" + }, + { + "kind": "array", + "element": 188 + }, + { + "kind": "object", + "properties": [ + { + "name": "capturedFormatVersion", + "type": 2, + "optional": true + }, + { + "name": "capturedThroughSeq", + "type": 189, + "optional": false + }, + { + "name": "compacted", + "type": 191, + "optional": false + }, + { + "name": "inputIndex", + "type": 2, + "optional": false + }, + { + "name": "label", + "type": 7, + "optional": false + }, + { + "name": "omittedBytes", + "type": 2, + "optional": false + }, + { + "name": "omittedMessages", + "type": 2, + "optional": false + }, + { + "name": "originalMessages", + "type": 2, + "optional": false + }, + { + "name": "retainedMessages", + "type": 2, + "optional": false + }, + { + "name": "sessionId", + "type": 7, + "optional": false + }, + { + "name": "truncated", + "type": 191, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "union", + "types": [ + 2, + 190 + ] + }, + { + "kind": "primitive", + "type": "null" + }, + { + "kind": "primitive", + "type": "boolean" + }, + { + "kind": "literal", + "value": 1 + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 34, + "optional": false + }, + { + "name": "kind", + "type": 51, + "optional": false + }, + { + "name": "sections", + "type": 36, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 34, + "optional": false + }, + { + "name": "kind", + "type": 53, + "optional": false + }, + { + "name": "sections", + "type": 36, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 34, + "optional": false + }, + { + "name": "kind", + "type": 55, + "optional": false + }, + { + "name": "sections", + "type": 36, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 34, + "optional": false + }, + { + "name": "kind", + "type": 57, + "optional": false + }, + { + "name": "sections", + "type": 36, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 34, + "optional": false + }, + { + "name": "kind", + "type": 59, + "optional": false + }, + { + "name": "sections", + "type": 36, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 34, + "optional": false + }, + { + "name": "kind", + "type": 61, + "optional": false + }, + { + "name": "sections", + "type": 36, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 34, + "optional": false + }, + { + "name": "kind", + "type": 63, + "optional": false + }, + { + "name": "sections", + "type": 36, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 34, + "optional": false + }, + { + "name": "kind", + "type": 65, + "optional": false + }, + { + "name": "sections", + "type": 36, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 34, + "optional": false + }, + { + "name": "kind", + "type": 67, + "optional": false + }, + { + "name": "sections", + "type": 36, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "array", + "element": 2 + }, + { + "kind": "union", + "types": [ + 204, + 205 + ] + }, + { + "kind": "literal", + "value": "append" + }, + { + "kind": "object", + "properties": [ + { + "name": "endSeq", + "type": 2, + "optional": false + }, + { + "name": "op", + "type": 164, + "optional": false + }, + { + "name": "startSeq", + "type": 2, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "developer/message" + } + ] + } + }, + { + "key": "event:session/title-llm-request", + "kind": "event", + "event": "session/title-llm-request", + "surface": false, + "digest": "79a4c87f8bfb4d6d05cd9952b99210d6a7db3b478c4a01d94b4c50602308c056", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "object", + "properties": [ + { + "name": "data", + "type": 1, + "optional": false + }, + { + "name": "ignorable", + "type": 29, + "optional": true + }, + { + "name": "seq", + "type": 2, + "optional": false + }, + { + "name": "time", + "type": 2, + "optional": false + }, + { + "name": "type", + "type": 212, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "maxTokens", + "type": 2, + "optional": false + }, + { + "name": "messageSeqs", + "type": 3, + "optional": false + }, + { + "name": "messages", + "type": 4, + "optional": false + }, + { + "name": "reasoningEffort", + "type": 10, + "optional": true + }, + { + "name": "route", + "type": 211, + "optional": false + }, + { + "name": "system", + "type": 10, + "optional": false + }, + { + "name": "titleProvider", + "type": 10, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "number" + }, + { + "kind": "array", + "element": 2 + }, + { + "kind": "array", + "element": 5 + }, + { + "kind": "union", + "types": [ + 6, + 37, + 206, + 207, + 209 + ] + }, + { + "kind": "object", + "properties": [ + { + "name": "content", + "type": 7, + "optional": false + }, + { + "name": "id", + "type": 10, + "optional": false + }, + { + "name": "isError", + "type": 34, + "optional": true + }, + { + "name": "role", + "type": 35, + "optional": false + }, + { + "name": "source", + "type": 36, + "optional": false + }, + { + "name": "toolCallId", + "type": 10, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "array", + "element": 8 + }, + { + "kind": "union", + "types": [ + 9, + 12, + 14, + 17, + 19, + 21, + 31 + ] + }, + { + "kind": "object", + "properties": [ + { + "name": "text", + "type": 10, + "optional": false + }, + { + "name": "type", + "type": 11, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "literal", + "value": "reasoning" + }, + { + "kind": "object", + "properties": [ + { + "name": "text", + "type": 10, + "optional": false + }, + { + "name": "type", + "type": 13, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "text" + }, + { + "kind": "object", + "properties": [ + { + "name": "tool", + "type": 15, + "optional": true + }, + { + "name": "toolName", + "type": 10, + "optional": false + }, + { + "name": "type", + "type": 16, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "never" + }, + { + "kind": "literal", + "value": "tool-addition" + }, + { + "kind": "object", + "properties": [ + { + "name": "toolName", + "type": 10, + "optional": false + }, + { + "name": "type", + "type": 18, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "tool-removal" + }, + { + "kind": "object", + "properties": [ + { + "name": "arguments", + "type": 10, + "optional": false + }, + { + "name": "id", + "type": 10, + "optional": false + }, + { + "name": "name", + "type": 10, + "optional": false + }, + { + "name": "type", + "type": 20, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "tool-call" + }, + { + "kind": "object", + "properties": [ + { + "name": "attachment", + "type": 22, + "optional": false + }, + { + "name": "offloaded", + "type": 29, + "optional": true + }, + { + "name": "type", + "type": 30, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "attachmentId", + "type": 10, + "optional": false + }, + { + "name": "bytes", + "type": 2, + "optional": false + }, + { + "name": "height", + "type": 2, + "optional": false + }, + { + "name": "mediaType", + "type": 23, + "optional": false + }, + { + "name": "name", + "type": 10, + "optional": true + }, + { + "name": "originalDimensions", + "type": 28, + "optional": true + }, + { + "name": "width", + "type": 2, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "union", + "types": [ + 24, + 25, + 26, + 27 + ] + }, + { + "kind": "literal", + "value": "image/gif" + }, + { + "kind": "literal", + "value": "image/jpeg" + }, + { + "kind": "literal", + "value": "image/png" + }, + { + "kind": "literal", + "value": "image/webp" + }, + { + "kind": "object", + "properties": [ + { + "name": "height", + "type": 2, + "optional": false + }, + { + "name": "width", + "type": 2, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": true + }, + { + "kind": "literal", + "value": "image" + }, + { + "kind": "object", + "properties": [ + { + "name": "attachment", + "type": 32, + "optional": false + }, + { + "name": "type", + "type": 33, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "attachmentId", + "type": 10, + "optional": false + }, + { + "name": "bytes", + "type": 2, + "optional": false + }, + { + "name": "name", + "type": 10, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "file" + }, + { + "kind": "primitive", + "type": "boolean" + }, + { + "kind": "literal", + "value": "tool" + }, + { + "kind": "object", + "properties": [ + { + "name": "callId", + "type": 10, + "optional": false + }, + { + "name": "kind", + "type": 35, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "content", + "type": 7, + "optional": false + }, + { + "name": "id", + "type": 10, + "optional": false + }, + { + "name": "role", + "type": 38, + "optional": false + }, + { + "name": "source", + "type": 39, + "optional": false, + "compatibility": { + "version": 1, + "policy": "session-source-attribution", + "binding": "session.developer-message.source", + "discriminator": "kind", + "unknownKinds": "preserve", + "attributionKinds": [ + "tmux-context", + "user-question-reply", + "working-directory" + ] + } + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "developer" + }, + { + "kind": "union", + "types": [ + 36, + 40, + 45, + 47, + 49, + 51, + 53, + 55, + 57, + 58, + 60, + 62, + 63, + 65, + 67, + 69, + 71, + 73, + 75, + 77, + 78, + 79, + 80, + 81, + 84, + 87, + 89, + 98, + 101, + 104, + 106, + 108, + 110, + 115, + 117, + 119, + 127, + 128, + 129, + 130, + 131, + 132, + 133, + 134, + 135, + 136, + 137, + 138, + 139, + 140, + 141, + 142, + 143, + 144, + 145, + 146, + 147, + 148, + 149, + 150, + 151, + 152, + 153, + 154, + 155, + 156, + 157, + 158, + 159, + 160, + 161, + 162, + 163, + 164, + 165, + 166, + 167, + 168, + 169, + 170, + 171, + 172, + 173, + 174, + 175, + 176, + 177, + 178, + 179, + 180, + 181, + 182, + 183, + 184, + 185, + 186, + 187, + 188, + 189, + 190, + 191, + 192, + 193, + 194, + 195, + 196, + 198, + 200, + 203, + 204, + 205 + ] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 41, + "optional": false + }, + { + "name": "kind", + "type": 42, + "optional": false + }, + { + "name": "sections", + "type": 43, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "snapshot" + }, + { + "kind": "literal", + "value": "time-context" + }, + { + "kind": "array", + "element": 44 + }, + { + "kind": "object", + "properties": [ + { + "name": "name", + "type": 10, + "optional": false + }, + { + "name": "text", + "type": 10, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 46, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "plan-mode" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 48, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "ptc-mode" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 50, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "repeat-tool-reminder" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 52, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "runtime-context" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 54, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "schedule" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 56, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "system-prompt" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 42, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 59, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "tmux-context" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 61, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "tool-goal" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 41, + "optional": false + }, + { + "name": "kind", + "type": 59, + "optional": false + }, + { + "name": "sections", + "type": 43, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 64, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "tool-jobs" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 66, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "tool-registry" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 68, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "user" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 70, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "user-approval" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 72, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "working-directory" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 74, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "cordis-host-runner" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 76, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "dsh-session-title-llm" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 41, + "optional": false + }, + { + "name": "kind", + "type": 61, + "optional": false + }, + { + "name": "sections", + "type": 43, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 41, + "optional": false + }, + { + "name": "kind", + "type": 64, + "optional": false + }, + { + "name": "sections", + "type": 43, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 41, + "optional": false + }, + { + "name": "kind", + "type": 70, + "optional": false + }, + { + "name": "sections", + "type": 43, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 41, + "optional": false + }, + { + "name": "kind", + "type": 76, + "optional": false + }, + { + "name": "sections", + "type": 43, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 82, + "optional": false + }, + { + "name": "kind", + "type": 83, + "optional": false + }, + { + "name": "senderSessionId", + "type": 10, + "optional": false + }, + { + "name": "summary", + "type": 10, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "notice" + }, + { + "kind": "literal", + "value": "subagent-settled" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 85, + "optional": false + }, + { + "name": "kind", + "type": 86, + "optional": false + }, + { + "name": "senderSessionId", + "type": 10, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "relay" + }, + { + "kind": "literal", + "value": "agent-message" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 82, + "optional": false + }, + { + "name": "kind", + "type": 88, + "optional": false + }, + { + "name": "summary", + "type": 10, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "hooks-claude-code" + }, + { + "kind": "object", + "properties": [ + { + "name": "baseline", + "type": 29, + "optional": true + }, + { + "name": "baselineIdentity", + "type": 10, + "optional": true + }, + { + "name": "changes", + "type": 90, + "optional": false + }, + { + "name": "form", + "type": 96, + "optional": false + }, + { + "name": "kind", + "type": 97, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "array", + "element": 91 + }, + { + "kind": "object", + "properties": [ + { + "name": "action", + "type": 92, + "optional": false + }, + { + "name": "digest", + "type": 10, + "optional": true + }, + { + "name": "path", + "type": 10, + "optional": false + }, + { + "name": "scope", + "type": 10, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "union", + "types": [ + 93, + 94, + 95 + ] + }, + { + "kind": "literal", + "value": "remove" + }, + { + "kind": "literal", + "value": "replace" + }, + { + "kind": "literal", + "value": "set" + }, + { + "kind": "literal", + "value": "instructions" + }, + { + "kind": "literal", + "value": "agent-instructions" + }, + { + "kind": "object", + "properties": [ + { + "name": "callId", + "type": 10, + "optional": false + }, + { + "name": "kind", + "type": 99, + "optional": false + }, + { + "name": "outcome", + "type": 100, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "user-question-reply" + }, + { + "kind": "literal", + "value": "answered" + }, + { + "kind": "object", + "properties": [ + { + "name": "clientTimeZone", + "type": 10, + "optional": true + }, + { + "name": "kind", + "type": 68, + "optional": false + }, + { + "name": "principal", + "type": 102, + "optional": true + }, + { + "name": "rpcId", + "type": 10, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 103, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "owner" + }, + { + "kind": "object", + "properties": [ + { + "name": "compactionId", + "type": 10, + "optional": false + }, + { + "name": "kind", + "type": 105, + "optional": false + }, + { + "name": "sourceCommandId", + "type": 10, + "optional": true + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "compact-checkpoint" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 82, + "optional": false + }, + { + "name": "kind", + "type": 107, + "optional": false + }, + { + "name": "summary", + "type": 10, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "hooks-codex" + }, + { + "kind": "object", + "properties": [ + { + "name": "deliveryId", + "type": 10, + "optional": false + }, + { + "name": "form", + "type": 82, + "optional": false + }, + { + "name": "kind", + "type": 109, + "optional": false + }, + { + "name": "provider", + "type": 10, + "optional": false + }, + { + "name": "ruleId", + "type": 10, + "optional": false + }, + { + "name": "source", + "type": 10, + "optional": false + }, + { + "name": "summary", + "type": 10, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "webhook" + }, + { + "kind": "object", + "properties": [ + { + "name": "entries", + "type": 111, + "optional": false + }, + { + "name": "form", + "type": 113, + "optional": false + }, + { + "name": "kind", + "type": 114, + "optional": false + }, + { + "name": "update", + "type": 29, + "optional": true + } + ], + "indices": [] + }, + { + "kind": "array", + "element": 112 + }, + { + "kind": "object", + "properties": [ + { + "name": "description", + "type": 10, + "optional": false + }, + { + "name": "name", + "type": 10, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "catalog" + }, + { + "kind": "literal", + "value": "skill-catalog" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 82, + "optional": false + }, + { + "name": "kind", + "type": 116, + "optional": false + }, + { + "name": "summary", + "type": 10, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "model-selection" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 96, + "optional": false + }, + { + "name": "kind", + "type": 118, + "optional": false + }, + { + "name": "name", + "type": 10, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "skill-invocation" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 120, + "optional": false + }, + { + "name": "kind", + "type": 121, + "optional": false + }, + { + "name": "references", + "type": 122, + "optional": false + }, + { + "name": "version", + "type": 126, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "recall" + }, + { + "kind": "literal", + "value": "session-reference" + }, + { + "kind": "array", + "element": 123 + }, + { + "kind": "object", + "properties": [ + { + "name": "capturedFormatVersion", + "type": 2, + "optional": true + }, + { + "name": "capturedThroughSeq", + "type": 124, + "optional": false + }, + { + "name": "compacted", + "type": 34, + "optional": false + }, + { + "name": "inputIndex", + "type": 2, + "optional": false + }, + { + "name": "label", + "type": 10, + "optional": false + }, + { + "name": "omittedBytes", + "type": 2, + "optional": false + }, + { + "name": "omittedMessages", + "type": 2, + "optional": false + }, + { + "name": "originalMessages", + "type": 2, + "optional": false + }, + { + "name": "retainedMessages", + "type": 2, + "optional": false + }, + { + "name": "sessionId", + "type": 10, + "optional": false + }, + { + "name": "truncated", + "type": 34, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "union", + "types": [ + 2, + 125 + ] + }, + { + "kind": "primitive", + "type": "null" + }, + { + "kind": "literal", + "value": 1 + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 41, + "optional": false + }, + { + "name": "kind", + "type": 88, + "optional": false + }, + { + "name": "sections", + "type": 43, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 82, + "optional": false + }, + { + "name": "kind", + "type": 46, + "optional": false + }, + { + "name": "summary", + "type": 10, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 82, + "optional": false + }, + { + "name": "kind", + "type": 50, + "optional": false + }, + { + "name": "summary", + "type": 10, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 82, + "optional": false + }, + { + "name": "kind", + "type": 52, + "optional": false + }, + { + "name": "summary", + "type": 10, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 82, + "optional": false + }, + { + "name": "kind", + "type": 54, + "optional": false + }, + { + "name": "summary", + "type": 10, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 82, + "optional": false + }, + { + "name": "kind", + "type": 42, + "optional": false + }, + { + "name": "summary", + "type": 10, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 82, + "optional": false + }, + { + "name": "kind", + "type": 59, + "optional": false + }, + { + "name": "summary", + "type": 10, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 82, + "optional": false + }, + { + "name": "kind", + "type": 61, + "optional": false + }, + { + "name": "summary", + "type": 10, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 82, + "optional": false + }, + { + "name": "kind", + "type": 64, + "optional": false + }, + { + "name": "summary", + "type": 10, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 82, + "optional": false + }, + { + "name": "kind", + "type": 70, + "optional": false + }, + { + "name": "summary", + "type": 10, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 82, + "optional": false + }, + { + "name": "kind", + "type": 76, + "optional": false + }, + { + "name": "summary", + "type": 10, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 96, + "optional": false + }, + { + "name": "kind", + "type": 88, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 96, + "optional": false + }, + { + "name": "kind", + "type": 107, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 96, + "optional": false + }, + { + "name": "kind", + "type": 116, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 96, + "optional": false + }, + { + "name": "kind", + "type": 46, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 96, + "optional": false + }, + { + "name": "kind", + "type": 50, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 96, + "optional": false + }, + { + "name": "kind", + "type": 52, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 41, + "optional": false + }, + { + "name": "kind", + "type": 107, + "optional": false + }, + { + "name": "sections", + "type": 43, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 96, + "optional": false + }, + { + "name": "kind", + "type": 54, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 96, + "optional": false + }, + { + "name": "kind", + "type": 42, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 96, + "optional": false + }, + { + "name": "kind", + "type": 59, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 96, + "optional": false + }, + { + "name": "kind", + "type": 61, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 96, + "optional": false + }, + { + "name": "kind", + "type": 64, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 96, + "optional": false + }, + { + "name": "kind", + "type": 70, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 96, + "optional": false + }, + { + "name": "kind", + "type": 76, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 120, + "optional": false + }, + { + "name": "kind", + "type": 88, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 120, + "optional": false + }, + { + "name": "kind", + "type": 107, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 41, + "optional": false + }, + { + "name": "kind", + "type": 116, + "optional": false + }, + { + "name": "sections", + "type": 43, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 120, + "optional": false + }, + { + "name": "kind", + "type": 116, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 120, + "optional": false + }, + { + "name": "kind", + "type": 46, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 120, + "optional": false + }, + { + "name": "kind", + "type": 50, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 120, + "optional": false + }, + { + "name": "kind", + "type": 52, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 120, + "optional": false + }, + { + "name": "kind", + "type": 54, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 120, + "optional": false + }, + { + "name": "kind", + "type": 42, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 120, + "optional": false + }, + { + "name": "kind", + "type": 59, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 120, + "optional": false + }, + { + "name": "kind", + "type": 61, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 120, + "optional": false + }, + { + "name": "kind", + "type": 64, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 41, + "optional": false + }, + { + "name": "kind", + "type": 46, + "optional": false + }, + { + "name": "sections", + "type": 43, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 120, + "optional": false + }, + { + "name": "kind", + "type": 70, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 120, + "optional": false + }, + { + "name": "kind", + "type": 76, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 85, + "optional": false + }, + { + "name": "kind", + "type": 88, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 85, + "optional": false + }, + { + "name": "kind", + "type": 107, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 85, + "optional": false + }, + { + "name": "kind", + "type": 116, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 85, + "optional": false + }, + { + "name": "kind", + "type": 46, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 85, + "optional": false + }, + { + "name": "kind", + "type": 50, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 85, + "optional": false + }, + { + "name": "kind", + "type": 52, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 85, + "optional": false + }, + { + "name": "kind", + "type": 54, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 41, + "optional": false + }, + { + "name": "kind", + "type": 50, + "optional": false + }, + { + "name": "sections", + "type": 43, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 85, + "optional": false + }, + { + "name": "kind", + "type": 42, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 85, + "optional": false + }, + { + "name": "kind", + "type": 59, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 85, + "optional": false + }, + { + "name": "kind", + "type": 61, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 85, + "optional": false + }, + { + "name": "kind", + "type": 64, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 85, + "optional": false + }, + { + "name": "kind", + "type": 70, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 85, + "optional": false + }, + { + "name": "kind", + "type": 76, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 113, + "optional": false + }, + { + "name": "kind", + "type": 88, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 113, + "optional": false + }, + { + "name": "kind", + "type": 107, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 113, + "optional": false + }, + { + "name": "kind", + "type": 116, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 113, + "optional": false + }, + { + "name": "kind", + "type": 46, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 41, + "optional": false + }, + { + "name": "kind", + "type": 52, + "optional": false + }, + { + "name": "sections", + "type": 43, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 113, + "optional": false + }, + { + "name": "kind", + "type": 50, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 113, + "optional": false + }, + { + "name": "kind", + "type": 52, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 113, + "optional": false + }, + { + "name": "kind", + "type": 54, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 113, + "optional": false + }, + { + "name": "kind", + "type": 42, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 113, + "optional": false + }, + { + "name": "kind", + "type": 59, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 113, + "optional": false + }, + { + "name": "kind", + "type": 61, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 113, + "optional": false + }, + { + "name": "kind", + "type": 64, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 113, + "optional": false + }, + { + "name": "kind", + "type": 70, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 113, + "optional": false + }, + { + "name": "kind", + "type": 76, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 41, + "optional": false + }, + { + "name": "kind", + "type": 54, + "optional": false + }, + { + "name": "sections", + "type": 43, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "goalId", + "type": 10, + "optional": false + }, + { + "name": "kind", + "type": 197, + "optional": false + }, + { + "name": "revision", + "type": 2, + "optional": false + }, + { + "name": "round", + "type": 2, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "goal" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 199, + "optional": false + }, + { + "name": "messageId", + "type": 10, + "optional": false + }, + { + "name": "senderId", + "type": 10, + "optional": false + }, + { + "name": "senderName", + "type": 10, + "optional": false + }, + { + "name": "teamId", + "type": 10, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "team-message" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 201, + "optional": false + }, + { + "name": "model", + "type": 10, + "optional": false + }, + { + "name": "provider", + "type": 10, + "optional": false + }, + { + "name": "replayState", + "type": 202, + "optional": true + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "model" + }, + { + "kind": "opaque", + "reason": "unknown" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 88, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 107, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 116, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "content", + "type": 7, + "optional": false + }, + { + "name": "id", + "type": 10, + "optional": false + }, + { + "name": "role", + "type": 68, + "optional": false + }, + { + "name": "source", + "type": 39, + "optional": false, + "compatibility": { + "version": 1, + "policy": "session-source-attribution", + "binding": "session.user-message.source", + "discriminator": "kind", + "unknownKinds": "preserve", + "attributionKinds": [ + "tmux-context", + "user-question-reply", + "working-directory" + ] + } + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "content", + "type": 7, + "optional": false + }, + { + "name": "id", + "type": 10, + "optional": false + }, + { + "name": "role", + "type": 208, + "optional": false + }, + { + "name": "source", + "type": 200, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "assistant" + }, + { + "kind": "object", + "properties": [ + { + "name": "content", + "type": 7, + "optional": false + }, + { + "name": "id", + "type": 10, + "optional": false + }, + { + "name": "role", + "type": 210, + "optional": false + }, + { + "name": "source", + "type": 55, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "system" + }, + { + "kind": "object", + "properties": [ + { + "name": "model", + "type": 10, + "optional": false + }, + { + "name": "provider", + "type": 10, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "session/title-llm-request" + } + ] + } + }, + { + "key": "event:user/message", + "kind": "event", + "event": "user/message", + "surface": true, + "digest": "2ae8594a998c9862a597129e782277bcc518266712a703b5c3161bdf552d13b8", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "object", + "properties": [ + { + "name": "data", + "type": 1, + "optional": false + }, + { + "name": "ignorable", + "type": 25, + "optional": true + }, + { + "name": "seq", + "type": 18, + "optional": false + }, + { + "name": "sourceEventSeqs", + "type": 200, + "optional": true + }, + { + "name": "surfaceOp", + "type": 201, + "optional": false + }, + { + "name": "time", + "type": 18, + "optional": false + }, + { + "name": "type", + "type": 204, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "content", + "type": 2, + "optional": false + }, + { + "name": "id", + "type": 5, + "optional": false + }, + { + "name": "role", + "type": 30, + "optional": false + }, + { + "name": "source", + "type": 31, + "optional": false, + "compatibility": { + "version": 1, + "policy": "session-source-attribution", + "binding": "session.user-message.source", + "discriminator": "kind", + "unknownKinds": "preserve", + "attributionKinds": [ + "tmux-context", + "user-question-reply", + "working-directory" + ] + } + } + ], + "indices": [] + }, + { + "kind": "array", + "element": 3 + }, + { + "kind": "union", + "types": [ + 4, + 7, + 9, + 12, + 14, + 16, + 27 + ] + }, + { + "kind": "object", + "properties": [ + { + "name": "text", + "type": 5, + "optional": false + }, + { + "name": "type", + "type": 6, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "literal", + "value": "reasoning" + }, + { + "kind": "object", + "properties": [ + { + "name": "text", + "type": 5, + "optional": false + }, + { + "name": "type", + "type": 8, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "text" + }, + { + "kind": "object", + "properties": [ + { + "name": "tool", + "type": 10, + "optional": true + }, + { + "name": "toolName", + "type": 5, + "optional": false + }, + { + "name": "type", + "type": 11, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "never" + }, + { + "kind": "literal", + "value": "tool-addition" + }, + { + "kind": "object", + "properties": [ + { + "name": "toolName", + "type": 5, + "optional": false + }, + { + "name": "type", + "type": 13, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "tool-removal" + }, + { + "kind": "object", + "properties": [ + { + "name": "arguments", + "type": 5, + "optional": false + }, + { + "name": "id", + "type": 5, + "optional": false + }, + { + "name": "name", + "type": 5, + "optional": false + }, + { + "name": "type", + "type": 15, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "tool-call" + }, + { + "kind": "object", + "properties": [ + { + "name": "attachment", + "type": 17, + "optional": false + }, + { + "name": "offloaded", + "type": 25, + "optional": true + }, + { + "name": "type", + "type": 26, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "attachmentId", + "type": 5, + "optional": false + }, + { + "name": "bytes", + "type": 18, + "optional": false + }, + { + "name": "height", + "type": 18, + "optional": false + }, + { + "name": "mediaType", + "type": 19, + "optional": false + }, + { + "name": "name", + "type": 5, + "optional": true + }, + { + "name": "originalDimensions", + "type": 24, + "optional": true + }, + { + "name": "width", + "type": 18, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "number" + }, + { + "kind": "union", + "types": [ + 20, + 21, + 22, + 23 + ] + }, + { + "kind": "literal", + "value": "image/gif" + }, + { + "kind": "literal", + "value": "image/jpeg" + }, + { + "kind": "literal", + "value": "image/png" + }, + { + "kind": "literal", + "value": "image/webp" + }, + { + "kind": "object", + "properties": [ + { + "name": "height", + "type": 18, + "optional": false + }, + { + "name": "width", + "type": 18, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": true + }, + { + "kind": "literal", + "value": "image" + }, + { + "kind": "object", + "properties": [ + { + "name": "attachment", + "type": 28, + "optional": false + }, + { + "name": "type", + "type": 29, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "attachmentId", + "type": 5, + "optional": false + }, + { + "name": "bytes", + "type": 18, + "optional": false + }, + { + "name": "name", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "file" + }, + { + "kind": "literal", + "value": "user" + }, + { + "kind": "union", + "types": [ + 32, + 35, + 37, + 39, + 41, + 43, + 45, + 47, + 49, + 51, + 53, + 55, + 58, + 60, + 61, + 62, + 63, + 64, + 65, + 66, + 67, + 68, + 69, + 70, + 71, + 73, + 74, + 75, + 76, + 77, + 78, + 79, + 80, + 81, + 82, + 83, + 84, + 85, + 87, + 88, + 89, + 90, + 91, + 92, + 93, + 94, + 95, + 96, + 97, + 98, + 99, + 101, + 102, + 103, + 104, + 105, + 106, + 107, + 108, + 109, + 110, + 111, + 112, + 113, + 115, + 117, + 120, + 121, + 122, + 123, + 124, + 126, + 127, + 128, + 129, + 131, + 132, + 133, + 134, + 135, + 137, + 138, + 139, + 141, + 143, + 144, + 152, + 155, + 157, + 160, + 162, + 164, + 168, + 170, + 178, + 182, + 183, + 184, + 185, + 186, + 187, + 188, + 189, + 190, + 191, + 192, + 193, + 194, + 196, + 198, + 199 + ] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 33, + "optional": false + }, + { + "name": "kind", + "type": 34, + "optional": false + }, + { + "name": "summary", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "notice" + }, + { + "kind": "literal", + "value": "model-selection" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 33, + "optional": false + }, + { + "name": "kind", + "type": 36, + "optional": false + }, + { + "name": "summary", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "plan-mode" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 33, + "optional": false + }, + { + "name": "kind", + "type": 38, + "optional": false + }, + { + "name": "summary", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "repeat-tool-reminder" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 33, + "optional": false + }, + { + "name": "kind", + "type": 40, + "optional": false + }, + { + "name": "summary", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "runtime-context" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 33, + "optional": false + }, + { + "name": "kind", + "type": 42, + "optional": false + }, + { + "name": "summary", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "schedule" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 33, + "optional": false + }, + { + "name": "kind", + "type": 44, + "optional": false + }, + { + "name": "summary", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "time-context" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 33, + "optional": false + }, + { + "name": "kind", + "type": 46, + "optional": false + }, + { + "name": "summary", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "tmux-context" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 33, + "optional": false + }, + { + "name": "kind", + "type": 48, + "optional": false + }, + { + "name": "summary", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "tool-goal" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 33, + "optional": false + }, + { + "name": "kind", + "type": 50, + "optional": false + }, + { + "name": "summary", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "tool-jobs" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 33, + "optional": false + }, + { + "name": "kind", + "type": 52, + "optional": false + }, + { + "name": "summary", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "user-approval" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 33, + "optional": false + }, + { + "name": "kind", + "type": 54, + "optional": false + }, + { + "name": "summary", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "dsh-session-title-llm" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 56, + "optional": false + }, + { + "name": "kind", + "type": 57, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "instructions" + }, + { + "kind": "literal", + "value": "hooks-claude-code" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 56, + "optional": false + }, + { + "name": "kind", + "type": 59, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "hooks-codex" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 56, + "optional": false + }, + { + "name": "kind", + "type": 34, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 56, + "optional": false + }, + { + "name": "kind", + "type": 36, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 56, + "optional": false + }, + { + "name": "kind", + "type": 38, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 56, + "optional": false + }, + { + "name": "kind", + "type": 40, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 56, + "optional": false + }, + { + "name": "kind", + "type": 42, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 56, + "optional": false + }, + { + "name": "kind", + "type": 44, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 56, + "optional": false + }, + { + "name": "kind", + "type": 46, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 56, + "optional": false + }, + { + "name": "kind", + "type": 48, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 56, + "optional": false + }, + { + "name": "kind", + "type": 50, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 56, + "optional": false + }, + { + "name": "kind", + "type": 52, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 56, + "optional": false + }, + { + "name": "kind", + "type": 54, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 72, + "optional": false + }, + { + "name": "kind", + "type": 57, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "recall" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 72, + "optional": false + }, + { + "name": "kind", + "type": 59, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 72, + "optional": false + }, + { + "name": "kind", + "type": 34, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 72, + "optional": false + }, + { + "name": "kind", + "type": 36, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 72, + "optional": false + }, + { + "name": "kind", + "type": 38, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 72, + "optional": false + }, + { + "name": "kind", + "type": 40, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 72, + "optional": false + }, + { + "name": "kind", + "type": 42, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 72, + "optional": false + }, + { + "name": "kind", + "type": 44, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 72, + "optional": false + }, + { + "name": "kind", + "type": 46, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 72, + "optional": false + }, + { + "name": "kind", + "type": 48, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 72, + "optional": false + }, + { + "name": "kind", + "type": 50, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 72, + "optional": false + }, + { + "name": "kind", + "type": 52, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 72, + "optional": false + }, + { + "name": "kind", + "type": 54, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 86, + "optional": false + }, + { + "name": "kind", + "type": 57, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "relay" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 86, + "optional": false + }, + { + "name": "kind", + "type": 59, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 86, + "optional": false + }, + { + "name": "kind", + "type": 34, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 86, + "optional": false + }, + { + "name": "kind", + "type": 36, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 86, + "optional": false + }, + { + "name": "kind", + "type": 38, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 86, + "optional": false + }, + { + "name": "kind", + "type": 40, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 86, + "optional": false + }, + { + "name": "kind", + "type": 42, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 86, + "optional": false + }, + { + "name": "kind", + "type": 44, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 86, + "optional": false + }, + { + "name": "kind", + "type": 46, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 86, + "optional": false + }, + { + "name": "kind", + "type": 48, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 86, + "optional": false + }, + { + "name": "kind", + "type": 50, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 86, + "optional": false + }, + { + "name": "kind", + "type": 52, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 86, + "optional": false + }, + { + "name": "kind", + "type": 54, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 100, + "optional": false + }, + { + "name": "kind", + "type": 57, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "catalog" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 100, + "optional": false + }, + { + "name": "kind", + "type": 59, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 100, + "optional": false + }, + { + "name": "kind", + "type": 34, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 100, + "optional": false + }, + { + "name": "kind", + "type": 36, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 100, + "optional": false + }, + { + "name": "kind", + "type": 38, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 100, + "optional": false + }, + { + "name": "kind", + "type": 40, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 100, + "optional": false + }, + { + "name": "kind", + "type": 42, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 100, + "optional": false + }, + { + "name": "kind", + "type": 44, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 100, + "optional": false + }, + { + "name": "kind", + "type": 46, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 100, + "optional": false + }, + { + "name": "kind", + "type": 48, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 100, + "optional": false + }, + { + "name": "kind", + "type": 50, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 100, + "optional": false + }, + { + "name": "kind", + "type": 52, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 100, + "optional": false + }, + { + "name": "kind", + "type": 54, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "goalId", + "type": 5, + "optional": false + }, + { + "name": "kind", + "type": 114, + "optional": false + }, + { + "name": "revision", + "type": 18, + "optional": false + }, + { + "name": "round", + "type": 18, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "goal" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 116, + "optional": false + }, + { + "name": "messageId", + "type": 5, + "optional": false + }, + { + "name": "senderId", + "type": 5, + "optional": false + }, + { + "name": "senderName", + "type": 5, + "optional": false + }, + { + "name": "teamId", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "team-message" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 118, + "optional": false + }, + { + "name": "model", + "type": 5, + "optional": false + }, + { + "name": "provider", + "type": 5, + "optional": false + }, + { + "name": "replayState", + "type": 119, + "optional": true + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "model" + }, + { + "kind": "opaque", + "reason": "unknown" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 57, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 59, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 34, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 36, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 125, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "ptc-mode" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 38, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 40, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 42, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 130, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "system-prompt" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 44, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 46, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 48, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 50, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 136, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "tool-registry" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 30, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 52, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 140, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "working-directory" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 142, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "cordis-host-runner" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 54, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "baseline", + "type": 25, + "optional": true + }, + { + "name": "baselineIdentity", + "type": 5, + "optional": true + }, + { + "name": "changes", + "type": 145, + "optional": false + }, + { + "name": "form", + "type": 56, + "optional": false + }, + { + "name": "kind", + "type": 151, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "array", + "element": 146 + }, + { + "kind": "object", + "properties": [ + { + "name": "action", + "type": 147, + "optional": false + }, + { + "name": "digest", + "type": 5, + "optional": true + }, + { + "name": "path", + "type": 5, + "optional": false + }, + { + "name": "scope", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "union", + "types": [ + 148, + 149, + 150 + ] + }, + { + "kind": "literal", + "value": "remove" + }, + { + "kind": "literal", + "value": "replace" + }, + { + "kind": "literal", + "value": "set" + }, + { + "kind": "literal", + "value": "agent-instructions" + }, + { + "kind": "object", + "properties": [ + { + "name": "callId", + "type": 5, + "optional": false + }, + { + "name": "kind", + "type": 153, + "optional": false + }, + { + "name": "outcome", + "type": 154, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "user-question-reply" + }, + { + "kind": "literal", + "value": "answered" + }, + { + "kind": "object", + "properties": [ + { + "name": "callId", + "type": 5, + "optional": false + }, + { + "name": "kind", + "type": 156, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "tool" + }, + { + "kind": "object", + "properties": [ + { + "name": "clientTimeZone", + "type": 5, + "optional": true + }, + { + "name": "kind", + "type": 30, + "optional": false + }, + { + "name": "principal", + "type": 158, + "optional": true + }, + { + "name": "rpcId", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 159, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "owner" + }, + { + "kind": "object", + "properties": [ + { + "name": "compactionId", + "type": 5, + "optional": false + }, + { + "name": "kind", + "type": 161, + "optional": false + }, + { + "name": "sourceCommandId", + "type": 5, + "optional": true + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "compact-checkpoint" + }, + { + "kind": "object", + "properties": [ + { + "name": "deliveryId", + "type": 5, + "optional": false + }, + { + "name": "form", + "type": 33, + "optional": false + }, + { + "name": "kind", + "type": 163, + "optional": false + }, + { + "name": "provider", + "type": 5, + "optional": false + }, + { + "name": "ruleId", + "type": 5, + "optional": false + }, + { + "name": "source", + "type": 5, + "optional": false + }, + { + "name": "summary", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "webhook" + }, + { + "kind": "object", + "properties": [ + { + "name": "entries", + "type": 165, + "optional": false + }, + { + "name": "form", + "type": 100, + "optional": false + }, + { + "name": "kind", + "type": 167, + "optional": false + }, + { + "name": "update", + "type": 25, + "optional": true + } + ], + "indices": [] + }, + { + "kind": "array", + "element": 166 + }, + { + "kind": "object", + "properties": [ + { + "name": "description", + "type": 5, + "optional": false + }, + { + "name": "name", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "skill-catalog" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 56, + "optional": false + }, + { + "name": "kind", + "type": 169, + "optional": false + }, + { + "name": "name", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "skill-invocation" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 72, + "optional": false + }, + { + "name": "kind", + "type": 171, + "optional": false + }, + { + "name": "references", + "type": 172, + "optional": false + }, + { + "name": "version", + "type": 177, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "session-reference" + }, + { + "kind": "array", + "element": 173 + }, + { + "kind": "object", + "properties": [ + { + "name": "capturedFormatVersion", + "type": 18, + "optional": true + }, + { + "name": "capturedThroughSeq", + "type": 174, + "optional": false + }, + { + "name": "compacted", + "type": 176, + "optional": false + }, + { + "name": "inputIndex", + "type": 18, + "optional": false + }, + { + "name": "label", + "type": 5, + "optional": false + }, + { + "name": "omittedBytes", + "type": 18, + "optional": false + }, + { + "name": "omittedMessages", + "type": 18, + "optional": false + }, + { + "name": "originalMessages", + "type": 18, + "optional": false + }, + { + "name": "retainedMessages", + "type": 18, + "optional": false + }, + { + "name": "sessionId", + "type": 5, + "optional": false + }, + { + "name": "truncated", + "type": 176, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "union", + "types": [ + 18, + 175 + ] + }, + { + "kind": "primitive", + "type": "null" + }, + { + "kind": "primitive", + "type": "boolean" + }, + { + "kind": "literal", + "value": 1 + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 179, + "optional": false + }, + { + "name": "kind", + "type": 57, + "optional": false + }, + { + "name": "sections", + "type": 180, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "snapshot" + }, + { + "kind": "array", + "element": 181 + }, + { + "kind": "object", + "properties": [ + { + "name": "name", + "type": 5, + "optional": false + }, + { + "name": "text", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 179, + "optional": false + }, + { + "name": "kind", + "type": 59, + "optional": false + }, + { + "name": "sections", + "type": 180, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 179, + "optional": false + }, + { + "name": "kind", + "type": 34, + "optional": false + }, + { + "name": "sections", + "type": 180, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 179, + "optional": false + }, + { + "name": "kind", + "type": 36, + "optional": false + }, + { + "name": "sections", + "type": 180, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 179, + "optional": false + }, + { + "name": "kind", + "type": 38, + "optional": false + }, + { + "name": "sections", + "type": 180, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 179, + "optional": false + }, + { + "name": "kind", + "type": 40, + "optional": false + }, + { + "name": "sections", + "type": 180, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 179, + "optional": false + }, + { + "name": "kind", + "type": 42, + "optional": false + }, + { + "name": "sections", + "type": 180, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 179, + "optional": false + }, + { + "name": "kind", + "type": 44, + "optional": false + }, + { + "name": "sections", + "type": 180, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 179, + "optional": false + }, + { + "name": "kind", + "type": 46, + "optional": false + }, + { + "name": "sections", + "type": 180, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 179, + "optional": false + }, + { + "name": "kind", + "type": 48, + "optional": false + }, + { + "name": "sections", + "type": 180, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 179, + "optional": false + }, + { + "name": "kind", + "type": 50, + "optional": false + }, + { + "name": "sections", + "type": 180, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 179, + "optional": false + }, + { + "name": "kind", + "type": 52, + "optional": false + }, + { + "name": "sections", + "type": 180, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 179, + "optional": false + }, + { + "name": "kind", + "type": 54, + "optional": false + }, + { + "name": "sections", + "type": 180, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 33, + "optional": false + }, + { + "name": "kind", + "type": 195, + "optional": false + }, + { + "name": "senderSessionId", + "type": 5, + "optional": false + }, + { + "name": "summary", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "subagent-settled" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 86, + "optional": false + }, + { + "name": "kind", + "type": 197, + "optional": false + }, + { + "name": "senderSessionId", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "agent-message" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 33, + "optional": false + }, + { + "name": "kind", + "type": 57, + "optional": false + }, + { + "name": "summary", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 33, + "optional": false + }, + { + "name": "kind", + "type": 59, + "optional": false + }, + { + "name": "summary", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "array", + "element": 18 + }, + { + "kind": "union", + "types": [ + 202, + 203 + ] + }, + { + "kind": "literal", + "value": "append" + }, + { + "kind": "object", + "properties": [ + { + "name": "endSeq", + "type": 18, + "optional": false + }, + { + "name": "op", + "type": 149, + "optional": false + }, + { + "name": "startSeq", + "type": 18, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "user/message" + } + ] + } + } + ], + "types": [] +} diff --git a/docs/persistence-changes/2026-10-13-user-rpc-principal.zh.md b/docs/persistence-changes/2026-10-13-user-rpc-principal.zh.md new file mode 100644 index 0000000000..5dca79c486 --- /dev/null +++ b/docs/persistence-changes/2026-10-13-user-rpc-principal.zh.md @@ -0,0 +1,60 @@ +--- +description: "记录持久化类型更改及其兼容性确认。" +kind: persistence-change +--- + +# 2026-10-13-user-rpc-principal + +[English](2026-10-13-user-rpc-principal.md) | 中文 + +## 概述 + +为 owner 以外的调用方提交的 prompt,在 user-rpc 消息来源上增加可选的 principal。 + +## 目录 + +- [声明](#declaration) +- [兼容性](#compatibility) +- [验证](#verification) +- [开发备注](#dev-note) + + +## 声明 + +```yaml persistence-change +schemaVersion: 1 +id: 2026-10-13-user-rpc-principal +baseline: false +changes: + - root: "event:agent/inbox/spliced" + previous: "2026-10-05-working-directory-attribution" + after: "0755b7d1128319ba2e389619cbbf2c961ad648882b4e91da7f3e9582cc8a2034" + decision: same-version + - root: "event:developer/message" + previous: "2026-10-05-working-directory-attribution" + after: "97e0162f983c27456ec71269fb4976b1851588b8c1aeac5d071d43e11f95e03c" + decision: same-version + - root: "event:session/title-llm-request" + previous: "2026-10-07-title-reasoning-effort" + after: "79a4c87f8bfb4d6d05cd9952b99210d6a7db3b478c4a01d94b4c50602308c056" + decision: same-version + - root: "event:user/message" + previous: "2026-10-05-working-directory-attribution" + after: "2ae8594a998c9862a597129e782277bcc518266712a703b5c3161bdf552d13b8" + decision: same-version +``` + + +## 兼容性 + +owner 的 prompt 不写该字段,因此其来源与所有既有 Session 日志都不变;缺少 principal 即为 owner。早于该字段的读取方会忽略它,并因 kind 仍为 'user' 而把消息当作普通用户消息。当前读取方读取旧日志时找不到 principal,把每条消息都视为 owner 的消息。该字段不进入任何模型请求:provider 适配器只读取模型可见的消息内容。SESSION_FORMAT_VERSION 不变。 + + +## 验证 + +pnpm exec vitest run packages/api/session-controller/tests/remote-access.host.spec.ts packages/subagent/subagent/tests/control.spec.ts packages/api/session-controller/tests/session-cold.host.spec.ts:非 owner 的 prompt 在 agent/inbox/spliced 中持久化 source.principal,owner 与既有时区来源保持逐字节不变;全部测试通过。 + + +## 开发备注 + +无。 diff --git a/docs/persistence-changes/historical-formats/README.i18n.yaml b/docs/persistence-changes/historical-formats/README.i18n.yaml index a85d9e2a90..e24f17e991 100644 --- a/docs/persistence-changes/historical-formats/README.i18n.yaml +++ b/docs/persistence-changes/historical-formats/README.i18n.yaml @@ -15,8 +15,8 @@ en: 14c57f30e676f6e3 zh: b1a0ccf26fab3727 /session-persistence-formats/format-references: - en: 553fd4963cf19abe - zh: 7ca3cd5d0a88d35a + en: 43a4a6600fa37a1a + zh: 72174fab8355c344 /session-persistence-formats/scope-and-evidence: en: b220062139ddac1e zh: 7c4c0efe05dafa9c diff --git a/docs/persistence-changes/historical-formats/README.md b/docs/persistence-changes/historical-formats/README.md index 8f06d0e1ee..2bc0daa565 100644 --- a/docs/persistence-changes/historical-formats/README.md +++ b/docs/persistence-changes/historical-formats/README.md @@ -32,7 +32,7 @@ The index is generated from validated snapshots and the current writer constant. | 1 | PR #3349 | [V1](v1.md) | [JSON](v1.schema.json) | 54 / 415 | | 2 | `dsh-v0.1.3-alpha.2` | [V2](v2.md) | [JSON](v2.schema.json) | 56 / 435 | | 3 | PR #4320 | [V3](v3.md) | [JSON](v3.schema.json) | 60 / 467 | -| 4 | Current checkout | [Current catalog](../../persistence-catalog.md) | [JSON](../../persistence-schema.json) | 63 / 597 | +| 4 | Current checkout | [Current catalog](../../persistence-catalog.md) | [JSON](../../persistence-schema.json) | 65 / 612 | diff --git a/docs/persistence-changes/historical-formats/README.zh.md b/docs/persistence-changes/historical-formats/README.zh.md index f377a801aa..15b08f318d 100644 --- a/docs/persistence-changes/historical-formats/README.zh.md +++ b/docs/persistence-changes/historical-formats/README.zh.md @@ -32,7 +32,7 @@ description: "查找从 V0 到工作区写入器版本的每个 Session 格式 | 1 | PR #3349 | [V1](v1.zh.md) | [JSON](v1.schema.json) | 54 / 415 | | 2 | `dsh-v0.1.3-alpha.2` | [V2](v2.zh.md) | [JSON](v2.schema.json) | 56 / 435 | | 3 | PR #4320 | [V3](v3.zh.md) | [JSON](v3.schema.json) | 60 / 467 | -| 4 | 当前工作树 | [当前目录](../../persistence-catalog.zh.md) | [JSON](../../persistence-schema.json) | 63 / 597 | +| 4 | 当前工作树 | [当前目录](../../persistence-catalog.zh.md) | [JSON](../../persistence-schema.json) | 65 / 612 | diff --git a/docs/persistence-schema.json b/docs/persistence-schema.json index 729e58948d..04344d9b87 100644 --- a/docs/persistence-schema.json +++ b/docs/persistence-schema.json @@ -406,7 +406,7 @@ "kind": "event", "event": "agent/inbox/spliced", "surface": false, - "digest": "9a36727716d0e73f681f3b725e7f28f89417c68174c8af0ff2b1ef52b08326a0", + "digest": "0755b7d1128319ba2e389619cbbf2c961ad648882b4e91da7f3e9582cc8a2034", "schema": { "root": 0, "nodes": [ @@ -435,7 +435,7 @@ }, { "name": "type", - "type": 204, + "type": 206, "optional": false } ], @@ -451,7 +451,7 @@ }, { "name": "outcome", - "type": 200, + "type": 202, "optional": true }, { @@ -466,7 +466,7 @@ }, { "name": "target", - "type": 201, + "type": 203, "optional": false } ], @@ -818,10 +818,10 @@ "types": [ 34, 39, - 42, + 41, + 43, 45, - 47, - 49, + 48, 51, 53, 55, @@ -833,9 +833,9 @@ 67, 69, 70, + 71, 72, 73, - 74, 75, 76, 77, @@ -846,9 +846,9 @@ 82, 83, 84, + 85, 86, 87, - 88, 89, 90, 91, @@ -872,9 +872,9 @@ 109, 110, 111, + 112, 113, 114, - 115, 116, 117, 118, @@ -885,48 +885,48 @@ 123, 124, 125, + 126, 127, - 129, + 128, + 130, 132, - 133, - 134, 135, 136, + 137, 138, 139, - 140, 141, + 142, 143, 144, - 145, 146, 147, + 148, 149, 150, - 151, + 152, 153, - 155, + 154, 156, - 164, + 158, + 159, 167, - 169, 170, 172, - 174, - 178, - 180, - 188, - 189, - 190, - 191, - 192, + 175, + 177, + 179, + 183, + 185, 193, 194, 195, 196, 197, 198, - 199 + 199, + 200, + 201 ] }, { @@ -956,7 +956,7 @@ }, { "kind": "literal", - "value": "dsh-session-title-llm" + "value": "tool-goal" }, { "kind": "array", @@ -983,12 +983,87 @@ "properties": [ { "name": "form", - "type": 40, + "type": 35, "optional": false }, { "name": "kind", - "type": 41, + "type": 40, + "optional": false + }, + { + "name": "sections", + "type": 37, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "tool-jobs" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 35, + "optional": false + }, + { + "name": "kind", + "type": 42, + "optional": false + }, + { + "name": "sections", + "type": 37, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "user-approval" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 35, + "optional": false + }, + { + "name": "kind", + "type": 44, + "optional": false + }, + { + "name": "sections", + "type": 37, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "dsh-session-title-llm" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 46, + "optional": false + }, + { + "name": "kind", + "type": 47, "optional": false }, { @@ -1017,12 +1092,12 @@ "properties": [ { "name": "form", - "type": 43, + "type": 49, "optional": false }, { "name": "kind", - "type": 44, + "type": 50, "optional": false }, { @@ -1046,12 +1121,12 @@ "properties": [ { "name": "form", - "type": 40, + "type": 46, "optional": false }, { "name": "kind", - "type": 46, + "type": 52, "optional": false }, { @@ -1071,12 +1146,12 @@ "properties": [ { "name": "form", - "type": 40, + "type": 46, "optional": false }, { "name": "kind", - "type": 48, + "type": 54, "optional": false }, { @@ -1096,12 +1171,12 @@ "properties": [ { "name": "form", - "type": 40, + "type": 46, "optional": false }, { "name": "kind", - "type": 50, + "type": 56, "optional": false }, { @@ -1121,12 +1196,12 @@ "properties": [ { "name": "form", - "type": 40, + "type": 46, "optional": false }, { "name": "kind", - "type": 52, + "type": 58, "optional": false }, { @@ -1146,12 +1221,12 @@ "properties": [ { "name": "form", - "type": 40, + "type": 46, "optional": false }, { "name": "kind", - "type": 54, + "type": 60, "optional": false }, { @@ -1171,12 +1246,12 @@ "properties": [ { "name": "form", - "type": 40, + "type": 46, "optional": false }, { "name": "kind", - "type": 56, + "type": 62, "optional": false }, { @@ -1196,12 +1271,12 @@ "properties": [ { "name": "form", - "type": 40, + "type": 46, "optional": false }, { "name": "kind", - "type": 58, + "type": 64, "optional": false }, { @@ -1221,12 +1296,12 @@ "properties": [ { "name": "form", - "type": 40, + "type": 46, "optional": false }, { "name": "kind", - "type": 60, + "type": 66, "optional": false }, { @@ -1246,12 +1321,12 @@ "properties": [ { "name": "form", - "type": 40, + "type": 46, "optional": false }, { "name": "kind", - "type": 62, + "type": 68, "optional": false }, { @@ -1271,82 +1346,7 @@ "properties": [ { "name": "form", - "type": 40, - "optional": false - }, - { - "name": "kind", - "type": 64, - "optional": false - }, - { - "name": "summary", - "type": 7, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "tool-goal" - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 40, - "optional": false - }, - { - "name": "kind", - "type": 66, - "optional": false - }, - { - "name": "summary", - "type": 7, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "tool-jobs" - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 40, - "optional": false - }, - { - "name": "kind", - "type": 68, - "optional": false - }, - { - "name": "summary", - "type": 7, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "user-approval" - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 40, + "type": 46, "optional": false }, { @@ -1367,13 +1367,76 @@ "properties": [ { "name": "form", - "type": 71, + "type": 46, "optional": false }, { "name": "kind", + "type": 40, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", "type": 46, "optional": false + }, + { + "name": "kind", + "type": 42, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 46, + "optional": false + }, + { + "name": "kind", + "type": 44, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 74, + "optional": false + }, + { + "name": "kind", + "type": 52, + "optional": false } ], "indices": [] @@ -1387,55 +1450,7 @@ "properties": [ { "name": "form", - "type": 71, - "optional": false - }, - { - "name": "kind", - "type": 48, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 71, - "optional": false - }, - { - "name": "kind", - "type": 50, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 71, - "optional": false - }, - { - "name": "kind", - "type": 52, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 71, + "type": 74, "optional": false }, { @@ -1451,7 +1466,7 @@ "properties": [ { "name": "form", - "type": 71, + "type": 74, "optional": false }, { @@ -1467,7 +1482,7 @@ "properties": [ { "name": "form", - "type": 71, + "type": 74, "optional": false }, { @@ -1483,7 +1498,7 @@ "properties": [ { "name": "form", - "type": 71, + "type": 74, "optional": false }, { @@ -1499,7 +1514,7 @@ "properties": [ { "name": "form", - "type": 71, + "type": 74, "optional": false }, { @@ -1515,7 +1530,7 @@ "properties": [ { "name": "form", - "type": 71, + "type": 74, "optional": false }, { @@ -1531,7 +1546,7 @@ "properties": [ { "name": "form", - "type": 71, + "type": 74, "optional": false }, { @@ -1547,7 +1562,7 @@ "properties": [ { "name": "form", - "type": 71, + "type": 74, "optional": false }, { @@ -1563,7 +1578,7 @@ "properties": [ { "name": "form", - "type": 71, + "type": 74, "optional": false }, { @@ -1579,12 +1594,60 @@ "properties": [ { "name": "form", - "type": 85, + "type": 74, "optional": false }, { "name": "kind", - "type": 46, + "type": 40, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 74, + "optional": false + }, + { + "name": "kind", + "type": 42, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 74, + "optional": false + }, + { + "name": "kind", + "type": 44, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 88, + "optional": false + }, + { + "name": "kind", + "type": 52, "optional": false } ], @@ -1599,55 +1662,7 @@ "properties": [ { "name": "form", - "type": 85, - "optional": false - }, - { - "name": "kind", - "type": 48, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 85, - "optional": false - }, - { - "name": "kind", - "type": 50, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 85, - "optional": false - }, - { - "name": "kind", - "type": 52, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 85, + "type": 88, "optional": false }, { @@ -1663,7 +1678,7 @@ "properties": [ { "name": "form", - "type": 85, + "type": 88, "optional": false }, { @@ -1679,7 +1694,7 @@ "properties": [ { "name": "form", - "type": 85, + "type": 88, "optional": false }, { @@ -1695,7 +1710,7 @@ "properties": [ { "name": "form", - "type": 85, + "type": 88, "optional": false }, { @@ -1711,7 +1726,7 @@ "properties": [ { "name": "form", - "type": 85, + "type": 88, "optional": false }, { @@ -1727,7 +1742,7 @@ "properties": [ { "name": "form", - "type": 85, + "type": 88, "optional": false }, { @@ -1743,7 +1758,7 @@ "properties": [ { "name": "form", - "type": 85, + "type": 88, "optional": false }, { @@ -1759,7 +1774,7 @@ "properties": [ { "name": "form", - "type": 85, + "type": 88, "optional": false }, { @@ -1775,7 +1790,7 @@ "properties": [ { "name": "form", - "type": 85, + "type": 88, "optional": false }, { @@ -1791,12 +1806,12 @@ "properties": [ { "name": "form", - "type": 43, + "type": 88, "optional": false }, { "name": "kind", - "type": 46, + "type": 40, "optional": false } ], @@ -1807,12 +1822,12 @@ "properties": [ { "name": "form", - "type": 43, + "type": 88, "optional": false }, { "name": "kind", - "type": 48, + "type": 42, "optional": false } ], @@ -1823,12 +1838,12 @@ "properties": [ { "name": "form", - "type": 43, + "type": 88, "optional": false }, { "name": "kind", - "type": 50, + "type": 44, "optional": false } ], @@ -1839,7 +1854,7 @@ "properties": [ { "name": "form", - "type": 43, + "type": 49, "optional": false }, { @@ -1855,7 +1870,7 @@ "properties": [ { "name": "form", - "type": 43, + "type": 49, "optional": false }, { @@ -1871,7 +1886,7 @@ "properties": [ { "name": "form", - "type": 43, + "type": 49, "optional": false }, { @@ -1887,7 +1902,7 @@ "properties": [ { "name": "form", - "type": 43, + "type": 49, "optional": false }, { @@ -1903,7 +1918,7 @@ "properties": [ { "name": "form", - "type": 43, + "type": 49, "optional": false }, { @@ -1919,7 +1934,7 @@ "properties": [ { "name": "form", - "type": 43, + "type": 49, "optional": false }, { @@ -1935,7 +1950,7 @@ "properties": [ { "name": "form", - "type": 43, + "type": 49, "optional": false }, { @@ -1951,7 +1966,7 @@ "properties": [ { "name": "form", - "type": 43, + "type": 49, "optional": false }, { @@ -1967,7 +1982,7 @@ "properties": [ { "name": "form", - "type": 43, + "type": 49, "optional": false }, { @@ -1983,7 +1998,7 @@ "properties": [ { "name": "form", - "type": 43, + "type": 49, "optional": false }, { @@ -1999,12 +2014,60 @@ "properties": [ { "name": "form", - "type": 112, + "type": 49, "optional": false }, { "name": "kind", - "type": 46, + "type": 40, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 49, + "optional": false + }, + { + "name": "kind", + "type": 42, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 49, + "optional": false + }, + { + "name": "kind", + "type": 44, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 115, + "optional": false + }, + { + "name": "kind", + "type": 52, "optional": false } ], @@ -2019,55 +2082,7 @@ "properties": [ { "name": "form", - "type": 112, - "optional": false - }, - { - "name": "kind", - "type": 48, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 112, - "optional": false - }, - { - "name": "kind", - "type": 50, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 112, - "optional": false - }, - { - "name": "kind", - "type": 52, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 112, + "type": 115, "optional": false }, { @@ -2083,7 +2098,7 @@ "properties": [ { "name": "form", - "type": 112, + "type": 115, "optional": false }, { @@ -2099,7 +2114,7 @@ "properties": [ { "name": "form", - "type": 112, + "type": 115, "optional": false }, { @@ -2115,7 +2130,7 @@ "properties": [ { "name": "form", - "type": 112, + "type": 115, "optional": false }, { @@ -2131,7 +2146,7 @@ "properties": [ { "name": "form", - "type": 112, + "type": 115, "optional": false }, { @@ -2147,7 +2162,7 @@ "properties": [ { "name": "form", - "type": 112, + "type": 115, "optional": false }, { @@ -2163,7 +2178,7 @@ "properties": [ { "name": "form", - "type": 112, + "type": 115, "optional": false }, { @@ -2179,7 +2194,7 @@ "properties": [ { "name": "form", - "type": 112, + "type": 115, "optional": false }, { @@ -2195,7 +2210,7 @@ "properties": [ { "name": "form", - "type": 112, + "type": 115, "optional": false }, { @@ -2206,6 +2221,54 @@ ], "indices": [] }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 115, + "optional": false + }, + { + "name": "kind", + "type": 40, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 115, + "optional": false + }, + { + "name": "kind", + "type": 42, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 115, + "optional": false + }, + { + "name": "kind", + "type": 44, + "optional": false + } + ], + "indices": [] + }, { "kind": "object", "properties": [ @@ -2216,7 +2279,7 @@ }, { "name": "kind", - "type": 126, + "type": 129, "optional": false }, { @@ -2241,7 +2304,7 @@ "properties": [ { "name": "kind", - "type": 128, + "type": 131, "optional": false }, { @@ -2276,7 +2339,7 @@ "properties": [ { "name": "kind", - "type": 130, + "type": 133, "optional": false }, { @@ -2291,7 +2354,7 @@ }, { "name": "replayState", - "type": 131, + "type": 134, "optional": true } ], @@ -2305,39 +2368,6 @@ "kind": "opaque", "reason": "unknown" }, - { - "kind": "object", - "properties": [ - { - "name": "kind", - "type": 46, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "kind", - "type": 48, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "kind", - "type": 50, - "optional": false - } - ], - "indices": [] - }, { "kind": "object", "properties": [ @@ -2349,21 +2379,6 @@ ], "indices": [] }, - { - "kind": "object", - "properties": [ - { - "name": "kind", - "type": 137, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "ptc-mode" - }, { "kind": "object", "properties": [ @@ -2402,7 +2417,7 @@ "properties": [ { "name": "kind", - "type": 142, + "type": 140, "optional": false } ], @@ -2410,7 +2425,7 @@ }, { "kind": "literal", - "value": "system-prompt" + "value": "ptc-mode" }, { "kind": "object", @@ -2445,6 +2460,21 @@ ], "indices": [] }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 145, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "system-prompt" + }, { "kind": "object", "properties": [ @@ -2461,7 +2491,40 @@ "properties": [ { "name": "kind", - "type": 148, + "type": 68, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 36, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 40, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 151, "optional": false } ], @@ -2487,7 +2550,7 @@ "properties": [ { "name": "kind", - "type": 68, + "type": 42, "optional": false } ], @@ -2498,7 +2561,7 @@ "properties": [ { "name": "kind", - "type": 152, + "type": 155, "optional": false } ], @@ -2513,7 +2576,7 @@ "properties": [ { "name": "kind", - "type": 154, + "type": 157, "optional": false } ], @@ -2528,7 +2591,7 @@ "properties": [ { "name": "kind", - "type": 36, + "type": 44, "optional": false } ], @@ -2549,17 +2612,17 @@ }, { "name": "changes", - "type": 157, + "type": 160, "optional": false }, { "name": "form", - "type": 71, + "type": 74, "optional": false }, { "name": "kind", - "type": 163, + "type": 166, "optional": false } ], @@ -2567,14 +2630,14 @@ }, { "kind": "array", - "element": 158 + "element": 161 }, { "kind": "object", "properties": [ { "name": "action", - "type": 159, + "type": 162, "optional": false }, { @@ -2598,9 +2661,9 @@ { "kind": "union", "types": [ - 160, - 161, - 162 + 163, + 164, + 165 ] }, { @@ -2629,12 +2692,12 @@ }, { "name": "kind", - "type": 165, + "type": 168, "optional": false }, { "name": "outcome", - "type": 166, + "type": 169, "optional": false } ], @@ -2658,7 +2721,7 @@ }, { "name": "kind", - "type": 168, + "type": 171, "optional": false } ], @@ -2681,6 +2744,11 @@ "type": 32, "optional": false }, + { + "name": "principal", + "type": 173, + "optional": true + }, { "name": "rpcId", "type": 7, @@ -2689,6 +2757,21 @@ ], "indices": [] }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 174, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "owner" + }, { "kind": "object", "properties": [ @@ -2699,7 +2782,7 @@ }, { "name": "kind", - "type": 171, + "type": 176, "optional": false }, { @@ -2724,12 +2807,12 @@ }, { "name": "form", - "type": 40, + "type": 46, "optional": false }, { "name": "kind", - "type": 173, + "type": 178, "optional": false }, { @@ -2764,17 +2847,17 @@ "properties": [ { "name": "entries", - "type": 175, + "type": 180, "optional": false }, { "name": "form", - "type": 112, + "type": 115, "optional": false }, { "name": "kind", - "type": 177, + "type": 182, "optional": false }, { @@ -2787,7 +2870,7 @@ }, { "kind": "array", - "element": 176 + "element": 181 }, { "kind": "object", @@ -2814,12 +2897,12 @@ "properties": [ { "name": "form", - "type": 71, + "type": 74, "optional": false }, { "name": "kind", - "type": 179, + "type": 184, "optional": false }, { @@ -2839,22 +2922,22 @@ "properties": [ { "name": "form", - "type": 85, + "type": 88, "optional": false }, { "name": "kind", - "type": 181, + "type": 186, "optional": false }, { "name": "references", - "type": 182, + "type": 187, "optional": false }, { "name": "version", - "type": 187, + "type": 192, "optional": false } ], @@ -2866,7 +2949,7 @@ }, { "kind": "array", - "element": 183 + "element": 188 }, { "kind": "object", @@ -2878,12 +2961,12 @@ }, { "name": "capturedThroughSeq", - "type": 184, + "type": 189, "optional": false }, { "name": "compacted", - "type": 186, + "type": 191, "optional": false }, { @@ -2923,7 +3006,7 @@ }, { "name": "truncated", - "type": 186, + "type": 191, "optional": false } ], @@ -2933,7 +3016,7 @@ "kind": "union", "types": [ 20, - 185 + 190 ] }, { @@ -2948,69 +3031,6 @@ "kind": "literal", "value": 1 }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 35, - "optional": false - }, - { - "name": "kind", - "type": 46, - "optional": false - }, - { - "name": "sections", - "type": 37, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 35, - "optional": false - }, - { - "name": "kind", - "type": 48, - "optional": false - }, - { - "name": "sections", - "type": 37, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 35, - "optional": false - }, - { - "name": "kind", - "type": 50, - "optional": false - }, - { - "name": "sections", - "type": 37, - "optional": false - } - ], - "indices": [] - }, { "kind": "object", "properties": [ @@ -3207,8 +3227,8 @@ { "kind": "union", "types": [ - 202, - 203 + 204, + 205 ] }, { @@ -6571,7 +6591,7 @@ "kind": "event", "event": "developer/message", "surface": true, - "digest": "ba5b10ea77d68f4a29439a9bd2a14f6a560acf55731749d57237757cc72fbc06", + "digest": "97e0162f983c27456ec71269fb4976b1851588b8c1aeac5d071d43e11f95e03c", "schema": { "root": 0, "nodes": [ @@ -6595,12 +6615,12 @@ }, { "name": "sourceEventSeqs", - "type": 200, + "type": 202, "optional": true }, { "name": "surfaceOp", - "type": 201, + "type": 203, "optional": false }, { @@ -6610,7 +6630,7 @@ }, { "name": "type", - "type": 204, + "type": 206, "optional": false } ], @@ -6984,10 +7004,10 @@ "types": [ 33, 38, - 41, + 40, + 42, 44, - 46, - 48, + 47, 50, 52, 54, @@ -6999,9 +7019,9 @@ 66, 68, 69, + 70, 71, 72, - 73, 74, 75, 76, @@ -7012,9 +7032,9 @@ 81, 82, 83, + 84, 85, 86, - 87, 88, 89, 90, @@ -7038,9 +7058,9 @@ 108, 109, 110, + 111, 112, 113, - 114, 115, 116, 117, @@ -7051,48 +7071,48 @@ 122, 123, 124, + 125, 126, - 128, + 127, + 129, 131, - 132, - 133, 134, 135, 136, + 137, 138, 139, - 140, 141, + 142, 143, 144, - 145, 146, 147, + 148, 149, - 151, + 150, 152, 154, - 156, - 164, + 155, + 157, + 159, 167, - 169, 170, 172, - 174, - 178, - 180, - 188, - 189, - 190, - 191, - 192, + 175, + 177, + 179, + 183, + 185, 193, 194, 195, 196, 197, 198, - 199 + 199, + 200, + 201 ] }, { @@ -7122,7 +7142,7 @@ }, { "kind": "literal", - "value": "user-approval" + "value": "tmux-context" }, { "kind": "array", @@ -7149,12 +7169,87 @@ "properties": [ { "name": "form", - "type": 39, + "type": 34, "optional": false }, { "name": "kind", - "type": 40, + "type": 39, + "optional": false + }, + { + "name": "sections", + "type": 36, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "tool-goal" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 34, + "optional": false + }, + { + "name": "kind", + "type": 41, + "optional": false + }, + { + "name": "sections", + "type": 36, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "tool-jobs" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 34, + "optional": false + }, + { + "name": "kind", + "type": 43, + "optional": false + }, + { + "name": "sections", + "type": 36, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "user-approval" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 45, + "optional": false + }, + { + "name": "kind", + "type": 46, "optional": false }, { @@ -7183,12 +7278,12 @@ "properties": [ { "name": "form", - "type": 42, + "type": 48, "optional": false }, { "name": "kind", - "type": 43, + "type": 49, "optional": false }, { @@ -7212,12 +7307,12 @@ "properties": [ { "name": "form", - "type": 39, + "type": 45, "optional": false }, { "name": "kind", - "type": 45, + "type": 51, "optional": false }, { @@ -7237,12 +7332,12 @@ "properties": [ { "name": "form", - "type": 39, + "type": 45, "optional": false }, { "name": "kind", - "type": 47, + "type": 53, "optional": false }, { @@ -7262,12 +7357,12 @@ "properties": [ { "name": "form", - "type": 39, + "type": 45, "optional": false }, { "name": "kind", - "type": 49, + "type": 55, "optional": false }, { @@ -7287,12 +7382,12 @@ "properties": [ { "name": "form", - "type": 39, + "type": 45, "optional": false }, { "name": "kind", - "type": 51, + "type": 57, "optional": false }, { @@ -7312,12 +7407,12 @@ "properties": [ { "name": "form", - "type": 39, + "type": 45, "optional": false }, { "name": "kind", - "type": 53, + "type": 59, "optional": false }, { @@ -7337,12 +7432,12 @@ "properties": [ { "name": "form", - "type": 39, + "type": 45, "optional": false }, { "name": "kind", - "type": 55, + "type": 61, "optional": false }, { @@ -7362,12 +7457,12 @@ "properties": [ { "name": "form", - "type": 39, + "type": 45, "optional": false }, { "name": "kind", - "type": 57, + "type": 63, "optional": false }, { @@ -7387,12 +7482,12 @@ "properties": [ { "name": "form", - "type": 39, + "type": 45, "optional": false }, { "name": "kind", - "type": 59, + "type": 65, "optional": false }, { @@ -7412,12 +7507,12 @@ "properties": [ { "name": "form", - "type": 39, + "type": 45, "optional": false }, { "name": "kind", - "type": 61, + "type": 67, "optional": false }, { @@ -7437,82 +7532,7 @@ "properties": [ { "name": "form", - "type": 39, - "optional": false - }, - { - "name": "kind", - "type": 63, - "optional": false - }, - { - "name": "summary", - "type": 7, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "tmux-context" - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 39, - "optional": false - }, - { - "name": "kind", - "type": 65, - "optional": false - }, - { - "name": "summary", - "type": 7, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "tool-goal" - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 39, - "optional": false - }, - { - "name": "kind", - "type": 67, - "optional": false - }, - { - "name": "summary", - "type": 7, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "tool-jobs" - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 39, + "type": 45, "optional": false }, { @@ -7533,13 +7553,76 @@ "properties": [ { "name": "form", - "type": 70, + "type": 45, "optional": false }, { "name": "kind", + "type": 39, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", "type": 45, "optional": false + }, + { + "name": "kind", + "type": 41, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 45, + "optional": false + }, + { + "name": "kind", + "type": 43, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 73, + "optional": false + }, + { + "name": "kind", + "type": 51, + "optional": false } ], "indices": [] @@ -7553,55 +7636,7 @@ "properties": [ { "name": "form", - "type": 70, - "optional": false - }, - { - "name": "kind", - "type": 47, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 70, - "optional": false - }, - { - "name": "kind", - "type": 49, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 70, - "optional": false - }, - { - "name": "kind", - "type": 51, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 70, + "type": 73, "optional": false }, { @@ -7617,7 +7652,7 @@ "properties": [ { "name": "form", - "type": 70, + "type": 73, "optional": false }, { @@ -7633,7 +7668,7 @@ "properties": [ { "name": "form", - "type": 70, + "type": 73, "optional": false }, { @@ -7649,7 +7684,7 @@ "properties": [ { "name": "form", - "type": 70, + "type": 73, "optional": false }, { @@ -7665,7 +7700,7 @@ "properties": [ { "name": "form", - "type": 70, + "type": 73, "optional": false }, { @@ -7681,7 +7716,7 @@ "properties": [ { "name": "form", - "type": 70, + "type": 73, "optional": false }, { @@ -7697,7 +7732,7 @@ "properties": [ { "name": "form", - "type": 70, + "type": 73, "optional": false }, { @@ -7713,7 +7748,7 @@ "properties": [ { "name": "form", - "type": 70, + "type": 73, "optional": false }, { @@ -7729,7 +7764,7 @@ "properties": [ { "name": "form", - "type": 70, + "type": 73, "optional": false }, { @@ -7745,12 +7780,60 @@ "properties": [ { "name": "form", - "type": 84, + "type": 73, "optional": false }, { "name": "kind", - "type": 45, + "type": 39, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 73, + "optional": false + }, + { + "name": "kind", + "type": 41, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 73, + "optional": false + }, + { + "name": "kind", + "type": 43, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 87, + "optional": false + }, + { + "name": "kind", + "type": 51, "optional": false } ], @@ -7765,55 +7848,7 @@ "properties": [ { "name": "form", - "type": 84, - "optional": false - }, - { - "name": "kind", - "type": 47, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 84, - "optional": false - }, - { - "name": "kind", - "type": 49, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 84, - "optional": false - }, - { - "name": "kind", - "type": 51, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 84, + "type": 87, "optional": false }, { @@ -7829,7 +7864,7 @@ "properties": [ { "name": "form", - "type": 84, + "type": 87, "optional": false }, { @@ -7845,7 +7880,7 @@ "properties": [ { "name": "form", - "type": 84, + "type": 87, "optional": false }, { @@ -7861,7 +7896,7 @@ "properties": [ { "name": "form", - "type": 84, + "type": 87, "optional": false }, { @@ -7877,7 +7912,7 @@ "properties": [ { "name": "form", - "type": 84, + "type": 87, "optional": false }, { @@ -7893,7 +7928,7 @@ "properties": [ { "name": "form", - "type": 84, + "type": 87, "optional": false }, { @@ -7909,7 +7944,7 @@ "properties": [ { "name": "form", - "type": 84, + "type": 87, "optional": false }, { @@ -7925,7 +7960,7 @@ "properties": [ { "name": "form", - "type": 84, + "type": 87, "optional": false }, { @@ -7941,7 +7976,7 @@ "properties": [ { "name": "form", - "type": 84, + "type": 87, "optional": false }, { @@ -7957,12 +7992,12 @@ "properties": [ { "name": "form", - "type": 42, + "type": 87, "optional": false }, { "name": "kind", - "type": 45, + "type": 39, "optional": false } ], @@ -7973,12 +8008,12 @@ "properties": [ { "name": "form", - "type": 42, + "type": 87, "optional": false }, { "name": "kind", - "type": 47, + "type": 41, "optional": false } ], @@ -7989,12 +8024,12 @@ "properties": [ { "name": "form", - "type": 42, + "type": 87, "optional": false }, { "name": "kind", - "type": 49, + "type": 43, "optional": false } ], @@ -8005,7 +8040,7 @@ "properties": [ { "name": "form", - "type": 42, + "type": 48, "optional": false }, { @@ -8021,7 +8056,7 @@ "properties": [ { "name": "form", - "type": 42, + "type": 48, "optional": false }, { @@ -8037,7 +8072,7 @@ "properties": [ { "name": "form", - "type": 42, + "type": 48, "optional": false }, { @@ -8053,7 +8088,7 @@ "properties": [ { "name": "form", - "type": 42, + "type": 48, "optional": false }, { @@ -8069,7 +8104,7 @@ "properties": [ { "name": "form", - "type": 42, + "type": 48, "optional": false }, { @@ -8085,7 +8120,7 @@ "properties": [ { "name": "form", - "type": 42, + "type": 48, "optional": false }, { @@ -8101,7 +8136,7 @@ "properties": [ { "name": "form", - "type": 42, + "type": 48, "optional": false }, { @@ -8117,7 +8152,7 @@ "properties": [ { "name": "form", - "type": 42, + "type": 48, "optional": false }, { @@ -8133,7 +8168,7 @@ "properties": [ { "name": "form", - "type": 42, + "type": 48, "optional": false }, { @@ -8149,7 +8184,7 @@ "properties": [ { "name": "form", - "type": 42, + "type": 48, "optional": false }, { @@ -8165,12 +8200,60 @@ "properties": [ { "name": "form", - "type": 111, + "type": 48, "optional": false }, { "name": "kind", - "type": 45, + "type": 39, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 48, + "optional": false + }, + { + "name": "kind", + "type": 41, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 48, + "optional": false + }, + { + "name": "kind", + "type": 43, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 114, + "optional": false + }, + { + "name": "kind", + "type": 51, "optional": false } ], @@ -8185,55 +8268,7 @@ "properties": [ { "name": "form", - "type": 111, - "optional": false - }, - { - "name": "kind", - "type": 47, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 111, - "optional": false - }, - { - "name": "kind", - "type": 49, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 111, - "optional": false - }, - { - "name": "kind", - "type": 51, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 111, + "type": 114, "optional": false }, { @@ -8249,7 +8284,7 @@ "properties": [ { "name": "form", - "type": 111, + "type": 114, "optional": false }, { @@ -8265,7 +8300,7 @@ "properties": [ { "name": "form", - "type": 111, + "type": 114, "optional": false }, { @@ -8281,7 +8316,7 @@ "properties": [ { "name": "form", - "type": 111, + "type": 114, "optional": false }, { @@ -8297,7 +8332,7 @@ "properties": [ { "name": "form", - "type": 111, + "type": 114, "optional": false }, { @@ -8313,7 +8348,7 @@ "properties": [ { "name": "form", - "type": 111, + "type": 114, "optional": false }, { @@ -8329,7 +8364,7 @@ "properties": [ { "name": "form", - "type": 111, + "type": 114, "optional": false }, { @@ -8345,7 +8380,7 @@ "properties": [ { "name": "form", - "type": 111, + "type": 114, "optional": false }, { @@ -8361,7 +8396,7 @@ "properties": [ { "name": "form", - "type": 111, + "type": 114, "optional": false }, { @@ -8372,6 +8407,54 @@ ], "indices": [] }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 114, + "optional": false + }, + { + "name": "kind", + "type": 39, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 114, + "optional": false + }, + { + "name": "kind", + "type": 41, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 114, + "optional": false + }, + { + "name": "kind", + "type": 43, + "optional": false + } + ], + "indices": [] + }, { "kind": "object", "properties": [ @@ -8382,7 +8465,7 @@ }, { "name": "kind", - "type": 125, + "type": 128, "optional": false }, { @@ -8407,7 +8490,7 @@ "properties": [ { "name": "kind", - "type": 127, + "type": 130, "optional": false }, { @@ -8442,7 +8525,7 @@ "properties": [ { "name": "kind", - "type": 129, + "type": 132, "optional": false }, { @@ -8457,7 +8540,7 @@ }, { "name": "replayState", - "type": 130, + "type": 133, "optional": true } ], @@ -8471,39 +8554,6 @@ "kind": "opaque", "reason": "unknown" }, - { - "kind": "object", - "properties": [ - { - "name": "kind", - "type": 45, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "kind", - "type": 47, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "kind", - "type": 49, - "optional": false - } - ], - "indices": [] - }, { "kind": "object", "properties": [ @@ -8526,21 +8576,6 @@ ], "indices": [] }, - { - "kind": "object", - "properties": [ - { - "name": "kind", - "type": 137, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "ptc-mode" - }, { "kind": "object", "properties": [ @@ -8579,7 +8614,7 @@ "properties": [ { "name": "kind", - "type": 142, + "type": 140, "optional": false } ], @@ -8587,7 +8622,7 @@ }, { "kind": "literal", - "value": "system-prompt" + "value": "ptc-mode" }, { "kind": "object", @@ -8622,6 +8657,21 @@ ], "indices": [] }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 145, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "system-prompt" + }, { "kind": "object", "properties": [ @@ -8633,36 +8683,6 @@ ], "indices": [] }, - { - "kind": "object", - "properties": [ - { - "name": "kind", - "type": 148, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "tool-registry" - }, - { - "kind": "object", - "properties": [ - { - "name": "kind", - "type": 150, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "user" - }, { "kind": "object", "properties": [ @@ -8674,6 +8694,43 @@ ], "indices": [] }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 39, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 41, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 151, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "tool-registry" + }, { "kind": "object", "properties": [ @@ -8685,6 +8742,32 @@ ], "indices": [] }, + { + "kind": "literal", + "value": "user" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 43, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 156, + "optional": false + } + ], + "indices": [] + }, { "kind": "literal", "value": "working-directory" @@ -8694,7 +8777,7 @@ "properties": [ { "name": "kind", - "type": 155, + "type": 158, "optional": false } ], @@ -8719,17 +8802,17 @@ }, { "name": "changes", - "type": 157, + "type": 160, "optional": false }, { "name": "form", - "type": 70, + "type": 73, "optional": false }, { "name": "kind", - "type": 163, + "type": 166, "optional": false } ], @@ -8737,14 +8820,14 @@ }, { "kind": "array", - "element": 158 + "element": 161 }, { "kind": "object", "properties": [ { "name": "action", - "type": 159, + "type": 162, "optional": false }, { @@ -8768,9 +8851,9 @@ { "kind": "union", "types": [ - 160, - 161, - 162 + 163, + 164, + 165 ] }, { @@ -8799,12 +8882,12 @@ }, { "name": "kind", - "type": 165, + "type": 168, "optional": false }, { "name": "outcome", - "type": 166, + "type": 169, "optional": false } ], @@ -8828,7 +8911,7 @@ }, { "name": "kind", - "type": 168, + "type": 171, "optional": false } ], @@ -8848,9 +8931,14 @@ }, { "name": "kind", - "type": 150, + "type": 153, "optional": false }, + { + "name": "principal", + "type": 173, + "optional": true + }, { "name": "rpcId", "type": 7, @@ -8859,6 +8947,21 @@ ], "indices": [] }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 174, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "owner" + }, { "kind": "object", "properties": [ @@ -8869,7 +8972,7 @@ }, { "name": "kind", - "type": 171, + "type": 176, "optional": false }, { @@ -8894,12 +8997,12 @@ }, { "name": "form", - "type": 39, + "type": 45, "optional": false }, { "name": "kind", - "type": 173, + "type": 178, "optional": false }, { @@ -8934,17 +9037,17 @@ "properties": [ { "name": "entries", - "type": 175, + "type": 180, "optional": false }, { "name": "form", - "type": 111, + "type": 114, "optional": false }, { "name": "kind", - "type": 177, + "type": 182, "optional": false }, { @@ -8957,7 +9060,7 @@ }, { "kind": "array", - "element": 176 + "element": 181 }, { "kind": "object", @@ -8984,12 +9087,12 @@ "properties": [ { "name": "form", - "type": 70, + "type": 73, "optional": false }, { "name": "kind", - "type": 179, + "type": 184, "optional": false }, { @@ -9009,22 +9112,22 @@ "properties": [ { "name": "form", - "type": 84, + "type": 87, "optional": false }, { "name": "kind", - "type": 181, + "type": 186, "optional": false }, { "name": "references", - "type": 182, + "type": 187, "optional": false }, { "name": "version", - "type": 187, + "type": 192, "optional": false } ], @@ -9036,7 +9139,7 @@ }, { "kind": "array", - "element": 183 + "element": 188 }, { "kind": "object", @@ -9048,12 +9151,12 @@ }, { "name": "capturedThroughSeq", - "type": 184, + "type": 189, "optional": false }, { "name": "compacted", - "type": 186, + "type": 191, "optional": false }, { @@ -9093,7 +9196,7 @@ }, { "name": "truncated", - "type": 186, + "type": 191, "optional": false } ], @@ -9103,7 +9206,7 @@ "kind": "union", "types": [ 2, - 185 + 190 ] }, { @@ -9118,69 +9221,6 @@ "kind": "literal", "value": 1 }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 34, - "optional": false - }, - { - "name": "kind", - "type": 45, - "optional": false - }, - { - "name": "sections", - "type": 36, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 34, - "optional": false - }, - { - "name": "kind", - "type": 47, - "optional": false - }, - { - "name": "sections", - "type": 36, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 34, - "optional": false - }, - { - "name": "kind", - "type": 49, - "optional": false - }, - { - "name": "sections", - "type": 36, - "optional": false - } - ], - "indices": [] - }, { "kind": "object", "properties": [ @@ -9377,8 +9417,8 @@ { "kind": "union", "types": [ - 202, - 203 + 204, + 205 ] }, { @@ -9395,7 +9435,7 @@ }, { "name": "op", - "type": 161, + "type": 164, "optional": false }, { @@ -10339,6 +10379,297 @@ ] } }, + { + "key": "event:job/registered", + "kind": "event", + "event": "job/registered", + "surface": false, + "digest": "f5520c1d261ca86ea03735799c311a0a9ede8babb588cf0a65422576d2ee0ad1", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "object", + "properties": [ + { + "name": "data", + "type": 1, + "optional": false + }, + { + "name": "ignorable", + "type": 5, + "optional": true + }, + { + "name": "seq", + "type": 3, + "optional": false + }, + { + "name": "time", + "type": 3, + "optional": false + }, + { + "name": "type", + "type": 6, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "jobId", + "type": 2, + "optional": false + }, + { + "name": "kind", + "type": 2, + "optional": false + }, + { + "name": "label", + "type": 2, + "optional": false + }, + { + "name": "owner", + "type": 2, + "optional": false + }, + { + "name": "startedAt", + "type": 3, + "optional": false + }, + { + "name": "toolCall", + "type": 4, + "optional": true + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "primitive", + "type": "number" + }, + { + "kind": "object", + "properties": [ + { + "name": "callId", + "type": 2, + "optional": false + }, + { + "name": "rootCallId", + "type": 2, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": true + }, + { + "kind": "literal", + "value": "job/registered" + } + ] + } + }, + { + "key": "event:job/settled", + "kind": "event", + "event": "job/settled", + "surface": false, + "digest": "edd37b1e4974af648633014435572089b1e8fb71910468a0767a7e28fdd22abf", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "object", + "properties": [ + { + "name": "data", + "type": 1, + "optional": false + }, + { + "name": "ignorable", + "type": 14, + "optional": true + }, + { + "name": "seq", + "type": 7, + "optional": false + }, + { + "name": "time", + "type": 7, + "optional": false + }, + { + "name": "type", + "type": 15, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "cause", + "type": 2, + "optional": false + }, + { + "name": "detail", + "type": 6, + "optional": true + }, + { + "name": "finishedAt", + "type": 7, + "optional": false + }, + { + "name": "jobId", + "type": 6, + "optional": false + }, + { + "name": "kind", + "type": 6, + "optional": false + }, + { + "name": "label", + "type": 6, + "optional": false + }, + { + "name": "owner", + "type": 6, + "optional": false + }, + { + "name": "producerReported", + "type": 8, + "optional": false + }, + { + "name": "startedAt", + "type": 7, + "optional": false + }, + { + "name": "status", + "type": 9, + "optional": false + }, + { + "name": "toolCall", + "type": 13, + "optional": true + } + ], + "indices": [] + }, + { + "kind": "union", + "types": [ + 3, + 4, + 5 + ] + }, + { + "kind": "literal", + "value": "producer" + }, + { + "kind": "literal", + "value": "teardown" + }, + { + "kind": "literal", + "value": "kill" + }, + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "primitive", + "type": "number" + }, + { + "kind": "primitive", + "type": "boolean" + }, + { + "kind": "union", + "types": [ + 10, + 11, + 12 + ] + }, + { + "kind": "literal", + "value": "completed" + }, + { + "kind": "literal", + "value": "failed" + }, + { + "kind": "literal", + "value": "killed" + }, + { + "kind": "object", + "properties": [ + { + "name": "callId", + "type": 6, + "optional": false + }, + { + "name": "rootCallId", + "type": 6, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": true + }, + { + "kind": "literal", + "value": "job/settled" + } + ] + } + }, { "key": "event:llm/retry", "kind": "event", @@ -11890,7 +12221,7 @@ "kind": "event", "event": "session/title-llm-request", "surface": false, - "digest": "b33545b3244861bcbf73b407d24b53402710f5acacab7ae939537e5504a68dc6", + "digest": "79a4c87f8bfb4d6d05cd9952b99210d6a7db3b478c4a01d94b4c50602308c056", "schema": { "root": 0, "nodes": [ @@ -11919,7 +12250,7 @@ }, { "name": "type", - "type": 210, + "type": 212, "optional": false } ], @@ -11950,7 +12281,7 @@ }, { "name": "route", - "type": 209, + "type": 211, "optional": false }, { @@ -11983,9 +12314,9 @@ "types": [ 6, 37, - 204, - 205, - 207 + 206, + 207, + 209 ] }, { @@ -12392,7 +12723,7 @@ 51, 53, 55, - 56, + 57, 58, 60, 62, @@ -12401,28 +12732,25 @@ 67, 69, 71, - 72, 73, - 76, + 75, + 77, + 78, 79, + 80, 81, - 83, - 92, - 95, - 97, + 84, + 87, + 89, 98, - 100, - 102, + 101, 104, - 109, - 111, + 106, + 108, + 110, + 115, + 117, 119, - 120, - 121, - 123, - 124, - 125, - 126, 127, 128, 129, @@ -12486,15 +12814,18 @@ 187, 188, 189, + 190, 191, 192, + 193, 194, - 197, + 195, + 196, 198, - 199, 200, - 201, - 203 + 203, + 204, + 205 ] }, { @@ -12524,7 +12855,7 @@ }, { "kind": "literal", - "value": "tool-goal" + "value": "time-context" }, { "kind": "array", @@ -12559,7 +12890,7 @@ }, { "kind": "literal", - "value": "runtime-context" + "value": "plan-mode" }, { "kind": "object", @@ -12574,7 +12905,7 @@ }, { "kind": "literal", - "value": "schedule" + "value": "ptc-mode" }, { "kind": "object", @@ -12589,7 +12920,7 @@ }, { "kind": "literal", - "value": "system-prompt" + "value": "repeat-tool-reminder" }, { "kind": "object", @@ -12604,7 +12935,7 @@ }, { "kind": "literal", - "value": "time-context" + "value": "runtime-context" }, { "kind": "object", @@ -12619,7 +12950,22 @@ }, { "kind": "literal", - "value": "tmux-context" + "value": "schedule" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 56, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "system-prompt" }, { "kind": "object", @@ -12632,21 +12978,6 @@ ], "indices": [] }, - { - "kind": "object", - "properties": [ - { - "name": "kind", - "type": 57, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "tool-jobs" - }, { "kind": "object", "properties": [ @@ -12660,7 +12991,7 @@ }, { "kind": "literal", - "value": "tool-registry" + "value": "tmux-context" }, { "kind": "object", @@ -12675,7 +13006,7 @@ }, { "kind": "literal", - "value": "user" + "value": "tool-goal" }, { "kind": "object", @@ -12687,7 +13018,7 @@ }, { "name": "kind", - "type": 57, + "type": 59, "optional": false }, { @@ -12711,7 +13042,7 @@ }, { "kind": "literal", - "value": "user-approval" + "value": "tool-jobs" }, { "kind": "object", @@ -12726,7 +13057,7 @@ }, { "kind": "literal", - "value": "working-directory" + "value": "tool-registry" }, { "kind": "object", @@ -12741,7 +13072,7 @@ }, { "kind": "literal", - "value": "cordis-host-runner" + "value": "user" }, { "kind": "object", @@ -12754,10 +13085,76 @@ ], "indices": [] }, + { + "kind": "literal", + "value": "user-approval" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 72, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "working-directory" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 74, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "cordis-host-runner" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 76, + "optional": false + } + ], + "indices": [] + }, { "kind": "literal", "value": "dsh-session-title-llm" }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 41, + "optional": false + }, + { + "name": "kind", + "type": 61, + "optional": false + }, + { + "name": "sections", + "type": 43, + "optional": false + } + ], + "indices": [] + }, { "kind": "object", "properties": [ @@ -12805,12 +13202,33 @@ "properties": [ { "name": "form", - "type": 74, + "type": 41, "optional": false }, { "name": "kind", - "type": 75, + "type": 76, + "optional": false + }, + { + "name": "sections", + "type": 43, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 82, + "optional": false + }, + { + "name": "kind", + "type": 83, "optional": false }, { @@ -12839,12 +13257,12 @@ "properties": [ { "name": "form", - "type": 77, + "type": 85, "optional": false }, { "name": "kind", - "type": 78, + "type": 86, "optional": false }, { @@ -12868,12 +13286,12 @@ "properties": [ { "name": "form", - "type": 74, + "type": 82, "optional": false }, { "name": "kind", - "type": 80, + "type": 88, "optional": false }, { @@ -12888,31 +13306,6 @@ "kind": "literal", "value": "hooks-claude-code" }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 74, - "optional": false - }, - { - "name": "kind", - "type": 82, - "optional": false - }, - { - "name": "summary", - "type": 10, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "hooks-codex" - }, { "kind": "object", "properties": [ @@ -12928,17 +13321,17 @@ }, { "name": "changes", - "type": 84, - "optional": false - }, - { - "name": "form", "type": 90, "optional": false }, + { + "name": "form", + "type": 96, + "optional": false + }, { "name": "kind", - "type": 91, + "type": 97, "optional": false } ], @@ -12946,14 +13339,14 @@ }, { "kind": "array", - "element": 85 + "element": 91 }, { "kind": "object", "properties": [ { "name": "action", - "type": 86, + "type": 92, "optional": false }, { @@ -12977,9 +13370,9 @@ { "kind": "union", "types": [ - 87, - 88, - 89 + 93, + 94, + 95 ] }, { @@ -13012,12 +13405,12 @@ }, { "name": "kind", - "type": 93, + "type": 99, "optional": false }, { "name": "outcome", - "type": 94, + "type": 100, "optional": false } ], @@ -13031,31 +13424,6 @@ "kind": "literal", "value": "answered" }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 74, - "optional": false - }, - { - "name": "kind", - "type": 96, - "optional": false - }, - { - "name": "summary", - "type": 10, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "model-selection" - }, { "kind": "object", "properties": [ @@ -13066,9 +13434,14 @@ }, { "name": "kind", - "type": 61, + "type": 68, "optional": false }, + { + "name": "principal", + "type": 102, + "optional": true + }, { "name": "rpcId", "type": 10, @@ -13077,6 +13450,21 @@ ], "indices": [] }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 103, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "owner" + }, { "kind": "object", "properties": [ @@ -13087,7 +13475,7 @@ }, { "name": "kind", - "type": 99, + "type": 105, "optional": false }, { @@ -13107,12 +13495,12 @@ "properties": [ { "name": "form", - "type": 74, + "type": 82, "optional": false }, { "name": "kind", - "type": 101, + "type": 107, "optional": false }, { @@ -13125,7 +13513,7 @@ }, { "kind": "literal", - "value": "plan-mode" + "value": "hooks-codex" }, { "kind": "object", @@ -13137,12 +13525,12 @@ }, { "name": "form", - "type": 74, + "type": 82, "optional": false }, { "name": "kind", - "type": 103, + "type": 109, "optional": false }, { @@ -13177,17 +13565,17 @@ "properties": [ { "name": "entries", - "type": 105, + "type": 111, "optional": false }, { "name": "form", - "type": 107, + "type": 113, "optional": false }, { "name": "kind", - "type": 108, + "type": 114, "optional": false }, { @@ -13200,7 +13588,7 @@ }, { "kind": "array", - "element": 106 + "element": 112 }, { "kind": "object", @@ -13231,12 +13619,37 @@ "properties": [ { "name": "form", - "type": 90, + "type": 82, "optional": false }, { "name": "kind", - "type": 110, + "type": 116, + "optional": false + }, + { + "name": "summary", + "type": 10, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "model-selection" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 96, + "optional": false + }, + { + "name": "kind", + "type": 118, "optional": false }, { @@ -13256,22 +13669,22 @@ "properties": [ { "name": "form", - "type": 112, + "type": 120, "optional": false }, { "name": "kind", - "type": 113, + "type": 121, "optional": false }, { "name": "references", - "type": 114, + "type": 122, "optional": false }, { "name": "version", - "type": 118, + "type": 126, "optional": false } ], @@ -13287,7 +13700,7 @@ }, { "kind": "array", - "element": 115 + "element": 123 }, { "kind": "object", @@ -13299,7 +13712,7 @@ }, { "name": "capturedThroughSeq", - "type": 116, + "type": 124, "optional": false }, { @@ -13354,7 +13767,7 @@ "kind": "union", "types": [ 2, - 117 + 125 ] }, { @@ -13375,7 +13788,7 @@ }, { "name": "kind", - "type": 80, + "type": 88, "optional": false }, { @@ -13391,76 +13804,9 @@ "properties": [ { "name": "form", - "type": 41, - "optional": false - }, - { - "name": "kind", "type": 82, "optional": false }, - { - "name": "sections", - "type": 43, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 74, - "optional": false - }, - { - "name": "kind", - "type": 122, - "optional": false - }, - { - "name": "summary", - "type": 10, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "repeat-tool-reminder" - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 41, - "optional": false - }, - { - "name": "kind", - "type": 96, - "optional": false - }, - { - "name": "sections", - "type": 43, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 74, - "optional": false - }, { "name": "kind", "type": 46, @@ -13479,12 +13825,12 @@ "properties": [ { "name": "form", - "type": 74, + "type": 82, "optional": false }, { "name": "kind", - "type": 48, + "type": 50, "optional": false }, { @@ -13500,7 +13846,7 @@ "properties": [ { "name": "form", - "type": 74, + "type": 82, "optional": false }, { @@ -13521,7 +13867,7 @@ "properties": [ { "name": "form", - "type": 74, + "type": 82, "optional": false }, { @@ -13542,7 +13888,7 @@ "properties": [ { "name": "form", - "type": 74, + "type": 82, "optional": false }, { @@ -13563,12 +13909,12 @@ "properties": [ { "name": "form", - "type": 74, + "type": 82, "optional": false }, { "name": "kind", - "type": 57, + "type": 59, "optional": false }, { @@ -13584,7 +13930,28 @@ "properties": [ { "name": "form", - "type": 74, + "type": 82, + "optional": false + }, + { + "name": "kind", + "type": 61, + "optional": false + }, + { + "name": "summary", + "type": 10, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 82, "optional": false }, { @@ -13605,7 +13972,7 @@ "properties": [ { "name": "form", - "type": 74, + "type": 82, "optional": false }, { @@ -13626,29 +13993,18 @@ "properties": [ { "name": "form", - "type": 90, - "optional": false - }, - { - "name": "kind", - "type": 80, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 90, - "optional": false - }, - { - "name": "kind", "type": 82, "optional": false + }, + { + "name": "kind", + "type": 76, + "optional": false + }, + { + "name": "summary", + "type": 10, + "optional": false } ], "indices": [] @@ -13658,13 +14014,13 @@ "properties": [ { "name": "form", - "type": 90, - "optional": false - }, - { - "name": "kind", "type": 96, "optional": false + }, + { + "name": "kind", + "type": 88, + "optional": false } ], "indices": [] @@ -13674,12 +14030,12 @@ "properties": [ { "name": "form", - "type": 90, + "type": 96, "optional": false }, { "name": "kind", - "type": 101, + "type": 107, "optional": false } ], @@ -13690,12 +14046,12 @@ "properties": [ { "name": "form", - "type": 90, + "type": 96, "optional": false }, { "name": "kind", - "type": 122, + "type": 116, "optional": false } ], @@ -13706,7 +14062,7 @@ "properties": [ { "name": "form", - "type": 90, + "type": 96, "optional": false }, { @@ -13722,12 +14078,12 @@ "properties": [ { "name": "form", - "type": 90, + "type": 96, "optional": false }, { "name": "kind", - "type": 48, + "type": 50, "optional": false } ], @@ -13738,7 +14094,7 @@ "properties": [ { "name": "form", - "type": 90, + "type": 96, "optional": false }, { @@ -13759,7 +14115,7 @@ }, { "name": "kind", - "type": 101, + "type": 107, "optional": false }, { @@ -13775,7 +14131,7 @@ "properties": [ { "name": "form", - "type": 90, + "type": 96, "optional": false }, { @@ -13791,7 +14147,7 @@ "properties": [ { "name": "form", - "type": 90, + "type": 96, "optional": false }, { @@ -13807,12 +14163,12 @@ "properties": [ { "name": "form", - "type": 90, + "type": 96, "optional": false }, { "name": "kind", - "type": 57, + "type": 59, "optional": false } ], @@ -13823,7 +14179,23 @@ "properties": [ { "name": "form", - "type": 90, + "type": 96, + "optional": false + }, + { + "name": "kind", + "type": 61, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 96, "optional": false }, { @@ -13839,7 +14211,7 @@ "properties": [ { "name": "form", - "type": 90, + "type": 96, "optional": false }, { @@ -13855,45 +14227,13 @@ "properties": [ { "name": "form", - "type": 112, - "optional": false - }, - { - "name": "kind", - "type": 80, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 112, - "optional": false - }, - { - "name": "kind", - "type": 82, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 112, - "optional": false - }, - { - "name": "kind", "type": 96, "optional": false + }, + { + "name": "kind", + "type": 76, + "optional": false } ], "indices": [] @@ -13903,12 +14243,28 @@ "properties": [ { "name": "form", - "type": 112, + "type": 120, "optional": false }, { "name": "kind", - "type": 101, + "type": 88, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 120, + "optional": false + }, + { + "name": "kind", + "type": 107, "optional": false } ], @@ -13924,7 +14280,7 @@ }, { "name": "kind", - "type": 122, + "type": 116, "optional": false }, { @@ -13940,12 +14296,12 @@ "properties": [ { "name": "form", - "type": 112, + "type": 120, "optional": false }, { "name": "kind", - "type": 122, + "type": 116, "optional": false } ], @@ -13956,7 +14312,7 @@ "properties": [ { "name": "form", - "type": 112, + "type": 120, "optional": false }, { @@ -13972,12 +14328,12 @@ "properties": [ { "name": "form", - "type": 112, + "type": 120, "optional": false }, { "name": "kind", - "type": 48, + "type": 50, "optional": false } ], @@ -13988,7 +14344,7 @@ "properties": [ { "name": "form", - "type": 112, + "type": 120, "optional": false }, { @@ -14004,7 +14360,7 @@ "properties": [ { "name": "form", - "type": 112, + "type": 120, "optional": false }, { @@ -14020,7 +14376,7 @@ "properties": [ { "name": "form", - "type": 112, + "type": 120, "optional": false }, { @@ -14036,12 +14392,12 @@ "properties": [ { "name": "form", - "type": 112, + "type": 120, "optional": false }, { "name": "kind", - "type": 57, + "type": 59, "optional": false } ], @@ -14052,7 +14408,23 @@ "properties": [ { "name": "form", - "type": 112, + "type": 120, + "optional": false + }, + { + "name": "kind", + "type": 61, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 120, "optional": false }, { @@ -14063,22 +14435,6 @@ ], "indices": [] }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 112, - "optional": false - }, - { - "name": "kind", - "type": 70, - "optional": false - } - ], - "indices": [] - }, { "kind": "object", "properties": [ @@ -14105,12 +14461,12 @@ "properties": [ { "name": "form", - "type": 77, + "type": 120, "optional": false }, { "name": "kind", - "type": 80, + "type": 70, "optional": false } ], @@ -14121,12 +14477,12 @@ "properties": [ { "name": "form", - "type": 77, + "type": 120, "optional": false }, { "name": "kind", - "type": 82, + "type": 76, "optional": false } ], @@ -14137,12 +14493,12 @@ "properties": [ { "name": "form", - "type": 77, + "type": 85, "optional": false }, { "name": "kind", - "type": 96, + "type": 88, "optional": false } ], @@ -14153,12 +14509,12 @@ "properties": [ { "name": "form", - "type": 77, + "type": 85, "optional": false }, { "name": "kind", - "type": 101, + "type": 107, "optional": false } ], @@ -14169,12 +14525,12 @@ "properties": [ { "name": "form", - "type": 77, + "type": 85, "optional": false }, { "name": "kind", - "type": 122, + "type": 116, "optional": false } ], @@ -14185,7 +14541,7 @@ "properties": [ { "name": "form", - "type": 77, + "type": 85, "optional": false }, { @@ -14201,12 +14557,12 @@ "properties": [ { "name": "form", - "type": 77, + "type": 85, "optional": false }, { "name": "kind", - "type": 48, + "type": 50, "optional": false } ], @@ -14217,7 +14573,7 @@ "properties": [ { "name": "form", - "type": 77, + "type": 85, "optional": false }, { @@ -14233,7 +14589,7 @@ "properties": [ { "name": "form", - "type": 77, + "type": 85, "optional": false }, { @@ -14254,7 +14610,7 @@ }, { "name": "kind", - "type": 48, + "type": 50, "optional": false }, { @@ -14270,7 +14626,7 @@ "properties": [ { "name": "form", - "type": 77, + "type": 85, "optional": false }, { @@ -14286,12 +14642,12 @@ "properties": [ { "name": "form", - "type": 77, + "type": 85, "optional": false }, { "name": "kind", - "type": 57, + "type": 59, "optional": false } ], @@ -14302,7 +14658,23 @@ "properties": [ { "name": "form", - "type": 77, + "type": 85, + "optional": false + }, + { + "name": "kind", + "type": 61, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 85, "optional": false }, { @@ -14318,7 +14690,7 @@ "properties": [ { "name": "form", - "type": 77, + "type": 85, "optional": false }, { @@ -14334,12 +14706,12 @@ "properties": [ { "name": "form", - "type": 107, + "type": 85, "optional": false }, { "name": "kind", - "type": 80, + "type": 76, "optional": false } ], @@ -14350,12 +14722,12 @@ "properties": [ { "name": "form", - "type": 107, + "type": 113, "optional": false }, { "name": "kind", - "type": 82, + "type": 88, "optional": false } ], @@ -14366,12 +14738,12 @@ "properties": [ { "name": "form", - "type": 107, + "type": 113, "optional": false }, { "name": "kind", - "type": 96, + "type": 107, "optional": false } ], @@ -14382,12 +14754,12 @@ "properties": [ { "name": "form", - "type": 107, + "type": 113, "optional": false }, { "name": "kind", - "type": 101, + "type": 116, "optional": false } ], @@ -14398,23 +14770,7 @@ "properties": [ { "name": "form", - "type": 107, - "optional": false - }, - { - "name": "kind", - "type": 122, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 107, + "type": 113, "optional": false }, { @@ -14451,12 +14807,12 @@ "properties": [ { "name": "form", - "type": 107, + "type": 113, "optional": false }, { "name": "kind", - "type": 48, + "type": 50, "optional": false } ], @@ -14467,7 +14823,7 @@ "properties": [ { "name": "form", - "type": 107, + "type": 113, "optional": false }, { @@ -14483,7 +14839,7 @@ "properties": [ { "name": "form", - "type": 107, + "type": 113, "optional": false }, { @@ -14499,7 +14855,7 @@ "properties": [ { "name": "form", - "type": 107, + "type": 113, "optional": false }, { @@ -14515,12 +14871,12 @@ "properties": [ { "name": "form", - "type": 107, + "type": 113, "optional": false }, { "name": "kind", - "type": 57, + "type": 59, "optional": false } ], @@ -14531,7 +14887,23 @@ "properties": [ { "name": "form", - "type": 107, + "type": 113, + "optional": false + }, + { + "name": "kind", + "type": 61, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 113, "optional": false }, { @@ -14547,7 +14919,7 @@ "properties": [ { "name": "form", - "type": 107, + "type": 113, "optional": false }, { @@ -14558,6 +14930,43 @@ ], "indices": [] }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 113, + "optional": false + }, + { + "name": "kind", + "type": 76, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 41, + "optional": false + }, + { + "name": "kind", + "type": 54, + "optional": false + }, + { + "name": "sections", + "type": 43, + "optional": false + } + ], + "indices": [] + }, { "kind": "object", "properties": [ @@ -14568,7 +14977,7 @@ }, { "name": "kind", - "type": 190, + "type": 197, "optional": false }, { @@ -14591,30 +15000,9 @@ { "kind": "object", "properties": [ - { - "name": "form", - "type": 41, - "optional": false - }, { "name": "kind", - "type": 54, - "optional": false - }, - { - "name": "sections", - "type": 43, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "kind", - "type": 193, + "type": 199, "optional": false }, { @@ -14649,7 +15037,7 @@ "properties": [ { "name": "kind", - "type": 195, + "type": 201, "optional": false }, { @@ -14664,7 +15052,7 @@ }, { "name": "replayState", - "type": 196, + "type": 202, "optional": true } ], @@ -14683,7 +15071,7 @@ "properties": [ { "name": "kind", - "type": 80, + "type": 88, "optional": false } ], @@ -14694,7 +15082,7 @@ "properties": [ { "name": "kind", - "type": 82, + "type": 107, "optional": false } ], @@ -14705,44 +15093,7 @@ "properties": [ { "name": "kind", - "type": 96, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "kind", - "type": 101, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "kind", - "type": 202, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "ptc-mode" - }, - { - "kind": "object", - "properties": [ - { - "name": "kind", - "type": 122, + "type": 116, "optional": false } ], @@ -14763,7 +15114,7 @@ }, { "name": "role", - "type": 61, + "type": 68, "optional": false }, { @@ -14801,12 +15152,12 @@ }, { "name": "role", - "type": 206, + "type": 208, "optional": false }, { "name": "source", - "type": 194, + "type": 200, "optional": false } ], @@ -14831,12 +15182,12 @@ }, { "name": "role", - "type": 208, + "type": 210, "optional": false }, { "name": "source", - "type": 49, + "type": 55, "optional": false } ], @@ -18857,7 +19208,7 @@ "kind": "event", "event": "user/message", "surface": true, - "digest": "2ace3b8059b82b6d9837fed518ca97a99eb37c4482df7aad0e71c85489428ade", + "digest": "2ae8594a998c9862a597129e782277bcc518266712a703b5c3161bdf552d13b8", "schema": { "root": 0, "nodes": [ @@ -18881,12 +19232,12 @@ }, { "name": "sourceEventSeqs", - "type": 198, + "type": 200, "optional": true }, { "name": "surfaceOp", - "type": 199, + "type": 201, "optional": false }, { @@ -18896,7 +19247,7 @@ }, { "name": "type", - "type": 202, + "type": 204, "optional": false } ], @@ -19253,7 +19604,7 @@ 49, 51, 53, - 56, + 55, 58, 60, 61, @@ -19266,7 +19617,7 @@ 68, 69, 70, - 72, + 71, 73, 74, 75, @@ -19279,7 +19630,7 @@ 82, 83, 84, - 86, + 85, 87, 88, 89, @@ -19292,7 +19643,7 @@ 96, 97, 98, - 100, + 99, 101, 102, 103, @@ -19305,40 +19656,38 @@ 110, 111, 112, - 114, - 116, - 119, + 113, + 115, + 117, 120, 121, 122, 123, - 125, + 124, 126, 127, 128, - 130, + 129, 131, 132, 133, 134, - 136, + 135, 137, 138, - 140, - 142, + 139, + 141, 143, - 151, - 154, - 156, + 144, + 152, + 155, 157, - 159, - 161, - 165, - 167, - 175, - 179, - 180, - 181, + 160, + 162, + 164, + 168, + 170, + 178, 182, 183, 184, @@ -19349,10 +19698,12 @@ 189, 190, 191, + 192, 193, - 195, + 194, 196, - 197 + 198, + 199 ] }, { @@ -19382,7 +19733,7 @@ }, { "kind": "literal", - "value": "plan-mode" + "value": "model-selection" }, { "kind": "object", @@ -19405,6 +19756,31 @@ ], "indices": [] }, + { + "kind": "literal", + "value": "plan-mode" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 33, + "optional": false + }, + { + "name": "kind", + "type": 38, + "optional": false + }, + { + "name": "summary", + "type": 5, + "optional": false + } + ], + "indices": [] + }, { "kind": "literal", "value": "repeat-tool-reminder" @@ -19419,7 +19795,7 @@ }, { "name": "kind", - "type": 38, + "type": 40, "optional": false }, { @@ -19444,7 +19820,7 @@ }, { "name": "kind", - "type": 40, + "type": 42, "optional": false }, { @@ -19469,7 +19845,7 @@ }, { "name": "kind", - "type": 42, + "type": 44, "optional": false }, { @@ -19494,7 +19870,7 @@ }, { "name": "kind", - "type": 44, + "type": 46, "optional": false }, { @@ -19519,7 +19895,7 @@ }, { "name": "kind", - "type": 46, + "type": 48, "optional": false }, { @@ -19544,7 +19920,7 @@ }, { "name": "kind", - "type": 48, + "type": 50, "optional": false }, { @@ -19569,7 +19945,7 @@ }, { "name": "kind", - "type": 50, + "type": 52, "optional": false }, { @@ -19594,7 +19970,7 @@ }, { "name": "kind", - "type": 52, + "type": 54, "optional": false }, { @@ -19614,12 +19990,12 @@ "properties": [ { "name": "form", - "type": 54, + "type": 56, "optional": false }, { "name": "kind", - "type": 55, + "type": 57, "optional": false } ], @@ -19638,12 +20014,12 @@ "properties": [ { "name": "form", - "type": 54, + "type": 56, "optional": false }, { "name": "kind", - "type": 57, + "type": 59, "optional": false } ], @@ -19658,27 +20034,7 @@ "properties": [ { "name": "form", - "type": 54, - "optional": false - }, - { - "name": "kind", - "type": 59, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "model-selection" - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 54, + "type": 56, "optional": false }, { @@ -19694,7 +20050,7 @@ "properties": [ { "name": "form", - "type": 54, + "type": 56, "optional": false }, { @@ -19710,7 +20066,7 @@ "properties": [ { "name": "form", - "type": 54, + "type": 56, "optional": false }, { @@ -19726,7 +20082,7 @@ "properties": [ { "name": "form", - "type": 54, + "type": 56, "optional": false }, { @@ -19742,7 +20098,7 @@ "properties": [ { "name": "form", - "type": 54, + "type": 56, "optional": false }, { @@ -19758,7 +20114,7 @@ "properties": [ { "name": "form", - "type": 54, + "type": 56, "optional": false }, { @@ -19774,7 +20130,7 @@ "properties": [ { "name": "form", - "type": 54, + "type": 56, "optional": false }, { @@ -19790,7 +20146,7 @@ "properties": [ { "name": "form", - "type": 54, + "type": 56, "optional": false }, { @@ -19806,7 +20162,7 @@ "properties": [ { "name": "form", - "type": 54, + "type": 56, "optional": false }, { @@ -19822,7 +20178,7 @@ "properties": [ { "name": "form", - "type": 54, + "type": 56, "optional": false }, { @@ -19838,12 +20194,28 @@ "properties": [ { "name": "form", - "type": 71, + "type": 56, "optional": false }, { "name": "kind", - "type": 55, + "type": 54, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 72, + "optional": false + }, + { + "name": "kind", + "type": 57, "optional": false } ], @@ -19858,23 +20230,7 @@ "properties": [ { "name": "form", - "type": 71, - "optional": false - }, - { - "name": "kind", - "type": 57, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 71, + "type": 72, "optional": false }, { @@ -19890,7 +20246,7 @@ "properties": [ { "name": "form", - "type": 71, + "type": 72, "optional": false }, { @@ -19906,7 +20262,7 @@ "properties": [ { "name": "form", - "type": 71, + "type": 72, "optional": false }, { @@ -19922,7 +20278,7 @@ "properties": [ { "name": "form", - "type": 71, + "type": 72, "optional": false }, { @@ -19938,7 +20294,7 @@ "properties": [ { "name": "form", - "type": 71, + "type": 72, "optional": false }, { @@ -19954,7 +20310,7 @@ "properties": [ { "name": "form", - "type": 71, + "type": 72, "optional": false }, { @@ -19970,7 +20326,7 @@ "properties": [ { "name": "form", - "type": 71, + "type": 72, "optional": false }, { @@ -19986,7 +20342,7 @@ "properties": [ { "name": "form", - "type": 71, + "type": 72, "optional": false }, { @@ -20002,7 +20358,7 @@ "properties": [ { "name": "form", - "type": 71, + "type": 72, "optional": false }, { @@ -20018,7 +20374,7 @@ "properties": [ { "name": "form", - "type": 71, + "type": 72, "optional": false }, { @@ -20034,7 +20390,7 @@ "properties": [ { "name": "form", - "type": 71, + "type": 72, "optional": false }, { @@ -20050,12 +20406,28 @@ "properties": [ { "name": "form", - "type": 85, + "type": 72, "optional": false }, { "name": "kind", - "type": 55, + "type": 54, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 86, + "optional": false + }, + { + "name": "kind", + "type": 57, "optional": false } ], @@ -20070,23 +20442,7 @@ "properties": [ { "name": "form", - "type": 85, - "optional": false - }, - { - "name": "kind", - "type": 57, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 85, + "type": 86, "optional": false }, { @@ -20102,7 +20458,7 @@ "properties": [ { "name": "form", - "type": 85, + "type": 86, "optional": false }, { @@ -20118,7 +20474,7 @@ "properties": [ { "name": "form", - "type": 85, + "type": 86, "optional": false }, { @@ -20134,7 +20490,7 @@ "properties": [ { "name": "form", - "type": 85, + "type": 86, "optional": false }, { @@ -20150,7 +20506,7 @@ "properties": [ { "name": "form", - "type": 85, + "type": 86, "optional": false }, { @@ -20166,7 +20522,7 @@ "properties": [ { "name": "form", - "type": 85, + "type": 86, "optional": false }, { @@ -20182,7 +20538,7 @@ "properties": [ { "name": "form", - "type": 85, + "type": 86, "optional": false }, { @@ -20198,7 +20554,7 @@ "properties": [ { "name": "form", - "type": 85, + "type": 86, "optional": false }, { @@ -20214,7 +20570,7 @@ "properties": [ { "name": "form", - "type": 85, + "type": 86, "optional": false }, { @@ -20230,7 +20586,7 @@ "properties": [ { "name": "form", - "type": 85, + "type": 86, "optional": false }, { @@ -20246,7 +20602,7 @@ "properties": [ { "name": "form", - "type": 85, + "type": 86, "optional": false }, { @@ -20262,12 +20618,28 @@ "properties": [ { "name": "form", - "type": 99, + "type": 86, "optional": false }, { "name": "kind", - "type": 55, + "type": 54, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 100, + "optional": false + }, + { + "name": "kind", + "type": 57, "optional": false } ], @@ -20282,23 +20654,7 @@ "properties": [ { "name": "form", - "type": 99, - "optional": false - }, - { - "name": "kind", - "type": 57, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 99, + "type": 100, "optional": false }, { @@ -20314,7 +20670,7 @@ "properties": [ { "name": "form", - "type": 99, + "type": 100, "optional": false }, { @@ -20330,7 +20686,7 @@ "properties": [ { "name": "form", - "type": 99, + "type": 100, "optional": false }, { @@ -20346,7 +20702,7 @@ "properties": [ { "name": "form", - "type": 99, + "type": 100, "optional": false }, { @@ -20362,7 +20718,7 @@ "properties": [ { "name": "form", - "type": 99, + "type": 100, "optional": false }, { @@ -20378,7 +20734,7 @@ "properties": [ { "name": "form", - "type": 99, + "type": 100, "optional": false }, { @@ -20394,7 +20750,7 @@ "properties": [ { "name": "form", - "type": 99, + "type": 100, "optional": false }, { @@ -20410,7 +20766,7 @@ "properties": [ { "name": "form", - "type": 99, + "type": 100, "optional": false }, { @@ -20426,7 +20782,7 @@ "properties": [ { "name": "form", - "type": 99, + "type": 100, "optional": false }, { @@ -20442,7 +20798,7 @@ "properties": [ { "name": "form", - "type": 99, + "type": 100, "optional": false }, { @@ -20458,7 +20814,7 @@ "properties": [ { "name": "form", - "type": 99, + "type": 100, "optional": false }, { @@ -20469,6 +20825,22 @@ ], "indices": [] }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 100, + "optional": false + }, + { + "name": "kind", + "type": 54, + "optional": false + } + ], + "indices": [] + }, { "kind": "object", "properties": [ @@ -20479,7 +20851,7 @@ }, { "name": "kind", - "type": 113, + "type": 114, "optional": false }, { @@ -20504,7 +20876,7 @@ "properties": [ { "name": "kind", - "type": 115, + "type": 116, "optional": false }, { @@ -20539,7 +20911,7 @@ "properties": [ { "name": "kind", - "type": 117, + "type": 118, "optional": false }, { @@ -20554,7 +20926,7 @@ }, { "name": "replayState", - "type": 118, + "type": 119, "optional": true } ], @@ -20568,17 +20940,6 @@ "kind": "opaque", "reason": "unknown" }, - { - "kind": "object", - "properties": [ - { - "name": "kind", - "type": 55, - "optional": false - } - ], - "indices": [] - }, { "kind": "object", "properties": [ @@ -20617,7 +20978,18 @@ "properties": [ { "name": "kind", - "type": 124, + "type": 36, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 125, "optional": false } ], @@ -20627,17 +20999,6 @@ "kind": "literal", "value": "ptc-mode" }, - { - "kind": "object", - "properties": [ - { - "name": "kind", - "type": 36, - "optional": false - } - ], - "indices": [] - }, { "kind": "object", "properties": [ @@ -20665,7 +21026,18 @@ "properties": [ { "name": "kind", - "type": 129, + "type": 42, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 130, "optional": false } ], @@ -20675,17 +21047,6 @@ "kind": "literal", "value": "system-prompt" }, - { - "kind": "object", - "properties": [ - { - "name": "kind", - "type": 42, - "optional": false - } - ], - "indices": [] - }, { "kind": "object", "properties": [ @@ -20724,7 +21085,18 @@ "properties": [ { "name": "kind", - "type": 135, + "type": 50, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 136, "optional": false } ], @@ -20750,7 +21122,7 @@ "properties": [ { "name": "kind", - "type": 50, + "type": 52, "optional": false } ], @@ -20761,7 +21133,7 @@ "properties": [ { "name": "kind", - "type": 139, + "type": 140, "optional": false } ], @@ -20776,7 +21148,7 @@ "properties": [ { "name": "kind", - "type": 141, + "type": 142, "optional": false } ], @@ -20791,7 +21163,7 @@ "properties": [ { "name": "kind", - "type": 52, + "type": 54, "optional": false } ], @@ -20812,17 +21184,17 @@ }, { "name": "changes", - "type": 144, + "type": 145, "optional": false }, { "name": "form", - "type": 54, + "type": 56, "optional": false }, { "name": "kind", - "type": 150, + "type": 151, "optional": false } ], @@ -20830,14 +21202,14 @@ }, { "kind": "array", - "element": 145 + "element": 146 }, { "kind": "object", "properties": [ { "name": "action", - "type": 146, + "type": 147, "optional": false }, { @@ -20861,9 +21233,9 @@ { "kind": "union", "types": [ - 147, 148, - 149 + 149, + 150 ] }, { @@ -20892,12 +21264,12 @@ }, { "name": "kind", - "type": 152, + "type": 153, "optional": false }, { "name": "outcome", - "type": 153, + "type": 154, "optional": false } ], @@ -20921,7 +21293,7 @@ }, { "name": "kind", - "type": 155, + "type": 156, "optional": false } ], @@ -20944,6 +21316,11 @@ "type": 30, "optional": false }, + { + "name": "principal", + "type": 158, + "optional": true + }, { "name": "rpcId", "type": 5, @@ -20952,6 +21329,21 @@ ], "indices": [] }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 159, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "owner" + }, { "kind": "object", "properties": [ @@ -20962,7 +21354,7 @@ }, { "name": "kind", - "type": 158, + "type": 161, "optional": false }, { @@ -20992,7 +21384,7 @@ }, { "name": "kind", - "type": 160, + "type": 163, "optional": false }, { @@ -21027,17 +21419,17 @@ "properties": [ { "name": "entries", - "type": 162, + "type": 165, "optional": false }, { "name": "form", - "type": 99, + "type": 100, "optional": false }, { "name": "kind", - "type": 164, + "type": 167, "optional": false }, { @@ -21050,7 +21442,7 @@ }, { "kind": "array", - "element": 163 + "element": 166 }, { "kind": "object", @@ -21077,12 +21469,12 @@ "properties": [ { "name": "form", - "type": 54, + "type": 56, "optional": false }, { "name": "kind", - "type": 166, + "type": 169, "optional": false }, { @@ -21102,22 +21494,22 @@ "properties": [ { "name": "form", - "type": 71, + "type": 72, "optional": false }, { "name": "kind", - "type": 168, + "type": 171, "optional": false }, { "name": "references", - "type": 169, + "type": 172, "optional": false }, { "name": "version", - "type": 174, + "type": 177, "optional": false } ], @@ -21129,7 +21521,7 @@ }, { "kind": "array", - "element": 170 + "element": 173 }, { "kind": "object", @@ -21141,12 +21533,12 @@ }, { "name": "capturedThroughSeq", - "type": 171, + "type": 174, "optional": false }, { "name": "compacted", - "type": 173, + "type": 176, "optional": false }, { @@ -21186,7 +21578,7 @@ }, { "name": "truncated", - "type": 173, + "type": 176, "optional": false } ], @@ -21196,7 +21588,7 @@ "kind": "union", "types": [ 18, - 172 + 175 ] }, { @@ -21216,17 +21608,17 @@ "properties": [ { "name": "form", - "type": 176, + "type": 179, "optional": false }, { "name": "kind", - "type": 55, + "type": 57, "optional": false }, { "name": "sections", - "type": 177, + "type": 180, "optional": false } ], @@ -21238,7 +21630,7 @@ }, { "kind": "array", - "element": 178 + "element": 181 }, { "kind": "object", @@ -21261,28 +21653,7 @@ "properties": [ { "name": "form", - "type": 176, - "optional": false - }, - { - "name": "kind", - "type": 57, - "optional": false - }, - { - "name": "sections", - "type": 177, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 176, + "type": 179, "optional": false }, { @@ -21292,7 +21663,7 @@ }, { "name": "sections", - "type": 177, + "type": 180, "optional": false } ], @@ -21303,7 +21674,7 @@ "properties": [ { "name": "form", - "type": 176, + "type": 179, "optional": false }, { @@ -21313,7 +21684,7 @@ }, { "name": "sections", - "type": 177, + "type": 180, "optional": false } ], @@ -21324,7 +21695,7 @@ "properties": [ { "name": "form", - "type": 176, + "type": 179, "optional": false }, { @@ -21334,7 +21705,7 @@ }, { "name": "sections", - "type": 177, + "type": 180, "optional": false } ], @@ -21345,7 +21716,7 @@ "properties": [ { "name": "form", - "type": 176, + "type": 179, "optional": false }, { @@ -21355,7 +21726,7 @@ }, { "name": "sections", - "type": 177, + "type": 180, "optional": false } ], @@ -21366,7 +21737,7 @@ "properties": [ { "name": "form", - "type": 176, + "type": 179, "optional": false }, { @@ -21376,7 +21747,7 @@ }, { "name": "sections", - "type": 177, + "type": 180, "optional": false } ], @@ -21387,7 +21758,7 @@ "properties": [ { "name": "form", - "type": 176, + "type": 179, "optional": false }, { @@ -21397,7 +21768,7 @@ }, { "name": "sections", - "type": 177, + "type": 180, "optional": false } ], @@ -21408,7 +21779,7 @@ "properties": [ { "name": "form", - "type": 176, + "type": 179, "optional": false }, { @@ -21418,7 +21789,7 @@ }, { "name": "sections", - "type": 177, + "type": 180, "optional": false } ], @@ -21429,7 +21800,7 @@ "properties": [ { "name": "form", - "type": 176, + "type": 179, "optional": false }, { @@ -21439,7 +21810,7 @@ }, { "name": "sections", - "type": 177, + "type": 180, "optional": false } ], @@ -21450,7 +21821,7 @@ "properties": [ { "name": "form", - "type": 176, + "type": 179, "optional": false }, { @@ -21460,7 +21831,7 @@ }, { "name": "sections", - "type": 177, + "type": 180, "optional": false } ], @@ -21471,7 +21842,7 @@ "properties": [ { "name": "form", - "type": 176, + "type": 179, "optional": false }, { @@ -21481,7 +21852,7 @@ }, { "name": "sections", - "type": 177, + "type": 180, "optional": false } ], @@ -21492,7 +21863,7 @@ "properties": [ { "name": "form", - "type": 176, + "type": 179, "optional": false }, { @@ -21502,7 +21873,28 @@ }, { "name": "sections", - "type": 177, + "type": 180, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 179, + "optional": false + }, + { + "name": "kind", + "type": 54, + "optional": false + }, + { + "name": "sections", + "type": 180, "optional": false } ], @@ -21518,7 +21910,7 @@ }, { "name": "kind", - "type": 192, + "type": 195, "optional": false }, { @@ -21543,12 +21935,12 @@ "properties": [ { "name": "form", - "type": 85, + "type": 86, "optional": false }, { "name": "kind", - "type": 194, + "type": 197, "optional": false }, { @@ -21563,27 +21955,6 @@ "kind": "literal", "value": "agent-message" }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 33, - "optional": false - }, - { - "name": "kind", - "type": 55, - "optional": false - }, - { - "name": "summary", - "type": 5, - "optional": false - } - ], - "indices": [] - }, { "kind": "object", "properties": [ @@ -21633,8 +22004,8 @@ { "kind": "union", "types": [ - 200, - 201 + 202, + 203 ] }, { @@ -21651,7 +22022,7 @@ }, { "name": "op", - "type": 148, + "type": 149, "optional": false }, { @@ -22012,6 +22383,15 @@ } ], "types": [ + { + "digest": "00c054d3018c198d92f459563331edd1385e58345c677bb499791df2da0ade9f", + "names": [ + "packages/session/session-title-llm/src/index.ts#SessionTitleLlmRequestEventData" + ], + "sources": [ + "packages/session/session-title-llm/src/index.ts:37" + ] + }, { "digest": "0116b1f89c05221d56b0ba35c83d302bf4857bc7277d247e9bbaf74b27d2c66e", "names": [], @@ -22031,7 +22411,7 @@ "digest": "023a355b2be0d688fb6e4df8388dadb942761d46cfe96d0f499aae9631a159d4", "names": [], "sources": [ - "packages/core/session/src/types.ts:206" + "packages/core/session/src/types.ts:207" ] }, { @@ -22047,12 +22427,12 @@ "sources": [] }, { - "digest": "045926475a46f625f84ae2be292b384817059f16927c69a9adab93717923ac19", + "digest": "040cd65423bd64264d225504f1c572d239eeb1793d0c2856ad8e42bb6e1e148b", "names": [ - "packages/session/session-title-llm/src/index.ts#SessionTitleLlmRequestEventData" + "packages/llm/llm/src/message.ts#Message" ], "sources": [ - "packages/session/session-title-llm/src/index.ts:37" + "packages/llm/llm/src/message.ts:196" ] }, { @@ -22075,9 +22455,14 @@ "packages/core/session/src/types.ts#SessionSeq" ], "sources": [ - "packages/core/session/src/types.ts:32" + "packages/core/session/src/types.ts:33" ] }, + { + "digest": "0755b7d1128319ba2e389619cbbf2c961ad648882b4e91da7f3e9582cc8a2034", + "names": [], + "sources": [] + }, { "digest": "07e1a58c58b593f507b8e7f25723bb0aa42baae6e00f5ecfddf28cb1e64d3235", "names": [ @@ -22112,7 +22497,7 @@ "digest": "0a30c3be336cdd4f8f34e66543a3d46d611c43986edd9b3ce6cbb9b721d3b5d3", "names": [], "sources": [ - "packages/core/session/src/types.ts:204" + "packages/core/session/src/types.ts:205" ] }, { @@ -22131,7 +22516,7 @@ "packages/llm/llm/src/types.ts#ReplayEnvelope" ], "sources": [ - "packages/llm/llm/src/types.ts:430" + "packages/llm/llm/src/types.ts:446" ] }, { @@ -22208,6 +22593,15 @@ "packages/attachment/attachment/src/types.ts:28" ] }, + { + "digest": "0f6265188dae7ba6faa26830a180bb4deb0760561f97ee0c9d399ccf6c9cf1f7", + "names": [ + "packages/llm/llm/src/message.ts#DeveloperMessage" + ], + "sources": [ + "packages/llm/llm/src/message.ts:157" + ] + }, { "digest": "0f8512903d94f57a4748fa1a2092e64342856796684e6b8343db685b192745ce", "names": [], @@ -22327,7 +22721,7 @@ "digest": "17d1afb770d9941936130996da00dc86782cfef731d8d6526162c301256a4ac3", "names": [], "sources": [ - "packages/core/session/src/types.ts:427" + "packages/core/session/src/types.ts:428" ] }, { @@ -22384,7 +22778,7 @@ "packages/core/session/src/types.ts#SessionHeader" ], "sources": [ - "packages/core/session/src/types.ts:94" + "packages/core/session/src/types.ts:95" ] }, { @@ -22418,7 +22812,7 @@ "digest": "1ccb56061942f5ccb0d30c9e4f2883b9d115ff906560029de637b61d3cde896d", "names": [], "sources": [ - "packages/core/session/src/types.ts:341" + "packages/core/session/src/types.ts:342" ] }, { @@ -22497,16 +22891,7 @@ "packages/core/session/src/types.ts#TurnEndCancelCause" ], "sources": [ - "packages/core/session/src/types.ts:196" - ] - }, - { - "digest": "1fa156c85450ca31063d0e0d2853028e6dac9687f2d3191fa811648210118154", - "names": [ - "packages/llm/llm/src/message.ts#MessageSource" - ], - "sources": [ - "packages/llm/llm/src/message.ts:136" + "packages/core/session/src/types.ts:197" ] }, { @@ -22567,6 +22952,11 @@ "names": [], "sources": [] }, + { + "digest": "24a8188c4d61b70a01af8cc576748765d36f132632ec55c0064f6fba482369fc", + "names": [], + "sources": [] + }, { "digest": "24b10f7cdae64928f89c49afc2eca0af9eb4a2f135e16f3416e6b8461fef1748", "names": [], @@ -22652,11 +23042,11 @@ "digest": "2abb711460e6238d851c30f3e4bdd87a2aa1887ee70575b3d3b7b25610673095", "names": [], "sources": [ - "packages/llm/llm/src/types.ts:455" + "packages/llm/llm/src/types.ts:471" ] }, { - "digest": "2ace3b8059b82b6d9837fed518ca97a99eb37c4482df7aad0e71c85489428ade", + "digest": "2ae8594a998c9862a597129e782277bcc518266712a703b5c3161bdf552d13b8", "names": [], "sources": [] }, @@ -22675,6 +23065,15 @@ "names": [], "sources": [] }, + { + "digest": "2cf075b013d363ad743b5a4982900678d108499c721bc11255137402003db711", + "names": [ + "packages/llm/llm/src/message.ts#UserMessage" + ], + "sources": [ + "packages/llm/llm/src/message.ts:162" + ] + }, { "digest": "2d11ca7b0d4493e244b74eba093227866b33249841e59a1e9bf56afed38604c3", "names": [], @@ -22733,11 +23132,6 @@ "packages/core/tools/src/types.ts:12" ] }, - { - "digest": "2fef90def36aa758ca096c5f6ad0e00dbb2101943e19ca54b05618801974894a", - "names": [], - "sources": [] - }, { "digest": "30d5a537b296685770b1241c4b4bbde19d5a31ff81aa23df73fab5e90b8fc65f", "names": [ @@ -22790,9 +23184,14 @@ "packages/core/session/src/types.ts#SurfaceOp" ], "sources": [ - "packages/core/session/src/types.ts:462" + "packages/core/session/src/types.ts:463" ] }, + { + "digest": "347de3380f2da308d0c74e08821f9e85559f55af4c5010795b110d00f7fc9dbd", + "names": [], + "sources": [] + }, { "digest": "34d1ab5c5df378186d6054b1a1beea9a41e41965d6f336f0bc0025c441a82e66", "names": [], @@ -22851,6 +23250,15 @@ "packages/feedback/message-feedback/src/types.ts:17" ] }, + { + "digest": "39cf2d3ff8bac81c62d49ac2ba38a095b18e80f3ad700819fa1f650a4e60197f", + "names": [ + "packages/jobs/jobs/src/types.ts#JobToolCall" + ], + "sources": [ + "packages/jobs/jobs/src/types.ts:16" + ] + }, { "digest": "3b04790e08b35241ffa0d38f6d7e4f8788aa921341f6c0d7574a329397c59408", "names": [], @@ -22877,7 +23285,7 @@ "packages/core/session/src/types.ts#OptionalSessionSeq" ], "sources": [ - "packages/core/session/src/types.ts:65" + "packages/core/session/src/types.ts:66" ] }, { @@ -22889,7 +23297,7 @@ "digest": "3bec87f9e6ab3da03346f49923d3c51621debc0159df68605e543c3b2a026f90", "names": [], "sources": [ - "packages/llm/llm/src/types.ts:453" + "packages/llm/llm/src/types.ts:469" ] }, { @@ -23000,6 +23408,11 @@ "names": [], "sources": [] }, + { + "digest": "454a0fa879e38076bdcdbd87ef561cd67d7e43e58a287cf7dbaa7b6cf7847020", + "names": [], + "sources": [] + }, { "digest": "45f2a07ea94573123b6ae14db787a40062dec3834f36d8be92c523d40ba28448", "names": [], @@ -23025,7 +23438,7 @@ "packages/llm/llm/src/types.ts#StreamChunk" ], "sources": [ - "packages/llm/llm/src/types.ts:452" + "packages/llm/llm/src/types.ts:468" ] }, { @@ -23101,14 +23514,9 @@ "digest": "4bb451330ff699f36b45539ceb0eb2606ead72084e2c5bb637c36cc80e2252db", "names": [], "sources": [ - "packages/core/session/src/types.ts:193" + "packages/core/session/src/types.ts:194" ] }, - { - "digest": "4be8fd28fefa5534675ea9aabd0a24efc2838b989bbc74da20a1efbd0daede8c", - "names": [], - "sources": [] - }, { "digest": "4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b", "names": [ @@ -23126,6 +23534,7 @@ "packages/goal/goal/src/types.ts#GoalId", "packages/interaction/commands/src/brand.ts#CommandId", "packages/interaction/user-approval/src/types.ts#ApprovalRequestId", + "packages/jobs/jobs/src/brand.ts#JobId", "packages/llm/llm-retry/src/brand.ts#RetryId", "packages/llm/llm/src/brand.ts#MessageId", "packages/llm/llm/src/brand.ts#ProviderRequestId", @@ -23137,10 +23546,10 @@ "packages/workflow/workflow/src/types.ts#WorkflowRunId" ], "sources": [ - "packages/api/session-controller/src/types.ts:403", + "packages/api/session-controller/src/types.ts:409", "packages/attachment/attachment/src/brand.ts:6", "packages/compaction/compaction/src/brand.ts:4", - "packages/core/session/src/types.ts:20", + "packages/core/session/src/types.ts:21", "packages/experimental/agent-team/src/types.ts:21", "packages/experimental/agent-team/src/types.ts:33", "packages/experimental/agent-team/src/types.ts:9", @@ -23151,6 +23560,7 @@ "packages/goal/goal/src/types.ts:17", "packages/interaction/commands/src/brand.ts:31", "packages/interaction/user-approval/src/types.ts:17", + "packages/jobs/jobs/src/brand.ts:19", "packages/llm/llm-retry/src/brand.ts:4", "packages/llm/llm/src/brand.ts:16", "packages/llm/llm/src/brand.ts:31", @@ -23258,7 +23668,7 @@ "digest": "5302e763151277614b97e77f00e3b326694c6c1247b916a20503c344dc970777", "names": [], "sources": [ - "packages/core/session/src/types.ts:375" + "packages/core/session/src/types.ts:376" ] }, { @@ -23267,7 +23677,7 @@ "packages/core/session/src/types.ts#RequestHeaderReason" ], "sources": [ - "packages/core/session/src/types.ts:273" + "packages/core/session/src/types.ts:274" ] }, { @@ -23284,7 +23694,7 @@ "digest": "54f9276a1617d7c12594722c89cfe864bcbe4517e9a6f9c98bab14f199a135e0", "names": [], "sources": [ - "packages/core/session/src/types.ts:355" + "packages/core/session/src/types.ts:356" ] }, { @@ -23301,7 +23711,7 @@ "digest": "56b7c047694875a843ba020dfea3c8380d54ae4ff88ab349e72004dc21e07c23", "names": [], "sources": [ - "packages/core/session/src/types.ts:221" + "packages/core/session/src/types.ts:222" ] }, { @@ -23363,13 +23773,6 @@ "names": [], "sources": [] }, - { - "digest": "5b55817a403569fbb2d379496df2e3f3f71107f6c577a218028ce187c60b61e6", - "names": [], - "sources": [ - "packages/core/session/src/types.ts:311" - ] - }, { "digest": "5b6fb1f226ff56402db08a6aae82af76100b14056271875f69ec526a1ae05d51", "names": [], @@ -23382,6 +23785,15 @@ "names": [], "sources": [] }, + { + "digest": "5bc2d7f02503fc6b3e1bb7e0b7fa22c0b9e4b9841fa3175b2ad8e607ae3525a8", + "names": [ + "packages/llm/llm/src/message.ts#MessageSource" + ], + "sources": [ + "packages/llm/llm/src/message.ts:136" + ] + }, { "digest": "5bc66269cfaf614c0ecda8ecb469f23101a7609793fc2b693ac23c4aecdb575e", "names": [], @@ -23397,6 +23809,11 @@ "names": [], "sources": [] }, + { + "digest": "5cb63db9fe57dc09c3a1c2b84c3edf73ff5b62960155d8436a2377a5c43ce32c", + "names": [], + "sources": [] + }, { "digest": "5cdbf342b7748d152bc987b68f58e565e5bba4b227e076e2d2120d87662e45cd", "names": [], @@ -23472,6 +23889,15 @@ "names": [], "sources": [] }, + { + "digest": "611d82d2dc50df706cf08a0fc04bc8eaf78083391fe6f10313de943e615dd2da", + "names": [ + "packages/jobs/jobs/src/types.ts#JobSettledData" + ], + "sources": [ + "packages/jobs/jobs/src/types.ts:40" + ] + }, { "digest": "616f93a5047de668e1c30d57b1786abd76beeb4123599637ec1dc5290320ede5", "names": [], @@ -23525,7 +23951,7 @@ "packages/llm/llm/src/types.ts#ToolSchema" ], "sources": [ - "packages/llm/llm/src/types.ts:473" + "packages/llm/llm/src/types.ts:489" ] }, { @@ -23578,13 +24004,6 @@ "names": [], "sources": [] }, - { - "digest": "6a4f72e2e179e17b922f2a9392c0e1c8f8c707f32494372454850a3eb184a6e7", - "names": [], - "sources": [ - "packages/api/session-controller/src/types.ts:408" - ] - }, { "digest": "6ab45ac76839722df912f3ba50f1c6b41f2d614bde11f1aabfae04117ccdc80d", "names": [], @@ -23608,14 +24027,14 @@ "digest": "6bc8e51124d0874bc89e1d0ceff381a9c3cd0fc240667d69f841ce4f60f65fe5", "names": [], "sources": [ - "packages/core/session/src/types.ts:330" + "packages/core/session/src/types.ts:331" ] }, { "digest": "6c03546143cdf6ae88f1745ac98c5139efa1588d1415249bbc5f3104d32ab800", "names": [], "sources": [ - "packages/core/session/src/types.ts:191" + "packages/core/session/src/types.ts:192" ] }, { @@ -23627,7 +24046,7 @@ "digest": "6c5a37d42ebdb12db4410699259335f1e149e3a0260425684c00b153afc8990d", "names": [], "sources": [ - "packages/core/session/src/types.ts:192" + "packages/core/session/src/types.ts:193" ] }, { @@ -23640,11 +24059,23 @@ "names": [], "sources": [] }, + { + "digest": "6cf082d0fb6034dea61636787ff0ddf43b6e59aad71e644435a479bc9b83ad65", + "names": [], + "sources": [] + }, + { + "digest": "6dacb1584f2bfbe209dc6b6f578d51da6d55595f64d0e63c727c98f359278def", + "names": [], + "sources": [ + "packages/api/session-controller/src/types.ts:418" + ] + }, { "digest": "6e52dd55a1d48954ed5464ec46fe34d9eb65f709baabc9561e7a279910b0c406", "names": [], "sources": [ - "packages/preset/agent-preset-registry/src/session.ts:28" + "packages/preset/agent-preset-registry/src/session.ts:31" ] }, { @@ -23731,6 +24162,15 @@ "names": [], "sources": [] }, + { + "digest": "750a2b129d67704df675c06823c20c80b65cbae89d5aaa914fd5a72e3df395b5", + "names": [ + "packages/jobs/jobs/src/types.ts#JobSettleCause" + ], + "sources": [ + "packages/jobs/jobs/src/types.ts:247" + ] + }, { "digest": "7554565be416b0342053ee6a46256c9dab07c487349b4a5f10bab1edc151c619", "names": [], @@ -23765,9 +24205,14 @@ "digest": "79650cfa10aa3c52440328d85e8a25f0ffadc8d7efd784e3c0819d8bb74a1985", "names": [], "sources": [ - "packages/core/session/src/types.ts:212" + "packages/core/session/src/types.ts:213" ] }, + { + "digest": "79a4c87f8bfb4d6d05cd9952b99210d6a7db3b478c4a01d94b4c50602308c056", + "names": [], + "sources": [] + }, { "digest": "79ddc907312f97cf9553d4841e8b3fc937097bd8d97900a790efad3c05255e99", "names": [], @@ -23929,13 +24374,6 @@ "names": [], "sources": [] }, - { - "digest": "83a665476a9ee9af05cb1fdded12dd647d1b97b491200d7f12ef1af7cdac4428", - "names": [], - "sources": [ - "packages/core/agent/src/types.ts:96" - ] - }, { "digest": "83d6916021eede8f2d83a1872716caa1efdec39a3681890996c55147187fdd12", "names": [], @@ -24035,16 +24473,7 @@ "digest": "88a610b831be6d92612a464be5c7d0b88ec24d97bd299837bf74bf7672183c9a", "names": [], "sources": [ - "packages/llm/llm/src/types.ts:456" - ] - }, - { - "digest": "89b2edff547be1ecfcd78ef18aeeb3cf507fcbf7d6093ec04fb111ef4c8e8a97", - "names": [ - "packages/llm/llm/src/message.ts#UserMessage" - ], - "sources": [ - "packages/llm/llm/src/message.ts:162" + "packages/llm/llm/src/types.ts:472" ] }, { @@ -24202,9 +24631,14 @@ "digest": "95080295115fbd83544520ba247b68a03790131f6a2eac323d388758cb542f65", "names": [], "sources": [ - "packages/llm/llm/src/types.ts:454" + "packages/llm/llm/src/types.ts:470" ] }, + { + "digest": "95a3ffccbf3c8afcf9a2f3b355f724da8e07e98dc47ee741166dde7f9ea058bd", + "names": [], + "sources": [] + }, { "digest": "95c0e88fcf0bafb2087f912ad9e2369de6ae6530cc0c4c2772c96205b57f7f60", "names": [], @@ -24229,6 +24663,11 @@ "packages/core/agent/src/types.ts:39" ] }, + { + "digest": "97e0162f983c27456ec71269fb4976b1851588b8c1aeac5d071d43e11f95e03c", + "names": [], + "sources": [] + }, { "digest": "9890113e97dd3541787bfe6704f2b223d3a890b5325ca0acae2b0398909b413e", "names": [], @@ -24268,11 +24707,6 @@ "packages/web/web-search-deepseek/src/provider.ts:64" ] }, - { - "digest": "9a36727716d0e73f681f3b725e7f28f89417c68174c8af0ff2b1ef52b08326a0", - "names": [], - "sources": [] - }, { "digest": "9a410ddf56792b2b7cd4a224a2e54dc4dee30a8e8e6b1c2b9a1d19992b217adc", "names": [ @@ -24288,7 +24722,7 @@ "packages/core/session/src/types.ts#EpochHeader" ], "sources": [ - "packages/core/session/src/types.ts:240" + "packages/core/session/src/types.ts:241" ] }, { @@ -24390,7 +24824,7 @@ "digest": "a2b49331716ba0a7078b026d145306a70eae64b7313c3e6204bd931e75ec3bb1", "names": [], "sources": [ - "packages/llm/llm/src/types.ts:459" + "packages/llm/llm/src/types.ts:475" ] }, { @@ -24457,7 +24891,7 @@ "digest": "a81059ca06999da1e712414520c2c65e491d052fcf9640fae20aa38f0a5dc735", "names": [], "sources": [ - "packages/core/session/src/types.ts:383", + "packages/core/session/src/types.ts:384", "packages/core/tools/src/types.ts:27" ] }, @@ -24487,6 +24921,11 @@ "packages/schedule/schedule/src/types.ts:173" ] }, + { + "digest": "a927d444c928ac0efd9dd8b2e7d7d04d711c264cb71578d81e0e2fd143ff669f", + "names": [], + "sources": [] + }, { "digest": "a99ec0993c62c058e50af9f68922202a3b54f5e259bc2abf1915a73381d2316d", "names": [], @@ -24539,7 +24978,7 @@ "digest": "ab7ec5f9eb2c6dfacca5da4c37cf7ba85d04172053ee6b1ebf1709ffcd99c1a7", "names": [], "sources": [ - "packages/core/session/src/types.ts:214", + "packages/core/session/src/types.ts:215", "packages/llm/llm/src/types.ts:159" ] }, @@ -24552,7 +24991,7 @@ "digest": "ad0c38a0adb82a94c925456df41586d9b5f9202fca18593eae2213d4445b4585", "names": [], "sources": [ - "packages/core/session/src/types.ts:390" + "packages/core/session/src/types.ts:391" ] }, { @@ -24576,7 +25015,7 @@ "packages/interaction/commands/src/types.ts#CommandSource" ], "sources": [ - "packages/core/session/src/types.ts:190", + "packages/core/session/src/types.ts:191", "packages/interaction/commands/src/types.ts:75", "packages/interaction/commands/src/types.ts:79", "packages/llm/llm/src/message.ts:111", @@ -24608,15 +25047,6 @@ "names": [], "sources": [] }, - { - "digest": "af99c95d46bed802870f1ebefe0ea11f92a03cb37b8e0cbe3f94b7aaaf509b08", - "names": [ - "packages/llm/llm/src/message.ts#DeveloperMessage" - ], - "sources": [ - "packages/llm/llm/src/message.ts:157" - ] - }, { "digest": "aff60ef947c18b012644852bdd0d5bfe72065e061dd563787c76c646b4a10f6b", "names": [], @@ -24647,7 +25077,7 @@ "packages/core/session/src/types.ts#RequestContext" ], "sources": [ - "packages/core/session/src/types.ts:254" + "packages/core/session/src/types.ts:255" ] }, { @@ -24691,11 +25121,6 @@ "packages/experimental/agent-team/src/types.ts:72" ] }, - { - "digest": "b33545b3244861bcbf73b407d24b53402710f5acacab7ae939537e5504a68dc6", - "names": [], - "sources": [] - }, { "digest": "b3681c026a0b98e98b103f057dfd217fbe904129c090efd668253ec9aea7fece", "names": [], @@ -24763,7 +25188,7 @@ "digest": "b6ef9c6210be8350cd465f485cf520031fffa6bf3809c61a1e713e834c5982d0", "names": [], "sources": [ - "packages/core/session/src/types.ts:202" + "packages/core/session/src/types.ts:203" ] }, { @@ -24842,12 +25267,12 @@ "sources": [] }, { - "digest": "ba5b10ea77d68f4a29439a9bd2a14f6a560acf55731749d57237757cc72fbc06", + "digest": "ba77b58995e36f14bb13886b0493d0a1a63d5db10b1a6cd9f6ac65a72cf832ec", "names": [], "sources": [] }, { - "digest": "ba77b58995e36f14bb13886b0493d0a1a63d5db10b1a6cd9f6ac65a72cf832ec", + "digest": "bab68657940075483f086fffb711682a45bcc99c43d659fbe1c36af21ac05dd7", "names": [], "sources": [] }, @@ -24880,7 +25305,7 @@ "digest": "bcf0caf62d964b2fcf5404bd5c909cfa9a21c3f3c96e6b7e36d9e33565223825", "names": [], "sources": [ - "packages/core/session/src/types.ts:464" + "packages/core/session/src/types.ts:465" ] }, { @@ -24939,6 +25364,11 @@ "packages/workflow/workflow/src/types.ts:110" ] }, + { + "digest": "c2f49004d9ae38a964e80850c87bc6849d6a872091a1d43e99eedf00c641e2a3", + "names": [], + "sources": [] + }, { "digest": "c43b119ce579b3e9983a0d3adc4ba402ebb9ba773d89738a2dab01ecf5813943", "names": [], @@ -25076,7 +25506,7 @@ "digest": "d4b739491689cc6bb13d07ffac1a32239875abda364c0d5edd8f74faf5e9c00d", "names": [], "sources": [ - "packages/llm/llm/src/types.ts:457" + "packages/llm/llm/src/types.ts:473" ] }, { @@ -25114,15 +25544,6 @@ "names": [], "sources": [] }, - { - "digest": "d901c4f5e856591c3217af983caa15bba568d580700f0958bab56b88004eced9", - "names": [ - "packages/llm/llm/src/message.ts#Message" - ], - "sources": [ - "packages/llm/llm/src/message.ts:196" - ] - }, { "digest": "d9196086e41e4dd5c2ce87f5c25529c8112a1731d258cf0728747d41a9ee3241", "names": [], @@ -25211,6 +25632,16 @@ "names": [], "sources": [] }, + { + "digest": "deebfc7c62177ea840f23c6362b8615bc78ffa93377dd5d243c04e541c413af5", + "names": [ + "packages/typert/protocol/src/types.ts#Principal" + ], + "sources": [ + "packages/typert/protocol/src/types.ts:457", + "packages/typert/protocol/src/types.ts:461" + ] + }, { "digest": "df5d9709b7db2a72510dc52052a23f0c38f39730af1504bab8bccabc14fae528", "names": [], @@ -25250,6 +25681,15 @@ "names": [], "sources": [] }, + { + "digest": "e3056e0dda3a379e895386e368c3ce42131460ea65e410e1d091cca98a347305", + "names": [ + "packages/jobs/jobs/src/types.ts#JobRegisteredData" + ], + "sources": [ + "packages/jobs/jobs/src/types.ts:24" + ] + }, { "digest": "e308ccf867a5398e316e0af8cb6ce238a8d33a63b9b384c8250a686786285f72", "names": [], @@ -25284,14 +25724,14 @@ "digest": "e481c46af3813e0010558e93f578b2986acd658404cec2d1679e3ac2a6c48654", "names": [], "sources": [ - "packages/core/session/src/types.ts:297" + "packages/core/session/src/types.ts:298" ] }, { "digest": "e4c18e294232c3ba6c9f1999f168263cc55956147cfe4121720899be8e52edd1", "names": [], "sources": [ - "packages/core/session/src/types.ts:361" + "packages/core/session/src/types.ts:362" ] }, { @@ -25366,7 +25806,7 @@ "digest": "eaa71be0fbf0f702df8ea6c11d0aa2f9782263d45a10d0fb14eda662f4bfe04c", "names": [], "sources": [ - "packages/core/session/src/types.ts:196" + "packages/core/session/src/types.ts:197" ] }, { @@ -25383,7 +25823,7 @@ "digest": "ec8b5ff7c77ca6893ac7ba5a0c5b2aefeb52f12c64450b7359660c2a7e8a53d7", "names": [], "sources": [ - "packages/core/session/src/types.ts:228" + "packages/core/session/src/types.ts:229" ] }, { @@ -25391,6 +25831,11 @@ "names": [], "sources": [] }, + { + "digest": "edd37b1e4974af648633014435572089b1e8fb71910468a0767a7e28fdd22abf", + "names": [], + "sources": [] + }, { "digest": "ee577a0cc18664c9daee50e9455153321155604f8f1817362f8f4a4b6fb9e1ad", "names": [], @@ -25414,10 +25859,15 @@ "digest": "ef11a3b7fa2033ae4279545ff8a8a2ee4e301b9b73e5b91654c48e6d08283545", "names": [], "sources": [ - "packages/core/session/src/types.ts:299", - "packages/core/session/src/types.ts:301" + "packages/core/session/src/types.ts:300", + "packages/core/session/src/types.ts:302" ] }, + { + "digest": "ef226a90ddac6cd8e631a3a337a3a3e27f9494be92befb7c718b404681d75e21", + "names": [], + "sources": [] + }, { "digest": "ef4f00923b9edbae15c7ef05320280bf61147ca17146cf59690359bfd5e5da45", "names": [], @@ -25434,7 +25884,7 @@ "packages/api/session-controller/src/types.ts#ModelSelection" ], "sources": [ - "packages/api/session-controller/src/types.ts:100" + "packages/api/session-controller/src/types.ts:101" ] }, { @@ -25443,7 +25893,7 @@ "packages/core/session/src/types.ts#TurnEndReason" ], "sources": [ - "packages/core/session/src/types.ts:232" + "packages/core/session/src/types.ts:233" ] }, { @@ -25464,7 +25914,7 @@ "digest": "f1a96afe7503ac1817de769cd0ba13d9b80111c7034c50106b3344ecc14cc515", "names": [], "sources": [ - "packages/llm/llm/src/types.ts:458" + "packages/llm/llm/src/types.ts:474" ] }, { @@ -25492,7 +25942,7 @@ "packages/llm/llm/src/types.ts#SystemPromptUpdate" ], "sources": [ - "packages/llm/llm/src/types.ts:396" + "packages/llm/llm/src/types.ts:412" ] }, { @@ -25500,6 +25950,18 @@ "names": [], "sources": [] }, + { + "digest": "f4ed4717b4c259e933b550a8dcd90dc93628cda8a3c7affcaa53b9c13c1d53eb", + "names": [], + "sources": [ + "packages/core/session/src/types.ts:312" + ] + }, + { + "digest": "f5520c1d261ca86ea03735799c311a0a9ede8babb588cf0a65422576d2ee0ad1", + "names": [], + "sources": [] + }, { "digest": "f56c5b59ca0161310c338d31dd16cf14287ccd602ff4181058df178c1c286d7f", "names": [ @@ -25567,7 +26029,7 @@ "digest": "f90eb4ab2d3897bc20c521a038e3119d581f791ec974b1a0004f63a8ab9e232d", "names": [], "sources": [ - "packages/core/session/src/types.ts:288", + "packages/core/session/src/types.ts:289", "packages/deliverables/workspace-changes/src/types.ts:106" ] }, @@ -25581,6 +26043,13 @@ "names": [], "sources": [] }, + { + "digest": "fa106c5b33b657e8ef16e8cbf39f3b7d44e5cbcdf4f9303d9db6410f28951e42", + "names": [], + "sources": [ + "packages/core/agent/src/types.ts:96" + ] + }, { "digest": "fa87537f4a9c1e25602990a8bf686b383e9e00352ec8946e16ae24ba7c1625be", "names": [ diff --git a/docs/subsystems/boot.i18n.yaml b/docs/subsystems/boot.i18n.yaml index 5d10d9774d..f0edbcf9b4 100644 --- a/docs/subsystems/boot.i18n.yaml +++ b/docs/subsystems/boot.i18n.yaml @@ -8,3 +8,6 @@ /profile-management/management-records: en: a1412da0219c2a09 zh: b6869fd548f4c693 +/profile-management/profile-reconciliation: + en: 42a093314af774d4 + zh: 7c1784a3af02b31c diff --git a/docs/subsystems/boot.md b/docs/subsystems/boot.md index 049049957a..8fb78deb28 100644 --- a/docs/subsystems/boot.md +++ b/docs/subsystems/boot.md @@ -18,6 +18,37 @@ The [boot package group](../../packages/boot/README.md) owns launcher-provided p `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; `bundle` and `version` name the package a finished installation added and its manifest version; `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. +## Profile reconciliation + + + +```ts type-equiv +/** Outcome of {@link Hmr.reconcileProfile}. */ +interface ProfileReconciliation { + /** Diagnostics for Loader entries left inactive. */ + warnings: string[] + /** Loaded modules whose files changed on disk and whose previous code keeps running until a restart. */ + pending: PendingModule[] + /** + * Why every change waits for a restart: `module-comparison` when module caches cannot be inspected or their files + * compared, even when no URLs are known; `package-moved` when the runtime resolution cannot publish a moved package. + */ + restartReason?: 'module-comparison' | 'package-moved' +} +``` + +```ts type-equiv +/** A changed module that loads only after a restart. */ +interface PendingModule { + url: string + /** + * `host-module`: the process entry point's static graph imports it. `unmanaged-plugin`: it is imported by a plugin + * that runs outside a traversable Loader entry. `module-comparison`: loaded modules could not be compared with disk. + */ + reason: 'host-module' | 'unmanaged-plugin' | 'module-comparison' +} +``` + @@ -78,6 +109,21 @@ async watchConfig(filename: string, refresh: () => Promise): Promise<() => * @returns Linked module URLs, or an empty list for an uncached module. */ async getLinked(url: string): Promise + +/** + * Recompose the profile from its manifest and patch files, replacing running plugins whose loaded files below the + * profile directory or its linked package targets changed on disk, and publishing its runtime resolution. Changed + * modules are evicted and the Loader entries running them stop; app-boot's {@link recomposeProfile} then publishes the + * resolution, applies the generation, starts the stopped entries with the current files and configuration, + * imports entries whose import failed again, and judges activation. Afterwards the modules below the mappings the + * recomposition dropped are evicted, so a removed or deselected package is imported afresh when it returns. Callers run + * inside {@link runExclusive}. + * @param requiredIds Entry ids whose inactive state is reported as a failure even when it predates this call. + * @returns Inactive-entry diagnostics, the changed modules that load after a restart, and a profile-wide restart reason. + * @throws The patch read, publication, preparation, apply, or activation failure of the recomposition, including a + * replacement plugin that fails to load or start, whose previous code no longer runs. + */ +async reconcileProfile(requiredIds: readonly string[] = []): Promise ``` Source: [`packages/boot/hmr/src/index.ts`](../../packages/boot/hmr/src/index.ts) diff --git a/docs/subsystems/boot.zh.md b/docs/subsystems/boot.zh.md index b2b800f3c9..20e7701f67 100644 --- a/docs/subsystems/boot.zh.md +++ b/docs/subsystems/boot.zh.md @@ -18,6 +18,37 @@ `ChangeResult.changed` 报告磁盘修改,独立于 `application`:`applied`、`restart-required`、`overridden` 或 `failed`。可选的 `error` 包含可本地化的错误码和外部诊断。`packageResult` 记录 pnpm 退出码、有界输出、截断标志及完整诊断日志路径;当管理器终止了一个停止打印的运行,还记录 `timedOut`。被终止的运行不论信号留下什么退出状态都归类为 `timeout`,因此安装与删除都报告失败而非成功,也不会再询问下一个注册表。`pendingBuilds` 列出整个 profile 尚未决定的包;`approvedBuilds` 记录本次操作授予权限的包名;`registries` 按顺序列出一次安装问过的注册表;`bundle` 与 `version` 给出完成的安装新增的包及其清单版本;`failedAt` 说明最后一次失败的运行连不上的是所问的注册表,还是 git 或 tarball spec 自身拉取的主机。 +## Profile 重新组合 + + + +```ts type-equiv +/** Outcome of {@link Hmr.reconcileProfile}. */ +interface ProfileReconciliation { + /** Diagnostics for Loader entries left inactive. */ + warnings: string[] + /** Loaded modules whose files changed on disk and whose previous code keeps running until a restart. */ + pending: PendingModule[] + /** + * Why every change waits for a restart: `module-comparison` when module caches cannot be inspected or their files + * compared, even when no URLs are known; `package-moved` when the runtime resolution cannot publish a moved package. + */ + restartReason?: 'module-comparison' | 'package-moved' +} +``` + +```ts type-equiv +/** A changed module that loads only after a restart. */ +interface PendingModule { + url: string + /** + * `host-module`: the process entry point's static graph imports it. `unmanaged-plugin`: it is imported by a plugin + * that runs outside a traversable Loader entry. `module-comparison`: loaded modules could not be compared with disk. + */ + reason: 'host-module' | 'unmanaged-plugin' | 'module-comparison' +} +``` + @@ -78,6 +109,21 @@ async watchConfig(filename: string, refresh: () => Promise): Promise<() => * @returns Linked module URLs, or an empty list for an uncached module. */ async getLinked(url: string): Promise + +/** + * Recompose the profile from its manifest and patch files, replacing running plugins whose loaded files below the + * profile directory or its linked package targets changed on disk, and publishing its runtime resolution. Changed + * modules are evicted and the Loader entries running them stop; app-boot's {@link recomposeProfile} then publishes the + * resolution, applies the generation, starts the stopped entries with the current files and configuration, + * imports entries whose import failed again, and judges activation. Afterwards the modules below the mappings the + * recomposition dropped are evicted, so a removed or deselected package is imported afresh when it returns. Callers run + * inside {@link runExclusive}. + * @param requiredIds Entry ids whose inactive state is reported as a failure even when it predates this call. + * @returns Inactive-entry diagnostics, the changed modules that load after a restart, and a profile-wide restart reason. + * @throws The patch read, publication, preparation, apply, or activation failure of the recomposition, including a + * replacement plugin that fails to load or start, whose previous code no longer runs. + */ +async reconcileProfile(requiredIds: readonly string[] = []): Promise ``` Source: [`packages/boot/hmr/src/index.ts`](../../packages/boot/hmr/src/index.ts) diff --git a/docs/subsystems/conversation.i18n.yaml b/docs/subsystems/conversation.i18n.yaml index d53eb92d3b..64ca728d7e 100644 --- a/docs/subsystems/conversation.i18n.yaml +++ b/docs/subsystems/conversation.i18n.yaml @@ -18,8 +18,8 @@ en: c216e2df128b8cb9 zh: c039cf84b0d86ad8 /conversation-assembly/definition-and-typed-chat-payload: - en: 5f24557a050d1df0 - zh: 47735627f802b6fa + en: d8f5e4773704c718 + zh: f3db8c0d01080dba /conversation-assembly/predecessor-reads: en: da849b3b5da81da8 zh: 37a647a439bc24b1 diff --git a/docs/subsystems/conversation.md b/docs/subsystems/conversation.md index ba4eb88102..ef6f6ffbdb 100644 --- a/docs/subsystems/conversation.md +++ b/docs/subsystems/conversation.md @@ -247,10 +247,24 @@ export function apply(ctx: ClientContext): void { `ConversationNodeDefinitionInput` accepts either this function or a read-only table from event type to matching function. Function-form registrations receive every event; table registrations receive only their listed types. The Registry copies table entries and precomputes ordered candidate Sets when registrations change, keeping candidate construction out of per-event dispatch. Replace a table by unregistering and registering again. Resolved `ConversationNodeDefinition` values retain a callable `match(event)` in both cases. +A match result with `exclusive: true` claims that event on the Definition's target. The assembler evaluates every match of the event before accepting any, so the outcome is independent of registration order: other Definitions of the claimed target build no Context for the event, and the target's unmatched-event fallback does not run. Definitions of other targets and Definitions without a target still receive it. Two claims on one target, or a claim from a Definition without a target, throw during dispatch; a claim returned by the fallback itself has no effect. A business Definition uses a claim to present one kind of message that a generic Definition of the same target would otherwise also present, such as a late question reply. + `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 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`. +A Chat Node also chooses where its row stands through `groupTree`. Positions are ordered from the outermost inward, and a Node at a position also has the effect of every inner one. Chat process grouping, whole-Turn folding, and Turn-input detection read only this position; [group-tree.ts](../../packages/client/ui-chat/src/client/contract/group-tree.ts) holds the complete contract. + +| The Node is | Declare | +|---|---| +| A person's input claimed in Step 1; its Turn opens an exchange | `exchange` | +| Another input of its Turn, such as later human input or a trigger notice; before the process it is the opening input, after it the whole Turn stays open | `input` | +| A Turn notice that stays outside whole-Turn folding, such as an error or the completed-turn footer | `turn` | +| An independent row that whole-Turn folding still hides, such as a model retry | `process` | +| Ordinary process content that joins the surrounding step run | omit `groupTree` | + +The [Chat business rules](../../packages/client/ui-chat/src/client/conversation-nodes/README.md#process-grouping) list the positions the built-in Nodes declare. When a generic Definition of the same target also matches the event your Node presents, claim the event with `exclusive: true` as described above. + ## Predecessor reads Some Definitions need the latest earlier State of another business kind. `start` receives a `ConversationContextReader`; call `reader.previous(kind)` there instead of accepting a Context collection or scanning events. The reader returns the nearest started Context before the current start `seq` as read-only data. diff --git a/docs/subsystems/conversation.zh.md b/docs/subsystems/conversation.zh.md index 7678e2b78c..a3ef583db2 100644 --- a/docs/subsystems/conversation.zh.md +++ b/docs/subsystems/conversation.zh.md @@ -249,10 +249,24 @@ export function apply(ctx: ClientContext): void { `ConversationNodeDefinitionInput` 接受上述函数,或事件类型到匹配函数的只读表。函数形式的注册接收所有事件;表形式只接收列出的类型。Registry 在注册关系变化时复制表条目并预计算有序候选 Set,不在逐事件分发时构造候选集合。替换表时先注销,再重新注册。两种形式解析后的 `ConversationNodeDefinition` 都保留可调用的 `match(event)`。 +match 结果带 `exclusive: true` 时,即在该 Definition 的 target 上认领这个事件。Assembler 先求出该事件的全部 match,再逐个接收,因此结果与注册顺序无关:被认领 target 上的其他 Definition 不为该事件建立 Context,该 target 的未匹配事件 fallback 也不运行;其他 target 的 Definition 与没有 target 的 Definition 照常接收。同一 target 被认领两次,或没有 target 的 Definition 发起认领,都会在分发时抛错;fallback 自身返回的认领不起作用。业务 Definition 用认领来呈现某一类消息,而同一 target 上的通用 Definition 原本也会呈现它,例如迟到的问题回答。 + `buildLocationData(context, scope)` 可以把 Definition 拥有的数据发布到引擎拥有的 Turn 或 Step 上。通过 declaration merging 为每个 key 指定精确 value 类型。同一 Location 内的另一个 Node 可以使用受限 slot hook(例如 `useTurnData(key)`)读取该值,无须取得 Session,也无须扫描 `snapshot.chat.nodes`。 `target` 与 `buildViewNode(context)` 必须同时声明一项由 target 拥有的渲染贡献。把 `context.key` 保留为 React 侧身份,根据持久排序证据选择 `anchorSeq`,并且只返回 renderer 可以直接使用的数据。某个 target Node 一旦发布,就要继续返回同一个 key;需要暂时离开可见流时使用 `visibility: 'hidden'`,不要改为返回 `null` 撤回它。 +Chat Node 还通过 `groupTree` 选择所在行的位置。位置由外到内排列,站在某个位置的 Node 同时具有所有内层位置的效果。Chat 的过程分组、整轮折叠和轮次输入判断只读取这个位置;完整契约见 [group-tree.ts](../../packages/client/ui-chat/src/client/contract/group-tree.ts)。 + +| Node 是 | 声明 | +|---|---| +| Step 1 认领的人的输入,所在轮次开启一个交互段 | `exchange` | +| 所在轮次的其他输入,如之后的人的输入或触发通知;位于过程之前时是开场输入,之后出现时整个轮次保持展开 | `input` | +| 留在整轮折叠之外的轮次通知,如错误或已完成轮次页脚 | `turn` | +| 整轮折叠仍会隐藏的独立行,如模型重试 | `process` | +| 并入相邻步骤组的普通过程内容 | 不声明 `groupTree` | + +各内置 Node 声明的位置见 [Chat 业务规则](../../packages/client/ui-chat/src/client/conversation-nodes/README.zh.md#process-grouping)。如果同一 target 的通用 Definition 也会匹配你的 Node 所呈现的事件,按上文用 `exclusive: true` 认领该事件。 + ## Predecessor read 有些 Definition 需要另一个业务 kind 在当前位置之前的最新 State。`start` 会收到 `ConversationContextReader`;应在这里调用 `reader.previous(kind)`,不要接收 Context 集合或扫描事件。Reader 返回当前 start `seq` 之前最近一个已启动 Context 的只读数据。 diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index c6c02c6c42..42f6d2e24a 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -557,7 +557,9 @@ serviceFor(agent: { ctx: Context }, name: K): */ async recompose(ctx: Context, id: string): Promise -/** Select a preset before a session starts its first turn. +/** Select a preset before a session starts its first turn. A Session whose + * log records the preset and whose Agent runs its current revision keeps its + * composition; nothing is logged or announced. * @param agent Target Agent. * @param agentPreset Requested identity. * @returns Committed preset identity. diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index beb65f2c60..9714c7c62f 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -569,7 +569,9 @@ serviceFor(agent: { ctx: Context }, name: K): */ async recompose(ctx: Context, id: string): Promise -/** Select a preset before a session starts its first turn. +/** Select a preset before a session starts its first turn. A Session whose + * log records the preset and whose Agent runs its current revision keeps its + * composition; nothing is logged or announced. * @param agent Target Agent. * @param agentPreset Requested identity. * @returns Committed preset identity. diff --git a/docs/subsystems/credentials.i18n.yaml b/docs/subsystems/credentials.i18n.yaml index 64722160fa..a46cfbac32 100644 --- a/docs/subsystems/credentials.i18n.yaml +++ b/docs/subsystems/credentials.i18n.yaml @@ -6,14 +6,14 @@ en: 4589c745baf092c7 zh: 3a782e6a06825201 /user-credentials/identity: - en: aba5a25c34e229cc - zh: 96493f925f60b533 + en: 0083032b9d19467d + zh: 15d7a7520bac094e /user-credentials/resolution: en: baaab4bca76bea6b zh: 54ccdc03ffd44f63 /user-credentials/description: - en: 17573160805d3014 - zh: 36a058a96f45df23 + en: f7b09588c0f34952 + zh: 1b93d673c5bf6a80 /user-credentials/change-commits: en: 464315a953330eb2 zh: d9815591f3abc2cb diff --git a/docs/subsystems/credentials.md b/docs/subsystems/credentials.md index ef0b5b04c3..70c705d94f 100644 --- a/docs/subsystems/credentials.md +++ b/docs/subsystems/credentials.md @@ -15,6 +15,7 @@ A reference names one credential as a POSIX-style environment-variable name. The type CredentialRef = Branded<'CredentialRef'> ``` + ## Resolution `resolve(ref)` returns the value with the provider-defined source layer that supplied it, or `undefined` while unconfigured. Consumers re-resolve at each operation and never cache across operations — that per-operation read is the hot-update mechanism. @@ -31,7 +32,7 @@ interface ResolvedCredential { ## Description -`describe(ref)` answers configuration surfaces without ever exposing a value: whether the reference resolves, from which layer, and whether `set` would currently succeed. The local provider reports a reference supplied by the live process environment as `writable: false` — a write would appear to succeed while resolution kept returning the shadowing value, so the seam rejects it and the UI can render the reference read-only up front. +`describe(ref)` answers configuration surfaces without ever exposing a value: whether the reference resolves, from which layer, and whether `set` would currently succeed. The local provider ranks its managed file above the launch environment, so saving a value always creates the effective writable layer; removing it reveals the launch environment or `.env` fallback again. ```ts type-equiv /** @@ -136,7 +137,7 @@ Source: [`packages/credentials/authorization/src/index.ts`](../../packages/crede Abstract credential service over two key spaces that answer two questions. -A CredentialRef answers "what is behind this environment-variable name", layered over the process environment, the provider-managed store, and `.env` files. One seam-wide rule binds that half: an empty stored value is absent everywhere — `resolve` skips it, `describe` reports it unconfigured — so a blank never masquerades as a configured secret. +A CredentialRef answers "what is behind this environment-variable name": the provider-managed store first, then ambient fallbacks such as the process environment and `.env` files. One seam-wide rule binds that half: an empty stored value is absent everywhere — `resolve` skips it, `describe` reports it unconfigured — so a blank never masquerades as a configured secret. A CredentialKey answers "what credential does this plugin hold for this id". Nothing can layer here — an authorization grant has no environment to be read from — so presence of the record is the whole fact, and modifyRecord is the only write path because a correct write depends on the current value (a token refresh is read-decide-replace under one lock). @@ -160,10 +161,9 @@ abstract resolve(ref: CredentialRef): Promise abstract describe(ref: CredentialRef): Promise /** - * Durably store one value in the provider-managed writable source. Rejects - * while a read-only source shadows the reference — the write would appear - * to succeed while resolution keeps returning the shadowing value — and - * rejects an empty value (use {@link unset}). + * Durably store one value in the provider-managed writable source. The stored + * value takes precedence over ambient fallbacks, and empty values are + * rejected (use {@link unset}). * @param ref - the reference to store. * @param value - the non-empty secret value. */ @@ -171,8 +171,8 @@ abstract set(ref: CredentialRef, value: string): Promise /** * Remove one reference from the provider-managed writable source; removing - * an absent reference is a no-op. Rejects while a read-only source shadows - * the reference, like {@link set}. + * an absent reference is a no-op. Ambient fallback layers may become + * effective again after removal. * @param ref - the reference to remove. */ abstract unset(ref: CredentialRef): Promise diff --git a/docs/subsystems/credentials.zh.md b/docs/subsystems/credentials.zh.md index d6a8d73938..14fb73bf13 100644 --- a/docs/subsystems/credentials.zh.md +++ b/docs/subsystems/credentials.zh.md @@ -15,6 +15,7 @@ type CredentialRef = Branded<'CredentialRef'> ``` + ## 解析 `resolve(ref)` 返回值及提供该值的来源层(由提供方定义);未配置期间返回 `undefined`。消费方在每个操作中重新解析,绝不跨操作缓存——这种按操作进行的读取正是热更新机制。 @@ -31,7 +32,7 @@ interface ResolvedCredential { ## 描述 -`describe(ref)` 在绝不暴露值的前提下回应配置界面:引用当前是否可解析、来自哪一层、`set` 当前能否成功。本地提供方把由当前进程环境供值的引用报告为 `writable: false`——那样的写入会表面成功而解析持续返回遮蔽值,因此 seam 直接拒绝,界面也得以提前把该引用渲染为只读。 +`describe(ref)` 在绝不暴露值的前提下回应配置界面:引用当前是否可解析、来自哪一层、`set` 当前能否成功。本地提供方将其管理的文件排在启动环境之前,因此保存值总会成为生效且可写的那一层;移除后会重新使用启动环境或 `.env` 后备值。 ```ts type-equiv /** @@ -136,7 +137,7 @@ Source: [`packages/credentials/authorization/src/index.ts`](../../packages/crede Abstract credential service over two key spaces that answer two questions. -A CredentialRef answers "what is behind this environment-variable name", layered over the process environment, the provider-managed store, and `.env` files. One seam-wide rule binds that half: an empty stored value is absent everywhere — `resolve` skips it, `describe` reports it unconfigured — so a blank never masquerades as a configured secret. +A CredentialRef answers "what is behind this environment-variable name": the provider-managed store first, then ambient fallbacks such as the process environment and `.env` files. One seam-wide rule binds that half: an empty stored value is absent everywhere — `resolve` skips it, `describe` reports it unconfigured — so a blank never masquerades as a configured secret. A CredentialKey answers "what credential does this plugin hold for this id". Nothing can layer here — an authorization grant has no environment to be read from — so presence of the record is the whole fact, and modifyRecord is the only write path because a correct write depends on the current value (a token refresh is read-decide-replace under one lock). @@ -160,10 +161,9 @@ abstract resolve(ref: CredentialRef): Promise abstract describe(ref: CredentialRef): Promise /** - * Durably store one value in the provider-managed writable source. Rejects - * while a read-only source shadows the reference — the write would appear - * to succeed while resolution keeps returning the shadowing value — and - * rejects an empty value (use {@link unset}). + * Durably store one value in the provider-managed writable source. The stored + * value takes precedence over ambient fallbacks, and empty values are + * rejected (use {@link unset}). * @param ref - the reference to store. * @param value - the non-empty secret value. */ @@ -171,8 +171,8 @@ abstract set(ref: CredentialRef, value: string): Promise /** * Remove one reference from the provider-managed writable source; removing - * an absent reference is a no-op. Rejects while a read-only source shadows - * the reference, like {@link set}. + * an absent reference is a no-op. Ambient fallback layers may become + * effective again after removal. * @param ref - the reference to remove. */ abstract unset(ref: CredentialRef): Promise diff --git a/docs/subsystems/jobs.i18n.yaml b/docs/subsystems/jobs.i18n.yaml index 17ea2f9f9e..61aa2da366 100644 --- a/docs/subsystems/jobs.i18n.yaml +++ b/docs/subsystems/jobs.i18n.yaml @@ -6,8 +6,11 @@ en: dd515d8548864a91 zh: 16a117d39989a373 /background-job-runtime/ids-and-status: - en: 529f401da4757a98 - zh: 041ff5e86b5dd8db + en: 4b286c70e4a83b99 + zh: 7edbf090e9cb6487 +/background-job-runtime/session-lifecycle-records: + en: 5fb99886e0ab2718 + zh: bdea51368c935895 /background-job-runtime/producer-contract: en: af0be338a0ba5e60 zh: fc0eefb8c31f3a98 diff --git a/docs/subsystems/jobs.md b/docs/subsystems/jobs.md index 6cc4e3e5a5..d00c691db2 100644 --- a/docs/subsystems/jobs.md +++ b/docs/subsystems/jobs.md @@ -6,7 +6,7 @@ Types shared by long-running producers, `ctx.jobs`, and job controls. The [seam ## Ids and status -`JobId` is a [branded id](core.md#branded-ids) generated as `-N`. Access control relies on owner authorization, not id secrecy. `JobKind` derives from a merge-extensible map; the registry treats kinds as opaque id namespaces. +`JobId` is a [branded id](core.md#branded-ids) generated as `-` with 16 lowercase UUID hex characters after the kind. Access control relies on owner authorization, not id secrecy. `JobKind` derives from a merge-extensible map; the registry treats kinds as opaque id namespaces. ```ts type-equiv /** @@ -20,10 +20,64 @@ interface JobKindMap { `JobStatus` is `'running' | 'stopping' | 'completed' | 'killed' | 'failed'`; producer-specific facts belong in `JobView.progress` while the job runs and in `JobView.detail` once it settled. + + +## Session lifecycle records + +Owned jobs publish `job/registered` and `job/settled` in their original owner Session. The globally unique `jobId` pairs those records and controls the live job. The SDK forwards these log-only records through `session.event`, independently of output reads. Timestamps describe registry commits, not CPU time or exact OS process boundaries; a missing settlement remains incomplete. `producerReported` means the producer fulfilled `done` with a terminal outcome; it is false for forced teardown or rejection and does not independently certify resource release. + +`job/settled` may follow `turn/end`. An SDK run result covers only its own observation window; consumers needing later settlements retain a Session subscription or read persisted history. Turn completion does not imply that every owned job stopped. + +Application teardown can stop Session event routing or close its writer before an owned job's final settlement reaches storage. This includes normal one-shot completion and signal shutdown, as well as hard process loss. A missing `job/settled` remains incomplete even if the live registry reported termination; consumers must not invent a finish timestamp. + +```ts type-equiv +/** Durable identity and registration time of one owned job execution. */ +interface JobRegisteredData { + /** Unique execution id accepted by job controls and retained in Session history. */ + readonly jobId: JobId + /** Original owner; copied history does not transfer execution ownership. */ + readonly owner: SessionId + /** Producer kind. */ + readonly kind: string + /** Producer-supplied label. */ + readonly label: string + /** Unix epoch milliseconds when registration committed. */ + readonly startedAt: number + /** Creating tool invocation, when the producer was called through a tool. */ + readonly toolCall?: JobToolCall +} +``` + +```ts type-equiv +/** Registry settlement, including whether the producer reported a terminal outcome. */ +interface JobSettledData extends JobRegisteredData { + /** Unix epoch milliseconds when the registry committed the terminal record. */ + readonly finishedAt: number + /** Recorded terminal outcome. */ + readonly status: JobOutcome['status'] + /** Source of the termination request, or producer completion. */ + readonly cause: JobSettleCause + /** True when done fulfilled; false for rejection or forced failure. Does not certify resource release. */ + readonly producerReported: boolean + /** Terminal reason, including a recorded kill reason when applicable. */ + readonly detail?: string +} +``` + ## Producer contract A `JobSpec` declares identity, the owning session, optional pull `output` sources, and a starter. The runtime finishes preflight before calling `run()` with the job's `JobHandle` and commits without a later failable step. Producers own execution resources; the runtime owns identity, access, lifecycle state, and the output ring. +```ts type-equiv +/** Tool dispatch that created a job; nested calls retain their root invocation. */ +interface JobToolCall { + /** Exact invocation, including a PTC sub-call when applicable. */ + readonly callId: SessionEventMap['tool/call']['callId'] + /** Root model-requested invocation owning this execution. */ + readonly rootCallId: SessionEventMap['tool/call']['callId'] +} +``` + ```ts type-equiv /** * Producer declaration passed to {@link JobRegistry.start}. The runtime @@ -43,6 +97,8 @@ interface JobSpec { * caller until service disposal. */ owner?: SessionId + /** Creating tool invocation, retained in the durable lifecycle records. */ + toolCall?: JobToolCall /** * Optional UTF-8 byte cap for each complete model-facing completion notice or * output read, including controller status metadata. Independent of ring @@ -76,7 +132,7 @@ interface JobSpec { * throwing, so a producer's trailing flush cannot break its own teardown path. */ interface JobHandle { - /** The registry-issued id (`-N`). */ + /** The registry-issued id (`-`). */ readonly id: JobId /** * Append one chunk to the output ring. Offsets advance by the chunk's UTF-8 @@ -219,7 +275,7 @@ interface JobOutputRead { * stream all consume this one shape. */ interface JobView { - /** The registry-issued id (`-N`). */ + /** The registry-issued id (`-`). */ readonly id: JobId /** * The producer kind the job was registered with: a Host-registered @@ -405,7 +461,7 @@ Implementations must honor these semantics: * rejection leaves no job id or execution resource. A throwing starter * leaves nothing registered; after it returns, registration cannot fail. * @param spec - job identity, owner, output sources, and synchronous starter. - * @returns the registry-issued `-N` id. + * @returns the registry-issued `-` id. */ abstract start(spec: JobSpec): JobId diff --git a/docs/subsystems/jobs.zh.md b/docs/subsystems/jobs.zh.md index b80f64fade..c592c7d996 100644 --- a/docs/subsystems/jobs.zh.md +++ b/docs/subsystems/jobs.zh.md @@ -6,7 +6,7 @@ ## ID 与状态 -`JobId` 是按 `-N` 生成的[品牌化 id](core.zh.md#branded-ids)。访问控制依赖拥有者授权,而非 id 的保密性。`JobKind` 派生自可合并扩展的 map;注册表将各个 kind 视为不透明的 id 命名空间。 +`JobId` 是按 `-` 生成的[品牌化 id](core.zh.md#branded-ids),kind 后是 UUID 的 16 位小写十六进制字符。访问控制依赖拥有者授权,而非 id 的保密性。`JobKind` 派生自可合并扩展的 map;注册表将各个 kind 视为不透明的 id 命名空间。 ```ts type-equiv /** @@ -20,10 +20,64 @@ interface JobKindMap { `JobStatus` 为 `'running' | 'stopping' | 'completed' | 'killed' | 'failed'`;生产方特有的事实在运行期间归入 `JobView.progress`,结算后归入 `JobView.detail`。 + + +## Session 生命周期记录 + +有所有者的 job 在原始所有者 Session 中发布 `job/registered` 与 `job/settled`。全局唯一的 `jobId` 同时配对这两条记录并控制运行中的 job。SDK 通过 `session.event` 转发这些仅日志记录,不依赖输出读取。时间戳描述注册表提交时刻,不是 CPU 时间或精确的 OS 进程边界;缺少结算时保持不完整。`producerReported` 表示生产方通过兑现 `done` 报告了终态;强制销毁或 promise 拒绝时为 false,不独立确认资源已释放。 + +`job/settled` 可能出现在 `turn/end` 之后。SDK run 结果只覆盖自身观测窗口;需要较晚结算的消费方应保留 Session 订阅或读取持久化历史。轮次完成不表示所有有主 job 都已停止。 + +应用销毁可能在有主 job 的最后一条结算写入存储前停止 Session 事件路由或关闭写句柄。正常的一次性运行结束、信号关停和进程硬退出都可能出现这种情况。即使运行期注册表报告了终止,缺少 `job/settled` 的区间仍不完整;消费方不得虚构结束时间戳。 + +```ts type-equiv +/** Durable identity and registration time of one owned job execution. */ +interface JobRegisteredData { + /** Unique execution id accepted by job controls and retained in Session history. */ + readonly jobId: JobId + /** Original owner; copied history does not transfer execution ownership. */ + readonly owner: SessionId + /** Producer kind. */ + readonly kind: string + /** Producer-supplied label. */ + readonly label: string + /** Unix epoch milliseconds when registration committed. */ + readonly startedAt: number + /** Creating tool invocation, when the producer was called through a tool. */ + readonly toolCall?: JobToolCall +} +``` + +```ts type-equiv +/** Registry settlement, including whether the producer reported a terminal outcome. */ +interface JobSettledData extends JobRegisteredData { + /** Unix epoch milliseconds when the registry committed the terminal record. */ + readonly finishedAt: number + /** Recorded terminal outcome. */ + readonly status: JobOutcome['status'] + /** Source of the termination request, or producer completion. */ + readonly cause: JobSettleCause + /** True when done fulfilled; false for rejection or forced failure. Does not certify resource release. */ + readonly producerReported: boolean + /** Terminal reason, including a recorded kill reason when applicable. */ + readonly detail?: string +} +``` + ## 生产方约定 `JobSpec` 声明身份、拥有者会话、可选的拉取式 `output` 源与启动器。运行时会在完成预检后携带该 job 的 `JobHandle` 调用 `run()`,随后提交注册,不再执行可能失败的步骤。生产方拥有执行资源;运行时拥有身份、访问、生命周期状态与输出环。 +```ts type-equiv +/** Tool dispatch that created a job; nested calls retain their root invocation. */ +interface JobToolCall { + /** Exact invocation, including a PTC sub-call when applicable. */ + readonly callId: SessionEventMap['tool/call']['callId'] + /** Root model-requested invocation owning this execution. */ + readonly rootCallId: SessionEventMap['tool/call']['callId'] +} +``` + ```ts type-equiv /** * Producer declaration passed to {@link JobRegistry.start}. The runtime @@ -43,6 +97,8 @@ interface JobSpec { * caller until service disposal. */ owner?: SessionId + /** Creating tool invocation, retained in the durable lifecycle records. */ + toolCall?: JobToolCall /** * Optional UTF-8 byte cap for each complete model-facing completion notice or * output read, including controller status metadata. Independent of ring @@ -76,7 +132,7 @@ interface JobSpec { * throwing, so a producer's trailing flush cannot break its own teardown path. */ interface JobHandle { - /** The registry-issued id (`-N`). */ + /** The registry-issued id (`-`). */ readonly id: JobId /** * Append one chunk to the output ring. Offsets advance by the chunk's UTF-8 @@ -219,7 +275,7 @@ interface JobOutputRead { * stream all consume this one shape. */ interface JobView { - /** The registry-issued id (`-N`). */ + /** The registry-issued id (`-`). */ readonly id: JobId /** * The producer kind the job was registered with: a Host-registered @@ -405,7 +461,7 @@ Implementations must honor these semantics: * rejection leaves no job id or execution resource. A throwing starter * leaves nothing registered; after it returns, registration cannot fail. * @param spec - job identity, owner, output sources, and synchronous starter. - * @returns the registry-issued `-N` id. + * @returns the registry-issued `-` id. */ abstract start(spec: JobSpec): JobId diff --git a/docs/subsystems/llm-streaming.i18n.yaml b/docs/subsystems/llm-streaming.i18n.yaml index c5426ddc70..f6fb858bef 100644 --- a/docs/subsystems/llm-streaming.i18n.yaml +++ b/docs/subsystems/llm-streaming.i18n.yaml @@ -36,8 +36,8 @@ en: 58dec2259b0b1c6f zh: fc99fe965acaabf3 /llm-streaming/the-model-request: - en: 824d18f76f990711 - zh: 8a2895513652e528 + en: 60c661bdcfec52dd + zh: c27a46f6c99efea1 /llm-streaming/the-model-request/the-request-envelope-llmcallconfig-and-the-logged-header: en: f2e6932e211f179b zh: 75112586a6f3f4aa diff --git a/docs/subsystems/llm-streaming.md b/docs/subsystems/llm-streaming.md index 4936ba4ae8..cd780fd0ed 100644 --- a/docs/subsystems/llm-streaming.md +++ b/docs/subsystems/llm-streaming.md @@ -498,11 +498,31 @@ interface LlmConfigurableProvider { * from outside. */ declared?: boolean + /** + * Route endpoint the adapter uses when the profile sets no `baseURL`; the + * adapter may still send an individual model to that model's own endpoint. + * Absent when the route has no fixed endpoint. + */ + defaultBaseURL?: string + /** Authentication the adapter performs when the profile names no credential reference; absent when it reports none. */ + nativeAuth?: LlmNativeAuth /** Configuration diagnostic for repair; unaffected models may remain serviceable. */ error?: string } ``` +A settings editor shows `defaultBaseURL` and `nativeAuth` for a profile that leaves the endpoint or the credential reference unset. `nativeAuth` lists environment variable names, never their values. + +```ts type-equiv +/** Authentication a provider route performs without a credential reference in its profile. */ +interface LlmNativeAuth { + /** Environment variables the route's authentication reads, in lookup order; empty when it reads none. */ + env: readonly string[] + /** Whether a stored OAuth sign-in can authenticate the route. */ + oauth: boolean +} +``` + ```ts type-equiv /** One adapter-discovered model; catalog membership is advisory, not request validation. */ interface LlmModelInfo { diff --git a/docs/subsystems/llm-streaming.zh.md b/docs/subsystems/llm-streaming.zh.md index 47d7c0df31..61ecc659bd 100644 --- a/docs/subsystems/llm-streaming.zh.md +++ b/docs/subsystems/llm-streaming.zh.md @@ -504,11 +504,31 @@ interface LlmConfigurableProvider { * from outside. */ declared?: boolean + /** + * Route endpoint the adapter uses when the profile sets no `baseURL`; the + * adapter may still send an individual model to that model's own endpoint. + * Absent when the route has no fixed endpoint. + */ + defaultBaseURL?: string + /** Authentication the adapter performs when the profile names no credential reference; absent when it reports none. */ + nativeAuth?: LlmNativeAuth /** Configuration diagnostic for repair; unaffected models may remain serviceable. */ error?: string } ``` +设置编辑器在提供方配置未设置端点或凭据引用时显示 `defaultBaseURL` 和 `nativeAuth`。`nativeAuth` 只列出环境变量名,不包含变量值。 + +```ts type-equiv +/** Authentication a provider route performs without a credential reference in its profile. */ +interface LlmNativeAuth { + /** Environment variables the route's authentication reads, in lookup order; empty when it reads none. */ + env: readonly string[] + /** Whether a stored OAuth sign-in can authenticate the route. */ + oauth: boolean +} +``` + ```ts type-equiv /** One adapter-discovered model; catalog membership is advisory, not request validation. */ interface LlmModelInfo { diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index 24d4833a81..4933734d57 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -848,17 +848,19 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise /** - * Search visible Session content without resuming an Agent. + * Search visible Session content without resuming an Agent. A non-owner + * receives only the hits whose Session `allows()` admits; `hasMore` + * describes the bounded result before that filtering. * @param request - literal message-content query. * @param signal - cancellation for list and search reads. * @returns authorized bounded Session search results. */ -@Remote('search') search(request: SessionSearchRequest, signal: AbortSignal): Promise +@Remote('search') async search(request: SessionSearchRequest, signal: AbortSignal): Promise /** - * Create or idempotently adopt one ordinary Session. - * @param request - requested identity, location, and Agent preset. - * @returns the Session identity and resolved preset when configured. + * Create one ordinary Session for a new conversation, reusing the named blank Session while it is reusable. + * @param request - optional blank Session to reuse, location, and Agent preset. + * @returns the created or reused Session identity and its preset when configured. */ @Remote('create') create(request: SessionCreateRequest): Promise @@ -933,7 +935,7 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' | * @param signal - caller cancellation before prompt admission begins. * @returns acknowledgement that the Agent accepted the prompt. */ -@Remote('prompt') prompt(request: SessionPromptRequest, signal: AbortSignal): Promise +@Remote('prompt') async prompt(request: SessionPromptRequest, signal: AbortSignal): Promise /** * Read one image proven reachable from the addressed Session log. @@ -962,7 +964,7 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' | * @param signal - cancellation for persistence reads. * @returns one chronological page. */ -@Remote('page') page(request: SessionPageRequest, signal: AbortSignal): Promise +@Remote({ name: 'page', resources: { session: addressedSession } }) page(request: SessionPageRequest, signal: AbortSignal): Promise /** * Follow one Session log from its opening or resume cursor. @@ -971,7 +973,7 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' | * @returns a complete opening snapshot followed by gap-free durable event * frames and optional cursorless assistant-stream frames. */ -@Remote({ mode: 'stream' }) follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable +@Remote({ mode: 'stream', resources: { session: addressedSession } }) follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable /** * Read exact projections, returning cached hints only when migration is required. @@ -986,7 +988,7 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' | * @param signal - cancellation owned by the Remote stream carrier. * @returns one complete baseline followed by live replacement frames. */ -@Remote({ mode: 'stream' }) control(signal: AbortSignal): AsyncIterable +@Remote({ mode: 'stream' }) async *control(signal: AbortSignal): AsyncIterable ``` Types: [SessionId](core.md) · [SessionInspection](persistence.md) · [SessionSearchRequest](session-query.md) diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index d214c4b471..a3e5446b61 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -852,17 +852,19 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise /** - * Search visible Session content without resuming an Agent. + * Search visible Session content without resuming an Agent. A non-owner + * receives only the hits whose Session `allows()` admits; `hasMore` + * describes the bounded result before that filtering. * @param request - literal message-content query. * @param signal - cancellation for list and search reads. * @returns authorized bounded Session search results. */ -@Remote('search') search(request: SessionSearchRequest, signal: AbortSignal): Promise +@Remote('search') async search(request: SessionSearchRequest, signal: AbortSignal): Promise /** - * Create or idempotently adopt one ordinary Session. - * @param request - requested identity, location, and Agent preset. - * @returns the Session identity and resolved preset when configured. + * Create one ordinary Session for a new conversation, reusing the named blank Session while it is reusable. + * @param request - optional blank Session to reuse, location, and Agent preset. + * @returns the created or reused Session identity and its preset when configured. */ @Remote('create') create(request: SessionCreateRequest): Promise @@ -937,7 +939,7 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' | * @param signal - caller cancellation before prompt admission begins. * @returns acknowledgement that the Agent accepted the prompt. */ -@Remote('prompt') prompt(request: SessionPromptRequest, signal: AbortSignal): Promise +@Remote('prompt') async prompt(request: SessionPromptRequest, signal: AbortSignal): Promise /** * Read one image proven reachable from the addressed Session log. @@ -966,7 +968,7 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' | * @param signal - cancellation for persistence reads. * @returns one chronological page. */ -@Remote('page') page(request: SessionPageRequest, signal: AbortSignal): Promise +@Remote({ name: 'page', resources: { session: addressedSession } }) page(request: SessionPageRequest, signal: AbortSignal): Promise /** * Follow one Session log from its opening or resume cursor. @@ -975,7 +977,7 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' | * @returns a complete opening snapshot followed by gap-free durable event * frames and optional cursorless assistant-stream frames. */ -@Remote({ mode: 'stream' }) follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable +@Remote({ mode: 'stream', resources: { session: addressedSession } }) follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable /** * Read exact projections, returning cached hints only when migration is required. @@ -990,7 +992,7 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' | * @param signal - cancellation owned by the Remote stream carrier. * @returns one complete baseline followed by live replacement frames. */ -@Remote({ mode: 'stream' }) control(signal: AbortSignal): AsyncIterable +@Remote({ mode: 'stream' }) async *control(signal: AbortSignal): AsyncIterable ``` Types: [SessionId](core.zh.md) · [SessionInspection](persistence.zh.md) · [SessionSearchRequest](session-query.zh.md) diff --git a/docs/subsystems/slots.i18n.yaml b/docs/subsystems/slots.i18n.yaml index 1dd04a5ab9..5da22cf2d1 100644 --- a/docs/subsystems/slots.i18n.yaml +++ b/docs/subsystems/slots.i18n.yaml @@ -15,8 +15,8 @@ en: 9cb70abb2e39b958 zh: cd4b6f1705d90e17 /web-client-slots/framework-provided-hooks: - en: 2fcfc55fbac80f72 - zh: ea3016f394c56b07 + en: c9f8d836214623ae + zh: 76dec55006396535 /web-client-slots/developer-provided-injection: en: 3481dfa6cd3097da zh: acbd6f06a7da1752 diff --git a/docs/subsystems/slots.md b/docs/subsystems/slots.md index 30f8acedb4..12a8d89b8f 100644 --- a/docs/subsystems/slots.md +++ b/docs/subsystems/slots.md @@ -84,11 +84,12 @@ The shipped adapters add these standard props. They are available according to t | Availability | Props | Owner | |---|---|---| -| every scope | `useSessions`, `useSessionStatus`, `useSessionRetainInfo` | `ui-session` | -| every scope | `useWorkspaces` | `ui-workspace` | +| every scope | `useSessions`, `useSessionStatus`, `useSessionRetainInfo`, `useSessionAccess` | `ui-session` | +| every scope | `useWorkspaces`, `useWorkspaceAccess` | `ui-workspace` | +| every scope | `useRemoteAccess` (the `global` resource) | `api-remote-access` | | every scope | `usePanelInfo` | `ui-layout` | -| `session` | `sessionId`, `useSession`, `useProjection` | `ui-session` | -| `session-maybe` | optional `sessionId`, `useSession`, `useProjection` results | `ui-session` | +| `session` | `sessionId`, `useSession`, `useProjection`, `useAccess` (the bound Session's answer) | `ui-session` | +| `session-maybe` | optional `sessionId`, `useSession`, `useProjection`, `useAccess` results | `ui-session` | | `session` | `useConversation`, `useInput`, `inputActions` | `ui-conversation` | | `session-maybe` | optional `useConversation`, `useInput`, `inputActions` results | `ui-conversation` | | `session` | `useChat` | `ui-chat` | diff --git a/docs/subsystems/slots.zh.md b/docs/subsystems/slots.zh.md index a660f720e4..77f3717f36 100644 --- a/docs/subsystems/slots.zh.md +++ b/docs/subsystems/slots.zh.md @@ -86,11 +86,12 @@ Slot 声明固定两个相互独立的维度。 | 可用范围 | Props | Owner | |---|---|---| -| 所有 scope | `useSessions`、`useSessionStatus`、`useSessionRetainInfo` | `ui-session` | -| 所有 scope | `useWorkspaces` | `ui-workspace` | +| 所有 scope | `useSessions`、`useSessionStatus`、`useSessionRetainInfo`、`useSessionAccess` | `ui-session` | +| 所有 scope | `useWorkspaces`、`useWorkspaceAccess` | `ui-workspace` | +| 所有 scope | `useRemoteAccess`(`global` 资源) | `api-remote-access` | | 所有作用域 | `usePanelInfo` | `ui-layout` | -| `session` | `sessionId`、`useSession`、`useProjection` | `ui-session` | -| `session-maybe` | 结果可选的 `sessionId`、`useSession`、`useProjection` | `ui-session` | +| `session` | `sessionId`、`useSession`、`useProjection`、`useAccess`(所绑定 Session 的答案) | `ui-session` | +| `session-maybe` | 结果可选的 `sessionId`、`useSession`、`useProjection`、`useAccess` | `ui-session` | | `session` | `useConversation`、`useInput`、`inputActions` | `ui-conversation` | | `session-maybe` | 结果可选的 `useConversation`、`useInput`、`inputActions` | `ui-conversation` | | `session` | `useChat` | `ui-chat` | diff --git a/docs/subsystems/subagent.i18n.yaml b/docs/subsystems/subagent.i18n.yaml index 104548c425..21fa5dbbde 100644 --- a/docs/subsystems/subagent.i18n.yaml +++ b/docs/subsystems/subagent.i18n.yaml @@ -9,8 +9,8 @@ en: 8c92b131f67997f6 zh: c2dc606a0859420c /subagents/local-children-and-activations: - en: d926d4777ec0f11e - zh: 0ae6076e24b66401 + en: 6f87231d18d82916 + zh: 9f064b23058f8766 /subagents/messages-and-interruption: en: 623b06b3c12540bc zh: f56c6341e1c62768 diff --git a/docs/subsystems/subagent.md b/docs/subsystems/subagent.md index 5647fab452..30e064f25c 100644 --- a/docs/subsystems/subagent.md +++ b/docs/subsystems/subagent.md @@ -19,10 +19,10 @@ Optional `request.cwd` selects the child's initial directory; relative paths res */ interface SubagentCapabilities { readonly agentOptions: boolean + /** Supports selecting a preset independently of the parent's composition. */ + readonly agentPreset: boolean readonly outputSchema: boolean readonly depthLimit: boolean - readonly toolFilter: boolean - readonly persona: boolean } ``` @@ -70,6 +70,12 @@ interface SubagentActivation { interface SubagentStartRequest { /** Initial child directory; relative paths resolve against the parent's current directory. Omitted inherits that directory at start. */ readonly cwd?: string + /** + * Independent preset id; omission joins the parent's current generation. + * Requires a provider with `capabilities.agentPreset` that does not inherit + * parent context. + */ + readonly agentPreset?: string /** Optional short display label persisted with a session-backed child. */ readonly label?: string /** Content delivered as the child's user message. */ @@ -108,22 +114,6 @@ interface SubagentStartRequest { * start otherwise. */ readonly maxDepth?: number - /** - * Optional child tool scoping. Requires {@link SubagentCapabilities.toolFilter}; - * rejected at start otherwise. In-process backends apply it as a scoped - * `tools.restrict()` in the child's creation window: the named tools vanish - * from the child's prompt AND refuse to execute (one visibility), with loud - * unknown-name validation. - */ - readonly toolFilter?: ToolRestriction - /** - * Optional per-child persona. Requires {@link SubagentCapabilities.persona}; - * rejected at start otherwise. In-process backends register it as a scoped - * `deployment:persona-prefix` section on the child, SHADOWING the deployment's - * persona for this child alone — same template semantics as the deployment - * persona (strict `{{…}}` interpolation against the registered variables). - */ - readonly persona?: string } ``` @@ -137,6 +127,8 @@ interface ResolvedSubagentStartRequest extends SubagentStartRequest { ## Local children and activations +Spawn alone supports an independent `agentPreset`; omission and fork inherit the parent's active composition. The [child preset rules](../../packages/subagent/subagent/README.md#restoring-child-composition) define explicit selection, event ordering, and historical restoration. + A local child has a durable Session and at most one live activation. The manager reserves identity and capacity, prepares the child through its backend, and owns input admission, cold resume, and child-first disposal. The activation result settles after resource release and completion notification. Disposal reports cleanup failure separately without replacing a captured result. Structured capture closes input for that activation; a later cold resume does not inherit the output schema. ```ts type-equiv diff --git a/docs/subsystems/subagent.zh.md b/docs/subsystems/subagent.zh.md index 30262036b7..c6d7119a25 100644 --- a/docs/subsystems/subagent.zh.md +++ b/docs/subsystems/subagent.zh.md @@ -19,10 +19,10 @@ */ interface SubagentCapabilities { readonly agentOptions: boolean + /** Supports selecting a preset independently of the parent's composition. */ + readonly agentPreset: boolean readonly outputSchema: boolean readonly depthLimit: boolean - readonly toolFilter: boolean - readonly persona: boolean } ``` @@ -70,6 +70,12 @@ interface SubagentActivation { interface SubagentStartRequest { /** Initial child directory; relative paths resolve against the parent's current directory. Omitted inherits that directory at start. */ readonly cwd?: string + /** + * Independent preset id; omission joins the parent's current generation. + * Requires a provider with `capabilities.agentPreset` that does not inherit + * parent context. + */ + readonly agentPreset?: string /** Optional short display label persisted with a session-backed child. */ readonly label?: string /** Content delivered as the child's user message. */ @@ -108,22 +114,6 @@ interface SubagentStartRequest { * start otherwise. */ readonly maxDepth?: number - /** - * Optional child tool scoping. Requires {@link SubagentCapabilities.toolFilter}; - * rejected at start otherwise. In-process backends apply it as a scoped - * `tools.restrict()` in the child's creation window: the named tools vanish - * from the child's prompt AND refuse to execute (one visibility), with loud - * unknown-name validation. - */ - readonly toolFilter?: ToolRestriction - /** - * Optional per-child persona. Requires {@link SubagentCapabilities.persona}; - * rejected at start otherwise. In-process backends register it as a scoped - * `deployment:persona-prefix` section on the child, SHADOWING the deployment's - * persona for this child alone — same template semantics as the deployment - * persona (strict `{{…}}` interpolation against the registered variables). - */ - readonly persona?: string } ``` @@ -137,6 +127,8 @@ interface ResolvedSubagentStartRequest extends SubagentStartRequest { ## 本地子代理与 activation +只有 spawn 支持独立的 `agentPreset`;省略选择与 fork 都继承父方的活跃组装。[子级 preset 规则](../../packages/subagent/subagent/README.zh.md#restoring-child-composition)定义显式选择、事件顺序和历史恢复行为。 + 本地子代理拥有持久 Session,最多有一个在线 activation。Manager 预留身份和容量,通过后端准备子代理,并管理输入准入、冷恢复以及先子后父的释放顺序。activation 结果在资源释放和完成通知之后结算。释放操作单独报告清理失败,不会覆盖已捕获的结果。结构化结果提交会关闭该 activation 的输入;之后的冷恢复不会继承输出 schema。 ```ts type-equiv diff --git a/docs/subsystems/translation.i18n.yaml b/docs/subsystems/translation.i18n.yaml index 51904d7464..58d834341f 100644 --- a/docs/subsystems/translation.i18n.yaml +++ b/docs/subsystems/translation.i18n.yaml @@ -9,8 +9,8 @@ en: 485abf6107c73e68 zh: 78a274d488274035 /text-translation/failures-and-data-handling: - en: cf92a1920650888c - zh: 3e8d3aa244cc8d46 + en: 436e55e924e3ad1e + zh: c8548fda1e86b078 /text-translation/persistent-translations: en: 40d175fbe66fd4cd zh: bff18511a8d386e9 diff --git a/docs/subsystems/translation.md b/docs/subsystems/translation.md index 8cbf6d3ac8..306b5baf84 100644 --- a/docs/subsystems/translation.md +++ b/docs/subsystems/translation.md @@ -14,7 +14,7 @@ The service exposes its configured `maxTextChars`, measured in UTF-16 code units `TranslationError` carries a `TranslationErrorCode` distinguishing text limits, HTTP failures, invalid responses, response limits, owned deadlines, and request failures. `TRANSLATION_SESSION_REQUIRED` rejects a missing persistence backend or Session; `TRANSLATION_SESSION_INACTIVE` rejects an uncached inactive Session before provider dispatch; `TRANSLATION_STORAGE_ERROR` rejects invalid saved records or failed durability. Provider diagnostics omit submitted text and response bodies. Failed calls do not select another provider. Responses are validated as external JSON and redirects are rejected. -`TRANSLATION_UNAVAILABLE` rejects a missing active built-in owner or registered route, unconfigured credentials, absent exact `deepseek-flash` catalog membership, an owner/model-list/official credential-reference change before dispatch, provider withdrawal, or a prepared call whose reasoning effort is not `off`. A native `finish` with reason `error` maps to `TRANSLATION_REQUEST_FAILED`; a missing or other non-`stop` finish maps to `TRANSLATION_INVALID_RESPONSE`. +`TRANSLATION_UNAVAILABLE` rejects a missing active built-in owner or registered route, unconfigured credentials, absent exact `deepseek-flash` catalog membership, an owner, model-configuration (Official `models` or Account `modelOverrides`), or Official credential-reference change before dispatch, provider withdrawal, or a prepared call whose reasoning effort is not `off`. A native `finish` with reason `error` maps to `TRANSLATION_REQUEST_FAILED`; a missing or other non-`stop` finish maps to `TRANSLATION_INVALID_RESPONSE`. Google receives source text in a form POST body; Bing receives it in a JSON POST body. Both are remote services. Their anonymous endpoints provide no supported developer availability or quota guarantee. Regional connectivity must be verified on the user's network. diff --git a/docs/subsystems/translation.zh.md b/docs/subsystems/translation.zh.md index 4600d369e4..a9ef52df83 100644 --- a/docs/subsystems/translation.zh.md +++ b/docs/subsystems/translation.zh.md @@ -14,7 +14,7 @@ `TranslationError` 的 `TranslationErrorCode` 区分文本超限、HTTP 失败、无效响应、响应超限、服务自身超时与请求失败。`TRANSLATION_SESSION_REQUIRED` 拒绝缺失的持久化后端或 Session,`TRANSLATION_SESSION_INACTIVE` 在调用 Provider 前拒绝未激活且缓存未命中的 Session,`TRANSLATION_STORAGE_ERROR` 拒绝无效保存记录或持久化失败。Provider 错误信息省略提交的文本与响应体。失败时不选择其他 Provider。响应作为外部 JSON 校验,重定向被拒绝。 -`TRANSLATION_UNAVAILABLE` 拒绝缺失的活跃内置所有者或已注册路由、未配置的凭据、目录中不存在精确的 `deepseek-flash`、派发前所有者/模型列表/官方凭据引用变化、提供方卸载,以及准备后的调用推理强度不是 `off` 的情况。原生 `finish` 的原因为 `error` 时映射为 `TRANSLATION_REQUEST_FAILED`;缺失 finish 或其他非 `stop` 原因映射为 `TRANSLATION_INVALID_RESPONSE`。 +`TRANSLATION_UNAVAILABLE` 拒绝缺失的活跃内置所有者或已注册路由、未配置的凭据、目录中不存在精确的 `deepseek-flash`、派发前所有者、模型配置(官方路由的 `models` 或账号路由的 `modelOverrides`)或官方凭据引用变化、提供方卸载,以及准备后的调用推理强度不是 `off` 的情况。原生 `finish` 的原因为 `error` 时映射为 `TRANSLATION_REQUEST_FAILED`;缺失 finish 或其他非 `stop` 原因映射为 `TRANSLATION_INVALID_RESPONSE`。 Google 在 form POST 请求体中接收原文,Bing 在 JSON POST 请求体中接收原文。两者都是远端服务。免登录端点不保证开发者 API 的可用性或免费额度。区域连通性须在用户网络上验证。 diff --git a/docs/subsystems/typert.i18n.yaml b/docs/subsystems/typert.i18n.yaml index a74d05084d..d6917635cf 100644 --- a/docs/subsystems/typert.i18n.yaml +++ b/docs/subsystems/typert.i18n.yaml @@ -9,14 +9,17 @@ en: 8725fa62f36364d7 zh: 0f2ff4bdf13ff55b /typert-remote-calls/invocation-descriptors: - en: 6417ffb792fd5385 - zh: c012707075a0b21f + en: 21e88d68154c2c92 + zh: 8c9e05c65ef0f34d +/typert-remote-calls/remote-access-resources: + en: fa95f70437c07395 + zh: d4870b678b569a89 /typert-remote-calls/typert-registry: - en: 29f578ba733cfd5a - zh: c486ff4ac2d1e3b4 + en: 5827ef50011811c9 + zh: 21a9e7b368ba211a /typert-remote-calls/host-gateway: - en: 90fab88fe51d17bb - zh: 2d4c3849cf2dc7d3 + en: 58b923de12e48f18 + zh: d3c2372d8becf65e /typert-remote-calls/consumer-remote: - en: 4836f8f44fef8fd3 - zh: 51f3ab2462a08ff4 + en: 324386c600a5fdf5 + zh: 7963501eb663e228 diff --git a/docs/subsystems/typert.md b/docs/subsystems/typert.md index f0ef75f223..fb5bca48fd 100644 --- a/docs/subsystems/typert.md +++ b/docs/subsystems/typert.md @@ -40,27 +40,31 @@ interface TypertLookupDefinition { An `InvocationDescriptor` is local reflection, not a wire message. Host and consumer builds generate corresponding descriptors; the request sends only the endpoint and named `args`. Strict codecs carry generated schema factories, while SRC codecs enforce JSON-safe inputs without structural type recovery. Unary result codecs can supply `encode()` for byte-containing subtrees and `decode()` for recursive native-byte validation; Client declarations narrow every `Uint8Array` to `ArrayBuffer` backing. Pure JSON results need neither byte detection nor Client decoding. Cancellation is an out-of-band carrier signal injected after business parameters and never enters `args`. +`createTypertRemoteInfo` returns a lazy class instance implementing `TypertRemoteInfo`, which extends the mountable `TypertRemoteContribution`. Its `methods` index aliases the direct objects in `descriptors`: each `TypertRemoteMethod` adds named `input(options?)` and `invoke`, retains documentation on the method and its parameters, and uses `result` as its only output codec. Unary `mode` is absent; stream `mode` is `'stream'`. Context-only receivers remain in descriptors and scoped declarations. Registry queries return base descriptors; `isTypertRemoteMethod` narrows generated direct capabilities without recovering endpoint-specific generics. + +`TypertSchema` guarantees parsing, not arbitrary Zod operations, although the raw factory spec requires Zod factories. Missing documentation or an unsupported optional JSON Schema projection does not invalidate an ordinary method. The info index and each input-policy view, parser, and derived projection are independently lazy and cached; no metadata or schema access invokes the network. The [protocol reference](<../../packages/typert/protocol/README.md>) defines these APIs, and the [generator reference](<../../packages/typert/generator/README.md#ordinary-remote-metadata>) defines their separate Host `./typert`, consumer `./remote`, and Client `./client/typert` artifacts. + +`TypertRemoteSpec` holds a package and endpoint-keyed `TypertRemoteMethodSpec` values. Each ordered `TypertRemoteParameterSpec` contains normal `codec` and `strictCodec` factories from the same checker-resolved type model; the runtime derives identities, wire-name defaults, and named-call views. `TypertRemoteInputOptions` has only `strict?: boolean`; `method.input(options?)` selects a cached `TypertRemoteValue` with matching parsing and optional documentation/projection, without changing Host dispatch or `result`. `RemoteDecoded` describes carrier-reconstructed unary results without narrowing arbitrary parser outputs. + ```ts type-equiv /** Codec attached to one invocation parameter or result. */ -type TypertCodec = - | { +type TypertCodec = + | TypertRemoteValue & { readonly mode: 'strict' readonly typeSymbol: string - /** Materialize and return the process-realm schema on first boundary use. */ - readonly create: () => TypertSchema /** * Decode a unary result whose fields require type-specific handling. * @param value - result reconstructed by the RPC carrier. * @returns the validated result, retaining native byte views. */ - readonly decode?: (value: unknown) => unknown + readonly decode?: ((value: unknown) => unknown) | undefined /** * Project typed binary fields into RPC result attachments. * @param value - native unary result. * @param writeBytes - records a byte view at its result-relative path and returns its JSON placeholder. * @returns JSON metadata with untouched JSON subtrees retained. */ - readonly encode?: (value: unknown, writeBytes: (bytes: Uint8Array, path: readonly (string | number)[]) => null) => unknown + readonly encode?: ((value: unknown, writeBytes: (bytes: Uint8Array, path: readonly (string | number)[]) => null) => unknown) | undefined } | { readonly mode: 'src-json' @@ -72,6 +76,8 @@ type TypertCodec = interface InvocationParameterDescriptor { /** Source-level parameter name. */ readonly name: string + /** Source parameter documentation, independent of JSON Schema support. */ + readonly description?: string /** Required key in the wire `args` object. */ readonly wire: string /** Whether the value is JSON or requires a registered Host lookup. */ @@ -135,6 +141,8 @@ interface InvocationDescriptor { } /** Codec for the unary result or each yielded stream item. */ readonly result: TypertCodec + /** Source-owned method documentation; ordinary methods need not have JSDoc. */ + readonly description?: string /** Source declaration used only for diagnostics. */ readonly sourceLocation?: InvocationSourceLocation } @@ -192,13 +200,35 @@ interface RemoteStreamHandle extends AsyncIterable { ```ts type-equiv /** - * One Peer's session on this Host. Connection owns it: `ctx` is the Cordis + * Merge-extensible caller identities. Each driver declares its own entry. + * Every entry is a JSON value with a unique literal `kind`, carries no + * credential, and is safe to persist in a Session log. + */ +interface PrincipalMap { + /** The local user who owns this Host. */ + owner: { readonly kind: 'owner' } +} +``` + +```ts type-equiv +/** One caller identity; the authorization layer reads only this. */ +type Principal = PrincipalMap[keyof PrincipalMap] +``` + +```ts type-equiv +/** + * One Peer's session on this Host. Its creator owns it: `ctx` is the Cordis * scope that owns connection-lifetime registrations and is disposed with the - * Peer. Who the Peer is and what it may do are not recorded here. + * Peer, and `principal` is the caller identity fixed when the Peer was created. */ interface PeerScope { readonly id: PeerId + /** Connection-lifetime registrations; disposed with the Peer. */ readonly ctx: Context + /** Caller identity fixed at creation by in-process Host code; no wire field can set it. */ + readonly principal: Principal + /** Aborted once the Peer is disposed; Remote streams that speak for the Peer end with it. */ + readonly signal: AbortSignal /** * Tear down every registration made through `ctx`. * @returns settles once the scope has quiesced; racing calls share one completion. @@ -207,6 +237,16 @@ interface PeerScope { } ``` +```ts type-equiv +/** Carrier capacity for buffered uplink frames of one logical stream. */ +interface RemoteUplinkWindow { + /** Maximum complete encoded frame bytes waiting for the Host consumer. */ + readonly maxBytes: number + /** UTF-8 envelope bytes added to a compact JSON item with a present value field. */ + readonly frameOverheadBytes: number +} +``` + ```ts type-equiv /** * The context of one Remote call, reachable inside the receiving method as @@ -221,10 +261,12 @@ interface RemoteInvocation { } /** Cordis service key of the receiving Service. */ readonly service: string - /** Peer the call speaks for; an in-process carrier speaks for the operator. */ + /** Peer the call speaks for; every carrier names it explicitly. */ readonly peer: PeerScope /** Carrier cancellation: Client cancel, socket close, or an uplink failure. */ readonly signal: AbortSignal + /** Carrier frame capacity; absent when the carrier imposes no encoded-frame inbox limit. */ + readonly uplinkWindow?: RemoteUplinkWindow | undefined /** * The Client's uplink items for this call. Available once; a second call * throws. With an uplink codec on the descriptor every item is decoded to @@ -240,9 +282,81 @@ interface RemoteInvocation { } ``` +## Remote access resources + +Every Remote method is under the access control of [`dsh-api-remote-access`](../../packages/api/remote-access/README.md): a non-owner call is allowed only when the policy for its principal kind binds, on every resource the call touches, a role that lists the endpoint action. The package that owns an identity type merge-declares its resource kind in `TypertResourceMap`; `global`, the built-in identity-less resource, is never a kind there. The Gateway reads a kind's identities from the `resources` readers of `@Remote({ resources })` or `@RemoteScope(key, { resources })`, else from a lookup parameter or scoped Context that declares the kind as its `resource`, else from the default field registered in `typert.resources`; a call that names no resource touches `global`. The [Peer principals Agent Note](../../.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md) owns the decision. + +```ts type-equiv +/** + * Merge-extensible resource kinds whose identities a Remote call or forwarded + * event may carry. The package that owns an identity type declares its kind; + * the value is that kind's identity type, a string type such as a branded id. + * `global` names the built-in identity-less resource and is never a kind here. + */ +interface TypertResourceMap {} +``` + +```ts type-equiv +/** One resource kind: the built-in `global` kind or a declared kind. */ +type RemoteAccessResourceKind = 'global' | TypertResourceKind +``` + +```ts type-equiv +/** + * One resource a call or forwarded event touches: `global`, or one identity + * of a declared kind. Identities are wire values, read before any lookup + * resolves or validates them. + */ +type RemoteAccessResource = + | { readonly kind: 'global' } + | { + [Kind in TypertResourceKind]: { readonly kind: Kind; readonly id: TypertResourceId } + }[TypertResourceKind] +``` + +```ts type-equiv +/** + * Explicit endpoint and resource choices for one Remote Scope method. + * @template WireArgs - the decorated method's positional parameters as the + * wire carries them; the default `never` admits readers of any tuple. + */ +interface RemoteScopeOptions { + /** Exported endpoint method; omission retains the implementation member name. */ + readonly name?: string + /** + * Per-kind readers of the resources one call touches. Each receives the + * method's arguments as its own parameters, JSON arguments as their Typert + * codecs validated them, a lookup parameter as its wire identity and a + * cancellation parameter as the call's signal, so it performs plain reads + * only, and returns one identity, several, or `undefined` for none. A + * throw, an empty list, or an identity other than a non-empty string + * refuses the call. For a kind without a reader, the Gateway reads the + * identity of a scoped Context whose Host adapter declares that `resource` + * and of lookup parameters declaring it, else the field registered in + * `typert.resources`: a top-level argument or, failing that, the first + * object argument. A call that carries no identity touches `global`. The + * readers and these defaults together must name every resource the method + * accesses: access control never checks a resource they omit. + */ + readonly resources?: TypertResourceReaders +} +``` + +```ts type-equiv +/** + * Explicit endpoint, stream, and resource choices for one direct Remote method. + * @template WireArgs - the decorated method's positional parameters as the + * wire carries them; the default `never` admits readers of any tuple. + */ +interface RemoteMethodOptions extends RemoteScopeOptions { + /** `stream`: deliver each Iterable item over the shared logical-stream carrier. */ + readonly mode?: 'stream' +} +``` + ## Typert registry -`ctx.typert` separates current-environment descriptors, explicitly selected Remote contributions, lookup providers, and scoped Context providers. A lookup provider owns the stable wire declaration and default resolver; Host composition can configure an effect-scoped synchronous or asynchronous resolver for the same key, and unloading that configuration restores the default policy. Registrations are Cordis-owned effects and return awaitable disposers. +`ctx.typert` separates current-environment descriptors, explicitly selected Remote contributions, lookup providers, scoped Context providers, and the default fields of resource kinds. A lookup provider owns the stable wire declaration and default resolver; Host composition can configure an effect-scoped synchronous or asynchronous resolver for the same key, and unloading that configuration restores the default policy. Registrations are Cordis-owned effects and return awaitable disposers. ```ts type-equiv /** Minimal Typert runtime consumed through dependency inversion. */ @@ -251,6 +365,7 @@ interface TypertRegistryContract { readonly remotes: TypertRemoteRegistry readonly lookups: TypertLookupRegistry readonly contexts: TypertContextRegistry + readonly resources: TypertResourceRegistry } ``` @@ -263,7 +378,7 @@ interface TypertRemoteNamespaceMap {} ## Host Gateway -Connection decodes its carrier envelope before calling `ctx.typertGateway`. The request carries exact named wire fields and the carrier's cancellation signal separately; infrastructure and boundary failures ride `TypertGatewayError`, whose `gateway/*` codes are ordinary `RemoteError` codes, so the RPC adapter passes every structurally identified `RemoteError` through with its code and details intact and folds only unrecognized exceptions into `gateway/internal`. +Connection decodes its carrier envelope before calling `ctx.typertGateway`. The request carries exact named wire fields, the carrier's cancellation signal, and the Peer the call speaks for separately; every carrier and in-process caller names that Peer, and the Gateway refuses a disposed one with `gateway/peer-disposed`. For a non-owner call the Gateway resolves its resources and asks `ctx.remoteAccess.decide()` after argument validation and before any lookup or Context resolution; infrastructure and boundary failures ride `TypertGatewayError`, whose `gateway/*` codes are ordinary `RemoteError` codes, so the RPC adapter passes every structurally identified `RemoteError` through with its code and details intact and folds only unrecognized exceptions into `gateway/internal`. ```ts type-equiv /** One Remote method request after a carrier has decoded its envelope. */ @@ -279,8 +394,10 @@ interface InvokeRemoteRequest { * `invocation.uplink()`; absent means an immediately ended iterable. */ readonly uplink?: AsyncIterable - /** Peer the call speaks for; absent means an in-process carrier, answered as the operator. */ - readonly peer?: PeerScope + /** Carrier-provided frame capacity; absent for carriers without an encoded-frame inbox limit. */ + readonly uplinkWindow?: RemoteUplinkWindow | undefined + /** Peer the call speaks for; every carrier and in-process caller names it explicitly. */ + readonly peer: PeerScope /** Carrier or direct-caller cancellation injected only into cancellation-aware methods. */ readonly signal?: AbortSignal } @@ -302,6 +419,7 @@ type TypertGatewayErrorCode = | 'gateway/lookup-not-found' | 'gateway/lookup-unavailable' | 'gateway/method-unavailable' + | 'gateway/peer-disposed' | 'gateway/protocol' | 'gateway/provider-mismatch' | 'gateway/result-invalid' @@ -316,8 +434,8 @@ interface TypertGateway { /** Carrier adapter shared by WebSocket and in-process transports. */ readonly wireStream: TypertGatewayWireStream /** - * Check for an active Client event stream. - * @returns whether a stream is open and has not been cancelled. + * Check for an active owner Client event stream, the only kind that can answer forwarded waterfalls. + * @returns whether an owner stream is open and has not been cancelled. */ hasLiveClient(): boolean /** @@ -350,6 +468,8 @@ interface TypertGateway { `ctx.remote` exposes only namespaces contributed by imported `/remote` artifacts. `$mount()` installs generated descriptors and concrete methods as one fiber-owned operation. Each namespace is a traced `remote.` Cordis child Service whose lifetime spans its mounted methods; no JavaScript Proxy or Host business Service type enters the consumer. +Metadata ownership is separate from connection ownership; the [registry reference](<../../packages/typert/registry/README.md>) covers offline registration and mount lifetimes. + ```ts type-equiv /** Client Remote capability implemented by the Gateway and consumed by Remote assemblies. */ interface TypertClientRemote extends TypertRemoteNamespaceMap { @@ -380,6 +500,88 @@ interface TypertClientRemote extends TypertRemoteNamespaceMap { Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). + + +### `ctx.remoteAccess` — `RemoteAccessService` + +Registry of access policies, the Gateway's access decisions, list filtering, and the per-Peer access snapshot. Every composition that mounts the Gateway mounts this service too. + +```ts cordis-catalog +/** + * Register the policy of one non-owner principal kind in the caller's + * fiber; open snapshots are recomputed on registration and removal. + * @param kind - the principal kind the policy serves; never `owner`. + * @param policy - business-owned bindings and refusal reasons for principals of `kind`. + * @returns disposer removing this policy. + * @throws Error when `kind` already has a policy. + */ +registerPolicy>(kind: K, policy: RemoteAccessPolicy): () => void + +/** + * Report that the bindings or condition answers for `principal` may have + * changed. Business packages call this on every grant, revocation, mode + * change, and expiry; open snapshots re-push, and the next failure of each + * policy is logged again. + * @param principal - affected principal; omitted for every principal. + */ +invalidate(principal?: Principal): void + +/** + * Gateway-only: decide one request whose resources the Gateway resolved. + * The owner is allowed, and so is `remoteAccess/follow`: every Peer reads + * its own snapshot. Otherwise the action is allowed only when, on every + * listed resource, some binding of the principal's policy names that + * resource, has a role listing the action, and has no condition or a + * condition that allows. `bindings()` is called once per decision. + * @param request - the caller or recipient, the action, the resources it + * touches, and a call's arguments. + * @returns `allow`; or `deny` with `no-allow` when the principal kind has + * no policy, the first refusing condition's reason on the first resource + * no binding allows, else `explain()`'s reason, else `no-allow`; + * `policy-failed` when `bindings()`, a condition, or `explain()` throws, + * logged once per policy until the next `invalidate()`. The Gateway + * throws `remote-access/denied` for a denied call and drops a denied event. + */ +decide(request: RemoteAccessRequest): RemoteAccessDecision + +/** + * Filter items inside a Remote method. The invocation is read from the + * Context that accessed this service, so call it as + * `this.ctx.remoteAccess.allows(resource)` from the Remote method. Each + * check reads the policy's bindings again, so a long-running stream sees + * binding changes from the next check on. + * @param resource - resource of one result item. + * @returns true for the owner and outside a Remote call; otherwise whether + * the invocation's endpoint action is allowed on `resource` without call + * arguments, false when the policy is absent or fails. + */ +allows(resource: RemoteAccessResource): boolean + +/** + * The baseline a page opens with before its snapshot stream connects. A + * business package rendering a non-owner page assigns this value to + * {@link REMOTE_ACCESS_SNAPSHOT_KEY} after every index injection row, + * replacing the owner baseline the webServer index carries. + * @param principal - the page's caller identity. + * @returns the owner baseline for the owner; otherwise the principal's current grants. + */ +snapshotFor(principal: Principal): RemoteAccessBaseline + +/** + * Stream the calling Peer's access snapshot: one baseline, then `upsert` + * and `remove` frames after each `invalidate()` and each policy + * registration or removal that changes a grant. The owner receives only + * `{ owner: true }`. The generation ends when `signal` aborts or this + * service unloads, so a reloaded service starts every reader from a fresh + * baseline. + * @param signal - generation cancellation. + * @returns the access frames of one generation. + */ +@Remote({ mode: 'stream' }) async *follow(signal: AbortSignal): AsyncIterable +``` + +Source: [`packages/api/remote-access/src/index.ts`](../../packages/api/remote-access/src/index.ts) + ### `ctx.typert` — `TypertRegistry` @@ -452,8 +654,8 @@ Resolve strict generated definitions or conservative SRC markers against current ```ts cordis-catalog /** - * Check for an active Client event stream. - * @returns whether a stream is open and has not been cancelled. + * Check for an active owner Client event stream, the only kind that can answer forwarded waterfalls. + * @returns whether an owner stream is open and has not been cancelled. */ hasLiveClient(): boolean diff --git a/docs/subsystems/typert.zh.md b/docs/subsystems/typert.zh.md index e2414dfc5e..11b3fd8dbb 100644 --- a/docs/subsystems/typert.zh.md +++ b/docs/subsystems/typert.zh.md @@ -40,27 +40,31 @@ interface TypertLookupDefinition { `InvocationDescriptor` 是本地反射信息,不是 wire message。Host 与消费方构建会生成彼此对应的 descriptor;请求只发送 endpoint 与具名 `args`。strict codec 携带生成的 schema factory,SRC codec 则在不恢复结构类型的前提下强制要求输入为 JSON 安全值。一元结果 codec 可提供 `encode()` 处理含字节的子树,并通过 `decode()` 递归校验原生字节;Client 声明将每个 `Uint8Array` 限定为以 `ArrayBuffer` 为底层缓冲区。纯 JSON 结果无需字节识别或 Client 解码。取消通过带外 carrier signal 表达:它在业务参数之后注入,绝不进入 `args`。 +`createTypertRemoteInfo` 返回实现 `TypertRemoteInfo` 的惰性类实例,后者扩展可挂载的 `TypertRemoteContribution`。其 `methods` 索引引用 `descriptors` 中的直接方法对象:每个 `TypertRemoteMethod` 增加具名 `input(options?)` 和 `invoke`,在方法及参数上保留文档,并以 `result` 作为唯一输出 codec。一元 `mode` 缺省;流式 `mode` 为 `'stream'`。仅使用 Context 接收器的方法保留在描述符和作用域声明中。Registry 查询返回基础描述符;`isTypertRemoteMethod` 收窄生成的直接调用能力,但不会恢复各端点的精确泛型。 + +`TypertSchema` 保证解析能力,不保证任意 Zod 操作,尽管原始 factory spec 要求 Zod factory。缺少文档或不支持可选的 JSON Schema 投影,不会使普通方法失效。info 索引与每种输入策略的视图、解析器和派生投影都独立惰性求值并缓存;读取元数据或 schema 绝不发起网络调用。[协议参考](<../../packages/typert/protocol/README.zh.md>)定义这些 API,[生成器参考](<../../packages/typert/generator/README.zh.md#ordinary-remote-metadata>)定义它们彼此分离的 Host `./typert`、消费方 `./remote` 和 Client `./client/typert` 产物。 + +`TypertRemoteSpec` 保存包名与按端点键控的 `TypertRemoteMethodSpec`。每个有序 `TypertRemoteParameterSpec` 条目都包含从同一 checker 解析后类型模型生成的普通 `codec` 和 `strictCodec` factory;运行时派生标识、默认 wire 名称和具名调用视图。`TypertRemoteInputOptions` 仅包含 `strict?: boolean`;`method.input(options?)` 选择缓存的 `TypertRemoteValue`,提供匹配的解析及可选文档/投影,而不改变 Host 分发或 `result`。`RemoteDecoded` 则描述 carrier 重建的一元结果,不会收窄任意解析器输出。 + ```ts type-equiv /** Codec attached to one invocation parameter or result. */ -type TypertCodec = - | { +type TypertCodec = + | TypertRemoteValue & { readonly mode: 'strict' readonly typeSymbol: string - /** Materialize and return the process-realm schema on first boundary use. */ - readonly create: () => TypertSchema /** * Decode a unary result whose fields require type-specific handling. * @param value - result reconstructed by the RPC carrier. * @returns the validated result, retaining native byte views. */ - readonly decode?: (value: unknown) => unknown + readonly decode?: ((value: unknown) => unknown) | undefined /** * Project typed binary fields into RPC result attachments. * @param value - native unary result. * @param writeBytes - records a byte view at its result-relative path and returns its JSON placeholder. * @returns JSON metadata with untouched JSON subtrees retained. */ - readonly encode?: (value: unknown, writeBytes: (bytes: Uint8Array, path: readonly (string | number)[]) => null) => unknown + readonly encode?: ((value: unknown, writeBytes: (bytes: Uint8Array, path: readonly (string | number)[]) => null) => unknown) | undefined } | { readonly mode: 'src-json' @@ -72,6 +76,8 @@ type TypertCodec = interface InvocationParameterDescriptor { /** Source-level parameter name. */ readonly name: string + /** Source parameter documentation, independent of JSON Schema support. */ + readonly description?: string /** Required key in the wire `args` object. */ readonly wire: string /** Whether the value is JSON or requires a registered Host lookup. */ @@ -135,6 +141,8 @@ interface InvocationDescriptor { } /** Codec for the unary result or each yielded stream item. */ readonly result: TypertCodec + /** Source-owned method documentation; ordinary methods need not have JSDoc. */ + readonly description?: string /** Source declaration used only for diagnostics. */ readonly sourceLocation?: InvocationSourceLocation } @@ -192,13 +200,35 @@ interface RemoteStreamHandle extends AsyncIterable { ```ts type-equiv /** - * One Peer's session on this Host. Connection owns it: `ctx` is the Cordis + * Merge-extensible caller identities. Each driver declares its own entry. + * Every entry is a JSON value with a unique literal `kind`, carries no + * credential, and is safe to persist in a Session log. + */ +interface PrincipalMap { + /** The local user who owns this Host. */ + owner: { readonly kind: 'owner' } +} +``` + +```ts type-equiv +/** One caller identity; the authorization layer reads only this. */ +type Principal = PrincipalMap[keyof PrincipalMap] +``` + +```ts type-equiv +/** + * One Peer's session on this Host. Its creator owns it: `ctx` is the Cordis * scope that owns connection-lifetime registrations and is disposed with the - * Peer. Who the Peer is and what it may do are not recorded here. + * Peer, and `principal` is the caller identity fixed when the Peer was created. */ interface PeerScope { readonly id: PeerId + /** Connection-lifetime registrations; disposed with the Peer. */ readonly ctx: Context + /** Caller identity fixed at creation by in-process Host code; no wire field can set it. */ + readonly principal: Principal + /** Aborted once the Peer is disposed; Remote streams that speak for the Peer end with it. */ + readonly signal: AbortSignal /** * Tear down every registration made through `ctx`. * @returns settles once the scope has quiesced; racing calls share one completion. @@ -207,6 +237,16 @@ interface PeerScope { } ``` +```ts type-equiv +/** Carrier capacity for buffered uplink frames of one logical stream. */ +interface RemoteUplinkWindow { + /** Maximum complete encoded frame bytes waiting for the Host consumer. */ + readonly maxBytes: number + /** UTF-8 envelope bytes added to a compact JSON item with a present value field. */ + readonly frameOverheadBytes: number +} +``` + ```ts type-equiv /** * The context of one Remote call, reachable inside the receiving method as @@ -221,10 +261,12 @@ interface RemoteInvocation { } /** Cordis service key of the receiving Service. */ readonly service: string - /** Peer the call speaks for; an in-process carrier speaks for the operator. */ + /** Peer the call speaks for; every carrier names it explicitly. */ readonly peer: PeerScope /** Carrier cancellation: Client cancel, socket close, or an uplink failure. */ readonly signal: AbortSignal + /** Carrier frame capacity; absent when the carrier imposes no encoded-frame inbox limit. */ + readonly uplinkWindow?: RemoteUplinkWindow | undefined /** * The Client's uplink items for this call. Available once; a second call * throws. With an uplink codec on the descriptor every item is decoded to @@ -240,9 +282,81 @@ interface RemoteInvocation { } ``` +## Remote 访问资源 + +每个 Remote 方法都受 [`dsh-api-remote-access`](../../packages/api/remote-access/README.zh.md) 的访问控制:只有当该 principal 种类的 policy 在调用访问的每个资源上都绑定了列出该端点 action 的 role 时,非 owner 调用才被放行。拥有某个身份类型的包把其资源种类声明合并进 `TypertResourceMap`;内置的无身份资源 `global` 从不是其中的种类。Gateway 从 `@Remote({ resources })` 或 `@RemoteScope(key, { resources })` 的 `resources` 读取函数读取某个种类的身份,其次是把该种类声明为 `resource` 的 lookup 参数或作用域 Context,再次是在 `typert.resources` 中登记的默认字段;不指向任何资源的调用访问 `global`。决策由 [Peer 身份属性 Agent Note](../../.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md) 负责。 + +```ts type-equiv +/** + * Merge-extensible resource kinds whose identities a Remote call or forwarded + * event may carry. The package that owns an identity type declares its kind; + * the value is that kind's identity type, a string type such as a branded id. + * `global` names the built-in identity-less resource and is never a kind here. + */ +interface TypertResourceMap {} +``` + +```ts type-equiv +/** One resource kind: the built-in `global` kind or a declared kind. */ +type RemoteAccessResourceKind = 'global' | TypertResourceKind +``` + +```ts type-equiv +/** + * One resource a call or forwarded event touches: `global`, or one identity + * of a declared kind. Identities are wire values, read before any lookup + * resolves or validates them. + */ +type RemoteAccessResource = + | { readonly kind: 'global' } + | { + [Kind in TypertResourceKind]: { readonly kind: Kind; readonly id: TypertResourceId } + }[TypertResourceKind] +``` + +```ts type-equiv +/** + * Explicit endpoint and resource choices for one Remote Scope method. + * @template WireArgs - the decorated method's positional parameters as the + * wire carries them; the default `never` admits readers of any tuple. + */ +interface RemoteScopeOptions { + /** Exported endpoint method; omission retains the implementation member name. */ + readonly name?: string + /** + * Per-kind readers of the resources one call touches. Each receives the + * method's arguments as its own parameters, JSON arguments as their Typert + * codecs validated them, a lookup parameter as its wire identity and a + * cancellation parameter as the call's signal, so it performs plain reads + * only, and returns one identity, several, or `undefined` for none. A + * throw, an empty list, or an identity other than a non-empty string + * refuses the call. For a kind without a reader, the Gateway reads the + * identity of a scoped Context whose Host adapter declares that `resource` + * and of lookup parameters declaring it, else the field registered in + * `typert.resources`: a top-level argument or, failing that, the first + * object argument. A call that carries no identity touches `global`. The + * readers and these defaults together must name every resource the method + * accesses: access control never checks a resource they omit. + */ + readonly resources?: TypertResourceReaders +} +``` + +```ts type-equiv +/** + * Explicit endpoint, stream, and resource choices for one direct Remote method. + * @template WireArgs - the decorated method's positional parameters as the + * wire carries them; the default `never` admits readers of any tuple. + */ +interface RemoteMethodOptions extends RemoteScopeOptions { + /** `stream`: deliver each Iterable item over the shared logical-stream carrier. */ + readonly mode?: 'stream' +} +``` + ## Typert 注册表 -`ctx.typert` 分开保存当前环境的 descriptor、显式选择的 Remote contribution、lookup 提供方与作用域上下文提供方。lookup 提供方拥有稳定 wire 声明和默认 resolver;Host 组合可以为同一个 key 配置 effect-scoped 同步或异步 resolver,配置卸载后恢复默认策略。各项注册都是由 Cordis 持有的 effect,并返回可等待的 disposer。 +`ctx.typert` 分开保存当前环境的 descriptor、显式选择的 Remote contribution、lookup 提供方、作用域上下文提供方与资源种类的默认字段。lookup 提供方拥有稳定 wire 声明和默认 resolver;Host 组合可以为同一个 key 配置 effect-scoped 同步或异步 resolver,配置卸载后恢复默认策略。各项注册都是由 Cordis 持有的 effect,并返回可等待的 disposer。 ```ts type-equiv /** Minimal Typert runtime consumed through dependency inversion. */ @@ -251,6 +365,7 @@ interface TypertRegistryContract { readonly remotes: TypertRemoteRegistry readonly lookups: TypertLookupRegistry readonly contexts: TypertContextRegistry + readonly resources: TypertResourceRegistry } ``` @@ -263,7 +378,7 @@ interface TypertRemoteNamespaceMap {} ## Host Gateway -Connection 会先解码 carrier envelope,再调用 `ctx.typertGateway`。请求将精确的具名 wire 字段与 carrier 的取消 signal 分开携带;基础设施与边界失败由 `TypertGatewayError` 承载,其 `gateway/*` 码就是普通的 `RemoteError` 码,因此 RPC 适配器会把每个经结构识别的 `RemoteError` 连同其 code 与 details 原样放行,只把无法识别的异常归并为 `gateway/internal`。 +Connection 会先解码 carrier envelope,再调用 `ctx.typertGateway`。请求将精确的具名 wire 字段、carrier 的取消 signal 与本次调用代表的 Peer 分开携带;每个载体与进程内调用方都显式给出该 Peer,Gateway 以 `gateway/peer-disposed` 拒绝已 dispose 的 Peer。对非 owner 调用,Gateway 在参数校验之后、任何 lookup 或 Context 解析之前解析其资源并询问 `ctx.remoteAccess.decide()`;基础设施与边界失败由 `TypertGatewayError` 承载,其 `gateway/*` 码就是普通的 `RemoteError` 码,因此 RPC 适配器会把每个经结构识别的 `RemoteError` 连同其 code 与 details 原样放行,只把无法识别的异常归并为 `gateway/internal`。 ```ts type-equiv /** One Remote method request after a carrier has decoded its envelope. */ @@ -279,8 +394,10 @@ interface InvokeRemoteRequest { * `invocation.uplink()`; absent means an immediately ended iterable. */ readonly uplink?: AsyncIterable - /** Peer the call speaks for; absent means an in-process carrier, answered as the operator. */ - readonly peer?: PeerScope + /** Carrier-provided frame capacity; absent for carriers without an encoded-frame inbox limit. */ + readonly uplinkWindow?: RemoteUplinkWindow | undefined + /** Peer the call speaks for; every carrier and in-process caller names it explicitly. */ + readonly peer: PeerScope /** Carrier or direct-caller cancellation injected only into cancellation-aware methods. */ readonly signal?: AbortSignal } @@ -302,6 +419,7 @@ type TypertGatewayErrorCode = | 'gateway/lookup-not-found' | 'gateway/lookup-unavailable' | 'gateway/method-unavailable' + | 'gateway/peer-disposed' | 'gateway/protocol' | 'gateway/provider-mismatch' | 'gateway/result-invalid' @@ -316,8 +434,8 @@ interface TypertGateway { /** Carrier adapter shared by WebSocket and in-process transports. */ readonly wireStream: TypertGatewayWireStream /** - * Check for an active Client event stream. - * @returns whether a stream is open and has not been cancelled. + * Check for an active owner Client event stream, the only kind that can answer forwarded waterfalls. + * @returns whether an owner stream is open and has not been cancelled. */ hasLiveClient(): boolean /** @@ -350,6 +468,8 @@ interface TypertGateway { `ctx.remote` 只暴露由已导入 `/remote` 产物贡献的 namespace。`$mount()` 会把生成的 descriptor 与具体方法作为一项由 fiber 持有的操作统一注册。每个 namespace 都是可追踪的 `remote.` Cordis 子服务,其生命周期覆盖已挂载的方法;JavaScript Proxy 与 Host 业务服务类型都不会进入消费方。 +元数据所有权与连接所有权相互独立;[注册表参考](<../../packages/typert/registry/README.zh.md>)说明离线注册与挂载生命周期。 + ```ts type-equiv /** Client Remote capability implemented by the Gateway and consumed by Remote assemblies. */ interface TypertClientRemote extends TypertRemoteNamespaceMap { @@ -380,6 +500,88 @@ interface TypertClientRemote extends TypertRemoteNamespaceMap { Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.zh.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). + + +### `ctx.remoteAccess` — `RemoteAccessService` + +Registry of access policies, the Gateway's access decisions, list filtering, and the per-Peer access snapshot. Every composition that mounts the Gateway mounts this service too. + +```ts cordis-catalog +/** + * Register the policy of one non-owner principal kind in the caller's + * fiber; open snapshots are recomputed on registration and removal. + * @param kind - the principal kind the policy serves; never `owner`. + * @param policy - business-owned bindings and refusal reasons for principals of `kind`. + * @returns disposer removing this policy. + * @throws Error when `kind` already has a policy. + */ +registerPolicy>(kind: K, policy: RemoteAccessPolicy): () => void + +/** + * Report that the bindings or condition answers for `principal` may have + * changed. Business packages call this on every grant, revocation, mode + * change, and expiry; open snapshots re-push, and the next failure of each + * policy is logged again. + * @param principal - affected principal; omitted for every principal. + */ +invalidate(principal?: Principal): void + +/** + * Gateway-only: decide one request whose resources the Gateway resolved. + * The owner is allowed, and so is `remoteAccess/follow`: every Peer reads + * its own snapshot. Otherwise the action is allowed only when, on every + * listed resource, some binding of the principal's policy names that + * resource, has a role listing the action, and has no condition or a + * condition that allows. `bindings()` is called once per decision. + * @param request - the caller or recipient, the action, the resources it + * touches, and a call's arguments. + * @returns `allow`; or `deny` with `no-allow` when the principal kind has + * no policy, the first refusing condition's reason on the first resource + * no binding allows, else `explain()`'s reason, else `no-allow`; + * `policy-failed` when `bindings()`, a condition, or `explain()` throws, + * logged once per policy until the next `invalidate()`. The Gateway + * throws `remote-access/denied` for a denied call and drops a denied event. + */ +decide(request: RemoteAccessRequest): RemoteAccessDecision + +/** + * Filter items inside a Remote method. The invocation is read from the + * Context that accessed this service, so call it as + * `this.ctx.remoteAccess.allows(resource)` from the Remote method. Each + * check reads the policy's bindings again, so a long-running stream sees + * binding changes from the next check on. + * @param resource - resource of one result item. + * @returns true for the owner and outside a Remote call; otherwise whether + * the invocation's endpoint action is allowed on `resource` without call + * arguments, false when the policy is absent or fails. + */ +allows(resource: RemoteAccessResource): boolean + +/** + * The baseline a page opens with before its snapshot stream connects. A + * business package rendering a non-owner page assigns this value to + * {@link REMOTE_ACCESS_SNAPSHOT_KEY} after every index injection row, + * replacing the owner baseline the webServer index carries. + * @param principal - the page's caller identity. + * @returns the owner baseline for the owner; otherwise the principal's current grants. + */ +snapshotFor(principal: Principal): RemoteAccessBaseline + +/** + * Stream the calling Peer's access snapshot: one baseline, then `upsert` + * and `remove` frames after each `invalidate()` and each policy + * registration or removal that changes a grant. The owner receives only + * `{ owner: true }`. The generation ends when `signal` aborts or this + * service unloads, so a reloaded service starts every reader from a fresh + * baseline. + * @param signal - generation cancellation. + * @returns the access frames of one generation. + */ +@Remote({ mode: 'stream' }) async *follow(signal: AbortSignal): AsyncIterable +``` + +Source: [`packages/api/remote-access/src/index.ts`](../../packages/api/remote-access/src/index.ts) + ### `ctx.typert` — `TypertRegistry` @@ -452,8 +654,8 @@ Resolve strict generated definitions or conservative SRC markers against current ```ts cordis-catalog /** - * Check for an active Client event stream. - * @returns whether a stream is open and has not been cancelled. + * Check for an active owner Client event stream, the only kind that can answer forwarded waterfalls. + * @returns whether an owner stream is open and has not been cancelled. */ hasLiveClient(): boolean diff --git a/docs/subsystems/web-client.i18n.yaml b/docs/subsystems/web-client.i18n.yaml index 29bb05c709..593dcc3494 100644 --- a/docs/subsystems/web-client.i18n.yaml +++ b/docs/subsystems/web-client.i18n.yaml @@ -24,8 +24,8 @@ en: c063711f8247c240 zh: 5e54758bbaf1670c /web-client-architecture/conversation-and-presentation: - en: 71362f4ac9490bcb - zh: 025507ca615f5e2a + en: 9a22492f0d4359c6 + zh: 55d449e383e88f62 /web-client-architecture/data-paths: en: a254278432416473 zh: 567c2d3c6a20b304 diff --git a/docs/subsystems/web-client.md b/docs/subsystems/web-client.md index 3f0d7ecf99..bb68cd64a1 100644 --- a/docs/subsystems/web-client.md +++ b/docs/subsystems/web-client.md @@ -57,7 +57,7 @@ This pairing is not a second source of business truth. Host controllers decide d Web and desktop share the [Coding Tools preference](../../packages/client/ui-settings/README.md#use-this-package). It controls diagnostic Views, [available preset choices](../../packages/client/ui-agent-preset/README.md), changed-file cards, and the builtin HTML preview policy without changing Session records. -`ui-session` installs the Session scope adapter and publishes `useSessions`, `useSessionStatus`, `useSessionRetainInfo`, `useSession`, `sessionId`, and `useProjection`. `SessionProvider` inherits an outer binding or binds an explicit `SessionReference`, so concurrent subtrees can target different Sessions. Domain adapters add further standard sources without putting React hooks on the model objects. +`ui-session` installs the Session scope adapter and publishes `useSessions`, `useSessionStatus`, `useSessionRetainInfo`, `useSessionAccess`, `useSession`, `sessionId`, `useProjection`, and the Session-scope `useAccess` over the [remote-access](../../packages/api/remote-access/README.md) Client service. `SessionProvider` inherits an outer binding or binds an explicit `SessionReference`, so concurrent subtrees can target different Sessions. Domain adapters add further standard sources without putting React hooks on the model objects. `ui-conversation` binds once to each `SessionBinding.eventSource`. Its event registry correlates durable Session events and Client-only `assistant/live-chunk` updates into stable business Contexts, and its view registry materializes target snapshots. Chat Assistant, Trajectory Assistant, and Turn Tail interpret both live chunks and the compact streams embedded in durable settlements, so reconnect and paged history reproduce the same Assistant state without durable token rows. `ui-chat` and `ui-trajectory` register separate Definitions and builders: they may interpret the same event family, but they do not import or share each other's final display model. The shell selects a registered view and passes its snapshot through standard hooks and Slots. [Conversation](conversation.md) defines Context identity, replay, Location data, target builders, and keyed renderers. diff --git a/docs/subsystems/web-client.zh.md b/docs/subsystems/web-client.zh.md index 5230eef917..a3d62f738f 100644 --- a/docs/subsystems/web-client.zh.md +++ b/docs/subsystems/web-client.zh.md @@ -57,7 +57,7 @@ Connection 拥有请求 URL 解析、request correlation、`/api` carrier、trus Web 和桌面端共享[代码工作工具偏好](../../packages/client/ui-settings/README.zh.md#use-this-package)。它控制诊断 View、[可用的预设选项](../../packages/client/ui-agent-preset/README.zh.md)、改动文件卡片和内置 HTML 预览策略,不改变 Session 记录。 -`ui-session` 安装 Session scope adapter,并提供 `useSessions`、`useSessionStatus`、`useSessionRetainInfo`、`useSession`、`sessionId` 和 `useProjection`。`SessionProvider` 可以继承外围 binding,也可以绑定显式 `SessionReference`,因此并存子树可以指向不同 Session。领域 adapter 可以继续添加标准 source,但不会把 React hook 放进 model object。 +`ui-session` 安装 Session scope adapter,并在 [remote-access](../../packages/api/remote-access/README.zh.md) Client 服务之上提供 `useSessions`、`useSessionStatus`、`useSessionRetainInfo`、`useSessionAccess`、`useSession`、`sessionId`、`useProjection` 和会话作用域的 `useAccess`。`SessionProvider` 可以继承外围 binding,也可以绑定显式 `SessionReference`,因此并存子树可以指向不同 Session。领域 adapter 可以继续添加标准 source,但不会把 React hook 放进 model object。 `ui-conversation` 对每个 `SessionBinding.eventSource` 只绑定一次。它的 event registry 把持久 Session event 与 Client-only `assistant/live-chunk` update 关联成稳定的业务 Context,view registry 则 materialize target snapshot。Chat Assistant、Trajectory Assistant 与 Turn Tail 同时解释 live chunk 和持久 settlement 中嵌入的紧凑 stream,因此重连与分页历史无需持久 token 行即可复现相同 Assistant 状态。`ui-chat` 与 `ui-trajectory` 分别注册自己的 Definition 和 builder:它们可以解释同一 event family,但不会导入或共享彼此的最终 display model。Shell 选择一个已注册 view,再通过标准 hook 与 Slot 交付其 snapshot。[Conversation](conversation.zh.md)定义 Context identity、replay、Location data、target builder 与 keyed renderer。 diff --git a/docs/subsystems/web-server.md b/docs/subsystems/web-server.md index 5150ddd01f..df76650336 100644 --- a/docs/subsystems/web-server.md +++ b/docs/subsystems/web-server.md @@ -116,13 +116,23 @@ createSharedFetchHandler(channel: '/api'): ConnectionFetchHandler requestRejection(request: ConnectionTrustRequest): ConnectionRequestRejection /** - * Admit one request: it passes {@link requestRejection} and speaks for the - * operator, or it is refused with that status. + * Admit one network request: it passes {@link requestRejection} and speaks + * for {@link browserPeer}, or it is refused with that status. * @param request - request headers from the HTTP or upgrade request. - * @returns the operator Peer, or the rejection status. + * @returns the admitted Peer, or the rejection status. */ admit(request: ConnectionTrustRequest): PeerAdmission +/** + * Create an independent Peer. In-process only: no route, header, query, or + * cookie reaches it. Disposed with the Context that read `ctx.connection`, + * or earlier by its own `dispose()`. + * @param options - caller identity the Peer speaks for. + * @param options.principal - principal fixed for the Peer's lifetime. + * @returns a new Peer distinct from every other Peer. + */ +createPeer(options: { readonly principal: Principal }): PeerScope + /** * Authenticate one frontend index request, owning a token redirect or 401. * The cookie this mints is `Secure` when the mounted Web carrier serves TLS, @@ -141,8 +151,26 @@ authorizeIndex(request: ConnectionIndexRequest, response: ConnectionIndexRespons authenticatedUrl(baseUrl: string): string ``` +Types: [Principal](typert.md) + Source: [`packages/client/connection/src/rpc.ts`](../../packages/client/connection/src/rpc.ts) + + +### `ctx.webApp` — `WebApp` + +The Web GUI this process serves, provided by the `web-runtime` row. + +```ts cordis-catalog +/** + * The URL this GUI advertises, also used for `DSH_WEB_URL` and the startup URL line. + * @returns the configured `publicUrl`, otherwise the listener origin. + */ +getAdvertisedUrl(): string +``` + +Source: [`packages/bundle/web-app/src/index.ts`](../../packages/bundle/web-app/src/index.ts) + ### `ctx.webServer` — `WebServer` @@ -150,6 +178,14 @@ Source: [`packages/client/connection/src/rpc.ts`](../../packages/client/connecti The browser HTTP carrier service. Activation loads any configured TLS material, then listens immediately; a material or listen failure rejects initialization, and the boot process reports the failed fiber. Route registration order does not affect requests because configured named routes must be distinct, and the fallback handler answers anything not yet claimed during startup with 404 until its owner registers. ```ts cordis-catalog +/** + * The root URL local clients use to reach the bound listener: the protocol, + * the {@link bindUrlHost} form of the bind address, and the listening port. + * @returns the origin, for example `http://127.0.0.1:3080`. + * @throws before `listen` binds a port, or when the bind address has no URL form. + */ +getOrigin(): string + /** * Register a named route. Duplicate (kind, path) throws — route patterns are * a composition-level contract, so a collision is a misconfiguration. diff --git a/docs/subsystems/web-server.zh.md b/docs/subsystems/web-server.zh.md index f91bfdcdbd..ed53d0ec22 100644 --- a/docs/subsystems/web-server.zh.md +++ b/docs/subsystems/web-server.zh.md @@ -116,13 +116,23 @@ createSharedFetchHandler(channel: '/api'): ConnectionFetchHandler requestRejection(request: ConnectionTrustRequest): ConnectionRequestRejection /** - * Admit one request: it passes {@link requestRejection} and speaks for the - * operator, or it is refused with that status. + * Admit one network request: it passes {@link requestRejection} and speaks + * for {@link browserPeer}, or it is refused with that status. * @param request - request headers from the HTTP or upgrade request. - * @returns the operator Peer, or the rejection status. + * @returns the admitted Peer, or the rejection status. */ admit(request: ConnectionTrustRequest): PeerAdmission +/** + * Create an independent Peer. In-process only: no route, header, query, or + * cookie reaches it. Disposed with the Context that read `ctx.connection`, + * or earlier by its own `dispose()`. + * @param options - caller identity the Peer speaks for. + * @param options.principal - principal fixed for the Peer's lifetime. + * @returns a new Peer distinct from every other Peer. + */ +createPeer(options: { readonly principal: Principal }): PeerScope + /** * Authenticate one frontend index request, owning a token redirect or 401. * The cookie this mints is `Secure` when the mounted Web carrier serves TLS, @@ -141,8 +151,26 @@ authorizeIndex(request: ConnectionIndexRequest, response: ConnectionIndexRespons authenticatedUrl(baseUrl: string): string ``` +Types: [Principal](typert.zh.md) + Source: [`packages/client/connection/src/rpc.ts`](../../packages/client/connection/src/rpc.ts) + + +### `ctx.webApp` — `WebApp` + +The Web GUI this process serves, provided by the `web-runtime` row. + +```ts cordis-catalog +/** + * The URL this GUI advertises, also used for `DSH_WEB_URL` and the startup URL line. + * @returns the configured `publicUrl`, otherwise the listener origin. + */ +getAdvertisedUrl(): string +``` + +Source: [`packages/bundle/web-app/src/index.ts`](../../packages/bundle/web-app/src/index.ts) + ### `ctx.webServer` — `WebServer` @@ -150,6 +178,14 @@ Source: [`packages/client/connection/src/rpc.ts`](../../packages/client/connecti The browser HTTP carrier service. Activation loads any configured TLS material, then listens immediately; a material or listen failure rejects initialization, and the boot process reports the failed fiber. Route registration order does not affect requests because configured named routes must be distinct, and the fallback handler answers anything not yet claimed during startup with 404 until its owner registers. ```ts cordis-catalog +/** + * The root URL local clients use to reach the bound listener: the protocol, + * the {@link bindUrlHost} form of the bind address, and the listening port. + * @returns the origin, for example `http://127.0.0.1:3080`. + * @throws before `listen` binds a port, or when the bind address has no URL form. + */ +getOrigin(): string + /** * Register a named route. Duplicate (kind, path) throws — route patterns are * a composition-level contract, so a collision is a misconfiguration. diff --git a/docs/subsystems/workspace.md b/docs/subsystems/workspace.md index 8e69b7c597..b363ba184d 100644 --- a/docs/subsystems/workspace.md +++ b/docs/subsystems/workspace.md @@ -277,35 +277,13 @@ Typed Remote control of transient Session-owned terminal processes. @Remote({ mode: 'stream' }) retain(sessionId: SessionId, id: WebTerminalId, signal: AbortSignal): AsyncIterable /** - * Attach to a terminal without binding its process lifetime to the transport. + * Attach terminal input and output to one physical Remote stream. * @param agent - Session owner supplied by the Gateway. * @param id - terminal identity. - * @param attachmentId - new exclusive input attachment. - * @param signal - physical stream cancellation. - * @returns screen recovery followed by output and metadata changes. + * @param signal - attachment cancellation; the process remains alive and input is never replayed. + * @returns screen recovery, output, writable state and provider-acceptance acknowledgements. */ -@Remote({ mode: 'stream' }) follow(agent: Agent, id: WebTerminalId, attachmentId: TerminalAttachmentId, signal: AbortSignal): AsyncIterable - -/** - * Deliver raw input, including Tab completion and control characters. - * @param agent - Session owner supplied by the Gateway. - * @param id - terminal identity. - * @param attachmentId - current writable attachment. - * @param data - input bytes represented as UTF-8 text. - * @returns after provider input acceptance. - */ -@Remote async write(agent: Agent, id: WebTerminalId, attachmentId: TerminalAttachmentId, data: string): Promise - -/** - * Update the dimensions of the PTY and recovery screen. - * @param agent - Session owner supplied by the Gateway. - * @param id - terminal identity. - * @param attachmentId - current writable attachment. - * @param cols - column count. - * @param rows - row count. - * @returns after the resize completes. - */ -@Remote async resize(agent: Agent, id: WebTerminalId, attachmentId: TerminalAttachmentId, cols: number, rows: number): Promise +@Remote({ mode: 'stream' }) attach(agent: Agent, id: WebTerminalId, signal: AbortSignal): RemoteStream /** * Rename a terminal without changing its shell. @@ -324,7 +302,7 @@ Typed Remote control of transient Session-owned terminal processes. @Remote async close(agent: Agent, id: WebTerminalId): Promise ``` -Types: [Agent](core.md) · [SessionId](core.md) +Types: [Agent](core.md) · [RemoteStream](typert.md) · [SessionId](core.md) Source: [`packages/api/terminal-controller/src/index.ts`](../../packages/api/terminal-controller/src/index.ts) @@ -409,11 +387,17 @@ Host service backing the generated `ctx.remote.workspace` namespace. @Remote('unpinSession') unpinSession(request: WorkspaceUnpinSessionRequest): Promise /** - * Stream a complete Workspace baseline followed by ordered increments. + * Stream a complete Workspace baseline followed by ordered increments. A + * non-owner receives only the Workspaces and Sessions `allows()` admits. + * When a delivered Workspace is no longer admitted at its next change or + * the next order change, the stream sends a `replace` frame with the + * complete filtered state instead of a `remove`, because a Client never + * resurrects a removed Workspace; a `remove` frame reports a deletion and + * reaches only a Client that received that Workspace. * @param signal - generation cancellation. * @returns baseline followed by ordered Workspace increments. */ -@Remote({ mode: 'stream' }) follow(signal: AbortSignal): AsyncIterable +@Remote({ mode: 'stream' }) async *follow(signal: AbortSignal): AsyncIterable ``` Source: [`packages/api/workspace-controller/src/index.ts`](../../packages/api/workspace-controller/src/index.ts) diff --git a/docs/subsystems/workspace.zh.md b/docs/subsystems/workspace.zh.md index 4c15fc2ffd..c04a05c97d 100644 --- a/docs/subsystems/workspace.zh.md +++ b/docs/subsystems/workspace.zh.md @@ -277,35 +277,13 @@ Typed Remote control of transient Session-owned terminal processes. @Remote({ mode: 'stream' }) retain(sessionId: SessionId, id: WebTerminalId, signal: AbortSignal): AsyncIterable /** - * Attach to a terminal without binding its process lifetime to the transport. + * Attach terminal input and output to one physical Remote stream. * @param agent - Session owner supplied by the Gateway. * @param id - terminal identity. - * @param attachmentId - new exclusive input attachment. - * @param signal - physical stream cancellation. - * @returns screen recovery followed by output and metadata changes. + * @param signal - attachment cancellation; the process remains alive and input is never replayed. + * @returns screen recovery, output, writable state and provider-acceptance acknowledgements. */ -@Remote({ mode: 'stream' }) follow(agent: Agent, id: WebTerminalId, attachmentId: TerminalAttachmentId, signal: AbortSignal): AsyncIterable - -/** - * Deliver raw input, including Tab completion and control characters. - * @param agent - Session owner supplied by the Gateway. - * @param id - terminal identity. - * @param attachmentId - current writable attachment. - * @param data - input bytes represented as UTF-8 text. - * @returns after provider input acceptance. - */ -@Remote async write(agent: Agent, id: WebTerminalId, attachmentId: TerminalAttachmentId, data: string): Promise - -/** - * Update the dimensions of the PTY and recovery screen. - * @param agent - Session owner supplied by the Gateway. - * @param id - terminal identity. - * @param attachmentId - current writable attachment. - * @param cols - column count. - * @param rows - row count. - * @returns after the resize completes. - */ -@Remote async resize(agent: Agent, id: WebTerminalId, attachmentId: TerminalAttachmentId, cols: number, rows: number): Promise +@Remote({ mode: 'stream' }) attach(agent: Agent, id: WebTerminalId, signal: AbortSignal): RemoteStream /** * Rename a terminal without changing its shell. @@ -324,7 +302,7 @@ Typed Remote control of transient Session-owned terminal processes. @Remote async close(agent: Agent, id: WebTerminalId): Promise ``` -Types: [Agent](core.zh.md) · [SessionId](core.zh.md) +Types: [Agent](core.zh.md) · [RemoteStream](typert.zh.md) · [SessionId](core.zh.md) Source: [`packages/api/terminal-controller/src/index.ts`](../../packages/api/terminal-controller/src/index.ts) @@ -409,11 +387,17 @@ Host service backing the generated `ctx.remote.workspace` namespace. @Remote('unpinSession') unpinSession(request: WorkspaceUnpinSessionRequest): Promise /** - * Stream a complete Workspace baseline followed by ordered increments. + * Stream a complete Workspace baseline followed by ordered increments. A + * non-owner receives only the Workspaces and Sessions `allows()` admits. + * When a delivered Workspace is no longer admitted at its next change or + * the next order change, the stream sends a `replace` frame with the + * complete filtered state instead of a `remove`, because a Client never + * resurrects a removed Workspace; a `remove` frame reports a deletion and + * reaches only a Client that received that Workspace. * @param signal - generation cancellation. * @returns baseline followed by ordered Workspace increments. */ -@Remote({ mode: 'stream' }) follow(signal: AbortSignal): AsyncIterable +@Remote({ mode: 'stream' }) async *follow(signal: AbortSignal): AsyncIterable ``` Source: [`packages/api/workspace-controller/src/index.ts`](../../packages/api/workspace-controller/src/index.ts) diff --git a/docs/tool-catalog.i18n.yaml b/docs/tool-catalog.i18n.yaml index 639b01a6e4..af0cd38b46 100644 --- a/docs/tool-catalog.i18n.yaml +++ b/docs/tool-catalog.i18n.yaml @@ -15,8 +15,8 @@ en: e0143916bb634762 zh: e0143916bb634762 /tool-schema-catalog/deepseek-ai-dsh-plugin-manager/plugin-manager: - en: ab4d1e7f7aa435b5 - zh: 70af9245b9b98039 + en: 55b6f02a3ef555cf + zh: 5263303aa6efd86e /tool-schema-catalog/deepseek-ai-dsh-mcp-resources: en: dd78bd55ec83a539 zh: dd78bd55ec83a539 diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 0d415975c6..f96fe0802d 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -54,7 +54,7 @@ This table connects model-visible tool names to the plugin package and service s ### `plugin_manager` -List plugins or bundles in the current profile, enable or disable them, install a bundle, or remove an installed bundle. Every action requires danger-full-access permission or approval for this call. Approval does not change the session permission mode. Changes affect every session in this profile. List first to obtain exact identifiers. Package installation can execute allowed build scripts. Live profiles apply changes immediately; startup profiles require restart. Incompatible DSH peer dependencies block installation and activation. Version exemptions risk crashes and data loss: warn the user and obtain explicit permission for the exact plugin and runtime versions before granting one. +List plugins or bundles in the current profile, enable or disable them, install a bundle, or remove an installed bundle. Every action requires danger-full-access permission or approval for this call. Approval does not change the session permission mode. Changes affect every session in this profile. List first to obtain exact identifiers. Package installation can execute allowed build scripts. Live profiles apply changes immediately, including upgrades of installed packages; a change result that is not applied carries nextStep, which says what to do or tell the user. Incompatible DSH peer dependencies block installation and activation. Version exemptions risk crashes and data loss: warn the user and obtain explicit permission for the exact plugin and runtime versions before granting one. ```json { @@ -1397,7 +1397,7 @@ Create a reminder in the current session that delivers prompt when it becomes du }, "title": { "type": "string", - "description": "Task name of at most 120 characters, shown on the task card and in task lists." + "description": "Concise task name in the user's language, at most 120 characters. Name only the core action and subject; keep essential target identifiers such as PR numbers and branch names. Do not include execution frequency (e.g. daily, hourly), scheduled dates/times, time zones, task status, or configuration and execution details (e.g. read-only mode, profile-tag filters), even when embedded in the requested or existing title. Do not append parenthetical notes. Put scheduling information in the timing parameters and execution instructions in prompt." }, "after_seconds": { "type": "number", @@ -1558,7 +1558,7 @@ Change a reminder in place, keeping its id. Supply a new title, prompt, or at mo }, "title": { "type": "string", - "description": "New task name of at most 120 characters." + "description": "New task name; follow the schedule_create title rules." }, "prompt": { "type": "string", diff --git a/docs/tool-catalog.zh.md b/docs/tool-catalog.zh.md index 1622182f0f..f7ed4a8c08 100644 --- a/docs/tool-catalog.zh.md +++ b/docs/tool-catalog.zh.md @@ -58,7 +58,7 @@ ### `plugin_manager` -列出当前 profile 中的插件或组合包,启用或禁用它们,安装组合包或移除已安装的组合包。每项操作都要求 danger-full-access 权限或本次调用的批准。批准不改变会话权限模式。变更影响该 profile 的所有会话。先列出条目以获取准确标识。包安装可能运行已获批准的构建脚本。支持热更新的 profile 立即应用变更;仅启动时加载的 profile 需要重启。不兼容的 DSH peer 依赖会阻止安装和激活。版本豁免可能导致崩溃和数据丢失:授权前必须警告用户,并获得用户对精确插件版本与运行时版本组合的明确许可。 +列出当前 profile 中的插件或组合包,启用或禁用它们,安装组合包或移除已安装的组合包。每项操作都要求 danger-full-access 权限或本次调用的批准。批准不改变会话权限模式。变更影响该 profile 的所有会话。先列出条目以获取准确标识。包安装可能运行已获批准的构建脚本。支持热更新的 profile 立即应用变更,包括已安装包的升级;未直接应用的变更结果带有 nextStep,说明下一步要做什么或告诉用户什么。不兼容的 DSH peer 依赖会阻止安装和激活。版本豁免可能导致崩溃和数据丢失:授权前必须警告用户,并获得用户对精确插件版本与运行时版本组合的明确许可。 ```json { @@ -1403,7 +1403,7 @@ create、edit、pause 和 resume 要求直接来自人类的根权限;complete }, "title": { "type": "string", - "description": "Task name of at most 120 characters, shown on the task card and in task lists." + "description": "Concise task name in the user's language, at most 120 characters. Name only the core action and subject; keep essential target identifiers such as PR numbers and branch names. Do not include execution frequency (e.g. daily, hourly), scheduled dates/times, time zones, task status, or configuration and execution details (e.g. read-only mode, profile-tag filters), even when embedded in the requested or existing title. Do not append parenthetical notes. Put scheduling information in the timing parameters and execution instructions in prompt." }, "after_seconds": { "type": "number", @@ -1564,7 +1564,7 @@ create、edit、pause 和 resume 要求直接来自人类的根权限;complete }, "title": { "type": "string", - "description": "New task name of at most 120 characters." + "description": "New task name; follow the schedule_create title rules." }, "prompt": { "type": "string", diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/job-id-identity/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-alpha.1/job-id-identity/guide.i18n.yaml new file mode 100644 index 0000000000..6e120f29bd --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.1/job-id-identity/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-alpha.1/job-id-identity/guide.md +/: + en: d6fdce6b58c67691 + zh: a103e5028f5442d0 +/background-job-ids-identify-one-execution: + en: 9b06780be69e93f6 + zh: c33945fd584c6a23 +/background-job-ids-identify-one-execution/change: + en: 047ec9d0337bfc0b + zh: 1218472855e81189 +/background-job-ids-identify-one-execution/migration: + en: 08921c9c25218c83 + zh: 7ff1d3ac63ae40ef diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/job-id-identity/guide.md b/docs/upgrade-guide/v0.2.1-alpha.1/job-id-identity/guide.md new file mode 100644 index 0000000000..16ded54d20 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.1/job-id-identity/guide.md @@ -0,0 +1,18 @@ +--- +kind: upgrade-guide +description: "JobId now identifies one background execution across registry lifetimes." +--- + +# Background job ids identify one execution + +English | [中文](guide.zh.md) + +## Change + +The job registry returns `-` instead of `-N`; the suffix is 16 lowercase UUID hex characters. The same `JobId` appears in live controls, `job/registered`, and `job/settled`; lifecycle records no longer carry `executionId`. The identifier remains owner-authorized. Existing Session histories remain unchanged and do not resume their jobs. + +## Migration + +1. Pass the exact id returned by a start result or `jobs.list()` to `job_output` and `job_kill`; use `job_output` with `wait: true` to wait. Remove code that constructs an id from the kind and an ordinal. +2. Pair SDK lifecycle events by `data.jobId`. Remove imports of `JobExecutionId` and code that maps it to a process-local `jobId`. +3. Verify a new job's registration and settlement contain the same `jobId`, and a later registry instance issues a different one. diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/job-id-identity/guide.zh.md b/docs/upgrade-guide/v0.2.1-alpha.1/job-id-identity/guide.zh.md new file mode 100644 index 0000000000..372a3b6c9f --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.1/job-id-identity/guide.zh.md @@ -0,0 +1,18 @@ +--- +kind: upgrade-guide +description: "JobId 现在跨注册表生命周期标识一次后台执行。" +--- + +# 后台 job id 标识一次执行 + +[English](guide.md) | 中文 + +## 变更 + +job 注册表返回 `-`,不再返回 `-N`;后缀是 UUID 的 16 位小写十六进制字符。运行期控制、`job/registered` 和 `job/settled` 使用同一个 `JobId`;生命周期记录不再携带 `executionId`。访问仍按所有者授权。已有 Session 历史保持原样,不会恢复其中的 job。 + +## 迁移 + +1. 把启动结果或 `jobs.list()` 返回的原始 id 传给 `job_output` 和 `job_kill`;等待时为 `job_output` 设置 `wait: true`。移除按 kind 和序号构造 id 的代码。 +2. 使用 `data.jobId` 配对 SDK 生命周期事件。移除 `JobExecutionId` 导入,以及将它映射为进程内 `jobId` 的代码。 +3. 核对新 job 的注册与结算含有相同 `jobId`,后续注册表实例发放不同 id。 diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/jsonl-persistence-plugin/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-alpha.1/jsonl-persistence-plugin/guide.i18n.yaml new file mode 100644 index 0000000000..57d0ff470e --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.1/jsonl-persistence-plugin/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-alpha.1/jsonl-persistence-plugin/guide.md +/: + en: eff30513d0de970b + zh: c16a32f374bd07e1 +/mount-jsonl-persistence-through-its-plugin: + en: ae3ff407a497cf5c + zh: 1b71e651967b8255 +/mount-jsonl-persistence-through-its-plugin/change: + en: ea5defddc1a9b989 + zh: 97902f22a4196b12 +/mount-jsonl-persistence-through-its-plugin/migration: + en: 93d9f202c59303e9 + zh: c5ced26ed819d7c5 diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/jsonl-persistence-plugin/guide.md b/docs/upgrade-guide/v0.2.1-alpha.1/jsonl-persistence-plugin/guide.md new file mode 100644 index 0000000000..a05bdef3eb --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.1/jsonl-persistence-plugin/guide.md @@ -0,0 +1,18 @@ +--- +kind: upgrade-guide +description: "JSONL persistence uses a namespace plugin import; direct service-class construction and class type annotations must use the persistence service instead." +--- + +# Mount JSONL persistence through its plugin + +English | [中文](guide.zh.md) + +## Change + +`@deepseek-ai/dsh-session-persistence-jsonl` exports a function plugin through named `name`, `Config`, and `apply` exports, with no default export. Default imports, direct construction, subclassing, `instanceof`, and class type annotations are affected. The plugin retains listeners and handles through the consumer unloads triggered by removing this provider; concurrent root disposal remains unordered. The `sessionPersistence` service API, YAML configuration, and stored files remain unchanged. + +## Migration + +1. Replace the default import with `import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'`. Existing `ctx.plugin(JsonlSessionPersistence, config)` calls remain valid; replace direct construction with this mounting call. For dynamic imports, use the module namespace returned by `await import(...)` instead of its `.default`. Consumers declare `inject: ['sessionPersistence']` and access `ctx.sessionPersistence`. +2. Use the `SessionPersistence` type from `@deepseek-ai/dsh-session-persistence` for service annotations. Replace concrete-class `instanceof` checks and subclasses with consumers of that service; the concrete JSONL class is private. +3. Await plugin startup and confirm a session can be created, flushed, and reopened. Register asynchronous consumer cleanup in the scope that injects `sessionPersistence` so backend unloading waits for it. diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/jsonl-persistence-plugin/guide.zh.md b/docs/upgrade-guide/v0.2.1-alpha.1/jsonl-persistence-plugin/guide.zh.md new file mode 100644 index 0000000000..0cf8323713 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.1/jsonl-persistence-plugin/guide.zh.md @@ -0,0 +1,18 @@ +--- +kind: upgrade-guide +description: "JSONL 持久化使用插件命名空间导入;直接构造服务类和类类型标注需要改用持久化服务。" +--- + +# 通过插件挂载 JSONL 持久化 + +[English](guide.md) | 中文 + +## 变更 + +`@deepseek-ai/dsh-session-persistence-jsonl` 通过命名导出的 `name`、`Config` 和 `apply` 提供函数插件,没有默认导出。默认导入、直接构造、继承、`instanceof` 和类类型标注会受到影响。插件会保留监听器和句柄,直到移除该提供者所触发的消费者卸载完成;根作用域的并发卸载仍不保证顺序。`sessionPersistence` 服务 API、YAML 配置和存储文件保持不变。 + +## 迁移 + +1. 将默认导入替换为 `import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'`。现有的 `ctx.plugin(JsonlSessionPersistence, config)` 调用仍然有效;将直接构造改为该挂载调用。动态导入直接使用 `await import(...)` 返回的模块命名空间,不再取其 `.default`。消费者声明 `inject: ['sessionPersistence']` 并访问 `ctx.sessionPersistence`。 +2. 服务类型标注使用 `@deepseek-ai/dsh-session-persistence` 的 `SessionPersistence` 类型。将针对具体类的 `instanceof` 检查和子类改为消费该服务;具体 JSONL 类是私有的。 +3. 等待插件启动完成,确认能够创建、flush 并重新打开会话。将消费者的异步清理注册到注入 `sessionPersistence` 的作用域中,以便后端卸载时等待它完成。 diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/subagent-preset-composition/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-alpha.1/subagent-preset-composition/guide.i18n.yaml new file mode 100644 index 0000000000..3a047980af --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.1/subagent-preset-composition/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-alpha.1/subagent-preset-composition/guide.md +/: + en: 0351f79f0f7acc90 + zh: c6815e557ffa1a2f +/configure-subagent-composition-through-presets: + en: aab975172f278acf + zh: 4e6dad4ef487cc52 +/configure-subagent-composition-through-presets/change: + en: e758e1d0c34846db + zh: 93675aa1b3f7be4e +/configure-subagent-composition-through-presets/migration: + en: 0ae5b3e77d8c4b83 + zh: 746b44875e23dea6 diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/subagent-preset-composition/guide.md b/docs/upgrade-guide/v0.2.1-alpha.1/subagent-preset-composition/guide.md new file mode 100644 index 0000000000..c41f6f4c26 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.1/subagent-preset-composition/guide.md @@ -0,0 +1,21 @@ +--- +kind: upgrade-guide +description: "Subagent persona and toolFilter configuration is replaced by Agent Preset composition." +--- + +# Configure subagent composition through presets + +English | [中文](guide.zh.md) + +## Change + +`dsh-tool-subagent` rejects `persona` and `toolFilter` configuration. New `SubagentStartRequest` values and provider capabilities no longer expose either field. In-process spawn selects a preset through `agentPreset`; its persona and tools come from that preset's plugins. Fork inherits the parent's preset and cannot independently change persona or restrict tools. Model and reasoning-effort options are unchanged. + +The shipped `standard`, `ptc`, and `cordis` delegation tools no longer hide `schedule_*` tools. Delegated callers still receive the existing execution refusal. Historical child Session descriptors retain their persona and tool restrictions when resumed. + +## Migration + +1. Remove `persona` and `toolFilter` from `dsh-tool-subagent` rows in your profile patches and custom presets. Update programmatic callers and provider capability declarations to remove the same fields. +2. For spawn, define an Agent Preset containing `dsh-persona` and the required tool plugins, then set the delegation tool's `agentPreset` to its id. Use `allowedAgentPresets` to offer several declared presets to the parent model. Presets select plugin compositions; there is no replacement per-child tool-name filter. +3. For fork, remove the overrides and accept the inherited composition. If the task needs a different composition, use spawn with a preset; it does not inherit the parent's conversation. +4. Start a new Session and delegate a task. Confirm that the child uses the selected preset's persona and tools. Existing persisted child Sessions need no manual edits. diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/subagent-preset-composition/guide.zh.md b/docs/upgrade-guide/v0.2.1-alpha.1/subagent-preset-composition/guide.zh.md new file mode 100644 index 0000000000..dd84e38eda --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.1/subagent-preset-composition/guide.zh.md @@ -0,0 +1,21 @@ +--- +kind: upgrade-guide +description: "Subagent 的 persona 与 toolFilter 配置由 Agent Preset 组装替代。" +--- + +# 通过 preset 配置子 Agent 组装 + +[English](guide.md) | 中文 + +## 变更 + +`dsh-tool-subagent` 拒绝 `persona` 与 `toolFilter` 配置。新的 `SubagentStartRequest` 和提供方能力声明也不再暴露这两个字段。进程内 spawn 通过 `agentPreset` 选择 preset,其 persona 与工具来自该 preset 的插件。fork 继承父方 preset,不能独立改变 persona 或限制工具。模型与推理强度选项保持不变。 + +出货的 `standard`、`ptc` 和 `cordis` 委派工具不再隐藏 `schedule_*` 工具。委派调用方仍会收到已有的执行拒绝。历史子 Session 的 descriptor 在恢复时仍应用其 persona 与工具限制。 + +## 迁移 + +1. 从 profile patch 和自定义 preset 的 `dsh-tool-subagent` 行中移除 `persona` 与 `toolFilter`。同步从程序化调用方和提供方能力声明中移除这两个字段。 +2. 对于 spawn,定义包含 `dsh-persona` 和所需工具插件的 Agent Preset,再将委派工具的 `agentPreset` 设为其 id。使用 `allowedAgentPresets` 向父模型开放多个已声明 preset。preset 选择插件组合,不提供替代的逐子级工具名过滤器。 +3. 对于 fork,移除覆盖并接受继承的组合。如果任务需要不同组合,使用带 preset 的 spawn;它不继承父方对话。 +4. 启动新 Session 并委派任务,确认子级使用所选 preset 的 persona 与工具。已有持久化子 Session 无须手动修改。 diff --git a/docs/upgrade-guide/v0.2.1-alpha.2/deepseek-account-model-catalog/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-alpha.2/deepseek-account-model-catalog/guide.i18n.yaml new file mode 100644 index 0000000000..5d77befdf5 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.2/deepseek-account-model-catalog/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-alpha.2/deepseek-account-model-catalog/guide.md +/: + en: 60a8441ae7ea6841 + zh: 3715b44e11c5131b +/deepseek-account-models-come-from-the-account-listing: + en: 2208c82c24d75a53 + zh: fa6c53c925aae6f2 +/deepseek-account-models-come-from-the-account-listing/change: + en: bf4c84372b8b1316 + zh: af1623701d19b62e +/deepseek-account-models-come-from-the-account-listing/migration: + en: 7ce26a5a1926b292 + zh: 35dfc17997834e4d diff --git a/docs/upgrade-guide/v0.2.1-alpha.2/deepseek-account-model-catalog/guide.md b/docs/upgrade-guide/v0.2.1-alpha.2/deepseek-account-model-catalog/guide.md new file mode 100644 index 0000000000..9bedcf6c07 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.2/deepseek-account-model-catalog/guide.md @@ -0,0 +1,27 @@ +--- +kind: upgrade-guide +description: "The DeepSeek account route no longer accepts a models catalog; the account listing supplies its models and modelOverrides adjusts them." +--- + +# DeepSeek account models come from the account listing + +English | [中文](guide.zh.md) + +## Change + +The `deepseek-account` route previously read its model catalog from the `models` field of the `llm-deepseek-account` entry, which the Models page could edit for the account. The route now lists models from the account's `GET /models` endpoint, serves the built-in catalog until a listing loads, and accepts per-model adjustments only through `modelOverrides`; the Models page no longer offers an editor for this route. A listed model can require a newer DeepSeek Harness; such a model is hidden, and a request for it fails with `CLIENT_UPGRADE_REQUIRED`. A `models` field left in the entry, including one saved by the earlier Models page editor, stops the plugin at load with `llm-deepseek-account: models is no longer configurable`, and Desktop and Web show no account models until the field is removed. Signed-in accounts without a saved catalog need no action. + +## Migration + +1. Open `$DSH_HOME/profiles//cordis.patch.yml` and find the entry with `id: llm-deepseek-account`. +2. Delete its `models` field. For each model whose fields you changed, move only the changed fields under `modelOverrides.`: + + ```yaml + - id: llm-deepseek-account + config: + modelOverrides: + deepseek-flash: + maxTokens: 131072 + ``` + +3. Restart DeepSeek Harness, sign in to the DeepSeek account, and confirm that the model selector lists the account models and the Models page shows the account as signed in. An override the listed model rejects, or one that names an unknown model, appears as the account's diagnostic on the Models page. diff --git a/docs/upgrade-guide/v0.2.1-alpha.2/deepseek-account-model-catalog/guide.zh.md b/docs/upgrade-guide/v0.2.1-alpha.2/deepseek-account-model-catalog/guide.zh.md new file mode 100644 index 0000000000..a95a72d633 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.2/deepseek-account-model-catalog/guide.zh.md @@ -0,0 +1,27 @@ +--- +kind: upgrade-guide +description: "DeepSeek 账号路由不再接受 models 目录;模型由账号的模型列表提供,单个模型通过 modelOverrides 调整。" +--- + +# DeepSeek 账号模型改由账号的模型列表提供 + +[English](guide.md) | 中文 + +## 变更 + +`deepseek-account` 路由此前从 `llm-deepseek-account` 条目的 `models` 字段读取模型目录,模型页可以为账号编辑这份目录。现在该路由从账号的 `GET /models` 端点列出模型,在列表加载之前使用内置目录,单个模型只能通过 `modelOverrides` 调整;模型页不再为该路由提供编辑器。列表中的模型可以要求更新版本的 DeepSeek Harness,这样的模型会被隐藏,请求它时以 `CLIENT_UPGRADE_REQUIRED` 失败。条目中遗留的 `models` 字段,包括早先模型页编辑器保存的,会让插件在加载时报 `llm-deepseek-account: models is no longer configurable` 并停止,删除该字段之前桌面端和 Web 都不显示账号模型。没有保存过账号目录的已登录账号无需操作。 + +## 迁移 + +1. 打开 `$DSH_HOME/profiles//cordis.patch.yml`,找到 `id: llm-deepseek-account` 的条目。 +2. 删除其中的 `models` 字段。对改过字段的模型,只把改过的字段移到 `modelOverrides.<模型 id>` 下: + + ```yaml + - id: llm-deepseek-account + config: + modelOverrides: + deepseek-flash: + maxTokens: 131072 + ``` + +3. 重启 DeepSeek Harness 并登录 DeepSeek 账号,确认模型选择器列出账号模型、模型页显示账号已登录。被列出的模型拒绝的覆盖,或指向未知模型的覆盖,会作为该账号的诊断信息显示在模型页上。 diff --git a/docs/upgrade-guide/v0.2.1-alpha.2/desktop-font-size-shortcuts/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-alpha.2/desktop-font-size-shortcuts/guide.i18n.yaml new file mode 100644 index 0000000000..a4a47e22a2 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.2/desktop-font-size-shortcuts/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-alpha.2/desktop-font-size-shortcuts/guide.md +/: + en: 0863e2ef201c6e98 + zh: e7042cab4852e9c8 +/desktop-font-size-shortcuts-reserve-custom-key-combinations: + en: 7c82d9faadaec1e0 + zh: 91d60eb7a660e1b3 +/desktop-font-size-shortcuts-reserve-custom-key-combinations/change: + en: cb025eb9ae5e18ae + zh: 094ec608c1ce4dad +/desktop-font-size-shortcuts-reserve-custom-key-combinations/migration: + en: bc66077939ee3df3 + zh: f8460bf5ec20ac58 diff --git a/docs/upgrade-guide/v0.2.1-alpha.2/desktop-font-size-shortcuts/guide.md b/docs/upgrade-guide/v0.2.1-alpha.2/desktop-font-size-shortcuts/guide.md new file mode 100644 index 0000000000..f674031611 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.2/desktop-font-size-shortcuts/guide.md @@ -0,0 +1,18 @@ +--- +kind: upgrade-guide +description: "Desktop font-size shortcuts reserve key combinations previously available to custom shortcuts." +--- + +# Desktop font-size shortcuts reserve custom key combinations + +English | [中文](guide.zh.md) + +## Change + +Desktop reserves `Cmd`/`Ctrl` + `=` (including `Shift` + `=` for `+`) and `Cmd`/`Ctrl` + `-` to adjust text, code, and terminal font sizes together. Use `Cmd` on macOS and `Ctrl` on Windows and Linux. Custom bindings using these combinations, including overlapping two-key chords, remain saved but no longer execute. Web shortcut behavior is unchanged. + +## Migration + +1. Open Settings → General → Keyboard shortcuts in Desktop and find any custom binding marked as conflicting with a font-size shortcut. +2. Record a nonconflicting combination for that action, or choose **Remove** to disable its keyboard shortcut. **Restore default** is available when the action's default does not conflict. +3. Try the reassigned action, then press `Cmd`/`Ctrl` + `=` and `Cmd`/`Ctrl` + `-`. Confirm that all three font sizes change in Settings and remain saved after reopening Desktop. diff --git a/docs/upgrade-guide/v0.2.1-alpha.2/desktop-font-size-shortcuts/guide.zh.md b/docs/upgrade-guide/v0.2.1-alpha.2/desktop-font-size-shortcuts/guide.zh.md new file mode 100644 index 0000000000..a9d052744f --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.2/desktop-font-size-shortcuts/guide.zh.md @@ -0,0 +1,18 @@ +--- +kind: upgrade-guide +description: "桌面端字号快捷键占用了原先可用于自定义快捷键的组合键。" +--- + +# 桌面端字号快捷键占用部分自定义组合键 + +[English](guide.md) | 中文 + +## 变更 + +桌面端保留 `Cmd`/`Ctrl` + `=`(包括输入 `+` 的 `Shift` + `=`)和 `Cmd`/`Ctrl` + `-`,用于一起调整正文、代码和终端字号。macOS 使用 `Cmd`,Windows 和 Linux 使用 `Ctrl`。使用这些组合键的自定义绑定,包括与其重叠的双键序列,仍会保存,但不再执行。Web 端快捷键行为不变。 + +## 迁移 + +1. 在桌面端打开「设置 → 通用 → 键盘快捷键」,找到标记为与字号快捷键冲突的自定义绑定。 +2. 为该操作录制不冲突的组合键,或选择「移除」禁用其快捷键。操作的默认快捷键不冲突时,也可以选择「恢复默认」。 +3. 尝试重新绑定的操作,再按 `Cmd`/`Ctrl` + `=` 和 `Cmd`/`Ctrl` + `-`。确认设置中的三组字号一起变化,并在重新打开桌面端后仍保留。 diff --git a/docs/upgrade-guide/v0.2.1-alpha.2/pi-ai-azure-provider/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-alpha.2/pi-ai-azure-provider/guide.i18n.yaml new file mode 100644 index 0000000000..46b8b3fcd7 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.2/pi-ai-azure-provider/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-alpha.2/pi-ai-azure-provider/guide.md +/: + en: 51d5b5b4d2fb1b8a + zh: 4dc9af2458a8fcac +/azure-provider-route-rename: + en: 105c73820d057e28 + zh: 91a7bfc5c282e164 +/azure-provider-route-rename/change: + en: d5ce9089d8aa7119 + zh: f337926f85b01bc1 +/azure-provider-route-rename/migration: + en: 8fb23fbb8184fa00 + zh: 8c44fb293a8dd70a diff --git a/docs/upgrade-guide/v0.2.1-alpha.2/pi-ai-azure-provider/guide.md b/docs/upgrade-guide/v0.2.1-alpha.2/pi-ai-azure-provider/guide.md new file mode 100644 index 0000000000..1b4119f4d7 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.2/pi-ai-azure-provider/guide.md @@ -0,0 +1,19 @@ +--- +kind: upgrade-guide +description: "The pi-ai Azure provider route is renamed from azure-openai-responses to azure." +--- + +# Azure provider route rename + +English | [中文](guide.zh.md) + +## Change + +The installed pi-ai catalog identifies the Azure provider as `azure` instead of `azure-openai-responses`. The `azure` route includes both Responses and Chat Completions models. The Responses API identifier remains `azure-openai-responses`, and the `AZURE_OPENAI_*` environment variables keep their names. Configurations and model selections that use the old provider key no longer resolve the Azure catalog. + +## Migration + +1. In the `llm-pi-ai` configuration in `$DSH_HOME/profiles//cordis.patch.yml` or another supplying layer, rename `config.providers.azure-openai-responses` to `config.providers.azure`. Preserve its existing fields and any `apiKeyEnv` reference. +2. Use catalog model IDs under `models`; map a custom Azure deployment name with `AZURE_OPENAI_DEPLOYMENT_NAME_MAP`. The mixed catalog cannot infer a protocol for a model ID absent from the catalog. An explicit route-level `api` overrides every model on that route; it does not select the Azure provider. +3. Update explicit request/default provider selections to `azure`, and reselect an Azure model when continuing an existing Session. Configure the new Azure entry in Settings → Models if its key was stored for the old entry. Do not edit historical Session events. +4. Confirm that Settings → Models offers `azure` and lists the expected models, then send a short request using the configured deployment. diff --git a/docs/upgrade-guide/v0.2.1-alpha.2/pi-ai-azure-provider/guide.zh.md b/docs/upgrade-guide/v0.2.1-alpha.2/pi-ai-azure-provider/guide.zh.md new file mode 100644 index 0000000000..ad62892268 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.2/pi-ai-azure-provider/guide.zh.md @@ -0,0 +1,19 @@ +--- +kind: upgrade-guide +description: "pi-ai 的 Azure 提供方路由从 azure-openai-responses 更名为 azure。" +--- + +# Azure 提供方路由更名 + +[English](guide.md) | 中文 + +## 变更 + +已安装的 pi-ai 目录使用 `azure` 标识 Azure 提供方,替代 `azure-openai-responses`。`azure` 路由同时包含 Responses 和 Chat Completions 模型。Responses API 标识仍为 `azure-openai-responses`,`AZURE_OPENAI_*` 环境变量名称也保持不变。使用旧提供方键的配置和模型选择无法再解析 Azure 目录。 + +## 迁移 + +1. 在 `$DSH_HOME/profiles//cordis.patch.yml` 或其他提供配置的层中,找到 `llm-pi-ai` 配置,将 `config.providers.azure-openai-responses` 改为 `config.providers.azure`。保留原有字段及任何 `apiKeyEnv` 引用。 +2. 在 `models` 中使用目录模型 ID;自定义 Azure 部署名称通过 `AZURE_OPENAI_DEPLOYMENT_NAME_MAP` 映射。混合协议目录无法为目录外的模型 ID 推断协议。显式设置路由级 `api` 会覆盖该路由所有模型的协议,并不表示选择 Azure 提供方。 +3. 将显式请求及默认模型配置中的提供方改为 `azure`,继续现有 Session 时重新选择 Azure 模型。若密钥存储在旧条目下,请在“设置 → 模型”中配置新的 Azure 条目。不要修改历史 Session 事件。 +4. 确认“设置 → 模型”提供 `azure` 且列出预期模型,再向所配置的部署发送一条简短请求。 diff --git a/docs/upgrade-guide/v0.2.1-alpha.2/pi-ai-model-discovery/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-alpha.2/pi-ai-model-discovery/guide.i18n.yaml new file mode 100644 index 0000000000..4412c0cf22 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.2/pi-ai-model-discovery/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-alpha.2/pi-ai-model-discovery/guide.md +/: + en: 40afcce4dbdcb337 + zh: 33c5647441bd1dcb +/pi-ai-model-visibility-without-a-credential: + en: 1bc74c0ce4085c3c + zh: 0a2be91b04c069c8 +/pi-ai-model-visibility-without-a-credential/change: + en: f92005c2a3d4645c + zh: 5bfb1685cdf3437c +/pi-ai-model-visibility-without-a-credential/migration: + en: 26d72c82e5c60031 + zh: 4b582d3e4d022dd8 diff --git a/docs/upgrade-guide/v0.2.1-alpha.2/pi-ai-model-discovery/guide.md b/docs/upgrade-guide/v0.2.1-alpha.2/pi-ai-model-discovery/guide.md new file mode 100644 index 0000000000..0427fda13a --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.2/pi-ai-model-discovery/guide.md @@ -0,0 +1,17 @@ +--- +kind: upgrade-guide +description: "pi-ai routes that name a credential hide their models until the credential is configured." +--- + +# pi-ai model visibility without a credential + +English | [中文](guide.zh.md) + +## Change + +A pi-ai route that names a credential through `apiKeyEnv` previously listed its configured models while that credential was missing, and requests then failed with `MISSING_CREDENTIAL`. Model discovery now returns an empty catalog for such a route until the credential resolves, so Desktop and Web hide its group. Other credential errors, such as a malformed key, still fail model discovery. Routes that use the provider's native authentication or name no credential keep their configured catalog. A saved or default selection absent from the catalog displays “Select model” without changing the stored selection. + +## Migration + +1. To use such a route, configure its key in Settings → Models, or supply the reference its `apiKeyEnv` names through the launch environment. +2. Reopen the model selector and confirm that the route's group appears. Custom model discovery consumers must handle an empty catalog before the credential is configured; a listed catalog does not mean that the remote API accepts the key. diff --git a/docs/upgrade-guide/v0.2.1-alpha.2/pi-ai-model-discovery/guide.zh.md b/docs/upgrade-guide/v0.2.1-alpha.2/pi-ai-model-discovery/guide.zh.md new file mode 100644 index 0000000000..b4855afad2 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.2/pi-ai-model-discovery/guide.zh.md @@ -0,0 +1,17 @@ +--- +kind: upgrade-guide +description: "指定了凭据的 pi-ai 路由在凭据配置之前隐藏其模型。" +--- + +# pi-ai 路由缺少凭据时的模型可见性 + +[English](guide.md) | 中文 + +## 变更 + +通过 `apiKeyEnv` 指定凭据的 pi-ai 路由,此前在凭据缺失时也会列出配置的模型,请求随后以 `MISSING_CREDENTIAL` 失败。现在凭据解析到之前,模型发现对这样的路由返回空目录,因此桌面端和 Web 隐藏它的分组。其他凭据错误(例如密钥格式错误)仍会让模型发现失败。使用提供方原生认证或未指定凭据的路由保留配置的目录。已保存或默认的选择不在目录中时,界面显示“请选择模型”,保存的选择保持不变。 + +## 迁移 + +1. 要使用这样的路由,请在设置 → 模型中配置密钥,或通过启动环境提供 `apiKeyEnv` 指定的凭据引用。 +2. 重新打开模型选择器,确认该路由的分组出现。自定义模型发现消费者必须处理配置凭据前的空目录;列出目录不代表远端 API 已接受密钥。 diff --git a/docs/upgrade-guide/v0.2.1-alpha.2/plugin-install-over-installed/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-alpha.2/plugin-install-over-installed/guide.i18n.yaml new file mode 100644 index 0000000000..d3693c2a4f --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.2/plugin-install-over-installed/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-alpha.2/plugin-install-over-installed/guide.md +/: + en: 036db2d1004f0aa0 + zh: fb026ec0af7949f6 +/installing-over-an-installed-plugin-package-is-refused-with-hmr: + en: a941bcec1d8f3b70 + zh: ac5ca3517057a52c +/installing-over-an-installed-plugin-package-is-refused-with-hmr/change: + en: e521f46141e8be0a + zh: 1d34929d5d90e466 +/installing-over-an-installed-plugin-package-is-refused-with-hmr/migration: + en: f72eeb19e689bbee + zh: 9ae1f049688edf38 diff --git a/docs/upgrade-guide/v0.2.1-alpha.2/plugin-install-over-installed/guide.md b/docs/upgrade-guide/v0.2.1-alpha.2/plugin-install-over-installed/guide.md new file mode 100644 index 0000000000..53b51a1113 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.2/plugin-install-over-installed/guide.md @@ -0,0 +1,20 @@ +--- +kind: upgrade-guide +description: "With HMR, installing a package that is already installed fails with already-installed; an upgrade removes the package first." +--- + +# Installing over an installed plugin package is refused with HMR + +English | [中文](guide.zh.md) + +## Change + +In a profile that runs HMR, which the shipped profiles do, `pluginManager.installBundle(spec)` used to run pnpm over a package the profile already depends on and report `restart-required` while the previous version kept running. It now fails before pnpm runs, with `application: 'failed'` and `error.code: 'already-installed'`, and changes no profile file, when the spec names an installed package: a registry name with or without a version, a local directory, or the git or tarball spec the dependency was saved with. Repeating an installation of the same spec fails the same way instead of reporting `changed: false`. + +This affects callers of `installBundle()` through the Host API and the remote client in `@deepseek-ai/dsh-api-remotes/client`, and the agent's `plugin_manager` tool (`install_bundle`). An installed package that cannot be removed, such as a bundle carrying plugin management, can no longer be installed over while HMR runs. The Web page's Update for an Official plugin removes the package and installs the new version itself. Without HMR, installing over an installed package still runs pnpm and reports `restart-required`. + +## Migration + +1. To install another version or location of an installed package, call `removeBundle(name)` first, then `installBundle(spec)`. For the agent tool, call `remove_bundle`, then `install_bundle`. The removal stops the bundle's plugins and drops its packages' mappings, so the new version is mapped afresh and starts without a restart. +2. If code switches exhaustively over `ManagementError['code']`, handle the `already-installed` code. +3. Confirm the migration: installing the package again returns `already-installed`, and after `removeBundle` the same `installBundle` call returns `applied` with the new `version`. diff --git a/docs/upgrade-guide/v0.2.1-alpha.2/plugin-install-over-installed/guide.zh.md b/docs/upgrade-guide/v0.2.1-alpha.2/plugin-install-over-installed/guide.zh.md new file mode 100644 index 0000000000..4a3d7adf53 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.2/plugin-install-over-installed/guide.zh.md @@ -0,0 +1,20 @@ +--- +kind: upgrade-guide +description: "启用 HMR 时,安装已安装的包会以 already-installed 失败;升级需要先移除该包。" +--- + +# 启用 HMR 时拒绝在已安装的插件包上再次安装 + +[English](guide.md) | 中文 + +## 变更 + +在运行 HMR(热模块替换)的 profile 中(随附的 profile 都运行 HMR),`pluginManager.installBundle(spec)` 此前会对 profile 已依赖的包运行 pnpm,并在旧版本继续运行时报告 `restart-required`。现在当 spec 指向已安装的包时,它会在运行 pnpm 之前失败,返回 `application: 'failed'` 和 `error.code: 'already-installed'`,且不修改任何 profile 文件。这里的 spec 包括带或不带版本的注册表包名、本地目录,以及保存依赖时使用的 git 或 tarball spec。重复安装同一个 spec 也会以同样方式失败,不再报告 `changed: false`。 + +受影响的调用方包括:通过 Host API 调用 `installBundle()` 的代码、`@deepseek-ai/dsh-api-remotes/client` 中的远端客户端,以及 agent 的 `plugin_manager` 工具(`install_bundle`)。启用 HMR 时,不能再在无法移除的已安装包(例如带有插件管理的组合包)上安装。Web 页面上官方插件的“更新”操作会自行移除该包并安装新版本。未启用 HMR 时,在已安装的包上安装仍会运行 pnpm 并报告 `restart-required`。 + +## 迁移 + +1. 要安装已安装包的其他版本或其他位置,先调用 `removeBundle(name)`,再调用 `installBundle(spec)`。使用 agent 工具时,先调用 `remove_bundle`,再调用 `install_bundle`。移除操作会停止该 bundle 的插件,并删除其各个包的映射,因此新版本会重新建立映射,无需重启即可启动。 +2. 如果代码对 `ManagementError['code']` 做穷尽 switch,需要处理 `already-installed`。 +3. 确认迁移结果:再次安装该包返回 `already-installed`;执行 `removeBundle` 后,同样的 `installBundle` 调用返回 `applied` 和新的 `version`。 diff --git a/docs/upgrade-guide/v0.2.1-alpha.2/sdk-minimal-image-compaction/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-alpha.2/sdk-minimal-image-compaction/guide.i18n.yaml new file mode 100644 index 0000000000..7855124a09 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.2/sdk-minimal-image-compaction/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-alpha.2/sdk-minimal-image-compaction/guide.md +/: + en: 5fc67054b45f1734 + zh: 0cb92b2e036dfb67 +/sdk-minimal-image-reading-and-automatic-compaction: + en: 1b0161d44c885284 + zh: 5babee2ad7dc2571 +/sdk-minimal-image-reading-and-automatic-compaction/change: + en: 42d90c20c9549611 + zh: f904303bea2a60a8 +/sdk-minimal-image-reading-and-automatic-compaction/migration: + en: e944f28ce8c2ef8d + zh: 3bd5fd84a02e5866 diff --git a/docs/upgrade-guide/v0.2.1-alpha.2/sdk-minimal-image-compaction/guide.md b/docs/upgrade-guide/v0.2.1-alpha.2/sdk-minimal-image-compaction/guide.md new file mode 100644 index 0000000000..a1cd0795fa --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.2/sdk-minimal-image-compaction/guide.md @@ -0,0 +1,32 @@ +--- +kind: upgrade-guide +description: "The sdk-minimal default tool roster adds read_image and automatically summarizes older context." +--- + +# SDK-minimal image reading and automatic compaction + +English | [中文](guide.zh.md) + +## Change + +`sdk-minimal` advertises `read_image` alongside its persistent shell and `working_directory`. Automatic compaction sends an additional model request to summarize older history when context pressure reaches the configured threshold. The complete Session log remains stored as uncompressed JSONL. Images that exceed the route budget are permanently replaced by attachment/path placeholders so the request can retry. SDK prompt text does not execute `/compact`. + +Clients that pin tool names, benchmark prompt size, or count model requests must account for these defaults. Image reading requires an adapter route that declares image input. See the [bundle reference](../../../../packages/bundle/sdk-minimal/README.md) for configuration and model effects. + +## Migration + +1. Accept the new defaults, or place this patch in `$DSH_HOME/profiles/sdk-minimal/cordis.patch.yml` to retain the previous default tools and disable automatic compaction: + +```yaml +- id: tool-fs + config: + read: false + readImage: false + write: false + edit: false +- id: compaction-basic + config: + auto: false +``` + +2. Launch a fresh SDK runtime. Without additional user plugins, its model requests contain the persistent `bash` or `pwsh` tool and `working_directory`; it sends no automatic summarization requests. diff --git a/docs/upgrade-guide/v0.2.1-alpha.2/sdk-minimal-image-compaction/guide.zh.md b/docs/upgrade-guide/v0.2.1-alpha.2/sdk-minimal-image-compaction/guide.zh.md new file mode 100644 index 0000000000..6d4d43a100 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.2/sdk-minimal-image-compaction/guide.zh.md @@ -0,0 +1,32 @@ +--- +kind: upgrade-guide +description: "sdk-minimal 默认工具清单新增 read_image,并自动生成较早上下文的摘要。" +--- + +# SDK-minimal 图片读取与自动压缩 + +[English](guide.md) | 中文 + +## 变更 + +`sdk-minimal` 在持久 shell 和 `working_directory` 之外公布 `read_image`。上下文压力达到配置阈值时,自动压缩会额外发起模型请求,生成较早历史的摘要。完整 Session 日志仍存储为未压缩 JSONL。图片超出路由预算时会被永久替换为包含附件与路径的占位文本,以便重试请求。SDK 提示词文本不会执行 `/compact`。 + +固定工具名称、基准提示词大小或统计模型请求数的客户端必须考虑这些默认行为。图片读取需要适配器路由声明支持图片输入。配置与模型影响见[组合包参考](../../../../packages/bundle/sdk-minimal/README.zh.md)。 + +## 迁移 + +1. 接受新增默认能力,或将以下 patch 写入 `$DSH_HOME/profiles/sdk-minimal/cordis.patch.yml`,保留原有默认工具并关闭自动压缩: + +```yaml +- id: tool-fs + config: + read: false + readImage: false + write: false + edit: false +- id: compaction-basic + config: + auto: false +``` + +2. 启动新的 SDK 运行时。未额外配置用户插件时,模型请求包含持久 `bash` 或 `pwsh` 工具及 `working_directory`;不会发起自动摘要请求。 diff --git a/docs/upgrade-guide/v0.2.1-alpha.2/stored-credentials-precedence/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-alpha.2/stored-credentials-precedence/guide.i18n.yaml new file mode 100644 index 0000000000..60dff113e1 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.2/stored-credentials-precedence/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-alpha.2/stored-credentials-precedence/guide.md +/: + en: 9fc8377bd2d6e38c + zh: b77fd76aeee0f592 +/a-stored-key-takes-precedence-over-an-exported-variable: + en: 0d002196a3f19850 + zh: 7dcbbb6a44216f01 +/a-stored-key-takes-precedence-over-an-exported-variable/change: + en: 4ac6ffc5e782f765 + zh: 51a69db953708bd3 +/a-stored-key-takes-precedence-over-an-exported-variable/migration: + en: d4f534f82476b5e9 + zh: 5371eaaae96a3759 diff --git a/docs/upgrade-guide/v0.2.1-alpha.2/stored-credentials-precedence/guide.md b/docs/upgrade-guide/v0.2.1-alpha.2/stored-credentials-precedence/guide.md new file mode 100644 index 0000000000..5726a62e23 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.2/stored-credentials-precedence/guide.md @@ -0,0 +1,25 @@ +--- +kind: upgrade-guide +description: "Stored credentials now precede environment fallbacks when the store is mounted; the shipped headless profile omits stored authentication." +--- + +# A stored key takes precedence over an exported variable + +English | [中文](guide.zh.md) + +## Change + +Earlier releases used an exported environment variable before the `refs` entry with the same name in `$DSH_HOME/.credentials.yaml` (`~/.dsh/.credentials.yaml` by default). This applied to `DEEPSEEK_API_KEY=… dsh`, a shell profile export, a CI secret, and a container `-e` value. While the name was exported, the Models page could not save a key for it. + +Now the stored entry wins. The exported variable applies only while the file stores no value for that name, and `$cwd/.env` and `$DSH_HOME/.env` still apply only after both. The Models page can save and replace a key while the name is exported. + +The shipped `headless` profile instead disables the credential store, authorization and DeepSeek account provider. It uses the launch environment, project `.env`, then Harness-home `.env`, and leaves `.credentials.yaml` untouched. Saved API keys and sign-ins no longer authenticate headless jobs by default. Web, SDK and ACP retain the store. + +You are affected when a harness home stores a value under `refs` and the process that launches `dsh` exports the same name. This includes a container that mounts such a home. Requests now use the stored key instead of the exported one. A CI job or container that starts with a fresh harness home, without `.credentials.yaml`, is not affected. The [stored credential precedence](../../../../.agents/notes/implemented/architecture/2026-10-09-stored-credential-precedence.md) Agent Note records the reason. + +## Migration + +1. Compare the names under `refs` in `$DSH_HOME/.credentials.yaml` with the variables that your shell, CI job, or container exports. A name that appears in only one place needs no action. +2. For each name that appears in both places, keep the stored key if it is the account you want. Otherwise, delete the name's line under `refs`. A running harness reloads the file, so the next request reads the exported variable without a restart. +3. For a headless job, export the required key and run `dsh headless ""`. To keep using a saved key or sign-in, apply the three-row [headless restoration patch](../../../../packages/bundle/headless/README.md#credentials); restored storage also restores saved-first precedence. Existing profile, home or command overlays can override the shipped rows, so inspect `dsh headless --dump-config` when checking the effective composition. +4. Confirm the intended request account and that the launching process exports a non-empty value where needed. Check names and presence without printing credential values. A headless run relying only on a saved credential now fails; restoring storage or supplying supported environment authentication resolves it. diff --git a/docs/upgrade-guide/v0.2.1-alpha.2/stored-credentials-precedence/guide.zh.md b/docs/upgrade-guide/v0.2.1-alpha.2/stored-credentials-precedence/guide.zh.md new file mode 100644 index 0000000000..239d0367ac --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.2/stored-credentials-precedence/guide.zh.md @@ -0,0 +1,25 @@ +--- +kind: upgrade-guide +description: "挂载存储时,已保存凭据现在优先于环境后备;随附 headless profile 不挂载存储认证。" +--- + +# 存储的密钥优先于导出的变量 + +[English](guide.md) | 中文 + +## 变更 + +早期版本会先使用导出的环境变量,再使用 `$DSH_HOME/.credentials.yaml`(默认为 `~/.dsh/.credentials.yaml`)中同名的 `refs` 条目。这适用于 `DEEPSEEK_API_KEY=… dsh`、shell 配置文件中的导出、CI 机密和容器 `-e` 值。该名字被导出期间,Models 页面无法为其保存密钥。 + +现在存储的条目优先。只有当文件没有为该名字存储值时,导出的变量才会生效;`$cwd/.env` 和 `$DSH_HOME/.env` 仍排在两者之后。即使该名字已被导出,Models 页面也可以保存和替换密钥。 + +随附的 `headless` profile 则禁用凭据存储、authorization 和 DeepSeek account 提供方。它依次使用启动环境、项目 `.env` 和 Harness home `.env`,并保持 `.credentials.yaml` 原样。已保存 API 密钥和登录凭据默认不再为 headless 任务提供认证。Web、SDK 和 ACP 保留存储。 + +如果某个 harness home 在 `refs` 下存储了值,而启动 `dsh` 的进程导出了同名变量,你就会受到影响。挂载了这类 home 的容器也包括在内。请求现在使用存储的密钥,而不是导出的密钥。以全新 harness home 启动、没有 `.credentials.yaml` 的 CI 作业或容器不受影响。[存储凭据优先级](../../../../.agents/notes/implemented/architecture/2026-10-09-stored-credential-precedence.zh.md) Agent Note 记录了原因。 + +## 迁移 + +1. 将 `$DSH_HOME/.credentials.yaml` 中 `refs` 下的名字,与 shell、CI 作业或容器导出的变量进行比较。只出现在其中一处的名字无需处理。 +2. 对同时出现在两处的名字,如果存储的密钥正是你要使用的账户,就保留它。否则,删除 `refs` 下该名字所在的行。运行中的 harness 会重新加载该文件,因此下一次请求无需重启即可读取导出的变量。 +3. 对 headless 任务,导出所需密钥并运行 `dsh headless ""`。要继续使用已保存密钥或登录凭据,请应用三行配置的 [headless 恢复 patch](../../../../packages/bundle/headless/README.zh.md#credentials);恢复存储也恢复已保存凭据优先。现有 profile、home 或命令覆盖可以覆盖随附行,因此核对有效组合时请查看 `dsh headless --dump-config`。 +4. 确认请求使用预期账户,并在需要时确认启动进程导出了非空值。只检查名字和是否存在,不要打印凭据值。仅依赖已保存凭据的 headless 运行现在会失败;恢复存储或提供受支持的环境认证即可解决。 diff --git a/docs/user/guide/providers.i18n.yaml b/docs/user/guide/providers.i18n.yaml index 6f9b6e315e..85cf647be1 100644 --- a/docs/user/guide/providers.i18n.yaml +++ b/docs/user/guide/providers.i18n.yaml @@ -9,8 +9,8 @@ en: 2c6b665adc256b4e zh: 2240e4c1573ebe9c /configure-models/add-a-third-party-provider: - en: 0fe5f88e692f618c - zh: 4f2e493818d8934f + en: 4b99a9b34d117eea + zh: dddeb5a08c0d3cc6 /configure-models/add-a-custom-model-api: en: 17867305a3e1b52a zh: 876fedf1f6f563e4 @@ -26,6 +26,9 @@ /configure-models/advanced-configuration/image-input: en: 75350cb615da03a4 zh: 6f8b3510085e4ac4 +/configure-models/advanced-configuration/deepseek-account-models: + en: fc09d02a89b211a7 + zh: e0f23a8912a24178 /configure-models/advanced-configuration/reasoning-effort: en: f7581a0760fdfd2c zh: dc6eaa16f74a0e9f diff --git a/docs/user/guide/providers.md b/docs/user/guide/providers.md index 773ff6722a..1fd84e9f1a 100644 --- a/docs/user/guide/providers.md +++ b/docs/user/guide/providers.md @@ -16,7 +16,9 @@ Keys are write-only. The page receives a redacted descriptor after saving, never Choose **Add model provider**. The card opens on **Third-party model provider**: pick a provider dsh ships with — the list shows provider ids such as `anthropic`, `openai`, `moonshotai` for Kimi, or `zai` for GLM — enter its API key, and save. The installed catalog supplies the endpoint, protocol, and model list. -Providers that sign in with OAuth, such as Codex, are not supported here yet. +The authentication **?** explains saved-credential priority and the environment fallbacks displayed for a key field. Leaving the key blank preserves existing authentication. Providers without native key authentication, such as `openai-codex`, show no key field unless the profile explicitly names a credential reference. + +For an existing profile that names a credential reference, **API key source** shows the layer currently supplying that reference: saved credentials, launch environment, `$cwd/.env`, or `$DSH_HOME/.env`. Native authentication without a named reference has no source label. ## Add a custom model API @@ -108,6 +110,21 @@ In pi-ai configuration, every list must name at least one modality except a mode Both fields state a claim about your endpoint rather than checking it. A model that declares images its endpoint does not serve is not caught here; the provider rejects the request instead. +### DeepSeek account models + +A signed-in DeepSeek account lists its models from the account service, so the Models page offers no model editor for it. Adjust a single model with `modelOverrides` in `$DSH_HOME/profiles//cordis.patch.yml`; the fields match a DeepSeek `models` entry, and unset fields keep the listed values: + +```yaml +- id: llm-deepseek-account + config: + modelOverrides: + deepseek-flash: + contextWindow: 524288 + maxTokens: 131072 +``` + +A model that needs a newer DeepSeek Harness fails with `CLIENT_UPGRADE_REQUIRED`, which names the version to install. The [account provider reference](../../../packages/llm/llm-deepseek-account/README.md) owns the listing, fallback, and gating rules. + ### Reasoning effort The model picker offers an **Effort** menu for a model that declares reasoning levels. A built-in provider's models inherit their levels from the installed catalog. A model you enter by hand declares none, so the Effort entry does not appear in the menu and the endpoint's own default decides whether the model thinks. Declare the levels with `reasoningEfforts` in `$DSH_HOME/profiles//cordis.patch.yml`: diff --git a/docs/user/guide/providers.zh.md b/docs/user/guide/providers.zh.md index 6d618ed1e9..340f6b8b97 100644 --- a/docs/user/guide/providers.zh.md +++ b/docs/user/guide/providers.zh.md @@ -16,7 +16,9 @@ 选择**添加模型提供商**。卡片默认打开在**第三方模型提供商**:选取 dsh 自带的提供商——列表显示的是提供商 id,例如 `anthropic`、`openai`、Kimi 对应的 `moonshotai`、GLM 对应的 `zai`——输入其 API 密钥并保存。已安装目录会提供端点、协议和模型列表。 -通过 OAuth 登录的提供商(例如 Codex)暂不支持。 +认证 **?** 说明已保存凭据的优先级,以及密钥字段所显示的环境后备。密钥留空会保留现有认证。没有原生密钥认证的提供商(例如 `openai-codex`)不显示密钥字段,除非 profile 显式指定了凭据引用。 + +已有 profile 指定凭据引用时,**API 密钥来源**显示当前为该引用供值的层:已保存凭据、启动环境、`$cwd/.env` 或 `$DSH_HOME/.env`。未指定引用的原生认证不显示来源标签。 ## 添加自定义模型 API @@ -108,6 +110,21 @@ DeepSeek 将省略的 `inputModalities` 视为纯文本,并拒绝空列表。 这两个字段都是对你端点的断言,而不是对它的检查。声明了端点并不提供的图片能力的模型不会在这里被拦下,改由提供商拒绝该请求。 +### DeepSeek 账号的模型 + +已登录 DeepSeek 账号的模型列表来自账号服务,因此模型页不为它提供模型编辑器。要调整单个模型,在 `$DSH_HOME/profiles//cordis.patch.yml` 中使用 `modelOverrides`;字段与 DeepSeek 的 `models` 条目相同,未设置的字段沿用列表中的值: + +```yaml +- id: llm-deepseek-account + config: + modelOverrides: + deepseek-flash: + contextWindow: 524288 + maxTokens: 131072 +``` + +需要更新版 DeepSeek Harness 的模型会以 `CLIENT_UPGRADE_REQUIRED` 失败,并写明需要安装的版本。列表、兜底和门控规则见[账号提供方参考](../../../packages/llm/llm-deepseek-account/README.zh.md)。 + ### 推理等级 对于声明了推理等级的模型,模型选择器会提供**推理等级**菜单。内置提供商的模型从已安装目录继承其等级。手动录入的模型不声明任何等级,因此模型菜单里不会出现推理等级项,由端点自身的默认值决定模型是否思考。请在 `$DSH_HOME/profiles//cordis.patch.yml` 中用 `reasoningEfforts` 声明等级: diff --git a/docs/user/guide/public-deployments.i18n.yaml b/docs/user/guide/public-deployments.i18n.yaml index cba21115ff..757429dcc0 100644 --- a/docs/user/guide/public-deployments.i18n.yaml +++ b/docs/user/guide/public-deployments.i18n.yaml @@ -6,8 +6,8 @@ en: 867565aa675f3fb3 zh: c60606b5e5bdb778 /publish-the-web-ui-behind-a-reverse-proxy/what-public-url-advertises: - en: 576292b93ccf299a - zh: ded5adc55396869c + en: 924b28b211478f01 + zh: b098d5a53a29c883 /publish-the-web-ui-behind-a-reverse-proxy/what-the-proxy-must-do: en: 2f93051763125365 zh: 1dd699cb84ecf034 diff --git a/docs/user/guide/public-deployments.md b/docs/user/guide/public-deployments.md index b6b4efc0ca..70e107fe6d 100644 --- a/docs/user/guide/public-deployments.md +++ b/docs/user/guide/public-deployments.md @@ -10,7 +10,7 @@ dsh --profile web --public-url https://app.example/ui/ --trusted-host app.exampl ## What `--public-url` advertises -`--public-url` accepts an `http://` or `https://` root with an optional mount prefix and normalizes it to end in `/`. It supplies the printed and opened startup URL, `DSH_WEB_URL`, and the web-surface orientation. The webserver keeps serving origin-root routes and never learns the mount. The `publicUrl` config field publishes the same advertisement. +`--public-url` accepts an `http://` or `https://` root with an optional mount prefix and normalizes it to end in `/`. It supplies the printed and opened startup URL, `DSH_WEB_URL`, and the Web GUI URL in the agent's runtime context. The webserver keeps serving origin-root routes and never learns the mount. The `publicUrl` config field publishes the same advertisement. ## What the proxy must do diff --git a/docs/user/guide/public-deployments.zh.md b/docs/user/guide/public-deployments.zh.md index 2cb0e8fd4a..93572f0db1 100644 --- a/docs/user/guide/public-deployments.zh.md +++ b/docs/user/guide/public-deployments.zh.md @@ -10,7 +10,7 @@ dsh --profile web --public-url https://app.example/ui/ --trusted-host app.exampl ## `--public-url` 公告什么 -`--public-url` 接受可带挂载前缀的 `http://` 或 `https://` 根,并把它归一化为以 `/` 结尾。它提供打印与打开的启动 URL、`DSH_WEB_URL` 与 web 表层定位。webserver 继续提供 origin-root 路由,且从不了解挂载。`publicUrl` 配置字段发布同样的公告。 +`--public-url` 接受可带挂载前缀的 `http://` 或 `https://` 根,并把它归一化为以 `/` 结尾。它提供打印与打开的启动 URL、`DSH_WEB_URL`,以及 agent 运行时上下文中的 Web GUI URL。webserver 继续提供 origin-root 路由,且从不了解挂载。`publicUrl` 配置字段发布同样的公告。 ## 代理必须做什么 diff --git a/docs/user/guide/python-sdk.i18n.yaml b/docs/user/guide/python-sdk.i18n.yaml index 1eac40d8fb..f911334f40 100644 --- a/docs/user/guide/python-sdk.i18n.yaml +++ b/docs/user/guide/python-sdk.i18n.yaml @@ -21,8 +21,8 @@ en: 592dd306b920cd83 zh: 8c22110621c12ba5 /get-started-with-the-python-sdk/install-or-define-plugins/opt-in-to-str-replace-editor: - en: c63ca1e3a99e6fe1 - zh: 2ceddea3f30d92d3 + en: 450d54b6e9e38469 + zh: f0dc06a08c073571 /get-started-with-the-python-sdk/understand-the-minimal-profile: - en: 5eaacc1bc52853c4 - zh: 5b94bfc79aac04d8 + en: 8ed0bfd3cc9cc967 + zh: a52dab4cb3dd09ea diff --git a/docs/user/guide/python-sdk.md b/docs/user/guide/python-sdk.md index 837e97aa27..438598c378 100644 --- a/docs/user/guide/python-sdk.md +++ b/docs/user/guide/python-sdk.md @@ -152,19 +152,15 @@ Another `profile` is valid when it includes `@deepseek-ai/dsh-sdk-app` or anothe ### Opt in to `str_replace_editor` -The bundled runtime includes `str_replace_editor`, but `sdk-minimal` omits it from the default Cordis tree. To use it, save this configuration as `editor.patch.yml`; `insert` adds both the editor and the filesystem provider that the minimal profile lacks: +The bundled runtime includes `str_replace_editor`, but `sdk-minimal` omits it from the default Cordis tree. To use it, save this configuration as `editor.patch.yml`; `insert` adds the editor using the existing filesystem provider: ```yaml - insert: - - id: fs-local - name: '@deepseek-ai/dsh-fs-local' - config: - cwd: !!js process.cwd() - id: tool-str-replace-editor name: '@deepseek-ai/dsh-tool-str-replace-editor' ``` -Pass `patches=("/absolute/path/to/editor.patch.yml",)` when constructing `DeepSeekHarness(profile="sdk-minimal", ...)`, or put the patch in `$DSH_HOME/profiles/sdk-minimal/cordis.patch.yml` for persistent configuration. On the next runtime launch, model requests include `str_replace_editor` beside the persistent shell. The local filesystem provider uses the runtime working directory for relative paths; like the minimal shell, it does not confine access to that directory. For the standard `sdk` profile, insert only the editor row so it uses the existing filesystem provider and policies. +Pass `patches=("/absolute/path/to/editor.patch.yml",)` when constructing `DeepSeekHarness(profile="sdk-minimal", ...)`, or put the patch in `$DSH_HOME/profiles/sdk-minimal/cordis.patch.yml` for persistent configuration. On the next runtime launch, model requests include `str_replace_editor` beside the default tools. The local filesystem provider uses the runtime working directory for relative paths; like the minimal shell, it does not confine access to that directory. The same patch works with the standard `sdk` profile. ## Understand the minimal profile @@ -172,12 +168,15 @@ Pass `patches=("/absolute/path/to/editor.patch.yml",)` when constructing `DeepSe |---|---| | System prompt | `DSH_SYSTEM_PROMPT`, falling back to `You are a helpful software engineer assistant.` | | Model in `minimal.py` | `--model`, then `DSH_MODEL`, then `deepseek-v4-flash` | -| Model-facing tool | Persistent `bash` on Linux/macOS or `pwsh` on Windows | +| Model-facing tools | Persistent `bash` on Linux/macOS or `pwsh` on Windows, `read_image`, and `working_directory` | | Shell timeout | 300 seconds | -| Runtime context and compaction | Absent | +| Optional runtime context | Absent | +| Context compaction | Automatic; summarizes older history through an additional model request | | Session persistence | Uncompressed JSONL under `/sessions` | -The profile's sole bundle inserts the complete tree over an empty root and does not include `dsh-base`; later base-profile tools therefore cannot appear implicitly. It contains the SDK protocol, one environment-configured DeepSeek adapter, local execution, and persistence, while filesystem tools, settings, managed credentials, OTel telemetry, Web tools, subagents, local instruction discovery, and compaction are absent. The [DeepSeek session-log contributor](../../../packages/session/session-log-deepseek/README.md) uploads unaccepted log events with DeepSeek requests by default, at most `maxBytes` (8 MiB) per request; set `session-log-deepseek.enabled: false` in a profile patch to disable it. It pins `danger-full-access`, so the platform-selected persistent shell can modify any path visible to the runtime; use a disposable checkout or container. +The profile's sole bundle inserts the complete tree over an empty root and does not include `dsh-base`; later base-profile tools therefore cannot appear implicitly. It contains the SDK protocol, one environment-configured DeepSeek adapter, local execution, and persistence, while text file tools, settings, managed credentials, OTel telemetry, Web tools, subagents, and local instruction discovery are absent. The [DeepSeek session-log contributor](../../../packages/session/session-log-deepseek/README.md) uploads unaccepted log events with DeepSeek requests by default, at most `maxBytes` (8 MiB) per request; set `session-log-deepseek.enabled: false` in a profile patch to disable it. It pins `danger-full-access`, so the platform-selected persistent shell can modify any path visible to the runtime; use a disposable checkout or container. + +Image reading requires an image-capable route. Automatic compaction preserves the complete Session log; see the [bundle reference](../../../packages/bundle/sdk-minimal/README.md) for model and storage effects. SDK prompt text does not execute `/compact` or other slash commands. The installed wheel still packages the full `web` profile and frontend assets. Run `dsh web` against an explicit `DSH_HOME` when a Python SDK deployment also needs the browser application; `web` is a separate CLI application and cannot serve a Python SDK client. diff --git a/docs/user/guide/python-sdk.zh.md b/docs/user/guide/python-sdk.zh.md index d7742bec58..b7a86e9860 100644 --- a/docs/user/guide/python-sdk.zh.md +++ b/docs/user/guide/python-sdk.zh.md @@ -152,19 +152,15 @@ dsh plugin --profile sdk-minimal add file:C:/work/my-plugin-bundle ### 显式启用 `str_replace_editor` -随附运行时包含 `str_replace_editor`,但 `sdk-minimal` 的默认 Cordis tree 不挂载它。要使用该工具,请将以下配置保存为 `editor.patch.yml`;`insert` 会添加 editor,以及极简 profile 缺少的文件系统后端: +随附运行时包含 `str_replace_editor`,但 `sdk-minimal` 的默认 Cordis tree 不挂载它。要使用该工具,请将以下配置保存为 `editor.patch.yml`;`insert` 会添加 editor,并使用已有的文件系统后端: ```yaml - insert: - - id: fs-local - name: '@deepseek-ai/dsh-fs-local' - config: - cwd: !!js process.cwd() - id: tool-str-replace-editor name: '@deepseek-ai/dsh-tool-str-replace-editor' ``` -构造 `DeepSeekHarness(profile="sdk-minimal", ...)` 时传入 `patches=("/absolute/path/to/editor.patch.yml",)`,或将 patch 写入 `$DSH_HOME/profiles/sdk-minimal/cordis.patch.yml` 以持久保存配置。下次运行时启动后,模型请求会在持久 shell 之外包含 `str_replace_editor`。本地文件系统后端以运行时工作目录解析相对路径;与极简 shell 一样,它不会将访问限制在该目录内。对于标准 `sdk` profile,只插入 editor 配置项,让它使用已有的文件系统后端与策略。 +构造 `DeepSeekHarness(profile="sdk-minimal", ...)` 时传入 `patches=("/absolute/path/to/editor.patch.yml",)`,或将 patch 写入 `$DSH_HOME/profiles/sdk-minimal/cordis.patch.yml` 以持久保存配置。下次运行时启动后,模型请求会在默认工具之外包含 `str_replace_editor`。本地文件系统后端以运行时工作目录解析相对路径;与极简 shell 一样,它不会将访问限制在该目录内。该 patch 也适用于标准 `sdk` profile。 ## 理解极简 profile @@ -172,12 +168,15 @@ dsh plugin --profile sdk-minimal add file:C:/work/my-plugin-bundle |---|---| | 系统提示词 | `DSH_SYSTEM_PROMPT`,未设置时为 `You are a helpful software engineer assistant.` | | `minimal.py` 的模型 | `--model`,然后是 `DSH_MODEL`,最后是 `deepseek-v4-flash` | -| 面向模型的工具 | Linux/macOS 上的持久 `bash` 或 Windows 上的 `pwsh` | +| 面向模型的工具 | Linux/macOS 上的持久 `bash` 或 Windows 上的 `pwsh`、`read_image` 和 `working_directory` | | Shell 超时 | 300 秒 | -| 运行时上下文与 compaction | 不存在 | +| 可选运行时上下文 | 不存在 | +| 上下文压缩 | 自动执行;通过额外模型请求生成较早历史的摘要 | | 会话持久化 | `/sessions` 下的未压缩 JSONL | -该 profile 的唯一组合包会在空根之上插入完整配置树,且不包含 `dsh-base`,因此基础 profile 以后新增的工具不会隐式出现。它包含 SDK 协议、一个由环境配置的 DeepSeek 适配器、本地执行与持久化;文件系统工具、settings、托管凭据、OTel 遥测、Web 工具、subagent、本地指令发现和 compaction 均不存在。[DeepSeek 会话日志贡献器](../../../packages/session/session-log-deepseek/README.zh.md)默认随 DeepSeek 请求上传未接受的日志事件,每次请求最多 `maxBytes`(8 MiB);在 profile patch 中设置 `session-log-deepseek.enabled: false` 可将其关闭。它固定使用 `danger-full-access`,因此按平台选择的持久 shell 可以修改运行时可见的任何路径;应使用一次性 checkout 或容器。 +该 profile 的唯一组合包会在空根之上插入完整配置树,且不包含 `dsh-base`,因此基础 profile 以后新增的工具不会隐式出现。它包含 SDK 协议、一个由环境配置的 DeepSeek 适配器、本地执行与持久化;文本文件工具、settings、托管凭据、OTel 遥测、Web 工具、subagent、本地指令发现均不存在。[DeepSeek 会话日志贡献器](../../../packages/session/session-log-deepseek/README.zh.md)默认随 DeepSeek 请求上传未接受的日志事件,每次请求最多 `maxBytes`(8 MiB);在 profile patch 中设置 `session-log-deepseek.enabled: false` 可将其关闭。它固定使用 `danger-full-access`,因此按平台选择的持久 shell 可以修改运行时可见的任何路径;应使用一次性 checkout 或容器。 + +图片读取需要支持图片输入的路由。自动压缩保留完整 Session 日志;模型与存储影响见[组合包参考](../../../packages/bundle/sdk-minimal/README.zh.md)。SDK 提示词文本不会执行 `/compact` 或其他斜杠命令。 已安装 wheel 仍会打包完整 `web` profile 与前端产物。如果 Python SDK 部署还需要浏览器应用,请针对显式 `DSH_HOME` 运行 `dsh web`;`web` 是独立 CLI 应用,不能为 Python SDK client 提供服务。 diff --git a/lefthook.yml b/lefthook.yml index 1d5c8898c6..75c03f9a26 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -21,16 +21,6 @@ pre-commit: run: node_modules/.bin/tsx scripts/run-oxlint.ts --config .oxlintrc.staged.json --fix --no-error-on-unmatched-pattern {staged_files} stage_fixed: true - # Regenerate rather than reject: a dependency edit that forgot the notices - # would otherwise fail the test lane long after the commit. The glob matches - # every input the generator reads, including the generator itself and the - # build-time pin source. Deleting a manifest cannot trigger this job — - # lefthook only inspects files present on disk — so that one case still - # falls through to the freshness assertion in the test lane. - - name: third-party notices (staged) - glob: '{package.json,*/package.json,*/*/package.json,*/*/*/package.json,*/*/*/*/package.json,pnpm-workspace.yaml,*/*/pnpm-workspace.yaml,pnpm-lock.yaml,vendor/README.md,python/*/pyproject.toml,scripts/primary-runtime/lock.json,scripts/gen-third-party-notices.ts,scripts/browser-bundled-externals.ts,scripts/build-exe-for-python-sdk.ts,tsconfig.base*.json,packages/*/*/src/**/*,packages/*/*/tsdown.config.ts,packages/client/tsdown.client.ts,apps/*/src/**/*,apps/*/vite.config.ts}' - run: node_modules/.bin/tsx scripts/gen-third-party-notices.ts && git add THIRD_PARTY_NOTICES.md - - name: whitespace (staged) run: git diff --cached --check diff --git a/package.json b/package.json index 2d76805736..3b9e5f2b36 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,8 @@ "scripts": { "prepare:primary-runtime": "tsx scripts/primary-runtime/prepare.ts", "build": "tsx scripts/build.ts", + "build:artifacts": "tsx scripts/build.ts --scope artifacts", + "build:check": "tsx scripts/build.ts --scope check", "build:bench": "npm run build:native-system && npm run build:lib && tsdown --config-loader native --config benchmarks/tsdown.config.ts", "build:official": "tsx scripts/build.ts --profile official", "build:lib": "pnpm run build:lib:host && pnpm run build:lib:client", @@ -48,11 +50,11 @@ "upload:latest:win:x64": "pnpm --filter @deepseek-ai/dsh-desktop run upload:latest:win:x64", "clean": "tsx scripts/clean.ts", "change-scope": "tsx scripts/change-scope.ts", - "typecheck": "npm run build:lib:host && npm run typecheck:contracts-ready", + "typecheck": "tsx scripts/build.ts --scope typecheck", "typecheck:contracts-ready": "tsc -b tsconfig.client.json", - "lint": "npm run build:lib:host && npm run lint:contracts-ready", + "lint": "tsx scripts/compile-referenced-projects.ts host-contracts && npm run lint:contracts-ready", "lint:contracts-ready": "tsx scripts/run-oxlint.ts .", - "lint:fix": "npm run build:lib:host && npm run lint:fix:contracts-ready", + "lint:fix": "tsx scripts/compile-referenced-projects.ts host-contracts && npm run lint:fix:contracts-ready", "lint:fix:contracts-ready": "tsx scripts/run-oxlint.ts --config .oxlintrc.staged.json packages/typert/generator/tests/fixtures/type-model --fix && tsx scripts/run-oxlint.ts . --fix", "duplication": "jscpd --config .jscpd.json packages scripts", "test": "pnpm run build:native-system && vitest run", @@ -99,7 +101,7 @@ "check:ci:windows-observational-ready": "tsx scripts/run-gates.ts ci-windows-observational-ready", "check:node-compat": "tsx scripts/run-gates.ts node-compat", "publint": "tsx scripts/publint-all.ts", - "doc-typecheck": "npm run build:lib:host && npm run doc-typecheck:contracts-ready", + "doc-typecheck": "tsx scripts/compile-referenced-projects.ts host-contracts && npm run doc-typecheck:contracts-ready", "doc-typecheck:contracts-ready": "tsx scripts/doc-typecheck.ts", "verify-md-wrap": "tsx scripts/verify-md-wrap.ts", "verify-md-links": "tsx scripts/verify-md-links.ts", @@ -240,6 +242,8 @@ "@types/mdast": "^4.0.4", "@types/papaparse": "^5.5.2", "@types/node": "^22.20.1", + "@types/react": "~18.3.1", + "@types/react-dom": "~18.3.0", "@types/semver": "^7.8.0", "@types/spdx-expression-parse": "^4.0.0", "@vitest/coverage-v8": "^4.1.11", diff --git a/packages/acp/acp/tests/harness.ts b/packages/acp/acp/tests/harness.ts index 51d89dab25..12ee80b95b 100644 --- a/packages/acp/acp/tests/harness.ts +++ b/packages/acp/acp/tests/harness.ts @@ -23,7 +23,7 @@ import type { ImageAttachmentLimits, ImageAttachmentRef, SaveImageAttachment, St import { type GenerateOptions, LlmAdapter, ReasoningEffortId, type LlmResolvedModelInfo, type StreamChunk } from '@deepseek-ai/dsh-llm' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' -import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' +import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import TokenMeter from '@deepseek-ai/dsh-token-meter' import * as AcpPlugin from '../src/index.ts' import type { AcpConfig } from '../src/index.ts' diff --git a/packages/api/README.i18n.yaml b/packages/api/README.i18n.yaml index 7c3e86cc1c..bbe9d4ab52 100644 --- a/packages/api/README.i18n.yaml +++ b/packages/api/README.i18n.yaml @@ -15,8 +15,8 @@ en: 1eb35b015a6c423f zh: a8a3dceee2c9e943 /api-remote-api-layers/packages: - en: 9bf51a5550fd4853 - zh: 4d13dd77d86f7f97 + en: 37388d921f2f47cf + zh: 24eb674c14dc2c6d /api-remote-api-layers/related-documentation: en: 507116ae09161df1 zh: f5e538ac5fdaf454 diff --git a/packages/api/README.md b/packages/api/README.md index 691a56856d..9f490b8134 100644 --- a/packages/api/README.md +++ b/packages/api/README.md @@ -28,6 +28,7 @@ The packages below provide the Remote layer; the package READMEs own the exhaust |---|---|---| | [`remotes/`](remotes/README.md) | Chooses which Host capabilities and events the Client can consume. | — | | [`gateway/`](gateway/README.md) | Carries typed unary calls, multiplexed streams with their Client uplinks, and forwarded Host events. | `ctx.typertGateway` / `ctx.remote` | +| [`remote-access/`](remote-access/README.md) | Decides which Remote methods non-owner Peers may call, filters list results, and streams each Peer's access snapshot. | `ctx.remoteAccess` / `ctx.remote.remoteAccess` | | [`job-controller/`](job-controller/README.md) | Streams one background job's observation record to the Client. | `ctx.jobController` / `ctx.remote.job` | | [`session-controller/`](session-controller/README.md) | Owns Session commands, history streams, live control state, and Agent/Session identity policy. | `ctx.sessionController` / `ctx.remote.session` | | [`settings-controller/`](settings-controller/README.md) | Owns the configuration-surface reads and writes over the settings-domain seams. | `ctx.settingsController`, `ctx.credentialsController` / `ctx.remote.settings`, `ctx.remote.credentials` | diff --git a/packages/api/README.zh.md b/packages/api/README.zh.md index 9303fbaae7..3d7ae60113 100644 --- a/packages/api/README.zh.md +++ b/packages/api/README.zh.md @@ -28,6 +28,7 @@ kind: "package-group" |---|---|---| | [`remotes/`](remotes/README.zh.md) | 决定 Client 可以消费哪些 Host 能力与事件。 | — | | [`gateway/`](gateway/README.zh.md) | 承载类型化一元调用、带 Client 上行的多路复用流,以及转发的 Host 事件。 | `ctx.typertGateway` / `ctx.remote` | +| [`remote-access/`](remote-access/README.zh.md) | 决定非 owner Peer 可以调用哪些 Remote 方法,过滤列表结果,并为每个 Peer 推送其访问快照。 | `ctx.remoteAccess` / `ctx.remote.remoteAccess` | | [`job-controller/`](job-controller/README.zh.md) | 把一个后台任务的观测 record 流式送到 Client。 | `ctx.jobController` / `ctx.remote.job` | | [`session-controller/`](session-controller/README.zh.md) | 拥有会话命令、历史记录流、实时控制状态与 Agent/Session 身份策略。 | `ctx.sessionController` / `ctx.remote.session` | | [`settings-controller/`](settings-controller/README.zh.md) | 拥有 settings 域各 seam 之上的配置界面读写。 | `ctx.settingsController`、`ctx.credentialsController` / `ctx.remote.settings`、`ctx.remote.credentials` | diff --git a/packages/api/gateway/README.i18n.yaml b/packages/api/gateway/README.i18n.yaml index e8cf3b7f4b..4b8cb2e1f4 100644 --- a/packages/api/gateway/README.i18n.yaml +++ b/packages/api/gateway/README.i18n.yaml @@ -15,11 +15,11 @@ en: d9859219cdb0f367 zh: 2a3cfa8cb786e327 /deepseek-ai-dsh-api-gateway/host-service-typertgatewayservice-ctx-key-typertgateway: - en: 5179435c09004549 - zh: c904462396531947 + en: 6019f9b47d9ab474 + zh: b72d9c378bdafffb /deepseek-ai-dsh-api-gateway/client-service-clientremote-ctx-key-remote: en: c106f0ca3e547f84 - zh: 833e30d7151c41c7 + zh: 8eadd3bffde9e49d /deepseek-ai-dsh-api-gateway/model-experience: en: 6cb309d3378f035d zh: 998042a02ee32850 @@ -27,8 +27,8 @@ en: af5abeee32f2d93b zh: b320bb727a223a13 /deepseek-ai-dsh-api-gateway/known-limitations-and-deferred-work: - en: 3ade2903f40a0a98 - zh: 9af1d557fefdbd89 + en: f817580f28d30dc2 + zh: 5262b8fbcf9d65e8 /deepseek-ai-dsh-api-gateway/known-limitations-and-deferred-work/dev-note: en: 10727471030aee01 zh: e6bee82abd12127d diff --git a/packages/api/gateway/README.md b/packages/api/gateway/README.md index 41cfc3a324..e31fa33e80 100644 --- a/packages/api/gateway/README.md +++ b/packages/api/gateway/README.md @@ -30,19 +30,23 @@ Strict mode reads generated invocation descriptors from `ctx.typert.local`. Look The Host entry registers a trusted-host interceptor on Connection's shared `/api` FetchHandler. Connection passes this composite handler through its HTTP bridge; the handler dispatches claimed endpoints to Gateway and returns 404 for unclaimed requests unless an exact Fetch route owns them. Direct `invoke()` calls preserve business errors; `TypertGatewayError` is a `RemoteError` subclass whose `gateway/*` codes name the failures owned by dispatch, binding, providers, lookup, Context, arguments, codecs, and the uplink: `gateway/uplink-overflow` for an uplink whose buffered frames exceed `streamInboxBytes`, `gateway/protocol` for an uplink item after `end`, and the codec code `gateway/input-invalid` for a rejected uplink item. A resolver that refuses on policy grounds — a cold-resume failure or an ownership fence — throws its own `RemoteError`, and the code it chose reaches the caller unchanged. +Every call names the Peer it speaks for: `InvokeRemoteRequest.peer` and the `peer` argument of `wireStream.open()` are required, and the Gateway never supplies a default Peer. Dispatch from a disposed Peer fails with `gateway/peer-disposed`, for unary calls, streams, `$events`, and `$events/result` alike, and wire paths check it before they read the payload. The Host service injects `remoteAccess` and does not start without [`dsh-api-remote-access`](../remote-access/README.md). After the endpoint and argument-field checks, every call validates its JSON arguments once against their Typert-compiled types, failing with `gateway/input-invalid`, and dispatch reuses the validated values. For a call whose Peer principal is not the owner, after that validation and before lookup and Context resolution, the Gateway resolves the resources the call touches and asks `ctx.remoteAccess.decide({ principal, action, resources, args })` with the endpoint as the action and the validated arguments as `args`, or refuses with reason `resource-invalid` without asking when the resources are invalid; a refused call throws `remote-access/denied` with details `{ action, reason }`, so a refused call fails before it activates any Session. An owner call is dispatched without resource resolution or a decision. + +Resources are read from the arguments validated against their Typert-compiled types, which drop object fields those types do not declare, while lookup and Context identities keep their wire values. For each resource kind, a reader in the method's `@Remote({ resources })` or `@RemoteScope(key, { resources })` options wins; it is called with the method's positional arguments, where lookup positions hold wire identities and the cancellation position holds the call's signal, and is read from the class metadata of the root Service instance. Otherwise every lookup parameter whose provider declares that kind through `resource` contributes its wire identity, an omitted optional one contributing none, and a Context method whose Host Context adapter declares that kind contributes its Context identity, read from the arguments before the Context is resolved. Otherwise the kind's default field registered in `typert.resources` is read from a top-level argument of that name or, failing that, from the first object argument. A reader that throws or returns an empty list, a default field holding an array, or an identity, including a Context identity, that is not a non-empty string makes the resolution `invalid`; a call that names no resource touches the `global` resource alone, and a call that names resources touches only those. A forwarded event carries every resource its entry declares, so there a reader returning `undefined` also makes the resolution `invalid`, while an entry without readers touches `global`. + A cancellation-aware Remote method declares `signal: AbortSignal` as its final Host parameter. The signal is descriptor metadata rather than a wire argument: Connection supplies it to the Gateway, and the Gateway injects it after decoded business parameters. SRC recognizes the reserved final name, while strict generation additionally requires the global `AbortSignal` type. -A stream Remote uses `@Remote({ mode: 'stream' })` and returns an `Iterable` or `AsyncIterable`. `ctx.typertGateway.stream()` applies the same endpoint, argument, lookup, and cancellation checks as unary invocation, then returns a cancellation-aware iterable over the business items. The Client opens the Gateway-owned `/api/remote.mux` WebSocket when its plugin activates and keeps it connected while idle. Connection owns the retry schedule; before each retry it asks the mux to cancel any candidate or active socket and make exactly one fresh physical attempt. The Host sends Ping control frames at the configured `websocketHeartbeatIntervalMs` interval (two seconds by default), and the browser answers Pong at the WebSocket protocol layer, so idle network intermediaries see traffic without any Remote stream frame. A socket that has not answered the previous Ping is terminated at the next interval. Independently cancellable logical streams share that socket; an in-process Connection carrier provides equivalent streams directly without opening it. +A stream Remote uses `@Remote({ mode: 'stream' })` and returns an `Iterable` or `AsyncIterable`. `ctx.typertGateway.stream()` applies the same endpoint, argument, lookup, and cancellation checks as unary invocation, then returns a cancellation-aware iterable over the business items. The Client opens the Gateway-owned `/api/remote.mux` WebSocket when its plugin activates and keeps it connected while idle. Connection owns the retry schedule; before each retry it asks the mux to cancel any candidate or active socket and make exactly one fresh physical attempt. The Host sends Ping control frames at the configured `websocketHeartbeatIntervalMs` interval (two seconds by default), and the browser answers Pong at the WebSocket protocol layer, so idle network intermediaries see traffic without any Remote stream frame. A Pong, any received frame, or a downlink write that waited in the full local send buffer and then completed counts as peer activity; a socket with no activity across two Ping intervals is terminated, so a congested link that delays the Pong behind data stays connected. Independently cancellable logical streams share that socket; an in-process Connection carrier provides equivalent streams directly without opening it. When the launcher supplies `ctx.appReady`, Gateway registers the WebSocket upgrade route only after successful application startup. Earlier connection attempts remain carrier failures handled by Connection's retry policy, so a restarting Host cannot accept streams while its controllers are still initializing. Unloading cancels a pending readiness subscription and closes any registered carrier. Embedded Hosts without `appReady` register immediately and own their startup ordering; in-process invocation is unchanged. -Every stream also carries a Client-to-Host uplink on the same logical stream. A method declares the uplink item type as the second type argument of its return type, `RemoteStream` from [`dsh-typert-protocol`](../../typert/protocol/README.md); `In` defaults to `never`, and a method that returns `Iterable`, `AsyncIterable`, or `RemoteStream` declares that it reads none. The running method reads its call as `this.ctx.invocation`: the Gateway derives the receiver from a Context carrying that `RemoteInvocation` — `request`, `service`, `peer`, `signal`, and `uplink()` — so nothing enters the parameter list, and `ctx.invocation` is `undefined` on a Context no Remote call derived. `uplink()` is available once per call. With a descriptor codec it decodes every item before delivery; without one — an SRC method, or a method whose `In` is `never` — it delivers JSON-safe `unknown` values. The Client sends each item as an `item` frame on the open logical stream, a top-level `undefined` item as an `item` frame without `value`, and its half-close as an `end` frame; the method observes the half-close as the end of the `uplink()` iteration and cancellation through `signal`. A rejected item fails the whole stream with `gateway/input-invalid` (field `uplink`). Uplink items are validated one by one at the Host because they arrive from the browser; downlink items are values the Host method produced and pass through unchecked. When the method finishes its downlink the Gateway returns the uplink iterator and drops unread items; items sent to a method that never takes its uplink wait in the inbox until then. Each logical stream buffers at most `streamInboxBytes` (262144 by default) of uplink frame bytes; overflow fails the stream with `gateway/uplink-overflow`, and an item after `end` fails it with `gateway/protocol`, in both cases without closing the socket. `item`, `end`, and `cancel` frames for a stream id the Host has already finished are dropped; only a duplicate `open` closes the socket. Gateway-owned streams such as `$events` return their uplink as soon as they open, so their `item` frames are dropped instead of buffered. A unary method may also call `uplink()`; its items are readable only while the method runs. Every stream speaks for the operator Peer: a WebSocket's streams for the Peer Connection admitted at upgrade, whose scope disposal closes the socket, and an in-process carrier's for the operator directly. +Every stream also carries a Client-to-Host uplink on the same logical stream. A method declares the uplink item type as the second type argument of its return type, `RemoteStream` from [`dsh-typert-protocol`](../../typert/protocol/README.md); `In` defaults to `never`, and a method that returns `Iterable`, `AsyncIterable`, or `RemoteStream` declares that it reads none. The running method reads its call as `this.ctx.invocation`: the Gateway derives the receiver from a Context carrying that `RemoteInvocation` — `request`, `service`, `peer`, `signal`, and `uplink()` — so nothing enters the parameter list, and `ctx.invocation` is `undefined` on a Context no Remote call derived. `uplink()` is available once per call. With a descriptor codec it decodes every item before delivery; without one — an SRC method, or a method whose `In` is `never` — it delivers JSON-safe `unknown` values. The Client sends each item as an `item` frame on the open logical stream, a top-level `undefined` item as an `item` frame without `value`, and its half-close as an `end` frame; the method observes the half-close as the end of the `uplink()` iteration and cancellation through `signal`. A rejected item fails the whole stream with `gateway/input-invalid` (field `uplink`). Uplink items are validated one by one at the Host because they arrive from the browser; downlink items are values the Host method produced and pass through unchecked. When the method finishes its downlink the Gateway returns the uplink iterator and drops unread items; items sent to a method that never takes its uplink wait in the inbox until then. Each logical stream buffers at most `streamInboxBytes` (262144 by default) of uplink frame bytes; overflow fails the stream with `gateway/uplink-overflow`, and an item after `end` fails it with `gateway/protocol`, in both cases without closing the socket. `item`, `end`, and `cancel` frames for a stream id the Host has already finished are dropped; only a duplicate `open` closes the socket. Gateway-owned streams such as `$events` return their uplink as soon as they open, so their `item` frames are dropped instead of buffered. A unary method may also call `uplink()`; its items are readable only while the method runs. Every stream speaks for an explicitly named Peer: a WebSocket's streams for the Peer Connection admitted at upgrade, whose scope disposal closes the socket, and an in-process carrier's for the Peer its caller passes. A stream method's `signal` also aborts when that Peer is disposed, so the stream ends with `gateway/cancelled`, and a `$events` stream ends with its Peer; a unary call already running is not cancelled. Stream cancellation and stopping keep read-local state, not a history of delivered items, on the Host downlink, Host uplink, and Client uplink pump. Completing the downlink also wakes pending uplink reads. -Host composition can register one application event source through `registerRemoteEvents()`. Gateway reserves the internal `$events` logical endpoint for that source, accepts only empty `args`, and aborts streams opened by the registration when the source is withdrawn. API Remotes owns the event selection, argument validation, per-Client queues, and the Host home sent in the opening `{ type: 'ready', clientId, host: { home } }` frame. Its source factory attaches incremental listeners synchronously, so the Client publishes the generation and starts baseline reads only after incremental delivery is ready. +Host composition can register one application event source through `registerRemoteEvents()`. Gateway reserves the internal `$events` logical endpoint for that source, accepts only empty `args`, and aborts streams opened by the registration when the source is withdrawn. API Remotes owns the event selection, argument validation, per-Client queues, and the Host home sent in the opening `{ type: 'ready', clientId, host: { home } }` frame. Its source factory attaches incremental listeners synchronously, so the Client publishes the generation and starts baseline reads only after incremental delivery is ready. A `$events` stream whose Peer principal is not the owner never receives a scoped waterfall it could answer; it receives a broadcast emit frame, whole and untrimmed, only when `ctx.remoteAccess.decide({ principal, action: 'event:', resources })` allows it on the resources the frame's readers name, or on `global` for a frame without readers; the resources are resolved once per frame, a frame whose resources are invalid reaches no non-owner stream without a decision, and a denied frame is dropped for that stream alone. An `$events/result` reply from such a Peer fails with `gateway/invocation-unavailable`. -`hasLiveClient()` checks the existing `$events` records for an uncancelled stream. A cancelled stream does not count even while its iterator is still awaiting cleanup; a bare WebSocket does not count. This synchronous observation does not guarantee delivery or Client provider readiness. +`hasLiveClient()` checks the existing `$events` records for an uncancelled stream whose Peer principal is the owner, the only kind that can answer a forwarded waterfall. A cancelled stream does not count even while its iterator is still awaiting cleanup; a bare WebSocket does not count. This synchronous observation does not guarantee delivery or Client provider readiness. ## Client service: `ClientRemote` (ctx key: `remote`) @@ -86,8 +90,9 @@ No direct effect; invoked business Services own any model-visible result. - `$stream()` supervises carrier replacement but does not infer replay semantics; each domain owns its resume cursor or replacement-baseline validation and normal-end classification. Connection generations reopen the internal `$events` stream; one-way notifications are not replayed, while pending scoped waterfalls retain their event id across replay. - Lookup resolvers are configured per key; an individual Remote parameter or endpoint cannot currently select a live-only policy under the same `agent`/`session` key. - Forwarded events reach `$on` without business-payload projection or redaction. Ordinary notifications are not replayed after reconnect; Agent-scoped waterfalls project only the top-level Agent identity needed to select the Client Context and carry their own pending lifetime. -- `websocketHeartbeatIntervalMs` is both the Ping cadence and the Pong deadline. The Host terminates a peer that does not answer before the next interval, so a deployment whose event loop or network can stall longer than this interval must raise it. -- Uplinks have no flow control beyond the bounded Host inbox: a Client that sends faster than the method reads, or that sends to a method never taking its uplink, fails its stream with `gateway/uplink-overflow`, and no uplink item is replayed across carrier generations; a domain that must resume an uplink carries its own acknowledgement cursor in the reopened request. +- `websocketHeartbeatIntervalMs` is both the Ping cadence and the activity deadline. The Host terminates a peer that shows no activity for two intervals, so a deployment whose event loop or network can stall longer than this interval must raise it. +- A completed queued write shows that the TCP peer acknowledged data. Behind a buffering reverse proxy that peer is the proxy, so a vanished Client during sustained downlink is detected only after the proxy stops accepting data. +- The Gateway exposes each WebSocket stream's inbox capacity and JSON envelope cost through `invocation.uplinkWindow`; carriers without an encoded-frame inbox limit leave it absent. Domain protocols can advertise this window and use acknowledgements to bound in-flight bytes. The Gateway itself does not issue credits: a Client that sends faster than the method reads, or that sends to a method never taking its uplink, fails its stream with `gateway/uplink-overflow`, and no uplink item is replayed across carrier generations; a domain that must resume an uplink carries its own acknowledgement cursor in the reopened request. diff --git a/packages/api/gateway/README.zh.md b/packages/api/gateway/README.zh.md index c73b0f45b6..4160546db6 100644 --- a/packages/api/gateway/README.zh.md +++ b/packages/api/gateway/README.zh.md @@ -30,19 +30,23 @@ kind: "package-reference" Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandler 上注册 trusted-host interceptor。Connection 把这个复合 handler 交给 HTTP bridge;handler 将已认领 endpoint 分发给 Gateway,未认领且没有精确 Fetch 路由负责的请求返回 404。直接调用 `invoke()` 会保留业务错误;`TypertGatewayError` 是 `RemoteError` 的子类,其 `gateway/*` 码命名了分发、绑定、提供方、查找、Context、参数、编解码器以及上行各自负责的故障:缓冲帧超过 `streamInboxBytes` 的上行报 `gateway/uplink-overflow`,`end` 之后的上行项报 `gateway/protocol`,被 codec 拒绝的上行项报编解码码 `gateway/input-invalid`。因策略而拒绝的解析器——冷恢复失败或 ownership fence——抛出自己的 `RemoteError`,它选定的码原样到达调用方。 +每次调用都指明它所代表的 Peer:`InvokeRemoteRequest.peer` 与 `wireStream.open()` 的 `peer` 参数都是必填项,Gateway 从不提供默认 Peer。来自已释放 Peer 的分发以 `gateway/peer-disposed` 失败,一元调用、流、`$events` 与 `$events/result` 一律如此,wire 路径在读取 payload 之前检查。Host 服务注入 `remoteAccess`,没有 [`dsh-api-remote-access`](../remote-access/README.zh.md) 就不启动。每次调用在 endpoint 与参数字段检查之后按 Typert 编译类型校验一次 JSON 参数,失败为 `gateway/input-invalid`,分派复用校验后的值。对 Peer 的 principal 不是 owner 的调用,Gateway 在该校验之后、lookup 与 Context 解析之前解析调用访问的资源,并以端点为 action、校验后的参数为 `args` 询问 `ctx.remoteAccess.decide({ principal, action, resources, args })`,资源非法时不询问而以原因 `resource-invalid` 拒绝;被拒的调用抛出 details 为 `{ action, reason }` 的 `remote-access/denied`,因此被拒绝的调用在激活任何 Session 之前就失败。owner 的调用不解析资源、不询问判定即分派。 + +资源从经 Typert 编译类型校验后的参数中读取,校验会移除类型未声明的对象字段,lookup 与 Context 身份保持 wire 值。对每个资源种类,方法 `@Remote({ resources })` 或 `@RemoteScope(key, { resources })` options 中的读取函数优先;它以方法的位置参数调用(lookup 位为 wire 身份,取消参数位为调用的 signal),从根 Service 实例的类元数据读取。否则 provider 经 `resource` 声明了该种类的每个 lookup 参数贡献其 wire 身份,省略的可选 lookup 参数不贡献;Host Context adapter 声明了该种类的 Context 方法贡献其 Context 身份,在解析 Context 之前直接从参数读取。再否则从同名顶层参数读取该种类在 `typert.resources` 中登记的默认字段,没有同名参数时读第一个对象参数的同名字段。读取函数抛错或返回空列表、默认字段读到数组、或某个身份(含 Context 身份)不是非空字符串时,解析结果为 `invalid`;不指明任何资源的调用只访问 `global` 资源,指明了资源的调用只访问这些资源。转发事件携带其条目声明的每个资源,因此在事件路径上读取函数返回 `undefined` 同样使解析结果为 `invalid`,而没有读取函数的条目访问 `global`。 + 支持取消的 Remote 方法会把 `signal: AbortSignal` 声明为最后一个 Host 参数。signal 是 descriptor 元数据,而不是 wire 参数:Connection 将它提供给 Gateway,Gateway 则在已解码的业务参数之后注入它。SRC 识别这个保留的末位参数名,严格生成还要求它具有全局 `AbortSignal` 类型。 -流式 Remote 使用 `@Remote({ mode: 'stream' })` 并返回 `Iterable` 或 `AsyncIterable`。`ctx.typertGateway.stream()` 执行与一元调用相同的 endpoint、参数、lookup 和取消校验,再返回可取消的业务项 iterable。Client 插件激活时打开 Gateway 自有的 `/api/remote.mux` WebSocket,并让它在空闲时保持连接。Connection 拥有重试调度;每次 retry 前,它要求 mux 取消候选或活动 socket,并且只做一次全新的物理连接尝试。Host 按配置的 `websocketHeartbeatIntervalMs` 间隔(默认 2 秒)发送 Ping 控制帧,浏览器在 WebSocket 协议层自动回复 Pong,使空闲网络中间层持续看到流量,而不新增 Remote 流帧。若 socket 尚未回复上一次 Ping,Host 会在下一间隔终止它。可独立取消的逻辑流共享这条连接;进程内 Connection 载体直接提供等价的流,不打开该 WebSocket。 +流式 Remote 使用 `@Remote({ mode: 'stream' })` 并返回 `Iterable` 或 `AsyncIterable`。`ctx.typertGateway.stream()` 执行与一元调用相同的 endpoint、参数、lookup 和取消校验,再返回可取消的业务项 iterable。Client 插件激活时打开 Gateway 自有的 `/api/remote.mux` WebSocket,并让它在空闲时保持连接。Connection 拥有重试调度;每次 retry 前,它要求 mux 取消候选或活动 socket,并且只做一次全新的物理连接尝试。Host 按配置的 `websocketHeartbeatIntervalMs` 间隔(默认 2 秒)发送 Ping 控制帧,浏览器在 WebSocket 协议层自动回复 Pong,使空闲网络中间层持续看到流量,而不新增 Remote 流帧。Pong、收到的任意帧,或曾在已满的本机发送缓冲区中等待后完成的下行写入,都视为对端活动;连续两个 Ping 间隔没有活动的 socket 会被终止,因此 Pong 被数据阻塞在拥塞链路上时连接仍会保持。可独立取消的逻辑流共享这条连接;进程内 Connection 载体直接提供等价的流,不打开该 WebSocket。 启动器提供 `ctx.appReady` 时,Gateway 仅在应用成功启动后注册 WebSocket 升级路由。此前的连接尝试仍属于载体故障,由 Connection 的重试策略处理,因此重启中的 Host 不会在控制器仍在初始化时接受流。卸载会取消尚未触发的就绪订阅,并关闭已注册的载体。不提供 `appReady` 的嵌入式 Host 会立即注册,并自行负责启动顺序;进程内调用不变。 -每条流还在同一条逻辑流上携带从 Client 到 Host 的上行。方法把上行项类型声明为返回类型的第二个类型参数——来自 [`dsh-typert-protocol`](../../typert/protocol/README.zh.md) 的 `RemoteStream`;`In` 缺省为 `never`,返回 `Iterable`、`AsyncIterable` 或 `RemoteStream` 的方法即声明自己不读上行。运行中的方法通过 `this.ctx.invocation` 读取本次调用:Gateway 从携带该 `RemoteInvocation`(`request`、`service`、`peer`、`signal` 与 `uplink()`)的 Context 派生接收者,因此没有任何东西进入参数列表,而非 Remote 调用派生的 Context 上 `ctx.invocation` 为 `undefined`。`uplink()` 每次调用只能取一次。描述符带 codec 时它在交付前逐项解码;不带时——SRC 方法,或 `In` 为 `never` 的方法——它交付经 JSON 安全校验的 `unknown` 值。Client 在已打开的逻辑流上把每一项作为 `item` 帧发送(顶层 `undefined` 项是不带 `value` 的 `item` 帧),以 `end` 帧表示半关闭;方法通过 `uplink()` 迭代结束感知半关闭,通过 `signal` 感知取消。被拒绝的项使整条流以 `gateway/input-invalid`(字段 `uplink`)失败。上行项在 Host 逐项校验,因为它们来自浏览器;下行项是 Host 方法产出的值,不经校验原样透传。方法结束下行时 Gateway 调用 uplink 迭代器的 return 并丢弃未读的项;发给从未取用 uplink 的方法的项在 inbox 里等到那时为止。每条逻辑流最多缓冲 `streamInboxBytes`(默认 262144)字节的上行帧;超限使流以 `gateway/uplink-overflow` 失败,`end` 之后的项使流以 `gateway/protocol` 失败,两种情况都不关闭 socket。发给 Host 已结束的流 id 的 `item`、`end` 与 `cancel` 帧被丢弃;只有重复的 `open` 会关闭 socket。`$events` 这类 Gateway 自有的流在打开时立即 return 其 uplink,因此它们的 `item` 帧被丢弃而不缓冲。一元方法也可以调用 `uplink()`;其项只在方法运行期间可读。每条流都代表操作者 Peer:WebSocket 上的流代表 Connection 在升级时接纳的 Peer,该 scope 释放时 socket 关闭;进程内载体的流直接代表操作者。 +每条流还在同一条逻辑流上携带从 Client 到 Host 的上行。方法把上行项类型声明为返回类型的第二个类型参数——来自 [`dsh-typert-protocol`](../../typert/protocol/README.zh.md) 的 `RemoteStream`;`In` 缺省为 `never`,返回 `Iterable`、`AsyncIterable` 或 `RemoteStream` 的方法即声明自己不读上行。运行中的方法通过 `this.ctx.invocation` 读取本次调用:Gateway 从携带该 `RemoteInvocation`(`request`、`service`、`peer`、`signal` 与 `uplink()`)的 Context 派生接收者,因此没有任何东西进入参数列表,而非 Remote 调用派生的 Context 上 `ctx.invocation` 为 `undefined`。`uplink()` 每次调用只能取一次。描述符带 codec 时它在交付前逐项解码;不带时——SRC 方法,或 `In` 为 `never` 的方法——它交付经 JSON 安全校验的 `unknown` 值。Client 在已打开的逻辑流上把每一项作为 `item` 帧发送(顶层 `undefined` 项是不带 `value` 的 `item` 帧),以 `end` 帧表示半关闭;方法通过 `uplink()` 迭代结束感知半关闭,通过 `signal` 感知取消。被拒绝的项使整条流以 `gateway/input-invalid`(字段 `uplink`)失败。上行项在 Host 逐项校验,因为它们来自浏览器;下行项是 Host 方法产出的值,不经校验原样透传。方法结束下行时 Gateway 调用 uplink 迭代器的 return 并丢弃未读的项;发给从未取用 uplink 的方法的项在 inbox 里等到那时为止。每条逻辑流最多缓冲 `streamInboxBytes`(默认 262144)字节的上行帧;超限使流以 `gateway/uplink-overflow` 失败,`end` 之后的项使流以 `gateway/protocol` 失败,两种情况都不关闭 socket。发给 Host 已结束的流 id 的 `item`、`end` 与 `cancel` 帧被丢弃;只有重复的 `open` 会关闭 socket。`$events` 这类 Gateway 自有的流在打开时立即 return 其 uplink,因此它们的 `item` 帧被丢弃而不缓冲。一元方法也可以调用 `uplink()`;其项只在方法运行期间可读。每条流都代表一个显式指明的 Peer:WebSocket 上的流代表 Connection 在升级时接纳的 Peer,该 scope 释放时 socket 关闭;进程内载体的流代表其调用方传入的 Peer。该 Peer 释放时,流方法的 `signal` 也随之中止,流以 `gateway/cancelled` 结束,`$events` 流也随其 Peer 结束;已在运行的一元调用不会被取消。 Host 下行、Host 上行和 Client 上行泵的取消与停止状态归属单次读取,不保留已交付项的历史。下行结束也会唤醒尚未完成的上行读取。 -Host 组合可通过 `registerRemoteEvents()` 注册唯一的应用事件 source。Gateway 为它保留内部 `$events` logical endpoint,只接受空 `args`,并在 source 撤回时中止该注册打开的流。事件名单、参数校验、每个 Client 的队列及 opening `{ type: 'ready', clientId, host: { home } }` frame 中的 Host home 由 API Remotes 拥有。source factory 在返回 iterable 前同步挂好增量 listener,因此 Client 只在增量投递就绪后发布 generation 并开始 baseline 读取。 +Host 组合可通过 `registerRemoteEvents()` 注册唯一的应用事件 source。Gateway 为它保留内部 `$events` logical endpoint,只接受空 `args`,并在 source 撤回时中止该注册打开的流。事件名单、参数校验、每个 Client 的队列及 opening `{ type: 'ready', clientId, host: { home } }` frame 中的 Host home 由 API Remotes 拥有。source factory 在返回 iterable 前同步挂好增量 listener,因此 Client 只在增量投递就绪后发布 generation 并开始 baseline 读取。Peer 的 principal 不是 owner 的 `$events` 流从不收到它可以作答的 scoped waterfall(瀑布式事件);只有当 `ctx.remoteAccess.decide({ principal, action: 'event:', resources })` 在该帧读取函数指明的资源上(帧没有读取函数时为 `global`)允许时,才整条、不裁剪地收到广播 emit 帧;每帧只解析一次资源,资源非法的帧不经判定即不送达任何非 owner 流,被拒的帧只对该流丢弃。这类 Peer 的 `$events/result` 回复以 `gateway/invocation-unavailable` 失败。 -`hasLiveClient()` 检查已有 `$events` 记录中是否有未取消的流。已取消的流即使尚未完成 iterator 清理也不计入,单独的 WebSocket 也不计入。这一同步观察不保证后续投递成功或 Client Provider 已就绪。 +`hasLiveClient()` 检查已有 `$events` 记录中是否有 Peer principal 为 owner 的未取消流,只有这类流能回答转发的 waterfall。已取消的流即使尚未完成 iterator 清理也不计入,单独的 WebSocket 也不计入。这一同步观察不保证后续投递成功或 Client Provider 已就绪。 ## Client 服务:`ClientRemote`(ctx key:`remote`) @@ -59,7 +63,7 @@ Host 组合可通过 `registerRemoteEvents()` 注册唯一的应用事件 source `ctx.remote.$stream()` 返回跨越多个物理载体代次的单消费方 `RemoteStream`。Host 仍在线时,它允许一次立即重试;Host 离线时,它等待下一代连接,并为每个流项标注物理代次。领域消费方校验并接受各代次的 opening value;业务与协议错误仍然终止流。一切终态失败离开本面时都是 `RemoteError`,包括重试耗尽和在 opening value 之前就结束的代次,因此流消费方与一元调用方用同一种方式判别。`RemoteStreamCarrierError` 命名的是可重试的物理丢失,它只作为 `carrierFailed` 回调参数到达领域,绝不作为终态结果。`RemoteSnapshotStream` 在此之上规定每代由一个初始快照和后续 delta 组成。`RemoteJournalStream` 基于领域提供的 entry 闭区间提供 follow-before-page、分页、重连追赶与缺口修复;它丢弃完整重复项,并拒绝缺口、倒置区间和部分重叠。领域还可以携带无 cursor 的通知:通知绝不推进或修复持久 cursor,在缺口修复期间收到的通知只会在 replacement page 提交后发布。若更新代次取代该修复,旧代次 held notification 会与其 page 一同丢弃。对任一种流执行 dispose(资源释放)时,系统会取消该流的请求,并在活动 iterator 完全停止后完成资源释放。 -`ctx.remote.$on()` 订阅一条被转发的 Host 事件。它的合法键恰好等于 Host 装配声明的转发选择,listener 类型就是事件所属包自己的 Cordis `Events` 声明,因此不存在会与之漂移的第二份签名。每个订阅归属调用方 fiber,并随该 fiber 一起消失。Client Remote 服务激活时就把 `$events` pump 注册为 Connection generation source,无论当前是否存在 `$on` listener。浏览器使用 Remote mux,进程内组合使用 `connection.rpc.open`;opening `ready` 项建立 Connection generation 并提供 Host 信息。物理 carrier 失败、Remote 流故障、意外正常结束、非 ready 首项或畸形事件项都会终止该 generation,由 Connection 按持续且间隔封顶的带抖动指数退避重开。普通通知按注册顺序运行并隔离 listener 失败;Agent-scoped waterfall(瀑布式事件)允许 listener 返回结果、调用 `next()` 或拒绝,Gateway 再通过现有 HTTP 一元载体回送该结果。 +`ctx.remote.$on()` 订阅一条被转发的 Host 事件。它的合法键恰好等于 Host 装配声明的转发选择,listener 类型就是事件所属包自己的 Cordis `Events` 声明,因此不存在会与之漂移的第二份签名。每个订阅归属调用方 fiber,并随该 fiber 一起消失。Client Remote 服务激活时就把 `$events` pump 注册为 Connection generation source,无论当前是否存在 `$on` listener。浏览器使用 Remote mux,进程内组合使用 `connection.rpc.open`;opening `ready` 项建立 Connection generation 并提供 Host 信息。物理 carrier 失败、Remote 流故障、意外正常结束、非 ready 首项或畸形事件项都会终止该 generation,由 Connection 按持续且间隔封顶的带抖动指数退避重开。普通通知按注册顺序运行并隔离 listener 失败;Agent-scoped waterfall 允许 listener 返回结果、调用 `next()` 或拒绝,Gateway 再通过现有 HTTP 一元载体回送该结果。 Client waterfall 的 Context 解析保持同步。解析器可以返回借用的 Context 或 `TypertOwnedValue`;Gateway 仅在处理器使用和回复结算均结束后释放 owned value。Context 解析失败保留既有的记录错误并委托语义,处理器失败产生拒绝回复。取消会抑制迟到回复,但不会释放处理器仍在使用的 Context。每个 handler 都必须响应 `request.signal` 并在取消后结束;插件销毁与 Connection generation 替换会等待未结束的 handler 结算。Session Context 的获取本身不执行历史 I/O。 @@ -86,8 +90,9 @@ Client waterfall 的 Context 解析保持同步。解析器可以返回借用的 - `$stream()` 监督载体替换,但不推断回放语义;各领域自行拥有恢复 cursor 或替换 baseline 的校验,以及正常结束的分类。Connection generation 会重开内部 `$events` 流;单向通知不会重放,仍处于 pending 的 scoped waterfall 则沿用同一个 event id 重放。 - lookup 解析器按 key 配置;当前无法让单个 Remote 参数或 endpoint 在同一 `agent`/`session` key 下选择 live-only 策略。 - 被转发的事件到达 `$on` 时不做业务载荷投影或脱敏。普通通知在重连后不重放;Agent-scoped waterfall 只投影选择 Client Context 所需的顶层 Agent 身份,并自行携带 pending 生命周期。 -- `websocketHeartbeatIntervalMs` 同时是 Ping 周期和 Pong 截止时间。对端未在下一周期前回复时,Host 会终止连接;如果部署的事件循环或网络可能停顿超过该间隔,必须调大此配置。 -- 上行除了有界的 Host inbox 之外没有流控:Client 发送快于方法读取,或发给从未取用 uplink 的方法时,其流以 `gateway/uplink-overflow` 失败;上行项不会跨载体代际重放,需要恢复上行的领域在重开的请求里自带确认游标。 +- `websocketHeartbeatIntervalMs` 同时是 Ping 周期和活动截止时间。对端连续两个周期没有活动时,Host 会终止连接;如果部署的事件循环或网络可能停顿超过该间隔,必须调大此配置。 +- 排队写入完成只说明 TCP 对端确认了数据。经过带缓冲的反向代理时,该对端是代理,因此持续下行期间 Client 消失,要等代理不再接收数据后才会被检测到。 +- Gateway 通过 `invocation.uplinkWindow` 提供每条 WebSocket 流的 inbox 容量与 JSON 封装开销;没有编码帧 inbox 限额的载体省略该字段。领域协议可以公布该窗口并通过确认约束在途字节数。Gateway 本身不发放额度:Client 发送快于方法读取,或发给从未取用 uplink 的方法时,其流以 `gateway/uplink-overflow` 失败;上行项不会跨载体代际重放,需要恢复上行的领域在重开的请求里自带确认游标。 diff --git a/packages/api/gateway/package.json b/packages/api/gateway/package.json index d4469cf8cd..af26594447 100644 --- a/packages/api/gateway/package.json +++ b/packages/api/gateway/package.json @@ -64,10 +64,10 @@ "@deepseek-ai/dsh-typert-protocol": "workspace:*" }, "peerDependencies": { - "@deepseek-ai/cordis": "workspace:~", - "@deepseek-ai/dsh-client-connection": "workspace:*" + "@deepseek-ai/cordis": "workspace:~" }, "devDependencies": { + "@deepseek-ai/dsh-api-remote-access": "workspace:*", "@deepseek-ai/dsh-brand": "workspace:*", "@deepseek-ai/dsh-cmdline": "workspace:*", "@deepseek-ai/dsh-client-connection": "workspace:*", diff --git a/packages/api/gateway/src/access.ts b/packages/api/gateway/src/access.ts new file mode 100644 index 0000000000..d85ebb3e65 --- /dev/null +++ b/packages/api/gateway/src/access.ts @@ -0,0 +1,172 @@ +/** + * Resource resolution for non-owner access decisions: the resources one call + * or one forwarded event touches, read from Typert-validated arguments before + * any lookup or Context resolves them. + * @module @deepseek-ai/dsh-api-gateway/src/access + */ + +import type { + InvocationDescriptor, + RemoteAccessResource, + TypertRegistryContract, + TypertResourceReader, +} from '@deepseek-ai/dsh-typert-protocol' + +const GLOBAL: readonly RemoteAccessResource[] = Object.freeze([Object.freeze({ kind: 'global' })]) + +/** Resource ids one source produced for a kind, or `invalid` for a malformed value. */ +type ResourceIds = readonly unknown[] | 'invalid' + +/** + * Resolve the resources one call touches. For each resource kind, a + * `resources` reader of the method wins; otherwise every lookup parameter + * whose provider declares the kind contributes its wire resource id, and so + * does the Context identity of a Context method whose Host adapter declares + * the kind; otherwise the kind's default field registered in + * `typert.resources` is read from a top-level argument or, failing that, + * from the first object argument. + * @param typert - registry holding default fields, lookup providers, and Host Context adapters. + * @param readers - the method's `resources` readers, or `undefined` when it declares none. + * @param descriptor - receiver, parameter order, lookups, and cancellation slot of the method. + * @param args - named arguments matched to the descriptor's field set: JSON + * arguments as their Typert codecs validated them, lookup and Context + * identities as wire values. + * @param signal - value placed in the cancellation slot of the positional arguments. + * @returns the touched resources, the global resource alone when none; + * `undefined` when the resources are invalid: a reader throws or returns + * an empty list, a default field holds an array, or a resource id is not a + * non-empty string. + */ +export function callResources( + typert: Pick, + readers: Readonly> | undefined, + descriptor: Pick, + args: Readonly>, + signal: AbortSignal, +): readonly RemoteAccessResource[] | undefined { + // Each later source replaces an earlier one for its kind and keeps the kind's position. + const sources = new Map ResourceIds>() + for (const { kind, field } of typert.resources.entries()) { + sources.set(kind, () => { + // A default field names exactly one resource id; only a reader may return several. + const id = defaultFieldId(descriptor, args, field) + return Array.isArray(id) ? 'invalid' : id === undefined ? [] : [id] + }) + } + for (const [kind, ids] of lookupResources(typert, descriptor, args)) { + sources.set(kind, () => ids) + } + let positional: readonly unknown[] | undefined + for (const [kind, reader] of Object.entries(readers ?? {})) { + sources.set(kind, () => { + positional ??= [ + ...descriptor.parameters.map(parameter => args[parameter.wire]), + ...descriptor.cancellation === undefined ? [] : [signal], + ] + return read(reader, positional, []) + }) + } + const resources: RemoteAccessResource[] = [] + for (const [kind, ids] of sources) { + if (!collect(resources, kind, ids())) return undefined + } + return resources.length === 0 ? GLOBAL : resources +} + +/** + * Resolve the resources one forwarded event notification touches. An event + * carries every resource it declares, so a reader returning `undefined` + * makes the notification invalid instead of a global one. + * @param readers - the event entry's declared readers, or `undefined` when it declares none. + * @param args - the event's validated JSON arguments, passed to each reader as its positional arguments. + * @returns the global resource alone without readers, including an empty + * reader set; otherwise the touched resources; `undefined` when the + * resources are invalid: a resource id is missing or malformed. + */ +export function eventResources( + readers: Readonly> | undefined, + args: readonly unknown[], +): readonly RemoteAccessResource[] | undefined { + const declared = Object.entries(readers ?? {}) + if (declared.length === 0) return GLOBAL + const resources: RemoteAccessResource[] = [] + for (const [kind, reader] of declared) { + if (!collect(resources, kind, read(reader, args, 'invalid'))) return undefined + } + return resources +} + +/** + * The resource ids lookup parameters and the Context identity name, per + * resource kind: the Context identity first, then lookup parameters in order. + * An optional lookup parameter the call omits names no resource id; the + * Context identity is required. + */ +function lookupResources( + typert: Pick, + descriptor: Pick, + args: Readonly>, +): ReadonlyMap { + const ids = new Map() + const add = (kind: string, id: unknown): void => { + const list = ids.get(kind) + if (list === undefined) ids.set(kind, [id]) + else list.push(id) + } + const receiver = descriptor.invocation + if (receiver.kind === 'context') { + const kind = typert.contexts.getHost(receiver.context)?.resource + // oxlint-disable-next-line typescript/no-unnecessary-condition -- domain packages merge resource kinds into TypertResourceMap. + if (kind !== undefined) add(kind, args[receiver.wire]) + } + for (const parameter of descriptor.parameters) { + if (parameter.lookup === undefined) continue + const kind = typert.lookups.get(parameter.lookup)?.resource + const id = args[parameter.wire] + // oxlint-disable-next-line typescript/no-unnecessary-condition -- domain packages merge resource kinds into TypertResourceMap. + if (kind !== undefined && id !== undefined) add(kind, id) + } + return ids +} + +/** Run one reader with spread positional arguments: `undefined` yields `absent`, a list names several and must not be empty. */ +function read(reader: TypertResourceReader, args: readonly unknown[], absent: ResourceIds): ResourceIds { + let value: unknown + try { + value = reader(...args) + } catch { + // Any reader error means it cannot name its resources, so they are invalid. + return 'invalid' + } + if (value === undefined) return absent + if (!Array.isArray(value)) return [value] + return value.length === 0 ? 'invalid' : value +} + +/** Append one kind's resource ids; false when any is malformed. */ +function collect(resources: RemoteAccessResource[], kind: string, ids: ResourceIds): boolean { + if (ids === 'invalid') return false + for (const id of ids) { + if (typeof id !== 'string' || id.length === 0) return false + // Kinds come from registrations, lookups, Context adapters, and reader keys, all typed by the merged resource map. + resources.push({ kind, id } as RemoteAccessResource) + } + return true +} + +/** The default field: a top-level argument, else that field of the first object argument. */ +function defaultFieldId( + descriptor: Pick, + args: Readonly>, + field: string, +): unknown { + if (descriptor.parameters.some(parameter => parameter.wire === field)) return args[field] + for (const parameter of descriptor.parameters) { + const value = args[parameter.wire] + if (typeof value !== 'object' || value === null || Array.isArray(value)) continue + if (!Object.hasOwn(value, field)) return undefined + const id: unknown = Reflect.get(value, field) + return id + } + return undefined +} diff --git a/packages/api/gateway/src/index.ts b/packages/api/gateway/src/index.ts index e97a065076..294fe78698 100644 --- a/packages/api/gateway/src/index.ts +++ b/packages/api/gateway/src/index.ts @@ -7,11 +7,12 @@ import { randomUUID } from 'node:crypto' import { Context, Service, symbols } from '@deepseek-ai/cordis' -import { - OperatorPeer, - type ConnectionRpcAttachment, - type ConnectionRpcHandler, +import type { + ConnectionRpcAttachment, + ConnectionRpcHandler, } from '@deepseek-ai/dsh-client-connection' +import type { RemoteAccessDecision } from '@deepseek-ai/dsh-api-remote-access' +import { callResources, eventResources } from './access.ts' import { Deque } from '@deepseek-ai/dsh-deque' import type { WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' @@ -26,7 +27,10 @@ import { type InvocationDescriptor, type InvocationParameterDescriptor, type PeerScope, + type Principal, + type RemoteAccessResource, type RemoteInvocation, + type RemoteMethodMarker, type TypertCodec, type TypertGatewayBinding, } from '@deepseek-ai/dsh-typert-protocol' @@ -100,6 +104,7 @@ interface PreparedInvocation { /** Carrier inputs `GatewayInvocation.uplink()` decodes on first use. */ interface UplinkSource { + readonly window: RemoteInvocation['uplinkWindow'] /** Carrier items; an immediately ended iterable when the carrier has none. */ readonly source: AsyncIterable /** Descriptor codec, or the JSON-safety codec when the descriptor declares no uplink. */ @@ -120,6 +125,10 @@ interface RemoteEventClient { readonly queue: RemoteEventQueue readonly signal: AbortSignal readonly deliveries: Map + /** The stream's Peer principal: only the owner answers scoped waterfalls and receives every broadcast. */ + readonly principal: Principal + /** Only owner streams receive scoped waterfalls they could answer; other Peers receive admitted broadcasts only. */ + readonly answers: boolean } interface PendingRemoteEvent { @@ -197,7 +206,7 @@ export class TypertGatewayError extends RemoteError { * @typert service typertGateway */ export class TypertGatewayService extends Service implements TypertGateway { - static inject = ['typert'] + static inject = ['typert', 'remoteAccess'] static Config: z = z.object({ websocketHeartbeatIntervalMs: z.number().step(1).min(1).max(MAX_TIMER_DELAY_MS) .default(DEFAULT_WEBSOCKET_HEARTBEAT_INTERVAL_MS), @@ -212,7 +221,6 @@ export class TypertGatewayService extends Service implements TypertGateway { } private srcClaims: ReadonlySet | undefined - private inProcessOperator: PeerScope | undefined private remoteEvents: RegisteredRemoteEventSource | undefined private readonly remoteEventClients = new Map() private readonly pendingRemoteEvents = new Map() @@ -240,8 +248,8 @@ export class TypertGatewayService extends Service implements TypertGateway { ctx.inject(['connection', 'webServer'], (webCtx) => { const listen = (): void => { const mux = new RemoteStreamMuxServer( - (endpoint, payload, uplink, peer, control) => - this.openWireStream(endpoint, payload, uplink, peer, control.signal, control), + (endpoint, payload, uplink, peer, control, window) => + this.openWireStream(endpoint, payload, uplink, peer, control.signal, control, window), this.wireStream.failure, resolved.websocketHeartbeatIntervalMs, resolved.streamInboxBytes, @@ -278,12 +286,12 @@ export class TypertGatewayService extends Service implements TypertGateway { } /** - * Check for an active Client event stream. - * @returns whether a stream is open and has not been cancelled. + * Check for an active owner Client event stream, the only kind that can answer forwarded waterfalls. + * @returns whether an owner stream is open and has not been cancelled. */ hasLiveClient(): boolean { for (const client of this.remoteEventClients.values()) { - if (!client.signal.aborted) return true + if (client.answers && !client.signal.aborted) return true } return false } @@ -331,6 +339,18 @@ export class TypertGatewayService extends Service implements TypertGateway { return this.srcClaims.has(endpoint) } + /** + * The `resources` readers of a method, read from the root Service's class + * metadata so a Context method's receiver is not resolved before access is + * decided; `undefined` when the Service is absent or the method declares none. + */ + private resourceReaders(descriptor: InvocationDescriptor): RemoteMethodMarker['resources'] { + const receiver: unknown = this.ctx.get(descriptor.service) + if (!isObject(receiver)) return undefined + const member = descriptor.implementation ?? descriptor.method + return remoteMethods(originalOf(receiver)).find(marker => marker.method === member)?.resources + } + private collectSrcClaims(): ReadonlySet { const claims = new Set() for (const [serviceKey, definition] of Object.entries(this.ctx.reflect.props)) { @@ -429,6 +449,11 @@ export class TypertGatewayService extends Service implements TypertGateway { ): Promise { if (endpoint === REMOTE_EVENT_RESULT_ENDPOINT) { try { + assertPeerLive(peer, endpoint) + // oxlint-disable-next-line typescript/no-unnecessary-condition -- other packages merge non-owner kinds into PrincipalMap. + if (peer.principal.kind !== 'owner') { + throw new TypertGatewayError('gateway/invocation-unavailable', endpoint, 'only owner Peers answer forwarded Remote events') + } const result = parseRemoteEventResultPayload(payload) const client = this.remoteEventClients.get(result.clientId) if (client === undefined) { @@ -447,34 +472,24 @@ export class TypertGatewayService extends Service implements TypertGateway { endpoint: string, payload: unknown, uplink: AsyncIterable, - peer: PeerScope | undefined, + peer: PeerScope, signal: AbortSignal, control: AbortController, + uplinkWindow?: RemoteInvocation['uplinkWindow'], ): Promise> { if (endpoint === REMOTE_EVENT_STREAM_ENDPOINT) { // A Gateway-owned stream reads no uplink: releasing it now keeps its items out of the bounded inbox. releaseUplink(uplink) - return this.openRemoteEvents(payload, signal) + assertPeerLive(peer, endpoint) + return this.openRemoteEvents(payload, signal, peer) } - return this.openStream({ ...remoteRequest(endpoint, payload, signal, peer), uplink }, control) - } - - /** - * The Peer an in-process carrier speaks for when it names none: the - * operator's Peer when Connection is mounted, otherwise an operator scope the - * Gateway owns for its own lifetime. - * @returns the operator Peer. - */ - private operatorPeer(): PeerScope { - const connection = this.ctx.get('connection') - if (connection !== undefined) return connection.operator - this.inProcessOperator ??= new OperatorPeer(this.ctx) - return this.inProcessOperator + return this.openStream({ ...remoteRequest(endpoint, payload, signal, peer), uplink, uplinkWindow }, control) } private async *openRemoteEvents( payload: unknown, signal: AbortSignal, + peer: PeerScope, ): AsyncGenerator< RemoteEventEmitFrame | RemoteEventInvocationFrame | RemoteEventCancellationFrame | RemoteEventReadyFrame @@ -500,7 +515,7 @@ export class TypertGatewayService extends Service implements TypertGateway { 'forwarded Remote event source is unavailable', ) } - const lifetime = AbortSignal.any([signal, registration.lifetime.signal]) + const lifetime = AbortSignal.any([signal, registration.lifetime.signal, peer.signal]) let clientId = randomUUID() as RemoteEventClientId while (this.remoteEventClients.has(clientId)) clientId = randomUUID() as RemoteEventClientId const client: RemoteEventClient = { @@ -508,9 +523,14 @@ export class TypertGatewayService extends Service implements TypertGateway { queue: new RemoteEventQueue(), signal: lifetime, deliveries: new Map(), + principal: peer.principal, + // oxlint-disable-next-line typescript/no-unnecessary-condition -- other packages merge non-owner kinds into PrincipalMap. + answers: peer.principal.kind === 'owner', } this.remoteEventClients.set(clientId, client) - for (const pending of this.pendingRemoteEvents.values()) this.deliverRemoteEvent(pending, client) + if (client.answers) { + for (const pending of this.pendingRemoteEvents.values()) this.deliverRemoteEvent(pending, client) + } try { yield { ...REMOTE_EVENT_STREAM_READY, clientId, host: registration.host } yield* client.queue.iterate(lifetime) @@ -543,7 +563,21 @@ export class TypertGatewayService extends Service implements TypertGateway { event: frame.event, args: frame.args, } - for (const client of this.remoteEventClients.values()) client.queue.push(wire) + let resolved = false + let resources: readonly RemoteAccessResource[] | undefined + for (const client of this.remoteEventClients.values()) { + if (!client.answers) { + // A non-owner receives the whole notification or nothing: its resources decide, payloads are never trimmed, + // and a notification with invalid resources reaches no non-owner. + if (!resolved) { + resources = eventResources(frame.resources, frame.args) + resolved = true + } + if (resources === undefined) continue + if (this.ctx.remoteAccess.decide({ principal: client.principal, action: `event:${frame.event}`, resources }).kind === 'deny') continue + } + client.queue.push(wire) + } } private startRemoteEvent(source: TypertRemoteEventInvocation): void { @@ -599,7 +633,11 @@ export class TypertGatewayService extends Service implements TypertGateway { this.pendingRemoteEvents.set(id, pending) for (const signal of signals) signal.addEventListener('abort', abort, { once: true }) if ([...signals].some(signal => signal.aborted)) abort() - else for (const client of this.remoteEventClients.values()) this.deliverRemoteEvent(pending, client) + else { + for (const client of this.remoteEventClients.values()) { + if (client.answers) this.deliverRemoteEvent(pending, client) + } + } } catch (error) { source.reject(error) } @@ -701,9 +739,36 @@ export class TypertGatewayService extends Service implements TypertGateway { control: AbortController, ): Promise { const endpoint = endpointOf(request.namespace, request.method) + assertPeerLive(request.peer, endpoint) const descriptor = this.resolveDescriptor(request.namespace, request.method, endpoint) assertExactArguments(request.args, descriptor, endpoint) - const receiverContext = await this.resolveReceiverContext(descriptor, request.args, endpoint) + const validated = validateArguments(descriptor, request.args, endpoint) + // Access precedes lookup resolution, so a refused call activates nothing. + // oxlint-disable-next-line typescript/no-unnecessary-condition -- other packages merge non-owner kinds into PrincipalMap. + if (request.peer.principal.kind !== 'owner') { + const resources = callResources( + this.ctx.typert, + this.resourceReaders(descriptor), + descriptor, + validated, + request.signal ?? NEVER_ABORTED_SIGNAL, + ) + const decision: RemoteAccessDecision = resources === undefined + ? { kind: 'deny', reason: 'resource-invalid' } + : this.ctx.remoteAccess.decide({ + principal: request.peer.principal, + action: `${descriptor.namespace}/${descriptor.method}`, + resources, + args: validated, + }) + if (decision.kind === 'deny') { + throw new RemoteError('remote-access/denied', `remote access: ${endpoint}: access denied (${decision.reason})`, { + action: endpoint, + reason: decision.reason, + }) + } + } + const receiverContext = await this.resolveReceiverContext(descriptor, validated, endpoint) const receiver: unknown = receiverContext.get(descriptor.service) if (!isObject(receiver)) { throw new TypertGatewayError( @@ -714,15 +779,16 @@ export class TypertGatewayService extends Service implements TypertGateway { } validateBinding(receiver, descriptor.service, descriptor.namespace, endpoint) const args = await Promise.all(descriptor.parameters.map(parameter => - this.resolveParameter(parameter, request.args, endpoint))) - const signal = methodSignal(request, control) + this.resolveParameter(parameter, validated, endpoint))) + const signal = methodSignal(request, control, descriptor.mode === 'stream') const invocation = new GatewayInvocation( { namespace: request.namespace, method: request.method, args: request.args }, descriptor.service, - request.peer ?? this.operatorPeer(), + request.peer, signal, { source: request.uplink ?? EMPTY_ASYNC_ITERABLE, + window: request.uplinkWindow, codec: descriptor.uplink?.codec ?? SRC_JSON_CODEC, endpoint, abort: (reason) => { control.abort(reason) }, @@ -942,13 +1008,10 @@ export class TypertGatewayService extends Service implements TypertGateway { args: Readonly>, endpoint: string, ): Promise { - // An absent field reached assertExactArguments' allowance, so this parameter - // takes undefined; a present-but-undefined field is not JSON-safe input and - // still fails decode. Lookup ids are never omissible, so absence here only - // ever belongs to a json parameter. + // Lookup ids are never omissible, so an absent field belongs to a json parameter that takes undefined. if (!Object.hasOwn(args, parameter.wire)) return undefined + if (parameter.source === 'json') return args[parameter.wire] const value = decode(parameter.codec, args[parameter.wire], endpoint, parameter.wire) - if (parameter.source === 'json') return value const key = parameter.lookup /* v8 ignore next -- registry validation rejects strict descriptors without a key, and SRC derivation always supplies one. */ if (key === undefined) { @@ -1085,12 +1148,18 @@ class RemoteEventQueue { this.waiter?.() } + /** + * Yield buffered and later frames until the queue ends or `signal` aborts. + * An abort discards the frames still buffered, so a revoked Peer receives none of them. + * @param signal - Client generation lifetime. + * @returns the queued wire frames in push order. + */ async *iterate(signal: AbortSignal): AsyncGenerator { const abort = (): void => { this.end() } signal.addEventListener('abort', abort, { once: true }) try { while (true) { - while (this.frames.size > 0) yield this.frames.popFront() as RemoteEventWireFrame + while (this.frames.size > 0 && !signal.aborted) yield this.frames.popFront() as RemoteEventWireFrame if (this.closed || signal.aborted) return await new Promise((resolve) => { this.waiter = resolve }) this.waiter = undefined @@ -1128,8 +1197,10 @@ function remoteRequest( endpoint: string, payload: unknown, signal: AbortSignal, - peer?: PeerScope, + peer: PeerScope, ): InvokeRemoteRequest { + // A disposed Peer is refused whatever it sent. + assertPeerLive(peer, endpoint) const segments = endpoint.split('/') if (segments.length !== 2 || segments[0] === '' || segments[1] === '') { throw new Error(`invalid Remote endpoint ${JSON.stringify(endpoint)}`) @@ -1143,20 +1214,34 @@ function remoteRequest( || !isPlainObject(payload.args)) { throw new Error('Remote payload must contain exactly one plain-object args field') } - return { namespace, method, args: payload.args, signal, ...(peer === undefined ? {} : { peer }) } + return { namespace, method, args: payload.args, signal, peer } } /** * The signal a method observes. A carrier that supplies an uplink fails the * stream through `control` when an item is rejected, so that invocation joins - * `control` with the carrier signal; every other invocation keeps the carrier - * signal's identity. + * `control` with the carrier signal; a stream also ends with its Peer. Every + * other invocation keeps the carrier signal's identity. */ -function methodSignal(request: InvokeRemoteRequest, control: AbortController): AbortSignal { +function methodSignal(request: InvokeRemoteRequest, control: AbortController, stream: boolean): AbortSignal { const carrier = request.signal - if (request.uplink === undefined) return carrier ?? NEVER_ABORTED_SIGNAL - if (carrier === undefined || carrier === control.signal) return control.signal - return AbortSignal.any([carrier, control.signal]) + if (request.uplink === undefined && !stream) return carrier ?? NEVER_ABORTED_SIGNAL + const joined = carrier === undefined || carrier === control.signal ? [control.signal] : [carrier, control.signal] + // `AbortSignal.any` leaves each source only a weak reference to the joined signal, + // so per-stream joins on a Host-lifetime Peer signal accumulate nothing. + return AbortSignal.any(stream ? [...joined, request.peer.signal] : joined) +} + +/** + * Refuse a call whose Peer has been disposed. + * @param peer - Peer the call speaks for. + * @param endpoint - endpoint named in the failure. + * @throws {@link TypertGatewayError} `gateway/peer-disposed` once the Peer is disposed. + */ +function assertPeerLive(peer: PeerScope, endpoint: string): void { + if (peer.signal.aborted) { + throw new TypertGatewayError('gateway/peer-disposed', endpoint, 'the calling Peer has been disposed') + } } function isIterable(value: unknown): value is Iterable | AsyncIterable { @@ -1309,6 +1394,8 @@ class GatewayInvocation implements RemoteInvocation { private decoder: UplinkDecoder | undefined private taken = false + get uplinkWindow(): RemoteInvocation['uplinkWindow'] { return this.uplink_.window } + /** * @param request - decoded endpoint and wire arguments. * @param service - Cordis service key of the receiver. @@ -1503,6 +1590,28 @@ function assertExactArguments( throw new TypertGatewayError('gateway/arguments-invalid', endpoint, `args fields do not match the descriptor: ${clauses.join('; ')}`) } +/** + * Decode every present JSON argument once through its parameter codec; a + * Typert codec drops object fields its compiled type does not declare. Lookup + * and Context identities keep their wire values; dispatch decodes and resolves + * them only after the access check. An absent field stays absent; a present + * `undefined` is not JSON-safe and fails decoding. + */ +function validateArguments( + descriptor: InvocationDescriptor, + args: Readonly>, + endpoint: string, +): Readonly> { + const entries: [string, unknown][] = [] + if (descriptor.invocation.kind === 'context') entries.push([descriptor.invocation.wire, args[descriptor.invocation.wire]]) + for (const parameter of descriptor.parameters) { + if (!Object.hasOwn(args, parameter.wire)) continue + const value = args[parameter.wire] + entries.push([parameter.wire, parameter.source === 'json' ? decode(parameter.codec, value, endpoint, parameter.wire) : value]) + } + return Object.fromEntries(entries) +} + function decode( codec: TypertCodec, value: unknown, diff --git a/packages/api/gateway/src/remote-error-codes.ts b/packages/api/gateway/src/remote-error-codes.ts index 5e560557cc..e61432b8c5 100644 --- a/packages/api/gateway/src/remote-error-codes.ts +++ b/packages/api/gateway/src/remote-error-codes.ts @@ -27,6 +27,7 @@ declare module '@deepseek-ai/dsh-typert-protocol' { 'gateway/lookup-not-found': TypertGatewayFaultDetails 'gateway/lookup-unavailable': TypertGatewayFaultDetails 'gateway/method-unavailable': TypertGatewayFaultDetails + 'gateway/peer-disposed': TypertGatewayFaultDetails 'gateway/protocol': TypertGatewayFaultDetails 'gateway/provider-mismatch': TypertGatewayFaultDetails 'gateway/result-invalid': TypertGatewayFaultDetails diff --git a/packages/api/gateway/src/stream-server.ts b/packages/api/gateway/src/stream-server.ts index 9454f03f2d..2473f1a8e6 100644 --- a/packages/api/gateway/src/stream-server.ts +++ b/packages/api/gateway/src/stream-server.ts @@ -3,7 +3,7 @@ import type { IncomingMessage } from 'node:http' import type { Duplex } from 'node:stream' import { Deque } from '@deepseek-ai/dsh-deque' -import { RemoteError, remoteErrorOf, type PeerScope } from '@deepseek-ai/dsh-typert-protocol' +import { RemoteError, remoteErrorOf, type PeerScope, type RemoteUplinkWindow } from '@deepseek-ai/dsh-typert-protocol' import WebSocket, { WebSocketServer, type RawData } from 'ws' import { parseRemoteStreamClientMessage, @@ -16,7 +16,7 @@ import { * Open one validated Remote stream for a decoded wire request on behalf of one * Peer. `uplink` carries the Client's items; `control` cancels the logical * stream, and aborting it with a Remote failure as the reason delivers that - * failure to the Client. + * failure to the Client. `window` describes this stream's complete-frame inbox budget. */ export type RemoteStreamOpener = ( endpoint: string, @@ -24,6 +24,7 @@ export type RemoteStreamOpener = ( uplink: AsyncIterable, peer: PeerScope, control: AbortController, + window: RemoteUplinkWindow, ) => Promise> /** The opener one socket uses: its Peer is fixed at upgrade time. */ @@ -32,6 +33,7 @@ type BoundStreamOpener = ( payload: unknown, uplink: AsyncIterable, control: AbortController, + window: RemoteUplinkWindow, ) => Promise> /** Convert an invocation or carrier failure to a stable wire value. */ @@ -72,12 +74,12 @@ export class RemoteStreamMuxServer { this.server.handleUpgrade(req, socket, head, (websocket) => { const release = bindPeer(websocket, peer) if (release === undefined) return - this.missedHeartbeats.set(websocket, 0) - websocket.on('pong', () => { this.missedHeartbeats.set(websocket, 0) }) + const alive = (): void => { this.missedHeartbeats.set(websocket, 0) } + alive() this.startHeartbeat() - const bound: BoundStreamOpener = (endpoint, payload, uplink, control) => - this.open(endpoint, payload, uplink, peer, control) - const connection = new RemoteStreamMuxConnection(websocket, bound, this.failure, this.streamInboxBytes) + const bound: BoundStreamOpener = (endpoint, payload, uplink, control, window) => + this.open(endpoint, payload, uplink, peer, control, window) + const connection = new RemoteStreamMuxConnection(websocket, bound, this.failure, this.streamInboxBytes, alive) const done = connection.run() this.connections.add(done) void done.then(() => { @@ -141,13 +143,23 @@ class RemoteStreamMuxConnection { private readonly open: BoundStreamOpener, private readonly failure: RemoteStreamFailureMapper, private readonly streamInboxBytes: number, + private readonly alive: () => void, ) {} + /** + * Serve frames until the socket closes. A Pong, any received frame, and a + * downlink write that waited in the local send buffer and then completed + * count as peer activity, so a congested link that delays the Pong behind + * data keeps its socket. A write that completes at once proves nothing + * about the peer and does not count. + */ async run(): Promise { const closed = new Promise((resolve) => { this.socket.once('close', resolve) this.socket.once('error', () => { this.socket.terminate() }) + this.socket.on('pong', this.alive) this.socket.on('message', (data, isBinary) => { + this.alive() if (isBinary) { this.socket.close(1003, 'text messages required') return @@ -230,7 +242,10 @@ class RemoteStreamMuxConnection { ): Promise { let outcome: { readonly failed: false } | { readonly failed: true; readonly error: unknown } try { - const source = await this.open(endpoint, payload, active.inbox, active.abort) + const source = await this.open(endpoint, payload, active.inbox, active.abort, { + maxBytes: this.streamInboxBytes, + frameOverheadBytes: Buffer.byteLength(JSON.stringify({ type: 'item', streamId, value: null }), 'utf8') - 'null'.length, + }) for await (const value of source) { await this.send({ type: 'item', streamId, value }) } @@ -283,10 +298,18 @@ class RemoteStreamMuxConnection { reject(new Error('api gateway: Remote stream socket is closed')) return } + let queued = false this.socket.send(text, (error) => { - if (error) reject(error) - else resolve() + if (error) { + reject(error) + return + } + if (queued) this.alive() + resolve() }) + // Read after send(): bytes still buffered mean the local send buffer was + // full, so their completion requires the peer to acknowledge data. + queued = this.socket.bufferedAmount > 0 })) this.writes = delivery.catch(() => undefined) return delivery diff --git a/packages/api/gateway/src/types.ts b/packages/api/gateway/src/types.ts index 9fa3a012b5..d83f120ab3 100644 --- a/packages/api/gateway/src/types.ts +++ b/packages/api/gateway/src/types.ts @@ -4,7 +4,7 @@ */ import type { Context } from '@deepseek-ai/cordis' -import type { PeerScope } from '@deepseek-ai/dsh-typert-protocol' +import type { PeerScope, RemoteUplinkWindow, TypertResourceReader } from '@deepseek-ai/dsh-typert-protocol' import type { RemoteEventHostInfo } from './stream-protocol.ts' /** One Remote method request after a carrier has decoded its envelope. */ @@ -20,8 +20,10 @@ export interface InvokeRemoteRequest { * `invocation.uplink()`; absent means an immediately ended iterable. */ readonly uplink?: AsyncIterable - /** Peer the call speaks for; absent means an in-process carrier, answered as the operator. */ - readonly peer?: PeerScope + /** Carrier-provided frame capacity; absent for carriers without an encoded-frame inbox limit. */ + readonly uplinkWindow?: RemoteUplinkWindow | undefined + /** Peer the call speaks for; every carrier and in-process caller names it explicitly. */ + readonly peer: PeerScope /** Carrier or direct-caller cancellation injected only into cancellation-aware methods. */ readonly signal?: AbortSignal } @@ -32,6 +34,12 @@ export interface TypertRemoteEventFrame { readonly event: string /** Original event argument list after the owner validates it for JSON transport. */ readonly args: readonly unknown[] + /** + * Readers of the resources the arguments carry, keyed by resource kind and + * called with the arguments as positional parameters. Absent or empty for + * a notification that touches the global resource. + */ + readonly resources?: Readonly> } /** Live Host values used to project one scoped Remote Event. */ @@ -88,7 +96,7 @@ export interface TypertGatewayWireStream { * @param payload - decoded carrier payload. * @param uplink - Client-to-Host items of the logical stream; a Gateway-owned endpoint returns its iterator * as soon as it opens, so the carrier drops those items instead of buffering them. - * @param peer - Peer the stream speaks for; `undefined` means the operator's in-process carrier. + * @param peer - Peer the stream speaks for; required, never defaulted. * @param signal - logical-stream cancellation. * @returns validated stream values. */ @@ -96,7 +104,7 @@ export interface TypertGatewayWireStream { endpoint: string, payload: unknown, uplink: AsyncIterable, - peer: PeerScope | undefined, + peer: PeerScope, signal: AbortSignal, ) => Promise> @@ -127,6 +135,7 @@ export type TypertGatewayErrorCode = | 'gateway/lookup-not-found' | 'gateway/lookup-unavailable' | 'gateway/method-unavailable' + | 'gateway/peer-disposed' | 'gateway/protocol' | 'gateway/provider-mismatch' | 'gateway/result-invalid' @@ -140,8 +149,8 @@ export interface TypertGateway { readonly wireStream: TypertGatewayWireStream /** - * Check for an active Client event stream. - * @returns whether a stream is open and has not been cancelled. + * Check for an active owner Client event stream, the only kind that can answer forwarded waterfalls. + * @returns whether an owner stream is open and has not been cancelled. */ hasLiveClient(): boolean diff --git a/packages/api/gateway/tests/echo-stream.host.spec.ts b/packages/api/gateway/tests/echo-stream.host.spec.ts index d56b529002..24a24f6918 100644 --- a/packages/api/gateway/tests/echo-stream.host.spec.ts +++ b/packages/api/gateway/tests/echo-stream.host.spec.ts @@ -7,7 +7,9 @@ import WebServer from '@deepseek-ai/dsh-host-webserver' import { Remote, TypertRemoteService, type RemoteStream } from '@deepseek-ai/dsh-typert-protocol' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import TypertGatewayService from '@deepseek-ai/dsh-api-gateway' +import RemoteAccessService from '@deepseek-ai/dsh-api-remote-access' import { browserCookie, provideBrowserCredentials } from './browser-credentials.ts' +import { ownerPeer } from './peer.ts' /** The smallest Remote stream that reads its uplink: every carrier has to serve it alike. */ class EchoService extends TypertRemoteService { @@ -36,6 +38,7 @@ describe('the echo stream on each carrier', () => { it('echoes uplink items through the in-process carrier', async () => { const ctx = await setup(false) const replies = await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'echo', method: 'echo', args: { prefix: '> ' }, uplink: items(['a', 'b']), }) await expect(collect(replies)).resolves.toEqual(['> a', '> b']) @@ -73,6 +76,7 @@ async function setup(transport: boolean): Promise { provideBrowserCredentials(ctx) } await ctx.plugin(TypertRegistry) + await ctx.plugin(RemoteAccessService) await ctx.plugin(TypertGatewayService, {}) if (transport) await ctx.plugin({ inject: [...connectionInject], apply: applyConnection }) await ctx.plugin(EchoService) diff --git a/packages/api/gateway/tests/gateway-access.host.spec.ts b/packages/api/gateway/tests/gateway-access.host.spec.ts new file mode 100644 index 0000000000..42cbb1bbff --- /dev/null +++ b/packages/api/gateway/tests/gateway-access.host.spec.ts @@ -0,0 +1,915 @@ +import { describe, expect, it, vi, afterEach } from 'vitest' +import { Context, symbols } from '@deepseek-ai/cordis' +import { apply as applyConnection, inject as connectionInject } from '@deepseek-ai/dsh-client-connection' +import RemoteAccessService, { + type RemoteAccessAction, + type RemoteAccessBinding, + type RemoteAccessCondition, + type RemoteAccessResource, + type RemoteAccessResourceKind, + type RemoteAccessRole, +} from '@deepseek-ai/dsh-api-remote-access' +import { + Remote, + RemoteScope, + TypertRemoteService, + type InvocationDescriptor, + type InvocationParameterDescriptor, + type PeerScope, + type Principal, + type PrincipalMap, + type TypertContext, + type TypertLookup, + type TypertResource, +} from '@deepseek-ai/dsh-typert-protocol' +import TypertRegistry from '@deepseek-ai/dsh-typert-registry' +import { z } from 'zod' +import TypertGatewayService, { type TypertRemoteEventDispatch } from '@deepseek-ai/dsh-api-gateway' +import { callResources, eventResources } from '../src/access.ts' +import { provideBrowserCredentials } from './browser-credentials.ts' +import { ownerPeer, peerFor } from './peer.ts' + +interface AccessAgent { + readonly id: string +} + +declare module '@deepseek-ai/dsh-typert-protocol' { + interface PrincipalMap { + /** Test-only non-owner caller shared by the access specs. */ + 'test-guest': { readonly kind: 'test-guest'; readonly guest: string } + } + interface TypertLookupMap { + gatewayAccessAgent: TypertLookup + } + interface TypertContextMap { + gatewayAccessScope: TypertContext + } + interface TypertResourceMap { + /** Test-only resource kinds; the Gateway itself names no kind. */ + 'test-shelf': TypertResource + 'test-desk': TypertResource + } +} + +type Guest = PrincipalMap['test-guest'] + +const OWNER: Principal = { kind: 'owner' } +const GUEST: Guest = { kind: 'test-guest', guest: 'gateway' } +const GLOBAL = { kind: 'global' } as const +const shelf = (id: string) => ({ kind: 'test-shelf', id }) as const +const desk = (id: string) => ({ kind: 'test-desk', id }) as const + +const FIXTURE_ACTIONS: readonly RemoteAccessAction[] = [ + 'accessFixture/open', 'accessFixture/read', 'accessFixture/byAgent', 'accessFixture/byLookup', + 'accessFixture/byLookupAndOption', 'accessFixture/byList', 'accessFixture/scoped', 'accessFixture/scopedOnDesk', + 'accessFixture/scopedByReader', 'accessFixture/annotate', 'absentAccess/read', 'event:fixture/notice', 'event:fixture/shelf', +] + +function role(resource: K): RemoteAccessRole { + return { name: `test:${resource}`, resource, actions: FIXTURE_ACTIONS } +} + +/** One binding granting every fixture action on `resource`. */ +function on(resource: RemoteAccessResource): RemoteAccessBinding { + switch (resource.kind) { + case 'global': + return { role: role('global'), resource } + case 'test-shelf': + return { role: role('test-shelf'), resource } + case 'test-desk': + return { role: role('test-desk'), resource } + default: + throw new Error('no fixture role for this resource kind') + } +} + +class AccessFixture extends TypertRemoteService { + readonly principals: Principal[] = [] + readonly requests: unknown[] = [] + release: PromiseWithResolvers = Promise.withResolvers() + + constructor(ctx: Context) { + super(ctx, 'accessFixture') + } + + @Remote + open(): string { + return this.seen() + } + + @Remote + read(request: { readonly shelfId?: unknown; readonly principal?: unknown }): string { + void request + return this.seen() + } + + @Remote({ resources: { 'test-shelf': agentId => agentId } }) + byAgent(agentId: string): string { + void agentId + return this.seen() + } + + @Remote + byLookup(agent: AccessAgent): string { + return `${agent.id}:${this.seen()}` + } + + @Remote({ resources: { 'test-desk': (agentId, request) => [agentId, request.deskId] } }) + byLookupAndOption(agent: AccessAgent, request: { readonly deskId: string; readonly shelfId: string }): string { + void request + return `${agent.id}:${this.seen()}` + } + + @Remote({ resources: { 'test-shelf': shelfIds => shelfIds } }) + byList(shelfIds: readonly string[]): string { + void shelfIds + return this.seen() + } + + @Remote + annotate(request: { readonly title: string }): string { + this.requests.push(request) + return this.seen() + } + + @RemoteScope('gatewayAccessScope') + scoped(): string { + return this.seen() + } + + @RemoteScope('gatewayAccessScope', { resources: { 'test-desk': request => request.deskId } }) + scopedOnDesk(request: { readonly deskId: string }): string { + void request + return this.seen() + } + + @RemoteScope('gatewayAccessScope', { resources: { 'test-shelf': request => request.shelfId } }) + scopedByReader(request: { readonly shelfId: string }): string { + void request + return this.seen() + } + + @Remote + async slow(): Promise { + await this.release.promise + return this.seen() + } + + @Remote({ mode: 'stream' }) + async *watch(signal: AbortSignal): AsyncIterable { + yield this.seen() + await new Promise((resolve) => { + if (signal.aborted) resolve() + else signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + } + + private seen(): string { + const principal = this.ctx.invocation?.peer.principal + if (principal !== undefined) this.principals.push(principal) + return principal?.kind ?? 'none' + } +} + +const roots: Context[] = [] + +afterEach(async () => { + vi.restoreAllMocks() + await Promise.all(roots.splice(0).map(ctx => ctx.fiber.dispose())) +}) + +async function setup(options: { readonly connection?: boolean } = {}): Promise<{ + readonly ctx: Context + readonly fixture: AccessFixture + /** Replace the guest's bindings: every fixture action on each listed resource. */ + readonly hold: (...resources: RemoteAccessResource[]) => void + readonly bindings: ReturnType readonly RemoteAccessBinding[]>> +}> { + const ctx = new Context() + roots.push(ctx) + await ctx.plugin(TypertRegistry) + await ctx.plugin(RemoteAccessService) + await ctx.plugin(TypertGatewayService) + if (options.connection === true) { + provideBrowserCredentials(ctx) + await ctx.plugin({ inject: [...connectionInject], apply: applyConnection }) + } + ctx.typert.resources.register('test-shelf', { field: 'shelfId' }) + await ctx.plugin(AccessFixture) + let held: readonly RemoteAccessBinding[] = [] + const bindings = vi.fn((guest: Guest): readonly RemoteAccessBinding[] => { void guest; return held }) + ctx.remoteAccess.registerPolicy('test-guest', { bindings }) + const receiver = ctx.get('accessFixture') as AccessFixture & { [symbols.original]?: AccessFixture } + return { + ctx, + fixture: receiver[symbols.original] ?? receiver, + hold: (...resources) => { held = resources.map(on) }, + bindings, + } +} + +/** Record every request the service decides from now on; the returned function lists their actions and resources in order. */ +function recordDecisions(): () => { readonly action: string; readonly resources: unknown }[] { + const decide = vi.spyOn(RemoteAccessService.prototype, 'decide') + return () => decide.mock.calls.map(([request]) => ({ action: request.action, resources: request.resources })) +} + +function call(ctx: Context, peer: PeerScope, method: string, args: Record = {}): Promise { + return ctx.typertGateway.invoke({ peer, namespace: 'accessFixture', method, args }) +} + +function registerScope(ctx: Context, resource: 'test-shelf' | undefined, resolve = vi.fn((id: string) => { void id; return ctx })): typeof resolve { + ctx.typert.contexts.registerHost('gatewayAccessScope', { + wire: 'scopeId', + wireTypeSymbol: '@fixture/access#ScopeId', + ...resource === undefined ? {} : { resource }, + resolve, + }) + return resolve +} + +function strictCodec(name: string, create: () => z.ZodType): InvocationParameterDescriptor['codec'] { + return { mode: 'strict', typeSymbol: `@fixture/access#${name}`, create } +} + +/** Give each listed fixture method a strict descriptor with these parameters. */ +function registerStrict(ctx: Context, methods: Readonly>): void { + ctx.typert.register({ + package: '@fixture/gateway-access-strict', + face: 'host', + schemas: [], + model: { services: [], events: [], objects: [] }, + invocations: Object.entries(methods).map(([method, parameters]) => ({ + id: `@fixture/gateway-access-strict#accessFixture/${method}`, + service: 'accessFixture', + namespace: 'accessFixture', + method, + invocation: { kind: 'direct' }, + parameters, + result: { mode: 'strict', typeSymbol: '@fixture/access#Result', create: () => z.string() }, + })), + }) +} + +describe('Gateway access checks', () => { + it('dispatches the owner without resolving resources or reading the policy', async () => { + const { ctx, bindings } = await setup() + const decide = vi.spyOn(RemoteAccessService.prototype, 'decide') + await expect(call(ctx, ownerPeer(ctx), 'open')).resolves.toBe('owner') + await expect(call(ctx, ownerPeer(ctx), 'read', { request: { shelfId: 7 } })).resolves.toBe('owner') + expect(decide).not.toHaveBeenCalled() + expect(bindings).not.toHaveBeenCalled() + }) + + it('starts only in a composition that mounts remoteAccess', async () => { + const ctx = new Context() + roots.push(ctx) + await ctx.plugin(TypertRegistry) + void ctx.plugin(TypertGatewayService) + await new Promise(resolve => setTimeout(resolve, 0)) + expect(ctx.get('typertGateway')).toBeUndefined() + await ctx.plugin(RemoteAccessService) + await vi.waitFor(() => { expect(ctx.get('typertGateway')).toBeDefined() }) + }) + + it('refuses a non-owner call no binding allows before the method runs', async () => { + const { ctx, fixture, hold } = await setup() + const guest = peerFor(ctx, GUEST) + await expect(call(ctx, guest, 'open')).rejects.toMatchObject({ + code: 'remote-access/denied', + message: 'remote access: accessFixture/open: access denied (no-allow)', + details: { action: 'accessFixture/open', reason: 'no-allow' }, + }) + expect(fixture.principals).toEqual([]) + hold(GLOBAL) + await expect(call(ctx, guest, 'open')).resolves.toBe('test-guest') + expect(fixture.principals).toEqual([GUEST]) + }) + + it('hands remoteAccess the endpoint action, the resolved resources, and the call arguments', async () => { + const { ctx, hold } = await setup() + hold(GLOBAL, shelf('s1'), shelf('s2')) + const decide = vi.spyOn(RemoteAccessService.prototype, 'decide') + const guest = peerFor(ctx, GUEST) + await call(ctx, guest, 'read', { request: { shelfId: 's1' } }) + await call(ctx, guest, 'byAgent', { agentId: 's2' }) + await call(ctx, guest, 'read', { request: {} }) + expect(decide.mock.calls.map(([request]) => request)).toEqual([ + { principal: GUEST, action: 'accessFixture/read', resources: [shelf('s1')], args: { request: { shelfId: 's1' } } }, + { principal: GUEST, action: 'accessFixture/byAgent', resources: [shelf('s2')], args: { agentId: 's2' } }, + { principal: GUEST, action: 'accessFixture/read', resources: [GLOBAL], args: { request: {} } }, + ]) + }) + + it('reads a lookup parameter\'s resource and lets a reader replace it for its kind', async () => { + const { ctx, hold } = await setup() + hold(shelf('s9'), desk('a1'), desk('a2'), desk('d9')) + const decisions = recordDecisions() + ctx.typert.lookups.register('gatewayAccessAgent', { + parameter: 'agent', + wire: 'agentId', + resource: 'test-desk', + hostTypeSymbol: '@fixture/access#Agent', + wireTypeSymbol: '@fixture/access#AgentId', + resolve: (id: string) => ({ id }), + }) + const guest = peerFor(ctx, GUEST) + await expect(call(ctx, guest, 'byLookup', { agentId: 'a1' })).resolves.toBe('a1:test-guest') + await expect(call(ctx, guest, 'byLookupAndOption', { agentId: 'a2', request: { deskId: 'd9', shelfId: 's9' } })) + .resolves.toBe('a2:test-guest') + expect(decisions().map(({ resources }) => resources)).toEqual([ + [desk('a1')], + // The reader owns `test-desk`; the registered `shelfId` field still names `test-shelf`. + [shelf('s9'), desk('a2'), desk('d9')], + ]) + }) + + it('refuses an empty reader list and a malformed default field with resource-invalid without asking remoteAccess', async () => { + const { ctx, hold, bindings } = await setup() + hold(shelf('s1'), shelf('s2')) + const decide = vi.spyOn(RemoteAccessService.prototype, 'decide') + const guest = peerFor(ctx, GUEST) + await expect(call(ctx, guest, 'byList', { shelfIds: [] })).rejects.toMatchObject({ + code: 'remote-access/denied', details: { action: 'accessFixture/byList', reason: 'resource-invalid' }, + }) + await expect(call(ctx, guest, 'read', { request: { shelfId: 7 } })).rejects.toMatchObject({ + code: 'remote-access/denied', details: { reason: 'resource-invalid' }, + }) + expect(decide).not.toHaveBeenCalled() + expect(bindings).not.toHaveBeenCalled() + // Every listed resource must be allowed. + await expect(call(ctx, guest, 'byList', { shelfIds: ['s1', 's2'] })).resolves.toBe('test-guest') + await expect(call(ctx, guest, 'byList', { shelfIds: ['s1', 's3'] })).rejects.toMatchObject({ details: { reason: 'no-allow' } }) + }) + + it('resolves a strict method whose Service is absent from its arguments alone', async () => { + const { ctx } = await setup() + const decisions = recordDecisions() + ctx.typert.register({ + package: '@fixture/gateway-access', + face: 'host', + schemas: [], + model: { services: [], events: [], objects: [] }, + invocations: [{ + id: '@fixture/gateway-access#absentAccess/read', + service: 'absentAccess', + namespace: 'absentAccess', + method: 'read', + invocation: { kind: 'direct' }, + parameters: [], + result: { mode: 'strict', typeSymbol: '@fixture/gateway-access#Result', create: () => z.string() }, + }], + }) + await expect(ctx.typertGateway.invoke({ peer: peerFor(ctx, GUEST), namespace: 'absentAccess', method: 'read', args: {} })) + .rejects.toMatchObject({ code: 'remote-access/denied', details: { action: 'absentAccess/read', reason: 'no-allow' } }) + expect(decisions()).toEqual([{ action: 'absentAccess/read', resources: [GLOBAL] }]) + }) + + it('checks access before resolving lookups, so a refused call activates nothing', async () => { + const { ctx, hold } = await setup() + const resolve = vi.fn((id: string) => ({ id })) + ctx.typert.lookups.register('gatewayAccessAgent', { + parameter: 'agent', + wire: 'agentId', + hostTypeSymbol: '@fixture/access#Agent', + wireTypeSymbol: '@fixture/access#AgentId', + resolve, + }) + const guest = peerFor(ctx, GUEST) + await expect(call(ctx, guest, 'byLookup', { agentId: 'a1' })).rejects.toMatchObject({ code: 'remote-access/denied' }) + expect(resolve).not.toHaveBeenCalled() + // A lookup without a resource kind contributes nothing, so the call touches the global resource. + hold(GLOBAL) + await expect(call(ctx, guest, 'byLookup', { agentId: 'a1' })).resolves.toBe('a1:test-guest') + expect(resolve).toHaveBeenCalledOnce() + }) + + it('keeps the principal out of reach of wire fields', async () => { + const { ctx, fixture, hold } = await setup({ connection: true }) + hold(GLOBAL) + const guest = ctx.connection.createPeer({ principal: GUEST }) + await expect(call(ctx, guest, 'read', { request: {}, principal: OWNER })).rejects.toMatchObject({ + code: 'gateway/arguments-invalid', + }) + const shared = ctx.connection.createSharedFetchHandler('/api') + const response = await shared.fetch(new Request('http://host/api/accessFixture/read', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + type: 'client-request', + rpcId: 'rpc-1', + method: 'accessFixture/read', + principal: OWNER, + payload: { args: { request: { principal: OWNER } } }, + }), + }), guest) + expect(await response.json()).toMatchObject({ result: { ok: true, value: 'test-guest' } }) + const payloadSibling = await shared.fetch(new Request('http://host/api/accessFixture/read', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + type: 'client-request', + rpcId: 'rpc-2', + method: 'accessFixture/read', + payload: { args: { request: {} }, principal: OWNER }, + }), + }), guest) + expect(await payloadSibling.json()).toMatchObject({ result: { ok: false } }) + expect(fixture.principals).toEqual([GUEST]) + }) +}) + +describe('Gateway access checks on Typert-validated arguments', () => { + const titleRequest = (create: () => z.ZodType = () => z.object({ title: z.string() })): InvocationParameterDescriptor => + ({ name: 'request', wire: 'request', source: 'json', codec: strictCodec('TitleRequest', create) }) + + it('resolves resources from the decoded arguments, so an undeclared nested field names no resource', async () => { + const { ctx, fixture, hold } = await setup() + const create = vi.fn(() => z.object({ title: z.string() })) + registerStrict(ctx, { + annotate: [titleRequest(create)], + read: [{ name: 'request', wire: 'request', source: 'json', codec: strictCodec('ShelfRequest', () => z.object({ shelfId: z.string() })) }], + }) + const decide = vi.spyOn(RemoteAccessService.prototype, 'decide') + const guest = peerFor(ctx, GUEST) + hold(shelf('s1')) + await expect(call(ctx, guest, 'annotate', { request: { title: 't', shelfId: 's1' } })).rejects.toMatchObject({ + code: 'remote-access/denied', details: { action: 'accessFixture/annotate', reason: 'no-allow' }, + }) + await expect(call(ctx, guest, 'read', { request: { shelfId: 's1', principal: OWNER } })).resolves.toBe('test-guest') + hold(GLOBAL) + await expect(call(ctx, guest, 'annotate', { request: { title: 't', shelfId: 's1' } })).resolves.toBe('test-guest') + expect(decide.mock.calls.map(([request]) => request)).toEqual([ + { principal: GUEST, action: 'accessFixture/annotate', resources: [GLOBAL], args: { request: { title: 't' } } }, + { principal: GUEST, action: 'accessFixture/read', resources: [shelf('s1')], args: { request: { shelfId: 's1' } } }, + { principal: GUEST, action: 'accessFixture/annotate', resources: [GLOBAL], args: { request: { title: 't' } } }, + ]) + // Dispatch receives the value the access check read, decoded once per call. + expect(fixture.requests).toEqual([{ title: 't' }]) + expect(create).toHaveBeenCalledTimes(2) + }) + + it('hands a condition the decoded arguments, so it cannot read an undeclared field', async () => { + const { ctx, bindings } = await setup() + registerStrict(ctx, { annotate: [titleRequest()] }) + const seen: unknown[] = [] + const condition: RemoteAccessCondition<'global'> = ({ args }) => { + seen.push(args) + return { kind: 'allow' } + } + bindings.mockImplementation(() => [{ role: role('global'), resource: GLOBAL, condition }]) + await expect(call(ctx, peerFor(ctx, GUEST), 'annotate', { request: { title: 't', readOnly: false } })).resolves.toBe('test-guest') + expect(seen).toEqual([{ request: { title: 't' } }]) + }) + + it('passes readers the decoded arguments and lookup wire identities, resolving no lookup for a refused call', async () => { + const { ctx, hold } = await setup() + const resolve = vi.fn((id: string) => ({ id })) + ctx.typert.lookups.register('gatewayAccessAgent', { + parameter: 'agent', + wire: 'agentId', + resource: 'test-desk', + hostTypeSymbol: '@fixture/access#Agent', + wireTypeSymbol: '@fixture/access#AgentId', + resolve, + }) + registerStrict(ctx, { + byLookupAndOption: [ + { name: 'agent', wire: 'agentId', source: 'lookup', lookup: 'gatewayAccessAgent', codec: strictCodec('AgentId', () => z.string()) }, + { name: 'request', wire: 'request', source: 'json', codec: strictCodec('DeskRequest', () => z.object({ deskId: z.string() })) }, + ], + }) + const decisions = recordDecisions() + const guest = peerFor(ctx, GUEST) + const args = { agentId: 'a2', request: { deskId: 'd9', shelfId: 's9' } } + hold(shelf('s9'), desk('a2')) + await expect(call(ctx, guest, 'byLookupAndOption', args)).rejects.toMatchObject({ details: { reason: 'no-allow' } }) + expect(resolve).not.toHaveBeenCalled() + hold(desk('a2'), desk('d9')) + await expect(call(ctx, guest, 'byLookupAndOption', args)).resolves.toBe('a2:test-guest') + expect(resolve).toHaveBeenCalledExactlyOnceWith('a2') + // The undeclared `shelfId` names no `test-shelf` resource. + expect(decisions().map(({ resources }) => resources)).toEqual([[desk('a2'), desk('d9')], [desk('a2'), desk('d9')]]) + }) + + it('refuses arguments their codec rejects with gateway/input-invalid before the access check', async () => { + const { ctx, fixture, bindings } = await setup() + registerStrict(ctx, { annotate: [titleRequest()] }) + const decide = vi.spyOn(RemoteAccessService.prototype, 'decide') + for (const peer of [peerFor(ctx, GUEST), ownerPeer(ctx)]) { + await expect(call(ctx, peer, 'annotate', { request: { title: 7, shelfId: 's1' } })).rejects.toMatchObject({ + code: 'gateway/input-invalid', details: { endpoint: 'accessFixture/annotate', field: 'request' }, + }) + } + expect(decide).not.toHaveBeenCalled() + expect(bindings).not.toHaveBeenCalled() + expect(fixture.requests).toEqual([]) + }) +}) + +describe('Gateway access checks for Context methods', () => { + it('reads the Context identity as the resource its adapter declares, before resolving the Context', async () => { + const { ctx, fixture, hold } = await setup() + const resolve = registerScope(ctx, 'test-shelf') + const guest = peerFor(ctx, GUEST) + await expect(call(ctx, guest, 'scoped', { scopeId: 's1' })).rejects.toMatchObject({ + code: 'remote-access/denied', details: { action: 'accessFixture/scoped', reason: 'no-allow' }, + }) + expect(resolve).not.toHaveBeenCalled() + hold(shelf('s1')) + await expect(call(ctx, guest, 'scoped', { scopeId: 's1' })).resolves.toBe('test-guest') + expect(resolve).toHaveBeenCalledWith('s1') + expect(fixture.principals).toEqual([GUEST]) + }) + + it('adds the resources a reader declares to the Context resource, and lets a reader replace it for its kind', async () => { + const { ctx, hold } = await setup() + registerScope(ctx, 'test-shelf') + const decisions = recordDecisions() + const guest = peerFor(ctx, GUEST) + hold(shelf('s1')) + await expect(call(ctx, guest, 'scopedOnDesk', { scopeId: 's1', request: { deskId: 'd1' } })).rejects.toMatchObject({ + details: { reason: 'no-allow' }, + }) + hold(shelf('s1'), desk('d1')) + await expect(call(ctx, guest, 'scopedOnDesk', { scopeId: 's1', request: { deskId: 'd1' } })).resolves.toBe('test-guest') + await expect(call(ctx, guest, 'scopedByReader', { scopeId: 's9', request: { shelfId: 's1' } })).resolves.toBe('test-guest') + expect(decisions().map(({ resources }) => resources)).toEqual([ + [shelf('s1'), desk('d1')], + [shelf('s1'), desk('d1')], + [shelf('s1')], + ]) + }) + + it('refuses a malformed Context identity with resource-invalid without asking remoteAccess or resolving the Context', async () => { + const { ctx, hold, bindings } = await setup() + const resolve = registerScope(ctx, 'test-shelf') + hold(GLOBAL, shelf('s1')) + const decide = vi.spyOn(RemoteAccessService.prototype, 'decide') + const guest = peerFor(ctx, GUEST) + for (const scopeId of [7, '']) { + await expect(call(ctx, guest, 'scoped', { scopeId })).rejects.toMatchObject({ + code: 'remote-access/denied', details: { action: 'accessFixture/scoped', reason: 'resource-invalid' }, + }) + } + expect(resolve).not.toHaveBeenCalled() + expect(decide).not.toHaveBeenCalled() + expect(bindings).not.toHaveBeenCalled() + }) + + it('resolves a Context method whose adapter declares no resource by the ordinary sources', async () => { + const { ctx, hold } = await setup() + registerScope(ctx, undefined) + const decisions = recordDecisions() + const guest = peerFor(ctx, GUEST) + await expect(call(ctx, guest, 'scoped', { scopeId: 's1' })).rejects.toMatchObject({ details: { reason: 'no-allow' } }) + hold(GLOBAL, desk('d1')) + await expect(call(ctx, guest, 'scoped', { scopeId: 's1' })).resolves.toBe('test-guest') + await expect(call(ctx, guest, 'scopedOnDesk', { scopeId: 's1', request: { deskId: 'd1' } })).resolves.toBe('test-guest') + expect(decisions().map(({ resources }) => resources)).toEqual([ + [GLOBAL], + [GLOBAL], + [desk('d1')], + ]) + }) +}) + +describe('Gateway Peer lifetime', () => { + it('refuses calls and streams from a disposed Peer without affecting other Peers', async () => { + const { ctx } = await setup({ connection: true }) + const peer = peerFor(ctx, OWNER) + await peer.dispose() + await expect(call(ctx, peer, 'open')).rejects.toMatchObject({ code: 'gateway/peer-disposed' }) + await expect(ctx.typertGateway.stream({ peer, namespace: 'accessFixture', method: 'watch', args: {} })) + .rejects.toMatchObject({ code: 'gateway/peer-disposed' }) + await expect(ctx.typertGateway.wireStream.open('$events', { args: {} }, (async function* () {})(), peer, new AbortController().signal)) + .rejects.toMatchObject({ code: 'gateway/peer-disposed' }) + const result = await ctx.connection.createSharedFetchHandler('/api').fetch(new Request('http://host/api/$events/result', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + type: 'client-request', + rpcId: 'rpc-disposed', + method: '$events/result', + payload: { args: { clientId: 'any', eventId: 'any', outcome: { kind: 'next' } } }, + }), + }), peer) + expect(await result.json()).toMatchObject({ result: { ok: false, error: { code: 'gateway/peer-disposed' } } }) + await expect(call(ctx, ownerPeer(ctx), 'open')).resolves.toBe('owner') + }) + + it('refuses a disposed Peer before reading its wire payload', async () => { + const { ctx } = await setup({ connection: true }) + const peer = peerFor(ctx, OWNER) + await peer.dispose() + for (const payload of [{}, { args: 1 }, { args: {}, extra: true }]) { + await expect(ctx.typertGateway.wireStream.open('accessFixture/watch', payload, (async function* () {})(), peer, new AbortController().signal)) + .rejects.toMatchObject({ code: 'gateway/peer-disposed' }) + } + const response = await ctx.connection.createSharedFetchHandler('/api').fetch(new Request('http://host/api/accessFixture/open', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ type: 'client-request', rpcId: 'rpc-malformed', method: 'accessFixture/open', payload: { args: 1 } }), + }), peer) + expect(await response.json()).toMatchObject({ result: { ok: false, error: { code: 'gateway/peer-disposed' } } }) + }) + + it('ends a disposed Peer\'s streams and lets its running unary calls finish', async () => { + const { ctx, fixture } = await setup() + const peer = peerFor(ctx, OWNER) + const other = peerFor(ctx, OWNER) + const stream = (await ctx.typertGateway.stream({ peer, namespace: 'accessFixture', method: 'watch', args: {} }))[Symbol.asyncIterator]() + const survivorAbort = new AbortController() + const survivor = (await ctx.typertGateway.stream({ + peer: other, namespace: 'accessFixture', method: 'watch', args: {}, signal: survivorAbort.signal, + }))[Symbol.asyncIterator]() + await expect(stream.next()).resolves.toEqual({ done: false, value: 'owner' }) + await expect(survivor.next()).resolves.toEqual({ done: false, value: 'owner' }) + const running = call(ctx, peer, 'slow') + const pending = stream.next() + await peer.dispose() + // The stream's method signal joins the Peer's, so disposal cancels it like a carrier cancel. + await expect(pending).rejects.toMatchObject({ code: 'gateway/cancelled' }) + fixture.release.resolve() + await expect(running).resolves.toBe('owner') + const next = survivor.next() + let settled = false + void next.then(() => { settled = true }, () => { settled = true }) + await new Promise(resolve => setTimeout(resolve, 10)) + expect(settled).toBe(false) + survivorAbort.abort() + await expect(next).rejects.toMatchObject({ code: 'gateway/cancelled' }) + }) +}) + +/** Install a pushable event source; `push` yields one dispatch to every open `$events` stream. */ +function eventSource(ctx: Context): (dispatch: TypertRemoteEventDispatch) => void { + const queue: TypertRemoteEventDispatch[] = [] + let wake: (() => void) | undefined + ctx.effect(() => ctx.typertGateway.registerRemoteEvents(async function* (signal) { + while (!signal.aborted) { + while (queue.length > 0) yield queue.shift() as TypertRemoteEventDispatch + await new Promise((resolve) => { + wake = resolve + signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + } + }, { home: '/fixture' })) + return (dispatch) => { + queue.push(dispatch) + wake?.() + } +} + +describe('Gateway forwarded events for non-owner Peers', () => { + it('delivers only allowed broadcasts and no answerable waterfall to a non-owner, and refuses its results', async () => { + const { ctx, hold } = await setup({ connection: true }) + hold(shelf('s1')) + const decide = vi.spyOn(RemoteAccessService.prototype, 'decide') + const push = eventSource(ctx) + const guest = ctx.connection.createPeer({ principal: GUEST }) + const abort = new AbortController() + const guestEvents = (await ctx.typertGateway.wireStream.open('$events', { args: {} }, (async function* () {})(), guest, abort.signal))[Symbol.asyncIterator]() + const guestReady = await guestEvents.next() + // A non-owner stream receives the same Host facts but cannot answer, so it does not count as a live Client. + expect(guestReady.value).toMatchObject({ type: 'ready', host: { home: '/fixture' } }) + expect(ctx.typertGateway.hasLiveClient()).toBe(false) + const ownerEvents = (await ctx.typertGateway.wireStream.open('$events', { args: {} }, (async function* () {})(), ctx.connection.browserPeer, abort.signal))[Symbol.asyncIterator]() + await expect(ownerEvents.next()).resolves.toMatchObject({ value: { type: 'ready', host: { home: '/fixture' } } }) + expect(ctx.typertGateway.hasLiveClient()).toBe(true) + + const agentCtx = ctx.extend({}) + const subject = {} + const settled = vi.fn() + push({ + event: 'fixture/ask', + request: { question: 'q', agent: subject }, + context: { value: agentCtx, subject, agentId: 'agent-1' }, + resolve: settled, + reject: settled, + }) + const resources = { 'test-shelf': (shelfId: unknown) => shelfId } + push({ event: 'fixture/notice', args: ['hello'] }) + push({ event: 'fixture/shelf', args: ['s2'], resources }) + push({ event: 'fixture/shelf', args: ['s1'], resources }) + await expect(ownerEvents.next()).resolves.toMatchObject({ value: { type: 'waterfall', event: 'fixture/ask' } }) + await expect(ownerEvents.next()).resolves.toMatchObject({ value: { type: 'emit', event: 'fixture/notice' } }) + await expect(ownerEvents.next()).resolves.toMatchObject({ value: { type: 'emit', event: 'fixture/shelf', args: ['s2'] } }) + await expect(ownerEvents.next()).resolves.toMatchObject({ value: { type: 'emit', event: 'fixture/shelf', args: ['s1'] } }) + // The global notice and the denied shelf never reach the non-owner; the allowed one arrives whole. + await expect(guestEvents.next()).resolves.toEqual({ done: false, value: { type: 'emit', event: 'fixture/shelf', args: ['s1'] } }) + expect(decide.mock.calls.map(([request]) => request)).toEqual([ + { principal: GUEST, action: 'event:fixture/notice', resources: [GLOBAL] }, + { principal: GUEST, action: 'event:fixture/shelf', resources: [shelf('s2')] }, + { principal: GUEST, action: 'event:fixture/shelf', resources: [shelf('s1')] }, + ]) + + const clientId: unknown = Reflect.get(guestReady.value as object, 'clientId') + const shared = ctx.connection.createSharedFetchHandler('/api') + const refused = await shared.fetch(new Request('http://host/api/$events/result', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + type: 'client-request', + rpcId: 'rpc-result', + method: '$events/result', + payload: { args: { clientId, eventId: 'any', outcome: { kind: 'next' } } }, + }), + }), guest) + expect(await refused.json()).toMatchObject({ + result: { ok: false, error: { code: 'gateway/invocation-unavailable', details: { endpoint: '$events/result' } } }, + }) + abort.abort() + await guestEvents.return?.() + await ownerEvents.return?.() + }) + + it('delivers a global event that a global role lists, and drops one whose resource is missing without asking remoteAccess', async () => { + const { ctx, hold } = await setup({ connection: true }) + hold(GLOBAL, shelf('s1')) + const decisions = recordDecisions() + const push = eventSource(ctx) + const abort = new AbortController() + const open = async (): Promise> => (await ctx.typertGateway.wireStream.open( + '$events', { args: {} }, (async function* () {})(), ctx.connection.createPeer({ principal: GUEST }), abort.signal, + ))[Symbol.asyncIterator]() + const streams = [await open(), await open()] + for (const events of streams) await events.next() + const missing = vi.fn(() => undefined) + push({ event: 'fixture/shelf', args: [null], resources: { 'test-shelf': missing } }) + push({ event: 'fixture/notice', args: ['hello'], resources: {} }) + for (const events of streams) { + await expect(events.next()).resolves.toEqual({ done: false, value: { type: 'emit', event: 'fixture/notice', args: ['hello'] } }) + } + // A frame's resources are resolved once for all non-owner streams. + expect(missing).toHaveBeenCalledOnce() + const notice = { action: 'event:fixture/notice', resources: [GLOBAL] } + expect(decisions()).toEqual([notice, notice]) + abort.abort() + for (const events of streams) await events.return?.() + }) + + it('discards the events buffered for a revoked non-owner Peer and keeps other streams', async () => { + const { ctx, hold } = await setup({ connection: true }) + hold(shelf('s1')) + const push = eventSource(ctx) + const abort = new AbortController() + const open = async (peer: PeerScope): Promise> => + (await ctx.typertGateway.wireStream.open('$events', { args: {} }, (async function* () {})(), peer, abort.signal))[Symbol.asyncIterator]() + const revoked = ctx.connection.createPeer({ principal: GUEST }) + const lagging = await open(revoked) + const other = await open(ctx.connection.createPeer({ principal: GUEST })) + const owner = await open(ctx.connection.browserPeer) + for (const events of [lagging, other, owner]) await events.next() + const resources = { 'test-shelf': (shelfId: unknown) => shelfId } + push({ event: 'fixture/shelf', args: ['s1'], resources }) + push({ event: 'fixture/shelf', args: ['s1'], resources }) + const frame = { done: false, value: { type: 'emit', event: 'fixture/shelf', args: ['s1'] } } + for (const events of [other, owner]) { + await expect(events.next()).resolves.toMatchObject(frame) + await expect(events.next()).resolves.toMatchObject(frame) + } + // Both frames were queued for the lagging stream before its Peer was revoked. + await revoked.dispose() + await expect(lagging.next()).resolves.toEqual({ done: true, value: undefined }) + push({ event: 'fixture/shelf', args: ['s1'], resources }) + for (const events of [other, owner]) await expect(events.next()).resolves.toMatchObject(frame) + abort.abort() + for (const events of [other, owner]) await events.return?.() + }) + + it('contains a throwing policy to the non-owner deliveries it decides', async () => { + const ctx = new Context() + roots.push(ctx) + await ctx.plugin(TypertRegistry) + await ctx.plugin(RemoteAccessService) + await ctx.plugin(TypertGatewayService) + provideBrowserCredentials(ctx) + await ctx.plugin({ inject: [...connectionInject], apply: applyConnection }) + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) + ctx.remoteAccess.registerPolicy('test-guest', { bindings: () => { throw new Error('binding store unavailable') } }) + const push = eventSource(ctx) + const abort = new AbortController() + const open = async (peer: PeerScope): Promise> => + (await ctx.typertGateway.wireStream.open('$events', { args: {} }, (async function* () {})(), peer, abort.signal))[Symbol.asyncIterator]() + const guestEvents = await open(ctx.connection.createPeer({ principal: GUEST })) + const ownerEvents = await open(ctx.connection.browserPeer) + await guestEvents.next() + await ownerEvents.next() + push({ event: 'fixture/shelf', args: ['s1'], resources: { 'test-shelf': (shelfId: unknown) => shelfId } }) + push({ event: 'fixture/notice', args: ['after'] }) + await expect(ownerEvents.next()).resolves.toMatchObject({ value: { type: 'emit', event: 'fixture/shelf' } }) + // The shared source survives the failure: later events still reach the owner. + await expect(ownerEvents.next()).resolves.toMatchObject({ value: { type: 'emit', event: 'fixture/notice' } }) + expect(warn).toHaveBeenCalledWith( + 'remote access: the "test-guest" policy failed to list bindings for event:fixture/shelf; its further failures are not logged until access state changes') + const guestNext = guestEvents.next() + abort.abort() + await expect(guestNext).resolves.toEqual({ done: true, value: undefined }) + await ownerEvents.return?.() + }) +}) + +describe('resource resolution', () => { + const NEVER = new AbortController().signal + const ROOM = { kind: 'test-shelf', id: 'r1' } as const + const DESK = { kind: 'test-desk', id: 'd1' } as const + const DIRECT: InvocationDescriptor['invocation'] = { kind: 'direct' } + + /** A direct method's descriptor fields carrying only what resource resolution reads. */ + function method(...wires: string[]): Pick { + return { invocation: DIRECT, parameters: wires.map(wire => ({ name: wire, wire, source: 'json' }) as InvocationParameterDescriptor) } + } + + async function registry(): Promise { + const ctx = new Context() + roots.push(ctx) + await ctx.plugin(TypertRegistry) + ctx.typert.resources.register('test-shelf', { field: 'roomId' }) + ctx.typert.resources.register('test-desk', { field: 'deskId' }) + return ctx.typert + } + + it('reads a default field from a top-level argument, else from the first object argument, else touches global', async () => { + const typert = await registry() + expect(callResources(typert, undefined, method('roomId'), { roomId: 'r1' }, NEVER)) + .toEqual([ROOM]) + expect(callResources(typert, undefined, method('count', 'tags', 'none', 'request'), { + count: 1, tags: ['r9'], none: null, request: { roomId: 'r1', deskId: 'd1' }, + }, NEVER)).toEqual([ROOM, DESK]) + // Only the first object argument is read; a later one naming the field does not count. + expect(callResources(typert, undefined, method('first', 'second'), { first: {}, second: { roomId: 'r1' } }, NEVER)) + .toEqual([GLOBAL]) + expect(callResources(typert, undefined, method(), {}, NEVER)).toEqual([GLOBAL]) + }) + + it('lets a reader replace the defaults for its kind, called with the positional wire arguments', async () => { + const typert = await registry() + const readers = { 'test-shelf': (ids: unknown) => ids } + expect(callResources(typert, readers, method('ids'), { ids: ['r1', 'r2'] }, NEVER)) + .toEqual([ROOM, { kind: 'test-shelf', id: 'r2' }]) + expect(callResources(typert, readers, method('ids', 'roomId'), { ids: undefined, roomId: 'r9' }, NEVER)) + .toEqual([GLOBAL]) + const signal = new AbortController().signal + const positional = vi.fn((...args: readonly unknown[]) => (args.at(-1) === signal ? args[1] : 'wrong')) + expect(callResources(typert, { 'test-desk': positional }, { + ...method('first', 'desk'), cancellation: { parameter: 'signal' }, + }, { first: 'f', desk: 'd1' }, signal)).toEqual([DESK]) + expect(positional).toHaveBeenCalledWith('f', 'd1', signal) + }) + + it('reads lookup parameters and a Context identity of a declared kind, an omitted optional lookup as none', async () => { + const typert = await registry() + typert.lookups.register('gatewayAccessAgent', { + parameter: 'agent', + wire: 'agentId', + resource: 'test-desk', + hostTypeSymbol: '@fixture/access#Agent', + wireTypeSymbol: '@fixture/access#AgentId', + resolve: (id: string) => ({ id }), + }) + typert.contexts.registerHost('gatewayAccessScope', { + wire: 'scopeId', wireTypeSymbol: '@fixture/access#ScopeId', resource: 'test-desk', resolve: () => undefined, + }) + const agent = { name: 'agent', wire: 'agentId', source: 'lookup', lookup: 'gatewayAccessAgent' } as InvocationParameterDescriptor + const unknownLookup = { name: 'other', wire: 'otherId', source: 'lookup', lookup: 'absentLookup' } as InvocationParameterDescriptor + expect(callResources(typert, undefined, { invocation: DIRECT, parameters: [agent, unknownLookup] }, { agentId: undefined, otherId: 'o1' }, NEVER)) + .toEqual([GLOBAL]) + expect(callResources(typert, undefined, { invocation: DIRECT, parameters: [agent] }, { agentId: 'd1' }, NEVER)) + .toEqual([DESK]) + const scoped = (context: string): InvocationDescriptor['invocation'] => ({ kind: 'context', context, wire: 'scopeId', codec: { mode: 'src-json' } }) + expect(callResources(typert, undefined, { invocation: scoped('gatewayAccessScope'), parameters: [agent] }, { scopeId: 'd0', agentId: 'd1' }, NEVER)) + .toEqual([{ kind: 'test-desk', id: 'd0' }, DESK]) + // A required Context identity is never omitted, so a missing one is malformed. + expect(callResources(typert, undefined, { invocation: scoped('gatewayAccessScope'), parameters: [] }, {}, NEVER)) + .toBeUndefined() + expect(callResources(typert, undefined, { invocation: scoped('absentScope'), parameters: [] }, { scopeId: 'd0' }, NEVER)) + .toEqual([GLOBAL]) + }) + + it('reports invalid resources when a reader throws or returns an empty list, or a resource id is not a non-empty string', async () => { + const typert = await registry() + const one = method('value') + const throwing = { 'test-shelf': () => { throw new TypeError('malformed call') } } + expect(callResources(typert, throwing, one, { value: 1 }, NEVER)).toBeUndefined() + for (const value of ['', 7, ['r1', ''], [null], []]) { + expect(callResources(typert, { 'test-shelf': (read: unknown) => read }, one, { value }, NEVER)).toBeUndefined() + } + // A default field names exactly one identity, so an array is malformed. + for (const roomId of [7, ['r1'], '']) { + expect(callResources(typert, undefined, method('roomId'), { roomId }, NEVER)).toBeUndefined() + } + }) + + it('reads a forwarded event through the readers its entry declares, and the global resource without them', () => { + expect(eventResources(undefined, ['r1'])).toEqual([GLOBAL]) + expect(eventResources({}, ['r1'])).toEqual([GLOBAL]) + expect(eventResources({ 'test-shelf': (id: unknown) => id }, ['r1'])).toEqual([ROOM]) + expect(eventResources({ 'test-desk': (_first: unknown, second: unknown) => second }, ['r1', 'd1'])) + .toEqual([DESK]) + // An event carries every resource it declares, so a missing one is invalid rather than a global event. + expect(eventResources({ 'test-shelf': () => undefined }, ['r1'])).toBeUndefined() + expect(eventResources({ 'test-shelf': () => [] }, ['r1'])).toBeUndefined() + expect(eventResources({ 'test-shelf': () => 7 }, ['r1'])).toBeUndefined() + }) +}) diff --git a/packages/api/gateway/tests/gateway-stream.host.spec.ts b/packages/api/gateway/tests/gateway-stream.host.spec.ts index 012f1b73fa..72a46e21f6 100644 --- a/packages/api/gateway/tests/gateway-stream.host.spec.ts +++ b/packages/api/gateway/tests/gateway-stream.host.spec.ts @@ -21,6 +21,7 @@ import { RemoteError, } from '@deepseek-ai/dsh-typert-protocol' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' +import RemoteAccessService from '@deepseek-ai/dsh-api-remote-access' declare module '@deepseek-ai/dsh-typert-protocol' { interface RemoteErrorDetailsMap { @@ -41,6 +42,7 @@ import type { RemoteEventClientId, RemoteEventInvocationFrame, } from '../src/stream-protocol.ts' +import { ownerPeer, peerFor } from './peer.ts' vi.mock('node:crypto', async (importOriginal) => { const actual = await importOriginal() @@ -56,6 +58,7 @@ class FeedService extends TypertRemoteService { readonly signals: AbortSignal[] = [] readonly peeked: unknown[] = [] readonly peers: PeerScope[] = [] + readonly windows: RemoteInvocation['uplinkWindow'][] = [] leftover: AsyncIterator | undefined source: Iterable = [] returns = 0 @@ -130,6 +133,7 @@ class FeedService extends TypertRemoteService { this.signals.push(signal) const invocation = this.invocation() this.peers.push(invocation.peer) + this.windows.push(invocation.uplinkWindow) try { for await (const item of invocation.uplink()) yield `${prefix}${item}` } finally { @@ -366,13 +370,13 @@ describe('Typert Remote streams', () => { const unregister = ctx.effect(() => ctx.typertGateway.registerRemoteEvents(source.source, REMOTE_HOST)) const abort = new AbortController() const empty = (async function* () {})() - const events = await ctx.typertGateway.wireStream.open('$events', { args: {} }, empty, undefined, abort.signal) + const events = await ctx.typertGateway.wireStream.open('$events', { args: {} }, empty, ownerPeer(ctx), abort.signal) const first = events[Symbol.asyncIterator]() await first.next() expect(ctx.typertGateway.hasLiveClient()).toBe(true) abort.abort() expect(ctx.typertGateway.hasLiveClient()).toBe(false) - const replacement = await ctx.typertGateway.wireStream.open('$events', { args: {} }, empty, undefined, new AbortController().signal) + const replacement = await ctx.typertGateway.wireStream.open('$events', { args: {} }, empty, ownerPeer(ctx), new AbortController().signal) const second = replacement[Symbol.asyncIterator]() try { await second.next() @@ -430,7 +434,7 @@ describe('Typert Remote streams', () => { 'feed/sync', { args: { label: 'wire' } }, toAsync([]), - undefined, + ownerPeer(ctx), new AbortController().signal, ) await expect(collect(source)).resolves.toEqual(['wire:one', 'wire:two']) @@ -439,7 +443,7 @@ describe('Typert Remote streams', () => { 'feed/echo', { args: { prefix: 'wire:' } }, toAsync(['a', 'b']), - undefined, + ownerPeer(ctx), new AbortController().signal, ) await expect(collect(echoed)).resolves.toEqual(['wire:a', 'wire:b']) @@ -448,13 +452,16 @@ describe('Typert Remote streams', () => { it('delivers uplink items through the in-process carrier and ends on half-close', async () => { const { ctx, service } = await setup(false) await expect(collect(await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'echo', args: { prefix: '> ' }, uplink: toAsync(['a', 'b']), }))).resolves.toEqual(['> a', '> b']) expect(service.returns).toBe(1) await expect(collect(await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'echo', args: { prefix: 'x' }, }))).resolves.toEqual([]) await expect(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'echo', args: { prefix: 'x' }, })).rejects.toMatchObject({ code: 'gateway/signature-invalid', @@ -472,6 +479,7 @@ describe('Typert Remote streams', () => { }), } await expect(collect(await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'ignore', args: { prefix: 'i' }, uplink: unread, }))).resolves.toEqual(['i:one', 'i:two']) await vi.waitFor(() => { expect(returned).toHaveBeenCalledOnce() }) @@ -480,6 +488,7 @@ describe('Typert Remote streams', () => { it('fails a stream whose uplink item fails its codec, even when the method swallows the failure', async () => { const { ctx, service } = await setup(false) const rejected = await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'echo', args: { prefix: '' }, uplink: toAsync(['ok', 1]), }) const iterator = rejected[Symbol.asyncIterator]() @@ -494,6 +503,7 @@ describe('Typert Remote streams', () => { expect(service.returns).toBe(1) await expect(collect(await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'swallow', args: {}, uplink: toAsync([1]), }))).rejects.toMatchObject({ code: 'gateway/input-invalid' }) }) @@ -502,6 +512,7 @@ describe('Typert Remote streams', () => { const { ctx, service } = await setup(false) const abort = new AbortController() const source = await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'peek', args: {}, uplink: neverYielding(), signal: abort.signal, }) const iterator = source[Symbol.asyncIterator]() @@ -518,6 +529,7 @@ describe('Typert Remote streams', () => { const { ctx, service } = await setup(false) const abort = new AbortController() const source = await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'hold', args: {}, uplink: toAsync(['unread']), signal: abort.signal, }) const iterator = source[Symbol.asyncIterator]() @@ -534,7 +546,9 @@ describe('Typert Remote streams', () => { it('exposes the call as ctx.invocation, delivers codec-less uplink items as unknown, and hands the uplink out once', async () => { const { ctx, service } = await setup(false) + const caller = peerFor(ctx, { kind: 'owner' }) await expect(collect(await ctx.typertGateway.stream({ + peer: caller, namespace: 'feed', method: 'context', args: { label: 'ctx' }, uplink: toAsync([1, { nested: true }]), }))).resolves.toEqual([ 'feed/context:feed:{"label":"ctx"}:ctx', @@ -545,18 +559,22 @@ describe('Typert Remote streams', () => { expect(service.peers.at(-1)?.id).toMatch(/^[0-9a-f-]{36}$/) expect(service.invocationOutsideCall()).toBeUndefined() await expect(collect(await ctx.typertGateway.stream({ + peer: caller, namespace: 'feed', method: 'context', args: { label: 'again' }, }))).resolves.toHaveLength(2) + expect(service.peers.at(-1)).toBe(caller) expect(service.peers.at(-1)).toBe(service.peers.at(-2)) await expect(service.peers.at(-1)?.dispose()).resolves.toBeUndefined() // Items sent to a method that never takes its uplink wait in the carrier and are dropped when the downlink ends. await expect(collect(await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'sync', args: { label: 'closed' }, uplink: toAsync(['unread']), }))).resolves.toEqual(['closed:one', 'closed:two']) // Without a codec an item still has to be a JSON-safe value. await expect(collect(await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'context', args: { label: 'bad' }, uplink: toAsync([1n]), }))).rejects.toMatchObject({ code: 'gateway/input-invalid', @@ -577,6 +595,7 @@ describe('Typert Remote streams', () => { }), } const source = await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'hold', args: {}, uplink: stuck, signal: abort.signal, }) const iterator = source[Symbol.asyncIterator]() @@ -590,6 +609,7 @@ describe('Typert Remote streams', () => { it('closes the uplink before returning a method blocked on it', async () => { const { ctx } = await setup(false) const source = await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'drain', args: {}, uplink: neverYielding(), }) const iterator = source[Symbol.asyncIterator]() @@ -606,6 +626,7 @@ describe('Typert Remote streams', () => { let index = 0 const returned = vi.fn(async (): Promise> => ({ done: true, value: undefined })) const source = await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'hold', args: {}, uplink: { [Symbol.asyncIterator]: () => ({ next: () => reads[index++]!.promise, return: returned }) }, }) @@ -637,9 +658,9 @@ describe('Typert Remote streams', () => { const { ctx, service } = await setup(false) service.source = { [Symbol.iterator]: () => ({ next: () => new ReadResult() }) } const source = await ctx.typertGateway.stream(direction === 'downlink' - ? { namespace: 'feed', method: 'items', args: {} } + ? { peer: ownerPeer(ctx), namespace: 'feed', method: 'items', args: {} } : { - namespace: 'feed', method: 'echo', args: { prefix: '' }, + peer: ownerPeer(ctx), namespace: 'feed', method: 'echo', args: { prefix: '' }, uplink: { [Symbol.asyncIterator]: () => ({ next: async () => new ReadResult() }) }, }) const iterator = source[Symbol.asyncIterator]() @@ -662,6 +683,7 @@ describe('Typert Remote streams', () => { const next = vi.fn((): IteratorResult => ({ done: false, value: 'item' })) service.source = { [Symbol.iterator]: () => ({ next }) } const source = await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'items', args: {}, signal: abort.signal, }) const iterator = source[Symbol.asyncIterator]() @@ -685,9 +707,9 @@ describe('Typert Remote streams', () => { } service.source = { [Symbol.iterator]: () => ({ next }) } const source = await ctx.typertGateway.stream(direction === 'downlink' - ? { namespace: 'feed', method: 'items', args: {}, signal: abort.signal } + ? { peer: ownerPeer(ctx), namespace: 'feed', method: 'items', args: {}, signal: abort.signal } : { - namespace: 'feed', method: 'hold', args: {}, signal: abort.signal, + peer: ownerPeer(ctx), namespace: 'feed', method: 'hold', args: {}, signal: abort.signal, uplink: { [Symbol.asyncIterator]: () => ({ next }) }, }) const iterator = source[Symbol.asyncIterator]() @@ -704,6 +726,7 @@ describe('Typert Remote streams', () => { it('reports done on every read after the uplink ended', async () => { const { ctx } = await setup(false) await expect(collect(await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'rereads', args: {}, uplink: toAsync(['a', 'b']), }))).resolves.toEqual(['a', 'b', 'done']) }) @@ -711,6 +734,7 @@ describe('Typert Remote streams', () => { it('accepts a top-level undefined uplink item where no codec applies and rejects it where one does', async () => { const { ctx } = await setup(false) await expect(collect(await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'context', args: { label: 'absent' }, uplink: toAsync([undefined, 'x']), }))).resolves.toEqual([ 'feed/context:feed:{"label":"absent"}:absent', @@ -719,6 +743,7 @@ describe('Typert Remote streams', () => { 'typert gateway: feed/context: invocation.uplink() is available once per call', ]) await expect(collect(await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'echo', args: { prefix: '' }, uplink: toAsync([undefined]), }))).rejects.toMatchObject({ code: 'gateway/input-invalid', details: { endpoint: 'feed/echo', field: 'uplink' } }) }) @@ -730,6 +755,7 @@ describe('Typert Remote streams', () => { [Symbol.asyncIterator]: () => ({ next: () => new Promise>(() => {}), return: returned }), } await expect(ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'unary', args: { label: 'a' }, uplink: unread, })).rejects.toMatchObject({ code: 'gateway/signature-invalid' }) await vi.waitFor(() => { expect(returned).toHaveBeenCalledOnce() }) @@ -738,6 +764,7 @@ describe('Typert Remote streams', () => { it('lets a unary method read uplink items while it runs', async () => { const { ctx } = await setup(false) await expect(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'unaryUplink', args: {}, uplink: toAsync(['a', 'b']), })).resolves.toEqual(['a', 'b']) }) @@ -745,10 +772,43 @@ describe('Typert Remote streams', () => { it('derives SRC descriptors whose uplink arrives without a codec', async () => { const { ctx } = await setup(false) await expect(collect(await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'srcEcho', args: { prefix: 's:' }, uplink: toAsync(['a', 2]), }))).resolves.toEqual(['s:a', 's:2']) }) + it('passes carrier capacity to the invocation and leaves unlimited carriers without a window', async () => { + const { ctx, service } = await setup(false) + const window = { maxBytes: 512, frameOverheadBytes: 40 } + for (const uplinkWindow of [undefined, window]) { + await expect(collect(await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), + namespace: 'feed', method: 'echo', args: { prefix: '' }, uplink: toAsync(['a']), uplinkWindow, + }))).resolves.toEqual(['a']) + } + expect(service.windows).toEqual([undefined, window]) + }) + + it('reports configured frame capacity and the encoded envelope size for the current stream id', async () => { + const { ctx, service } = await setup(true, { streamInboxBytes: 512 }) + const socket = new WebSocket(`ws://127.0.0.1:${String(ctx.webServer.port)}/api/remote.mux`, { headers: { cookie: browserCookie(ctx) } }) + await once(socket, 'open') + try { + const streamId = '界"window' + sendOpen(socket, streamId, 'feed/echo', { prefix: '' }) + socket.send(JSON.stringify({ type: 'end', streamId })) + await vi.waitFor(() => { expect(service.windows).toHaveLength(1) }) + const value = 'line\n界' + const complete = JSON.stringify({ type: 'item', streamId, value }) + expect(service.windows[0]).toEqual({ + maxBytes: 512, frameOverheadBytes: Buffer.byteLength(complete, 'utf8') - Buffer.byteLength(JSON.stringify(value), 'utf8'), + }) + } finally { + socket.close() + await once(socket, 'close') + } + }) + it('echoes uplink frames over the WebSocket carrier and fails misused uplinks per stream', async () => { const { ctx, service } = await setup(true) const socket = new WebSocket(`ws://127.0.0.1:${String(ctx.webServer.port)}/api/remote.mux`, { @@ -770,7 +830,7 @@ describe('Typert Remote streams', () => { ]) }) expect(service.returns).toBe(1) - expect(service.peers.at(-1)).toBe(ctx.connection.operator) + expect(service.peers.at(-1)).toBe(ctx.connection.browserPeer) sendOpen(socket, 'rejected', 'feed/echo', { prefix: '' }) socket.send(JSON.stringify({ type: 'item', streamId: 'rejected', value: 1 })) @@ -827,6 +887,7 @@ describe('Typert Remote streams', () => { const { ctx, service } = await setup(false) const abort = new AbortController() const source = await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'follow', args: { label: 'a' }, @@ -837,35 +898,44 @@ describe('Typert Remote streams', () => { const pending = iterator.next() abort.abort(new Error('fixture cancellation')) await expect(pending).rejects.toThrow('Remote invocation "feed/follow" was aborted') - expect(service.signals).toEqual([abort.signal]) + // A stream's method signal also ends with its Peer, so it follows the caller's signal rather than being it. + expect(service.signals).toHaveLength(1) + expect(service.signals[0]?.reason).toBe(abort.signal.reason) expect(service.returns).toBe(1) await expect(collect(await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'sync', args: { label: 'b' }, }))).resolves.toEqual(['b:one', 'b:two']) await expect(collect(await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'invalid', args: {}, }))).resolves.toEqual([42]) await expect(collect(await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'nonJson', args: {}, }))).resolves.toEqual([1n]) await expect(ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'missing', args: {}, })).rejects.toMatchObject({ code: 'gateway/result-invalid' }) await expect(collect(await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'src', args: { label: 'c' }, }))).resolves.toEqual(['c:src']) const abortedBeforeOpen = new AbortController() abortedBeforeOpen.abort(new Error('cancelled before open')) await expect(ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'abortBeforeOpen', args: {}, signal: abortedBeforeOpen.signal, })).rejects.toThrow('Remote invocation "feed/abortBeforeOpen" was aborted') const abortedBeforeIteration = new AbortController() abortedBeforeIteration.abort(new Error('cancelled before iteration')) const preCancelled = await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'sync', args: { label: 'ignored' }, signal: abortedBeforeIteration.signal, }) await expect(collect(preCancelled)).rejects.toThrow('Remote invocation "feed/sync" was aborted') @@ -874,9 +944,11 @@ describe('Typert Remote streams', () => { it('keeps unary and stream invocation modes distinct', async () => { const { ctx } = await setup(false) await expect(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'sync', args: { label: 'a' }, })).rejects.toMatchObject({ code: 'gateway/signature-invalid' } satisfies Partial) await expect(ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'unary', args: { label: 'a' }, })).rejects.toMatchObject({ code: 'gateway/signature-invalid' } satisfies Partial) }) @@ -971,7 +1043,7 @@ describe('Typert Remote streams', () => { const idle: AsyncIterable = { [Symbol.asyncIterator]: () => ({ next: () => new Promise>(() => {}), return: returned }), } - const events = await ctx.typertGateway.wireStream.open('$events', { args: {} }, idle, undefined, new AbortController().signal) + const events = await ctx.typertGateway.wireStream.open('$events', { args: {} }, idle, ownerPeer(ctx), new AbortController().signal) await vi.waitFor(() => { expect(returned).toHaveBeenCalledOnce() }) await events[Symbol.asyncIterator]().return?.() @@ -1226,7 +1298,7 @@ describe('Typert Remote streams', () => { it('retries a colliding Remote event Client id before opening the second generation', async () => { const { ctx } = await setup(true) - // Connection minted the operator Peer id above; only the event Client ids are counted below. + // Connection minted the browser Peer id above; only the event Client ids are counted below. randomUuid.mockClear() const source = new RemoteEventSourceProbe() const unregister = ctx.typertGateway.registerRemoteEvents(source.source, REMOTE_HOST) @@ -1560,6 +1632,7 @@ async function setup( provideBrowserCredentials(ctx) } await ctx.plugin(TypertRegistry) + await ctx.plugin(RemoteAccessService) await ctx.plugin(TypertGatewayService, gatewayConfig) if (transport) { await ctx.plugin({ inject: [...connectionInject], apply: applyConnection }) diff --git a/packages/api/gateway/tests/gateway.host.spec.ts b/packages/api/gateway/tests/gateway.host.spec.ts index e0fcbec649..8f3f6fddc4 100644 --- a/packages/api/gateway/tests/gateway.host.spec.ts +++ b/packages/api/gateway/tests/gateway.host.spec.ts @@ -7,7 +7,6 @@ import { apply as applyConnection, inject as connectionInject } from '@deepseek- import type { ConnectionRpcHandler, HostConnectionHandle, - PeerId, PeerScope, } from '@deepseek-ai/dsh-client-connection' import type { WebServer, WebRoute } from '@deepseek-ai/dsh-host-webserver' @@ -23,7 +22,9 @@ import { } from '@deepseek-ai/dsh-typert-protocol' import TypertRegistry, { type TypertContribution } from '@deepseek-ai/dsh-typert-registry' import TypertGatewayService, { TypertGatewayError } from '@deepseek-ai/dsh-api-gateway' +import RemoteAccessService from '@deepseek-ai/dsh-api-remote-access' import { provideBrowserCredentials } from './browser-credentials.ts' +import { ownerPeer } from './peer.ts' interface FixtureAgent { readonly id: string @@ -116,12 +117,12 @@ class FakeConnectionService extends Service { channel: string | undefined matches: ((endpoint: string) => boolean) | undefined handler: FakeRpcHandler | undefined - /** The operator Peer every call this fake dispatches speaks for. */ - readonly operator: PeerScope + /** The browser Peer every call this fake dispatches speaks for. */ + readonly browserPeer: PeerScope constructor(ctx: Context) { super(ctx, 'connection') - this.operator = { id: 'fake-operator' as PeerId, ctx, dispose: () => Promise.resolve() } + this.browserPeer = ownerPeer(ctx) } get rpc() { @@ -135,7 +136,7 @@ class FakeConnectionService extends Service { owner.effect(() => { this.channel = channel this.matches = matches - this.handler = (endpoint, payload, signal) => handler(endpoint, payload, signal, this.operator) + this.handler = (endpoint, payload, signal) => handler(endpoint, payload, signal, this.browserPeer) return () => { this.channel = undefined this.matches = undefined @@ -407,6 +408,7 @@ describe('TypertGatewayService', () => { const abort = new AbortController() await expect(caller.typertGateway.invoke({ + peer: ownerPeer(caller), namespace: 'goals', method: 'create', args: { agentId: 'agent-1', request: { title: ' ship ' } }, @@ -416,6 +418,7 @@ describe('TypertGatewayService', () => { expect(service.lastSignal).toBe(abort.signal) await expect(caller.typertGateway.invoke({ + peer: ownerPeer(caller), namespace: 'goals', method: 'create', args: { agentId: 'agent-1', request: { title: 'again' } }, @@ -431,6 +434,7 @@ describe('TypertGatewayService', () => { registerStrict(ctx, [renameDescriptor()]) await expect(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'rename', args: { agentId: 'agent-1', request: { title: 'land' } }, @@ -446,6 +450,7 @@ describe('TypertGatewayService', () => { const abort = new AbortController() await expect(caller.typertGateway.invoke({ + peer: ownerPeer(caller), namespace: 'goals', method: 'create', args: { agentId: 'agent-1', request: { title: 'ship' } }, @@ -464,6 +469,7 @@ describe('TypertGatewayService', () => { registerAgentLookup(ctx, { id: 'agent-1' }) await expect(ctx.extend({ fixtureScope: 'unbound' }).typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'create', args: { agentId: 'agent-1', request: { title: 'ship' } }, @@ -477,6 +483,7 @@ describe('TypertGatewayService', () => { await dispose() await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'create', args: { agentId: 'agent-1', request: { title: 'ship' } }, @@ -490,6 +497,7 @@ describe('TypertGatewayService', () => { ctx.typert.contexts.registerHost('gatewayFixture', contextProvider(scoped)) await expect(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'rename', args: { agentId: 'agent-1', request: { title: 'land' } }, @@ -503,15 +511,19 @@ describe('TypertGatewayService', () => { await ctx.plugin(InheritedMethodService) await expect(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'exported', method: 'execute', args: { value: 'ship' }, })).resolves.toBe('ship') await expect(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'empty', method: 'ping', args: {}, })).resolves.toBe('pong') await expect(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'inherited', method: 'run', args: { value: 'land' }, })).resolves.toBe('land') await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'other', method: 'absent', args: {}, }), 'gateway/invocation-unavailable') }) @@ -521,6 +533,7 @@ describe('TypertGatewayService', () => { await colliding.plugin(CollidingWireService) registerAgentLookup(colliding, { id: 'agent-1' }) await expectCode(colliding.typertGateway.invoke({ + peer: ownerPeer(colliding), namespace: 'colliding-wire', method: 'run', args: { agentId: 'agent-1' }, @@ -528,6 +541,7 @@ describe('TypertGatewayService', () => { const missing = await setup() await expectCode(missing.ctx.typertGateway.invoke({ + peer: ownerPeer(missing.ctx), namespace: 'goals', method: 'rename', args: { agentId: 'agent-1', request: { title: 'land' } }, @@ -537,6 +551,7 @@ describe('TypertGatewayService', () => { await contextCollision.plugin(ContextWireService) contextCollision.typert.contexts.registerHost('gatewayFixture', contextProvider(contextCollision.extend())) await expectCode(contextCollision.typertGateway.invoke({ + peer: ownerPeer(contextCollision), namespace: 'context-wire', method: 'run', args: { agentId: 'agent-1' }, @@ -551,6 +566,7 @@ describe('TypertGatewayService', () => { await disposeLookup() await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'create', args: { agentId: 'agent-1', request: { title: 'ship' } }, @@ -559,6 +575,7 @@ describe('TypertGatewayService', () => { registerAgentLookup(ctx, agent) await serviceFiber.dispose() await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'create', args: { agentId: 'agent-1', request: { title: 'ship' } }, @@ -573,6 +590,7 @@ describe('TypertGatewayService', () => { await dispose() await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'rename', args: { agentId: 'agent-1', request: { title: 'land' } }, @@ -583,6 +601,7 @@ describe('TypertGatewayService', () => { resolve: () => { throw new Error('provider failed') }, }) const error = await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'rename', args: { agentId: 'agent-1', request: { title: 'land' } }, @@ -600,6 +619,7 @@ describe('TypertGatewayService', () => { registerStrict(ctx, [renameDescriptor()]) await expect(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'rename', args: { agentId: 'agent-1', request: { title: 'land' } }, @@ -615,6 +635,7 @@ describe('TypertGatewayService', () => { wire: 'differentAgentId', }) await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'rename', args: { agentId: 'agent-1', request: { title: 'land' } }, @@ -626,6 +647,7 @@ describe('TypertGatewayService', () => { resolve: () => undefined, }) await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'rename', args: { agentId: 'agent-1', request: { title: 'land' } }, @@ -640,6 +662,7 @@ describe('TypertGatewayService', () => { resolve: async () => { throw new Error('lookup failed') }, }) const failure = await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'create', args: { agentId: 'agent-1', request: { title: 'ship' } }, @@ -652,6 +675,7 @@ describe('TypertGatewayService', () => { resolve: () => Promise.resolve(undefined), }) await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'create', args: { agentId: 'agent-1', request: { title: 'ship' } }, @@ -663,6 +687,7 @@ describe('TypertGatewayService', () => { resolve: async id => ({ id }), }) await expect(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'create', args: { agentId: 'agent-1', request: { title: 'ship' } }, @@ -675,6 +700,7 @@ describe('TypertGatewayService', () => { await dispose() await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'passthrough', args: { value: 'would pass through SRC' }, @@ -684,12 +710,14 @@ describe('TypertGatewayService', () => { it('seeds the no-downgrade guard from definitions present before Gateway startup', async () => { const ctx = new Context() await ctx.plugin(TypertRegistry) + await ctx.plugin(RemoteAccessService) const dispose = registerStrict(ctx, [passthroughDescriptor()]) await ctx.plugin(TypertGatewayService) await ctx.plugin(GoalService) await dispose() await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'passthrough', args: { value: 'would pass through SRC' }, @@ -699,6 +727,7 @@ describe('TypertGatewayService', () => { it('retains the no-downgrade guard across Gateway Service reloads', async () => { const ctx = new Context() await ctx.plugin(TypertRegistry) + await ctx.plugin(RemoteAccessService) const gatewayFiber = ctx.plugin(TypertGatewayService) await gatewayFiber await ctx.plugin(GoalService) @@ -709,6 +738,7 @@ describe('TypertGatewayService', () => { await ctx.plugin(TypertGatewayService) await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'passthrough', args: { value: 'would pass through SRC' }, @@ -721,6 +751,7 @@ describe('TypertGatewayService', () => { await ctx.plugin(SecondSharedService) const error = await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'shared', method: 'run', args: { value: 'ship' }, @@ -739,6 +770,7 @@ describe('TypertGatewayService', () => { const ctx = await setupGateway() await ctx.plugin(testCase.plugin) await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: testCase.namespace, method: 'run', args: testCase.args, @@ -753,6 +785,7 @@ describe('TypertGatewayService', () => { ctx.typert.lookups.register('gatewayFixtureAlias', provider) await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'create', args: { agentId: 'agent-1', request: { title: 'ship' } }, @@ -764,16 +797,19 @@ describe('TypertGatewayService', () => { registerAgentLookup(ctx, { id: 'agent-1' }) await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'create', args: { request: { title: 'ship' } }, }), 'gateway/arguments-invalid') await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'create', args: { agentId: 'agent-1', request: { title: 'ship' }, optional: true }, }), 'gateway/arguments-invalid') await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'create', args: [] as unknown as Record, @@ -786,6 +822,7 @@ describe('TypertGatewayService', () => { registerStrict(ctx, [strictOnlyDescriptor()]) await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'strictOnly', args: { request: { title: 1 } }, @@ -793,6 +830,7 @@ describe('TypertGatewayService', () => { service.nextResult = { title: 1 } await expect(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'strictOnly', args: { request: { title: 'ship' } }, @@ -805,6 +843,7 @@ describe('TypertGatewayService', () => { service.nextResult = 1n await expect(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'strictOnly', args: { request: { title: 'ship' } }, @@ -824,6 +863,7 @@ describe('TypertGatewayService', () => { ])('rejects non-JSON SRC input %#', async (value) => { const { ctx } = await setup() await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'passthrough', args: { value }, @@ -836,6 +876,7 @@ describe('TypertGatewayService', () => { // cannot see which are optional, so an absent field is admitted; the case // above keeps an explicitly undefined field rejected. await expect(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'passthrough', args: {}, @@ -848,6 +889,7 @@ describe('TypertGatewayService', () => { const cyclic: { self?: unknown } = {} cyclic.self = cyclic await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'passthrough', args: { value: cyclic }, @@ -856,6 +898,7 @@ describe('TypertGatewayService', () => { const result = new Date(0) service.nextResult = result await expect(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'passthrough', args: { value: null }, @@ -865,6 +908,7 @@ describe('TypertGatewayService', () => { it('accepts dense JSON and rejects decorated arrays and object properties', async () => { const { ctx } = await setup() await expect(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'passthrough', args: { value: [1, { nested: true }] }, @@ -882,6 +926,7 @@ describe('TypertGatewayService', () => { Object.defineProperty(accessor, 'value', { get: () => true, enumerable: true }) for (const value of [sparseWithExtra, symbolArray, symbolObject, hidden, accessor]) { await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'passthrough', args: { value }, }), 'gateway/input-invalid') } @@ -896,6 +941,7 @@ describe('TypertGatewayService', () => { registerStrict(ctx, [createDescriptor()]) await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'create', args: { agentId: 'agent-1', request: { title: 'ship' } }, @@ -906,6 +952,7 @@ describe('TypertGatewayService', () => { const ctx = await setupGateway() await ctx.plugin(WrongBindingService) await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'wrong-binding', method: 'run', args: { value: 'ship' }, @@ -914,6 +961,7 @@ describe('TypertGatewayService', () => { await ctx.plugin(GoalService) registerStrict(ctx, [{ ...passthroughDescriptor(), method: 'missing' }]) await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'missing', args: { value: 'ship' }, @@ -931,6 +979,7 @@ describe('TypertGatewayService', () => { method: 'run', }]) await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'no-binding', method: 'run', args: { value: 'ship' }, }), 'gateway/binding-invalid') @@ -954,6 +1003,7 @@ describe('TypertGatewayService', () => { }], }) await expect(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'plain', method: 'run', args: { value: 'land' }, })).resolves.toBe('land') }) @@ -968,6 +1018,7 @@ describe('TypertGatewayService', () => { }) try { await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'missing-method', method: 'run', args: { value: 'ship' }, }), 'gateway/method-unavailable') } finally { @@ -981,6 +1032,7 @@ describe('TypertGatewayService', () => { service.businessError = failure await expect(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'fail', args: { request: { reason: 'fixture' } }, @@ -990,6 +1042,7 @@ describe('TypertGatewayService', () => { it('reports an absent endpoint without retaining receiver state', async () => { const { ctx } = await setup() await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'absent', args: {}, @@ -999,6 +1052,7 @@ describe('TypertGatewayService', () => { it('mounts a shared /api interceptor through an optional Connection and returns existing RPC results', async () => { const ctx = new Context().extend({ fixtureScope: 'rpc-caller' }) await ctx.plugin(TypertRegistry) + await ctx.plugin(RemoteAccessService) await ctx.plugin(FakeConnectionService) const gatewayFiber = ctx.plugin(TypertGatewayService) await gatewayFiber @@ -1031,11 +1085,12 @@ describe('TypertGatewayService', () => { abort.abort(new Error('client disconnected')) expect(service.lastSignal?.aborted).toBe(true) await expect(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'create', args: { agentId: 'agent-1', request: { title: 'direct' } }, })).resolves.toMatchObject({ title: 'direct' }) - expect(service.lastPeer).toBe(connection.operator) + expect(service.lastPeer).toBe(connection.browserPeer) const invalid = await handler('goals/create', { invalid: true }, signal) expect(invalid).toMatchObject({ ok: false, @@ -1207,6 +1262,7 @@ describe('TypertGatewayService', () => { it('claims and validates in-process Remote event results for the active Client generation', async () => { const ctx = new Context() await ctx.plugin(TypertRegistry) + await ctx.plugin(RemoteAccessService) await ctx.plugin(FakeConnectionService) await ctx.plugin(TypertGatewayService) const connection = rawConnection(ctx) @@ -1229,7 +1285,9 @@ describe('TypertGatewayService', () => { }) })(), { home: '/home/fixture' }) const carrier = new AbortController() - const events = rawGatewayEventHarness(ctx).openRemoteEvents({ args: {} }, carrier.signal) + const events = (await ctx.typertGateway.wireStream.open( + '$events', { args: {} }, (async function* () {})(), ownerPeer(ctx), carrier.signal, + ))[Symbol.asyncIterator]() const opening = await events.next() expect(opening).toMatchObject({ done: false, @@ -1252,7 +1310,7 @@ describe('TypertGatewayService', () => { value: undefined, }) - await events.return(undefined) + await events.return?.(undefined) await unregister() await ctx.fiber.dispose() }) @@ -1260,6 +1318,7 @@ describe('TypertGatewayService', () => { it('preserves a lookup policy rejection through the Connection RPC result', async () => { const ctx = new Context() await ctx.plugin(TypertRegistry) + await ctx.plugin(RemoteAccessService) await ctx.plugin(FakeConnectionService) await ctx.plugin(TypertGatewayService) await ctx.plugin(GoalService) @@ -1284,6 +1343,7 @@ describe('TypertGatewayService', () => { it('caches SRC ownership until the Cordis Service set changes', async () => { const ctx = new Context() await ctx.plugin(TypertRegistry) + await ctx.plugin(RemoteAccessService) await ctx.plugin(FakeConnectionService) await ctx.plugin(TypertGatewayService) const observedFiber = ctx.plugin(ObservedClaimService) @@ -1319,6 +1379,7 @@ describe('TypertGatewayService', () => { const connectionFiber = ctx.plugin({ inject: [...connectionInject], apply: applyConnection }) await connectionFiber await ctx.plugin(TypertRegistry) + await ctx.plugin(RemoteAccessService) const gatewayFiber = ctx.plugin(TypertGatewayService) await gatewayFiber const goalFiber = ctx.plugin(GoalService) @@ -1428,6 +1489,7 @@ async function setup(): Promise<{ async function setupGateway(): Promise { const ctx = new Context() await ctx.plugin(TypertRegistry) + await ctx.plugin(RemoteAccessService) await ctx.plugin(TypertGatewayService) return ctx } @@ -1444,17 +1506,6 @@ function rawConnection(ctx: Context): FakeConnectionService { return receiver[symbols.original] ?? receiver } -interface GatewayEventHarness { - openRemoteEvents(payload: unknown, signal: AbortSignal): AsyncGenerator -} - -function rawGatewayEventHarness(ctx: Context): GatewayEventHarness { - const receiver = ctx.get('typertGateway') as unknown as GatewayEventHarness & { - [symbols.original]?: GatewayEventHarness - } - return receiver[symbols.original] ?? receiver -} - function registerStrict(ctx: Context, descriptors: readonly InvocationDescriptor[]): () => Promise { return ctx.typert.register({ package: '@fixture/gateway', diff --git a/packages/api/gateway/tests/peer.ts b/packages/api/gateway/tests/peer.ts new file mode 100644 index 0000000000..2571e791d5 --- /dev/null +++ b/packages/api/gateway/tests/peer.ts @@ -0,0 +1,30 @@ +/** Owner Peers for Gateway specs: every in-process call names its Peer explicitly. */ +import type { Context } from '@deepseek-ai/cordis' +import { Peer } from '@deepseek-ai/dsh-client-connection' +import type { PeerScope, Principal } from '@deepseek-ai/dsh-typert-protocol' + +const owners = new WeakMap() + +/** + * The owner Peer of one test Context tree, created on first use. + * @param ctx - any Context of the tree; the Peer hangs under its root. + * @returns the tree's shared owner Peer. + */ +export function ownerPeer(ctx: Context): PeerScope { + let peer = owners.get(ctx.root) + if (peer === undefined) { + peer = new Peer(ctx.root, { principal: { kind: 'owner' } }) + owners.set(ctx.root, peer) + } + return peer +} + +/** + * A fresh Peer of one principal under the test Context tree. + * @param ctx - any Context of the tree; the Peer hangs under its root. + * @param principal - identity the Peer speaks for. + * @returns an independent Peer. + */ +export function peerFor(ctx: Context, principal: Principal): PeerScope { + return new Peer(ctx.root, { principal }) +} diff --git a/packages/api/gateway/tests/stream-server.host.spec.ts b/packages/api/gateway/tests/stream-server.host.spec.ts index 469aa25b0c..87c0784d5d 100644 --- a/packages/api/gateway/tests/stream-server.host.spec.ts +++ b/packages/api/gateway/tests/stream-server.host.spec.ts @@ -67,6 +67,54 @@ describe('Remote stream mux server carrier lifecycle', () => { await closed }) + it('counts inbound frames as liveness while Pong is delayed', async () => { + const entry = await startMux(async (_endpoint, _payload, _uplink, _peer, control) => waitForAbort(control.signal), 20) + const client = await connect(entry.url, false) + const terminated = vi.spyOn(acceptedSocket(entry.mux), 'terminate') + const traffic = setInterval(() => { client.send(JSON.stringify({ type: 'cancel', streamId: 'absent' })) }, 5) + try { + await pings(client, 4) + expect(terminated).not.toHaveBeenCalled() + } finally { + clearInterval(traffic) + } + await vi.waitFor(() => { expect(terminated).toHaveBeenCalledOnce() }) + }) + + it('counts a downlink write that waited in the send buffer as liveness', async () => { + const stop = Promise.withResolvers() + const entry = await startMux(async () => steadyItems(stop.promise), 20) + const client = await connect(entry.url, false) + const serverSocket = acceptedSocket(entry.mux) + const terminated = vi.spyOn(serverSocket, 'terminate') + Object.defineProperty(serverSocket, 'bufferedAmount', { configurable: true, value: 1 }) + client.send(openFrame('congested')) + try { + await pings(client, 4) + expect(terminated).not.toHaveBeenCalled() + } finally { + stop.resolve(undefined) + Reflect.deleteProperty(serverSocket, 'bufferedAmount') + } + await vi.waitFor(() => { expect(terminated).toHaveBeenCalledOnce() }) + }) + + it('does not count a downlink write that completed at once as liveness', async () => { + const stop = Promise.withResolvers() + const entry = await startMux(async () => steadyItems(stop.promise), 20) + const client = await connect(entry.url, false) + const terminated = vi.spyOn(acceptedSocket(entry.mux), 'terminate') + const items = vi.fn() + client.on('message', items) + client.send(openFrame('flowing')) + try { + await vi.waitFor(() => { expect(terminated).toHaveBeenCalledOnce() }) + expect(items).toHaveBeenCalled() + } finally { + stop.resolve(undefined) + } + }) + it('keeps the socket when a delayed Pong arrives before the final check', async () => { const entry = await startMux(async (_endpoint, _payload, _uplink, _peer, control) => waitForAbort(control.signal), 20) const client = await connect(entry.url, false) @@ -527,6 +575,8 @@ async function fixturePeer(): Promise { return { id: 'fixture-peer' as PeerId, ctx: fiber.ctx, + principal: { kind: 'owner' }, + signal: new AbortController().signal, dispose: async () => { await fiber.dispose() }, } } @@ -585,6 +635,19 @@ async function *echoUplink(uplink: AsyncIterable): AsyncIterable): AsyncIterable { + const stopped = stop.then(() => true) + while (!await Promise.race([stopped, new Promise((resolve) => { setTimeout(() => { resolve(false) }, 5) })])) { + yield 'item' + } +} + +/** Resolve after the client has received `count` Ping frames. */ +async function pings(client: WebSocket, count: number): Promise { + for (let received = 0; received < count; received++) await once(client, 'ping') +} + async function *waitForAbort(signal: AbortSignal): AsyncIterable { await new Promise((resolve) => { if (signal.aborted) resolve() diff --git a/packages/api/gateway/tests/strict-source.host.spec.ts b/packages/api/gateway/tests/strict-source.host.spec.ts new file mode 100644 index 0000000000..fcf4988c12 --- /dev/null +++ b/packages/api/gateway/tests/strict-source.host.spec.ts @@ -0,0 +1,80 @@ +import { Context, Service } from '@deepseek-ai/cordis' +import { afterEach, expect, it } from 'vitest' +import { z } from 'zod' +import { bindTypertRemote, Remote } from '@deepseek-ai/dsh-typert-protocol' +import TypertRegistry, { type TypertContribution } from '@deepseek-ai/dsh-typert-registry' +import RemoteAccessService from '@deepseek-ai/dsh-api-remote-access' +import TypertGateway from '../src/index.ts' +import { ownerPeer } from './peer.ts' + +const contexts: Context[] = [] +afterEach(async () => { await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) }) + +const contribution: TypertContribution = { + package: '@fixture/strict', face: 'host', schemas: [], + model: { services: [], events: [], objects: [] }, + invocations: [{ + id: '@fixture/strict#items/read', service: 'items', namespace: 'items', method: 'read', + invocation: { kind: 'direct' }, + parameters: [{ name: 'request', wire: 'request', source: 'json', codec: { + mode: 'strict', typeSymbol: 'Request', create: () => z.strictObject({ title: z.string() }), + } }], + result: { mode: 'strict', typeSymbol: 'string', create: () => z.string() }, + }], +} + +class ItemsService extends Service { + readonly typertRemote + calls = 0 + constructor(ctx: Context) { + super(ctx, 'items') + this.typertRemote = bindTypertRemote(this, 'items') + } + @Remote + read(request: { title: string }): string { + this.calls++ + return request.title + } +} + +async function setup() { + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(TypertRegistry) + await ctx.plugin(RemoteAccessService) + await ctx.plugin(TypertGateway) + const service = new ItemsService(ctx) + return { ctx, service } +} + +it('validates source dispatch with the ordinary local Registry before invoking the Service', async () => { + const { ctx, service } = await setup() + ctx.typert.register(contribution) + await expect(ctx.typertGateway.invoke({ namespace: 'items', method: 'read', args: { request: { title: 'ready' } }, peer: ownerPeer(ctx) })) + .resolves.toBe('ready') + for (const args of [{}, { request: { title: 'ready', extra: true } }, { request: { title: 1 } }]) { + const code: unknown = expect.stringMatching(/^gateway\/(arguments|input)-invalid$/) + await expect(ctx.typertGateway.invoke({ namespace: 'items', method: 'read', args, peer: ownerPeer(ctx) })) + .rejects.toMatchObject({ code }) + } + expect(service.calls).toBe(1) +}) + +it('allows limited source fallback without recovering erased object constraints', async () => { + const { ctx, service } = await setup() + await expect(ctx.typertGateway.invoke({ namespace: 'items', method: 'read', args: { request: { title: 'ready', extra: true } }, peer: ownerPeer(ctx) })) + .resolves.toBe('ready') + expect(service.calls).toBe(1) +}) + +it('refuses source fallback after the strict definition owner unloads', async () => { + const { ctx, service } = await setup() + const owner = ctx.plugin({ inject: ['typert'], apply(ctx) { ctx.typert.register(contribution) } }) + await owner + expect(ctx.typert.local.get('items/read')).toBe(contribution.invocations[0]) + await owner.dispose() + expect(ctx.typert.local.get('items/read')).toBeUndefined() + await expect(ctx.typertGateway.invoke({ namespace: 'items', method: 'read', args: { request: { title: 'ready' } }, peer: ownerPeer(ctx) })) + .rejects.toMatchObject({ code: 'gateway/definition-unavailable' }) + expect(service.calls).toBe(0) +}) diff --git a/packages/api/gateway/tsconfig.host.json b/packages/api/gateway/tsconfig.host.json index 5cb68a7183..7694cea7cb 100644 --- a/packages/api/gateway/tsconfig.host.json +++ b/packages/api/gateway/tsconfig.host.json @@ -6,6 +6,7 @@ "tsBuildInfoFile": "lib/tsconfig.host.tsbuildinfo" }, "files": [ + "src/access.ts", "src/index.ts", "src/remote-error-codes.ts", "src/stream-protocol.ts", @@ -28,6 +29,9 @@ { "path": "../../client/connection/tsconfig.host.json" }, + { + "path": "../remote-access/tsconfig.host.json" + }, { "path": "../../host/webserver" }, diff --git a/packages/api/gateway/tsdown.config.ts b/packages/api/gateway/tsdown.config.ts index 2f199f0d1f..71d5bd77f8 100644 --- a/packages/api/gateway/tsdown.config.ts +++ b/packages/api/gateway/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../../client/tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-api-gateway', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/api/job-controller/tsdown.config.ts b/packages/api/job-controller/tsdown.config.ts index 0391bc61bf..0e42a1163e 100644 --- a/packages/api/job-controller/tsdown.config.ts +++ b/packages/api/job-controller/tsdown.config.ts @@ -1,7 +1,7 @@ import { clientBundle } from '../../client/tsdown.client.ts' export default clientBundle( - '@deepseek-ai/dsh-api-job-controller', + import.meta.dirname, ['lib/types/index.js'], { hostPhase: true }, ) diff --git a/packages/api/remote-access/README.i18n.yaml b/packages/api/remote-access/README.i18n.yaml new file mode 100644 index 0000000000..df8ad59235 --- /dev/null +++ b/packages/api/remote-access/README.i18n.yaml @@ -0,0 +1,34 @@ +# Bilingual-pair consistency record for README.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/api/remote-access/README.md +/: + en: bbd88553f12d4f3d + zh: c5fa4ac3f9746170 +/deepseek-ai-dsh-api-remote-access: + en: 15713a51f827c9c1 + zh: a1349a5f83efc82c +/deepseek-ai-dsh-api-remote-access/summary: + en: ee81bdee09f42c9b + zh: 41c6864e59c24519 +/deepseek-ai-dsh-api-remote-access/table-of-contents: + en: a18ecc232e3c3933 + zh: b8e43580403f6d77 +/deepseek-ai-dsh-api-remote-access/host-service-remoteaccessservice-ctx-key-remoteaccess: + en: 9175835ea5c5bff1 + zh: 0e33b266fdac3d13 +/deepseek-ai-dsh-api-remote-access/client-face: + en: 1db01235b6af84fa + zh: c0ced511cc1841a0 +/deepseek-ai-dsh-api-remote-access/model-experience: + en: 8b07250cecacd3aa + zh: d28ab93980bdcdad +/deepseek-ai-dsh-api-remote-access/model-experience/kv-cache-effect: + en: 60835eca1cee4e0e + zh: 3b5579ceccd5b5e7 +/deepseek-ai-dsh-api-remote-access/known-limitations-and-deferred-work: + en: 66b483bf5144ac46 + zh: 84bde5e0b1af270b +/deepseek-ai-dsh-api-remote-access/known-limitations-and-deferred-work/dev-note: + en: e67f633dc6fe7773 + zh: e88aeb617ffb74ea diff --git a/packages/api/remote-access/README.md b/packages/api/remote-access/README.md new file mode 100644 index 0000000000..8d9df9e542 --- /dev/null +++ b/packages/api/remote-access/README.md @@ -0,0 +1,97 @@ +--- +description: "Method-level Remote access control for non-owner Peers: role-based policy decisions, list filtering, and the per-Peer access snapshot." +kind: "package-reference" +--- + +# @deepseek-ai/dsh-api-remote-access + +English | [中文](README.zh.md) + +## Summary + +Decides which Remote methods a non-owner Peer may call and which forwarded events it may receive. Business packages define roles, each an action set over one resource kind, and register one policy per non-owner principal kind that binds roles to resources. The [API Gateway](../gateway/README.md) resolves the resources a call or event touches and asks this package before it resolves lookups or decodes arguments. List methods filter items through `allows()`, and each Peer follows its own access snapshot, so the Client can hide entries the Host would refuse. The [Peer principals Agent Note](../../../.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md) owns the design. + +## Table of Contents + +- [Host service: `RemoteAccessService` (ctx key: `remoteAccess`)](#host-service) +- [Client face](#client-face) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Host service: `RemoteAccessService` (ctx key: `remoteAccess`) + +Every composition that mounts [API Gateway](../gateway/README.md) mounts this service: the Gateway injects `remoteAccess` and does not start without it. [`bundle/base`](../../bundle/base/cordis.patch.yml) loads it beside `typert-gateway`. The owner (`{ kind: 'owner' }`) is never checked; every method below concerns non-owner principals. + +| Member | Contract | +|---|---| +| `registerPolicy(kind, policy)` | Registers the `RemoteAccessPolicy` of one non-owner principal kind; its `bindings()` and `explain()` receive principals typed by that kind. A second policy for the same kind throws. The registration is a Cordis effect of the caller's fiber; registration and removal recompute open snapshots. | +| `invalidate(principal?)` | Reports that bindings or condition answers for one principal, or for every principal, may have changed. A business package calls it on every grant, revocation, mode change, and expiry. | +| `decide(request)` | Gateway-only. `request` is `{ principal, action, resources, args? }`: the caller or recipient, the endpoint `ns/method` or `event:`, the valid resources the Gateway resolved, and a call's arguments validated against their Typert-compiled types, with lookup parameters as wire identities. Returns `{ kind: 'allow' }` or `{ kind: 'deny', reason }`; the Gateway throws `remote-access/denied` with details `{ action, reason }` for a denied call and drops a denied event without building an error. The owner and `remoteAccess/follow` are always allowed. | +| `allows(resource)` | Reads the invocation of the Context that accessed the service, so a list method calls `this.ctx.remoteAccess.allows(resource)` for each item. Decides the call's endpoint action on that resource without arguments, reading the bindings again on every check. True for the owner and outside a Remote call. | +| `snapshotFor(principal)` | The baseline a page opens with: `{ type: 'baseline', owner: true }` for the owner, otherwise the principal's current grants. | +| `follow(signal)` | Remote stream `remoteAccess/follow`: one `baseline`, then `upsert` and `remove` frames. The owner receives only `{ type: 'baseline', owner: true }`. The stream ends when `signal` aborts or the service unloads, so a reloaded service starts every reader from a fresh baseline. | + +A role `{ name, resource, actions }` is a plain value naming an action set over one resource kind, `global` or a kind declared in `TypertResourceMap`. A binding `{ role, resource, condition? }` grants the role on one resource of the role's kind. A policy answers `bindings(principal)` and an optional `explain(request)` synchronously from state it holds in memory. A request is allowed only when, on every resolved resource, some binding names that resource, its role lists the action, and it has no condition or its condition allows. Resources are independent: a grant on `global` implies nothing on any other resource, and no resource implies another. A condition receives `{ action, resource, args? }` and runs only for a binding whose resource and role match; `args` is absent for events, snapshots, and list filtering. `bindings()` runs once per `decide()`, once per `allows()` check, and once per snapshot computation; it is never cached across decisions, so time-dependent state applies to the next call. + +The Gateway resolves resources from `@Remote` and `@RemoteScope` `resources` readers, lookup and Host Context adapter `resource` declarations, and the default fields in `typert.resources`, and the [API Gateway](../gateway/README.md) owns that order; this package reads no argument field. A call that names no resource touches the `global` resource alone, so only a `global` role can allow it; putting an action in a `global` role lets the principal call it on any resource the call does not name. The Gateway refuses a call whose resources are invalid with `resource-invalid` without asking `decide()`. + +When `bindings()`, a condition, or `explain()` throws, the decision is refused with `policy-failed`; in a snapshot, a throwing `bindings()` leaves the principal no grant and a throwing condition excludes its action. A faulty rule therefore never breaks the shared `$events` source, the snapshot stream, or page rendering. The service logger reports the first failure of each policy after each `invalidate()` and each policy registration or removal; later failures of that policy stay silent until then. + +A forwarded event is decided as the action `event:` on the resources its forwarding entry's readers name, or on `global` when the entry declares none, and reaches a non-owner whole or not at all; its payload is never trimmed. An event carries every resource it declares, so a reader that returns `undefined` makes the event's resources invalid, and the Gateway delivers it to no non-owner without asking `decide()`. An event names at most one resource, so collection changes travel on follow streams (`session/follow`, `session/control`, `workspace/follow`), whose methods decide each item inline through `allows()`. + +The service adds a `global` row named `REMOTE_ACCESS_SNAPSHOT_KEY` (`__DSH_REMOTE_ACCESS__`) holding the owner baseline `{ type: 'baseline', owner: true }` to every index the webServer renders through `webserver/index-inject`. A business package that renders a non-owner page, such as a liveshare viewer page rendered on the owner Host, places an assignment of that global to `ctx.remoteAccess.snapshotFor(principal)` after every index injection row, for example a `global` row appended to the end of `ctx.webServer.collectIndexInjections()`. The later assignment replaces the owner baseline, and the Client entry reads the global when it mounts, so the page opens with the viewer's grants instead of the owner's. + +| `details.reason` of `remote-access/denied` | Cause | +|---|---| +| `resource-invalid` | Given by the Gateway before `decide()`: a resource reader threw or returned an empty list, or a resource id, including a Context identity, is not a non-empty string. | +| `no-allow` | The principal kind has no policy, or no binding allows the action on some resource and neither a condition nor `explain()` names a reason. | +| `policy-failed` | `bindings()`, a condition, or `explain()` threw; the failure is logged as described above. | +| condition or `explain()` reason | On the first resource no binding allows, the first refusing condition of a matching binding names the reason; without one, `explain(request)` does. | + +Snapshot grants list, per resource the principal's bindings name, the sorted endpoint actions of those bindings' roles that `decide()` allows there without call arguments, so the snapshot agrees with `decide()` for the same principal, action, and resource; a resource with no such action gets no grant, and the stream pushes `remove` once its allowed set becomes empty. `event:` actions never enter the snapshot. Snapshots are recomputed only after `invalidate()` and policy registration or removal. + + +## Client face + +The Client entry reads the baseline the index injected under `REMOTE_ACCESS_SNAPSHOT_KEY` synchronously, mounts the generated `remoteAccess` contribution itself through `mountAccess(ctx, contribution, initial)`, follows the snapshot stream, and provides: + +- the Client service `ctx.remoteAccess.source(kind, id, action)`, an identity-stable boolean source per `(kind, id, action)` for one resource of a declared kind; +- the standard Hook `useRemoteAccess(action)`, which answers on the `global` resource. + +Domain UI packages publish their resource Hooks over `source()`: [ui-session](../../client/ui-session/README.md) provides `useSessionAccess(sessionId, action)` and the Session-scope `useAccess(action)` on Session resources, a different name from the `global` `useRemoteAccess`, and [ui-workspace](../../client/ui-workspace/README.md) provides `useWorkspaceAccess(workspaceId, action)` on Workspace resources. A Hook's `action` is a `RemoteEndpoint`, an endpoint some Client Remote namespace declares. A non-owner answer is whether that resource's grant lists the action and never falls back to the `global` answer, so a resource without a grant, or whose grant lists no action, answers false. Every answer is true for the owner; a non-owner page without an injected baseline, and a non-owner page whose stream fails terminally, answers false; the terminal failure is reported with its error, for a non-owner with the advice to reload the page and for the owner with the note that its answers are unaffected; a reconnect keeps the last snapshot. A malformed injected baseline fails the mount. Hooks never issue requests, and the Host still checks every call. [`dsh-client-test-runtime`](../../test-support/client-runtime/README.md) replaces this row with allow-all access in Client specs. + + +## Model Experience + +None, as this package decides Remote access for Client callers and registers nothing model-facing. + +#### KV Cache effect + +No direct effect. + +## Known Limitations and Deferred Work + + + +- Snapshot and list-filtering decisions carry no call arguments, so a condition that refines its answer from `args` can refuse a call that the snapshot shows as allowed. +- Resource readers and automatic resolution must cover every resource a method touches; a resource the call does not name is not checked, so a principal granted on the named resources reaches it. +- Default resource resolution matches wire field names; adding a field with a registered kind's name to a method adds that kind's resource to the call. +- Exact Connection Fetch routes do not pass through the Gateway; the shared Fetch handler refuses them to every non-owner Peer with 403 until each route declares its own access rule. +- Interaction participation (answering forwarded waterfalls) is owner-only: non-owner `$events` streams receive only the broadcast frames whose `event:` action the policy allows on the event's resources. +- `api-session/added` carries the whole `SessionSummary`, so a non-owner allowed to receive it also learns its `parentSessionId`. +- A granted Session's cwd and tool paths already contain the owner's absolute paths, so the `$events` ready frame gives a non-owner the full `host`, including `home`; the Gateway does not hide `home` separately. +- `workspace/follow` notices a Workspace whose visibility a policy revoked only when that Workspace or the Workspace order next changes, and then replaces the non-owner's baseline; an access change alone sends no frame. + + +### Dev Note + +
+Working context for maintainers — click to expand + +None. + +
diff --git a/packages/api/remote-access/README.zh.md b/packages/api/remote-access/README.zh.md new file mode 100644 index 0000000000..8d593f7132 --- /dev/null +++ b/packages/api/remote-access/README.zh.md @@ -0,0 +1,97 @@ +--- +description: "非 owner Peer 的 Remote 方法级访问控制:基于角色的 policy 判定、列表过滤与每个 Peer 的访问快照。" +kind: "package-reference" +--- + +# @deepseek-ai/dsh-api-remote-access + +[English](README.md) | 中文 + +## 概述 + +决定非 owner Peer 可以调用哪些 Remote 方法、可以收到哪些转发事件。业务包定义 role(限定一种资源种类的 action 集合),并为每种非 owner principal kind 注册一个 policy,把 role 绑定到资源上。[API Gateway](../gateway/README.zh.md) 解析一次调用或一个事件访问的资源,并在解析 lookup、解码参数之前询问本包。列表方法经 `allows()` 逐项过滤;每个 Peer 订阅自己的访问快照,Client 据此隐藏 Host 会拒绝的入口。设计由 [Peer 身份属性 Agent Note](../../../.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md) 负责。 + +## 目录 + +- [Host 服务:`RemoteAccessService`(ctx key:`remoteAccess`)](#host-service) +- [Client 面](#client-face) +- [模型体验](#model-experience) +- [已知限制与暂缓事项](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## Host 服务:`RemoteAccessService`(ctx key:`remoteAccess`) + +挂载 [API Gateway](../gateway/README.zh.md) 的组合都要挂载本服务:Gateway 注入 `remoteAccess`,没有它就不启动。[`bundle/base`](../../bundle/base/cordis.patch.yml) 紧随 `typert-gateway` 装配它。owner(`{ kind: 'owner' }`)从不受检查;下列成员都只针对非 owner principal。 + +| 成员 | 契约 | +|---|---| +| `registerPolicy(kind, policy)` | 为一种非 owner principal kind 注册 `RemoteAccessPolicy`;其 `bindings()` 与 `explain()` 收到的 principal 按该 kind 定型。同一 kind 再注册一个 policy 即抛错。登记是调用方 fiber 的 Cordis effect;注册与撤回都会重新计算已打开的快照。 | +| `invalidate(principal?)` | 报告某个 principal(省略时为所有 principal)的 binding 或 condition 结论可能已变化。业务包在每次授权、撤权、模式切换与过期时调用。 | +| `decide(request)` | 仅供 Gateway 使用。`request` 为 `{ principal, action, resources, args? }`:调用方或接收方、端点 `ns/method` 或 `event:`、Gateway 解析出的合法资源,以及调用中按 Typert 编译类型校验后的参数(lookup 参数为 wire 身份)。返回 `{ kind: 'allow' }` 或 `{ kind: 'deny', reason }`;调用被拒时由 Gateway 抛出 details 为 `{ action, reason }` 的 `remote-access/denied`,事件被拒时由 Gateway 直接丢弃、不构造错误。owner 与 `remoteAccess/follow` 恒放行。 | +| `allows(resource)` | 读访问本服务的 Context 上的 invocation,因此列表方法对每一项调用 `this.ctx.remoteAccess.allows(resource)`。不带参数判定该调用的端点 action 在该资源上是否被允许,每次判定都重新读取 binding。owner 与 Remote 调用之外恒为 true。 | +| `snapshotFor(principal)` | 页面打开时的 baseline:owner 为 `{ type: 'baseline', owner: true }`,否则为该 principal 当前的 grant。 | +| `follow(signal)` | Remote stream `remoteAccess/follow`:一个 `baseline`,之后是 `upsert` 与 `remove` 帧。owner 只收到 `{ type: 'baseline', owner: true }`。`signal` abort 或服务卸载时流结束,因此重载后的服务从新的 baseline 回答每个读者。 | + +role `{ name, resource, actions }` 是普通值,表示限定一种资源种类(`global` 或在 `TypertResourceMap` 中声明的种类)的 action 集合。binding `{ role, resource, condition? }` 在该 role 种类的一个资源上授予该 role。policy 根据预先载入内存的状态同步回答 `bindings(principal)` 与可选的 `explain(request)`。只有在每个已解析资源上都有某个 binding 指向该资源、其 role 列出该 action、且没有 condition 或 condition 放行时,请求才被放行。资源之间相互独立:`global` 上的授权不蕴含任何其他资源上的授权,任何资源也不蕴含另一个资源。condition 收到 `{ action, resource, args? }`,只对资源与 role 都匹配的 binding 调用;事件、快照与列表过滤时没有 `args`。`bindings()` 在每次 `decide()`、每次 `allows()` 判定与每次快照计算中各调用一次,不跨判定缓存,因此依赖时间的状态对下一次调用即时生效。 + +Gateway 依次从 `@Remote` 与 `@RemoteScope` 的 `resources` 读取函数、lookup 与 Host Context adapter 的 `resource` 声明、`typert.resources` 中的默认字段解析资源,解析顺序由 [API Gateway](../gateway/README.zh.md) 负责;本包不读取任何参数字段。不指明任何资源的调用只访问 `global` 资源,因此只有 `global` 级 role 能放行;把某个 action 放进 `global` 级 role,等于允许该 principal 在调用未指明的任何资源上调用它。资源非法的调用由 Gateway 以 `resource-invalid` 拒绝,不询问 `decide()`。 + +`bindings()`、condition 或 `explain()` 抛错时,本次判定以 `policy-failed` 拒绝;计算快照时,`bindings()` 抛错即该 principal 没有 grant,condition 抛错即该 action 不放行。有问题的规则因此不会破坏共享的 `$events` 事件源、快照流或页面渲染。每次 `invalidate()` 以及每次 policy 注册或撤回之后,服务日志对每个 policy 只报告第一次失败;此后该 policy 的失败不再记录,直到下一次上述变化。 + +转发事件按其转发条目的读取函数指明的资源(条目未声明时为 `global`)判定 action `event:`,对非 owner 整条送达或整条不送,从不裁剪 payload。事件携带其声明的每个资源,因此读取函数返回 `undefined` 时该事件的资源非法,Gateway 不询问 `decide()`,不送达任何非 owner。一个事件至多指明一个资源,因此集合类变化走 follow 类流(`session/follow`、`session/control`、`workspace/follow`),由这些方法内联经 `allows()` 逐项判定。 + +本服务经 `webserver/index-inject` 为 webServer 渲染的每个 index 加入名为 `REMOTE_ACCESS_SNAPSHOT_KEY`(`__DSH_REMOTE_ACCESS__`)的 `global` 行,值为 owner baseline `{ type: 'baseline', owner: true }`。渲染非 owner 页面的业务包(例如在 owner Host 上渲染 liveshare 读者页)在所有 index 注入行之后放置一条把该全局赋值为 `ctx.remoteAccess.snapshotFor(principal)` 的语句,例如追加到 `ctx.webServer.collectIndexInjections()` 末尾的一条 `global` 行。后写的赋值覆盖 owner baseline,Client 入口在挂载时读取该全局,因此页面以读者的 grant 而非 owner 的快照打开。 + +| `remote-access/denied` 的 `details.reason` | 原因 | +|---|---| +| `resource-invalid` | 由 Gateway 在 `decide()` 之前给出:资源读取函数抛错或返回空列表,或某个资源 id(含 Context 身份)不是非空字符串。 | +| `no-allow` | 该 principal kind 没有 policy,或在某个资源上没有 binding 放行该 action,且 condition 与 `explain()` 都没有给出原因。 | +| `policy-failed` | `bindings()`、condition 或 `explain()` 抛错;该失败按上文规则记录日志。 | +| condition 或 `explain()` 的原因 | 在第一个没有 binding 放行的资源上,取匹配 binding 中第一个拒绝的 condition 的原因;没有时取 `explain(request)` 的返回值。 | + +快照 grant 按该 principal 的 binding 所指的每个资源,列出这些 binding 的 role 中、经 `decide()` 在该资源上不带调用参数放行的端点 action(已排序),因此在同一 principal、action 与资源上快照与 `decide()` 一致;没有此类 action 的资源不出 grant,放行集合变空时推送 `remove`。`event:` action 不进入快照。快照只在 `invalidate()` 与 policy 注册或撤回之后重算。 + + +## Client 面 + +Client 入口同步读取 index 注入在 `REMOTE_ACCESS_SNAPSHOT_KEY` 下的 baseline,经 `mountAccess(ctx, contribution, initial)` 自行挂载生成的 `remoteAccess` contribution,订阅快照流,并提供: + +- Client 服务 `ctx.remoteAccess.source(kind, id, action)`,为已声明种类的某个资源上的每个 `(kind, id, action)` 提供一个身份稳定的布尔 source; +- 标准 Hook `useRemoteAccess(action)`,回答 `global` 资源上的访问。 + +领域 UI 包在 `source()` 之上发布各自资源的 Hook:[ui-session](../../client/ui-session/README.zh.md) 提供 Session 资源上的 `useSessionAccess(sessionId, action)` 与会话作用域的 `useAccess(action)`(与 `global` 资源上的 `useRemoteAccess` 不同名),[ui-workspace](../../client/ui-workspace/README.zh.md) 提供 Workspace 资源上的 `useWorkspaceAccess(workspaceId, action)`。Hook 的 `action` 类型为 `RemoteEndpoint`,即某个 Client Remote namespace 声明的端点。非 owner 的答案是该资源的 grant 是否列出该 action,不回退 `global` 答案,因此没有 grant 或 grant 未列出任何 action 的资源回答 false。owner 的所有答案为 true;没有注入 baseline 的非 owner 页面、快照流终止失败的非 owner 页面,所有答案为 false;终止失败会连同错误一并报告,非 owner 附“刷新页面重连”的提示,owner 附“答案不受影响”的说明;重连期间保留上次快照。注入的 baseline 格式错误时挂载失败。Hook 从不发请求,Host 仍检查每次调用。[`dsh-client-test-runtime`](../../test-support/client-runtime/README.zh.md) 在 Client spec 中以全允许的访问替换本行。 + + +## 模型体验 + +无:本包为 Client 调用方判别 Remote 访问,不注册任何面向模型的内容。 + +#### KV Cache 影响 + +无直接影响。 + +## 已知限制与暂缓事项 + + + +- 快照与列表过滤的判定不带调用参数,因此按 `args` 细化结论的 condition 可能拒绝快照显示为允许的调用。 +- 资源读取函数与自动解析必须覆盖方法访问的全部资源;调用未指明的资源不受检查,持有已指明资源授权的 principal 可以访问它。 +- 默认资源解析按 wire 字段名匹配;给方法新增与已登记种类同名的字段,会让调用多访问该种类的资源。 +- exact Connection Fetch route 不经过 Gateway;在各路由声明自己的访问规则之前,共享 Fetch handler 对所有非 owner Peer 返回 403。 +- 交互参与(回答转发的 waterfall)仅限 owner:非 owner 的 `$events` 流只收到 policy 在事件资源上放行其 `event:` action 的广播帧。 +- `api-session/added` 携带完整的 `SessionSummary`,因此被允许接收它的非 owner 也会得知其 `parentSessionId`。 +- 被授权 Session 的 cwd 与工具路径本就包含 owner 的绝对路径,因此 `$events` ready 帧向非 owner 提供完整的 `host`(含 `home`);Gateway 不单独隐藏 `home`。 +- policy 撤销某个 Workspace 的可见性后,`workspace/follow` 只在该 Workspace 或 Workspace 顺序下一次变化时察觉并重推非 owner 的 baseline;仅访问变化不会发送帧。 + + +### 开发备注 + +
+维护者工作上下文——点击展开 + +无。 + +
diff --git a/packages/api/remote-access/package.json b/packages/api/remote-access/package.json new file mode 100644 index 0000000000..74d0b741db --- /dev/null +++ b/packages/api/remote-access/package.json @@ -0,0 +1,83 @@ +{ + "name": "@deepseek-ai/dsh-api-remote-access", + "description": "Method-level Remote access control for non-owner Peers and the per-Peer access snapshot", + "version": "0.2.1-alpha.2", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/api/remote-access" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./types": { + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./typert": { + "types": "./lib/typert.host.d.ts", + "default": "./lib/typert.host.js" + }, + "./remote": { + "types": "./lib/typert.remote-client.d.ts", + "default": "./lib/typert.remote-client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dsh": { + "client": { + "external": [ + "@deepseek-ai/dsh-api-gateway/client" + ], + "inject": [ + "@deepseek-ai/dsh-api-gateway", + "@deepseek-ai/dsh-client-ui-renderer" + ], + "platform": "web" + } + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "files": [ + "lib/index.js", + "lib/client.js", + "lib/types/**/*.js", + "lib/types/**/*.d.ts", + "lib/typert.host.js", + "lib/typert.host.d.ts", + "lib/typert.remote-client.js", + "lib/typert.remote-client.d.ts" + ], + "license": "MIT", + "dependencies": { + "@deepseek-ai/dsh-typert-protocol": "workspace:*", + "zod": "4.4.3" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:~" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-api-gateway": "workspace:*", + "@deepseek-ai/dsh-client-connection": "workspace:*", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", + "@deepseek-ai/dsh-client-ui-slots": "workspace:*", + "@deepseek-ai/dsh-host-webserver": "workspace:*", + "@deepseek-ai/dsh-typert-registry": "workspace:*" + } +} diff --git a/packages/api/remote-access/src/client/index.ts b/packages/api/remote-access/src/client/index.ts new file mode 100644 index 0000000000..396f56a16a --- /dev/null +++ b/packages/api/remote-access/src/client/index.ts @@ -0,0 +1,33 @@ +/** + * Client face: starts from the access snapshot the index injected, follows + * the calling Peer's snapshot stream, provides the `remoteAccess` Client + * service whose `source(kind, id, action)` answers for any declared-kind + * resource, and publishes `useRemoteAccess(action)` on the global resource. + * Domain UI adapters publish their own resource Hooks over `source()`. Every + * answer is a boolean: true for the owner, false without a snapshot or after + * a non-owner stream fails; the Host enforces every call regardless. + * @module @deepseek-ai/dsh-api-remote-access/client + */ + +import type { Context } from '@deepseek-ai/cordis' +import remoteAccessRemote from '@deepseek-ai/dsh-api-remote-access/remote' +import { REMOTE_ACCESS_SNAPSHOT_KEY } from '../snapshot.ts' +import { mountAccess } from './mount.ts' +import { parseInjectedBaseline } from './state.ts' + +export type { + RemoteAccessAction, RemoteAccessEndpoint, RemoteAccessFrame, RemoteAccessGrant, RemoteAccessResource, TypertResourceKind, +} from '../types.ts' +export type { ClientRemoteAccess, RemoteEndpoint } from './mount.ts' + +/** Required Client services: the Remote mount face and the Slot registry. */ +export const inject = ['remote', 'slots'] + +/** + * Mount the generated `remoteAccess` namespace and publish the access Hooks. + * @param ctx - Client root Context. + * @returns disposer that withdraws the namespace after the stream stops. + */ +export function apply(ctx: Context): Promise<() => Promise> { + return mountAccess(ctx, remoteAccessRemote, parseInjectedBaseline(Reflect.get(globalThis, REMOTE_ACCESS_SNAPSHOT_KEY))) +} diff --git a/packages/api/remote-access/src/client/mount.ts b/packages/api/remote-access/src/client/mount.ts new file mode 100644 index 0000000000..257aa396c2 --- /dev/null +++ b/packages/api/remote-access/src/client/mount.ts @@ -0,0 +1,132 @@ +/** + * Client access mount: follows the calling Peer's access snapshot, provides + * the `remoteAccess` Client service, and publishes the global-resource `useRemoteAccess` Hook. + * The entry module supplies the generated `remoteAccess` contribution, so + * specs mount a fake one. + * @module @deepseek-ai/dsh-api-remote-access/src/client/mount + */ + +import { Service, type Context } from '@deepseek-ai/cordis' +import { + RemoteSnapshotStream, + RemoteStreamCarrierError, + type ClientRemote, +} from '@deepseek-ai/dsh-api-gateway/client' +import type {} from '@deepseek-ai/dsh-api-remote-access/remote' +import type { HostObservable, RootStandardSourceContribution } from '@deepseek-ai/dsh-client-ui-slots' +// Type-only: the SlotRegistry (`ctx.slots`) merge. +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import type { TypertRemoteContribution, TypertRemoteMap, TypertResourceKind } from '@deepseek-ai/dsh-typert-protocol' +import type { RemoteAccessFrame } from '../types.ts' +import { ClientAccessState, type Baseline } from './state.ts' + +/** Every endpoint the Client Remote namespaces declare. */ +export type RemoteEndpoint = Extract + +/** Client access answers over the calling Peer's snapshot; it never issues a request. */ +export interface ClientRemoteAccess { + /** + * Whether the current Peer may call `action` on one resource. + * True for the owner; false without a baseline and after a non-owner + * stream fails; otherwise whether the resource's grant lists the action. + * @param kind - resource kind declared in `TypertResourceMap`. + * @param id - resource identity. + * @param action - endpoint action the caller would invoke. + * @returns identity-stable boolean source for this `(kind, id, action)`. + */ + source(kind: TypertResourceKind, id: string, action: string): HostObservable +} + +declare module '@deepseek-ai/cordis' { + interface Context { + /** Client access answers for the calling Peer. */ + remoteAccess: ClientRemoteAccess + } +} + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface GlobalStandardProps { + /** Whether the current Peer may call `action` on the global resource. */ + useRemoteAccess: (action: RemoteEndpoint) => boolean + } +} + +type IncrementFrame = Exclude + +/** The `remoteAccess` Client service over one snapshot owner. */ +class ClientRemoteAccessService extends Service implements ClientRemoteAccess { + /** + * @param ctx - Client Context the service is provided on. + * @param state - snapshot owner answering every source. + */ + constructor(ctx: Context, private readonly state: ClientAccessState) { + super(ctx, 'remoteAccess') + } + + source(kind: TypertResourceKind, id: string, action: string): HostObservable { + return this.state.resource(kind, id, action) + } +} + +/** + * Mount the `remoteAccess` namespace, follow the access snapshot, provide the + * `remoteAccess` Client service, and publish the global-resource `useRemoteAccess` Hook. + * Answers start from the page's injected baseline, or false without one, + * and follow the stream's frames from then on. + * @param ctx - Client root Context injecting `remote` and `slots`. + * @param contribution - generated `remoteAccess` Remote contribution. + * @param initial - the page's injected opening baseline, read before the stream connects. + * @returns disposer that withdraws the namespace after the stream stops. + */ +export async function mountAccess( + ctx: Context, + contribution: TypertRemoteContribution, + initial?: Baseline, +): Promise<() => Promise> { + const state = new ClientAccessState(initial) + new ClientRemoteAccessService(ctx, state) + ctx.slots.provideRoot({ + keyedHooks: { remoteAccess: action => state.global(action) }, + } satisfies RootStandardSourceContribution) + const unmount = await ctx.remote.$mount(contribution) + const follow = ctx.inject(['remote.remoteAccess'], (remoteCtx) => { + const control = createAccessStream(remoteCtx.remote, state, remoteCtx.logger) + control.start() + remoteCtx.effect(() => async () => { await control.dispose() }, 'remote-access.client.follow') + }) + return async () => { + await follow.dispose() + await unmount() + } +} + +/** + * Create the reconnecting access snapshot stream. A carrier loss keeps the + * last snapshot while the stream reconnects; a terminal failure keeps the + * owner's answers and turns a non-owner's answers false. + */ +function createAccessStream( + remote: ClientRemote, + state: ClientAccessState, + logger: Context['logger'], +): RemoteSnapshotStream { + const stream = remote.$stream({ + name: 'Remote access stream', + open: signal => remote.remoteAccess.follow(signal), + ended: accepted => accepted + ? new RemoteStreamCarrierError('Remote access stream ended without a terminal result') + : new Error('Remote access stream ended before its opening snapshot'), + }) + return new RemoteSnapshotStream(stream, { + name: 'Remote access stream', + isSnapshot: (frame): frame is Baseline => frame.type === 'baseline', + replace: (frame) => { state.replace(frame) }, + update: (frame) => { state.update(frame) }, + failed: (error) => { + // Only a non-owner's answers turn false after a terminal failure, so only its report names the recovery. + if (state.owner) logger.error('access snapshot stream failed; owner access answers are unaffected', error) + else logger.error('access snapshot stream failed; reload the page to reconnect', error) + state.unavailable() + }, + }) +} diff --git a/packages/api/remote-access/src/client/state.ts b/packages/api/remote-access/src/client/state.ts new file mode 100644 index 0000000000..b0ee02b400 --- /dev/null +++ b/packages/api/remote-access/src/client/state.ts @@ -0,0 +1,208 @@ +/** + * React-free Client access state: the latest `remoteAccess/follow` snapshot + * and identity-stable boolean sources per resource and action. + * @module @deepseek-ai/dsh-api-remote-access/src/client/state + */ + +import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots' +import type { RemoteAccessFrame, RemoteAccessGrant, RemoteAccessResource } from '../types.ts' + +/** Actions allowed per resource identity, for one declared resource kind. */ +type KindGrants = ReadonlyMap> + +type AccessSnapshot = + /** No baseline, or a non-owner snapshot stream failed: every answer is false. */ + | { readonly kind: 'closed' } + | { readonly kind: 'owner' } + | { + readonly kind: 'granted' + readonly global: ReadonlySet + readonly resources: ReadonlyMap + } + +type GrantedSnapshot = Extract + +const CLOSED: AccessSnapshot = Object.freeze({ kind: 'closed' }) +const OWNER_SNAPSHOT: AccessSnapshot = Object.freeze({ kind: 'owner' }) +const GLOBAL_KEY = '\u0000global' + +/** The opening frame of a snapshot generation. */ +export type Baseline = Extract + +/** + * Validate the opening baseline an index injection row assigned to the page. + * @param value - the injected global; `undefined` when the page carries none. + * @returns the baseline, or `undefined` when none was injected. + * @throws TypeError when the injected value is not a baseline frame. + */ +export function parseInjectedBaseline(value: unknown): Baseline | undefined { + if (value === undefined) return undefined + if (!isRecord(value) || value['type'] !== 'baseline') throw new TypeError('remote access: the injected access snapshot is not a baseline frame') + if (value['owner'] === true) return { type: 'baseline', owner: true } + const grants = value['grants'] + if (value['owner'] !== false || !Array.isArray(grants) || !grants.every(isGrant)) { + throw new TypeError('remote access: the injected access snapshot carries malformed grants') + } + return { type: 'baseline', owner: false, grants } +} + +function isGrant(value: unknown): value is RemoteAccessGrant { + if (!isRecord(value) || !isResource(value['resource'])) return false + const actions = value['actions'] + return Array.isArray(actions) && actions.every(action => typeof action === 'string') +} + +function isResource(value: unknown): value is RemoteAccessResource { + if (!isRecord(value) || typeof value['kind'] !== 'string') return false + return value['kind'] === 'global' || typeof value['id'] === 'string' +} + +function isRecord(value: unknown): value is Readonly> { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** + * Snapshot owner for one Client generation. Sources are cached per + * `(kind, id, action)` so Hook bindings keep their identity; each source + * computes its boolean from the current snapshot. + */ +export class ClientAccessState { + private snapshot: AccessSnapshot = CLOSED + + /** @param initial - the page's injected opening baseline; without one every answer is false until a baseline arrives. */ + constructor(initial?: Baseline) { + if (initial !== undefined) this.replace(initial) + } + private readonly listeners = new Set<() => void>() + private readonly sources = new Map>() + + /** + * Answer for one action on the global resource. + * @param action - endpoint action the caller may invoke. + * @returns identity-stable boolean source. + */ + global(action: string): HostObservable { + return this.cached(GLOBAL_KEY, '', action) + } + + /** + * Answer for one action on one resource of a declared kind. + * @param kind - declared resource kind. + * @param id - resource identity. + * @param action - endpoint action the caller may invoke. + * @returns identity-stable boolean source. + */ + resource(kind: string, id: string, action: string): HostObservable { + return this.cached(kind, id, action) + } + + /** + * Replace the snapshot from one generation's opening frame. + * @param frame - owner or non-owner baseline. + */ + replace(frame: Baseline): void { + if (frame.owner) { + this.publish(OWNER_SNAPSHOT) + return + } + let global: ReadonlySet = new Set() + const resources = new Map>>() + // The baseline is unpublished, so its maps are filled in place. + for (const { resource, actions } of frame.grants) { + if (resource.kind === 'global') { + global = new Set(actions) + continue + } + let grants = resources.get(resource.kind) + if (grants === undefined) { + grants = new Map() + resources.set(resource.kind, grants) + } + grants.set(resource.id, new Set(actions)) + } + this.publish({ kind: 'granted', global, resources }) + } + + /** + * Apply one increment of the current generation. + * @param frame - `upsert` or `remove`. + */ + update(frame: Exclude): void { + const current = this.snapshot + if (current.kind !== 'granted') return + switch (frame.type) { + case 'upsert': + this.publish(withGrant(current, frame.grant)) + return + case 'remove': + this.publish(withGrant(current, { resource: frame.resource, actions: [] }, true)) + return + /* v8 ignore next -- the generated Remote codec validates this closed union. */ + default: + return assertNever(frame) + } + } + + /** Whether the current snapshot is the owner's, whose answers never depend on the stream. */ + get owner(): boolean { + return this.snapshot.kind === 'owner' + } + + /** The stream failed terminally: the owner keeps every answer; a non-owner's answers become false. */ + unavailable(): void { + if (this.snapshot.kind !== 'owner') this.publish(CLOSED) + } + + private cached(kind: string, id: string, action: string): HostObservable { + const key = `${kind}\u0000${id}\u0000${action}` + let source = this.sources.get(key) + if (source === undefined) { + source = { + getSnapshot: () => answer(this.snapshot, kind, id, action), + subscribe: (listener) => { + this.listeners.add(listener) + return () => { this.listeners.delete(listener) } + }, + } + this.sources.set(key, source) + } + return source + } + + private publish(next: AccessSnapshot): void { + this.snapshot = next + for (const listener of [...this.listeners]) listener() + } +} + +function answer(snapshot: AccessSnapshot, kind: string, id: string, action: string): boolean { + switch (snapshot.kind) { + case 'closed': + return false + case 'owner': + return true + case 'granted': { + const allowed = kind === GLOBAL_KEY ? snapshot.global : snapshot.resources.get(kind)?.get(id) + return allowed?.has(action) ?? false + } + /* v8 ignore next -- closed union. */ + default: + return assertNever(snapshot) + } +} + +function withGrant(snapshot: GrantedSnapshot, grant: RemoteAccessGrant, remove = false): GrantedSnapshot { + const resource = grant.resource + if (resource.kind === 'global') return { ...snapshot, global: new Set(grant.actions) } + const resources = new Map(snapshot.resources) + const grants = new Map(resources.get(resource.kind)) + if (remove) grants.delete(resource.id) + else grants.set(resource.id, new Set(grant.actions)) + resources.set(resource.kind, grants) + return { ...snapshot, resources } +} + +/* v8 ignore next 3 -- closed-union backstop. */ +function assertNever(value: never): never { + throw new Error(`unreachable access frame: ${JSON.stringify(value)}`) +} diff --git a/packages/api/remote-access/src/index.ts b/packages/api/remote-access/src/index.ts new file mode 100644 index 0000000000..5b3cf41e0f --- /dev/null +++ b/packages/api/remote-access/src/index.ts @@ -0,0 +1,424 @@ +/** + * Host method-level access control. The Gateway asks `decide()` before it + * dispatches a non-owner call or forwards an event to a non-owner Peer; list + * methods filter items through `allows()`; every Peer reads its own access + * snapshot, first from the index and then from `follow()`. Business packages + * register one policy per non-owner principal kind. + * @module @deepseek-ai/dsh-api-remote-access + */ + +import type { Context } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/dsh-host-webserver' +import { + Remote, + TypertRemoteService, + principalOf, + type Principal, + type PrincipalMap, + type RemoteAccessResource, +} from '@deepseek-ai/dsh-typert-protocol' +import { REMOTE_ACCESS_SNAPSHOT_KEY } from './snapshot.ts' +import type { + RemoteAccessAction, + RemoteAccessDecision, + RemoteAccessFrame, + RemoteAccessGrant, + RemoteAccessPolicy, + RemoteAccessRequest, +} from './types.ts' + +export type * from './types.ts' +export { REMOTE_ACCESS_SNAPSHOT_KEY } from './snapshot.ts' + +/** The opening frame of a snapshot generation. */ +export type RemoteAccessBaseline = Extract + +declare module '@deepseek-ai/cordis' { + interface Context { + /** Host method-level access control for non-owner Peers. */ + remoteAccess: RemoteAccessService + } +} + +/** The snapshot endpoint every Peer may read for itself. */ +const FOLLOW_ACTION: RemoteAccessAction = 'remoteAccess/follow' +const ALLOW: RemoteAccessDecision = Object.freeze({ kind: 'allow' }) +const NO_ALLOW: RemoteAccessDecision = Object.freeze({ kind: 'deny', reason: 'no-allow' }) +const POLICY_FAILED: RemoteAccessDecision = Object.freeze({ kind: 'deny', reason: 'policy-failed' }) +const OWNER: Principal = Object.freeze({ kind: 'owner' }) +const OWNER_BASELINE: RemoteAccessBaseline = Object.freeze({ type: 'baseline', owner: true }) + +/** + * A binding seen without its resource kind. Method syntax keeps the condition + * of every kind assignable; each condition is called only with a resource + * equal to its own binding's resource. + */ +interface ErasedBinding { + readonly role: { readonly actions: readonly RemoteAccessAction[] } + readonly resource: RemoteAccessResource + condition?(request: { + readonly action: RemoteAccessAction + readonly resource: RemoteAccessResource + readonly args?: Readonly> + }): RemoteAccessDecision +} + +/** One registered policy with the principal kind it serves. */ +interface PolicyEntry { + readonly kind: string + readonly policy: RemoteAccessPolicy +} + +/** A condition's refusal. */ +type Refusal = Extract + +/** The answer for one action on one resource, before any `explain()`. */ +type Evaluation = + | { readonly kind: 'allow' } + /** No binding allows; `refusal` is the first refusing condition's decision, if any condition ran. */ + | { readonly kind: 'refuse'; readonly refusal: Refusal | undefined } + | { readonly kind: 'failed' } + +const ALLOWED: Evaluation = Object.freeze({ kind: 'allow' }) +const FAILED: Evaluation = Object.freeze({ kind: 'failed' }) + +/** One open non-owner snapshot generation; changes coalesce until its reader wakes. */ +class AccessFollower { + private dirty = false + private wake: (() => void) | undefined + + /** @param principal - the generation's caller identity; never the owner. */ + constructor(readonly principal: Principal) {} + + /** Mark the generation stale and wake its reader. */ + touch(): void { + this.dirty = true + this.wake?.() + } + + /** + * Wait until the generation is stale or cancelled. + * @param signal - generation cancellation. + * @returns true when stale; false once cancelled. + */ + async changed(signal: AbortSignal): Promise { + const abort = (): void => { this.wake?.() } + signal.addEventListener('abort', abort, { once: true }) + try { + while (!this.dirty && !signal.aborted) { + await new Promise((resolve) => { this.wake = resolve }) + this.wake = undefined + } + } finally { + signal.removeEventListener('abort', abort) + } + this.dirty = false + return !signal.aborted + } +} + +/** + * Registry of access policies, the Gateway's access decisions, list + * filtering, and the per-Peer access snapshot. Every composition that mounts + * the Gateway mounts this service too. + */ +export class RemoteAccessService extends TypertRemoteService { + private readonly policies = new Map() + /** Policies whose failure was logged since the last `invalidate()`; their later failures stay silent. */ + private readonly reported = new Set() + private readonly followers = new Set() + /** Aborted when this instance unloads; every open snapshot generation ends with it. */ + private readonly lifetime = new AbortController() + + /** @param ctx - owning Host plugin Context. */ + constructor(ctx: Context) { + super(ctx, 'remoteAccess') + ctx.effect(() => () => { this.lifetime.abort() }, 'remoteAccess: snapshot generations') + // Every index the webServer renders belongs to the owner; snapshotFor documents non-owner pages. + ctx.inject(['webServer'], (webCtx) => { + webCtx.on('webserver/index-inject', (table) => { + table.push({ kind: 'global', name: REMOTE_ACCESS_SNAPSHOT_KEY, value: OWNER_BASELINE }) + }) + }) + } + + /** + * Register the policy of one non-owner principal kind in the caller's + * fiber; open snapshots are recomputed on registration and removal. + * @param kind - the principal kind the policy serves; never `owner`. + * @param policy - business-owned bindings and refusal reasons for principals of `kind`. + * @returns disposer removing this policy. + * @throws Error when `kind` already has a policy. + */ + registerPolicy>(kind: K, policy: RemoteAccessPolicy): () => void { + const policies = this.policies + if (policies.has(kind)) throw new Error(`remote access: principal kind ${JSON.stringify(kind)} already has a policy`) + const entry: PolicyEntry = { kind, policy } + const dispose = this.ctx.effect(() => { + policies.set(kind, entry) + this.invalidate() + return () => { + policies.delete(kind) + this.invalidate() + } + }, `remoteAccess.registerPolicy(${JSON.stringify(kind)})`) + return () => { void dispose() } + } + + /** + * Report that the bindings or condition answers for `principal` may have + * changed. Business packages call this on every grant, revocation, mode + * change, and expiry; open snapshots re-push, and the next failure of each + * policy is logged again. + * @param principal - affected principal; omitted for every principal. + */ + invalidate(principal?: Principal): void { + this.reported.clear() + const key = principal === undefined ? undefined : canonicalJson(principal) + for (const follower of this.followers) { + if (key === undefined || canonicalJson(follower.principal) === key) follower.touch() + } + } + + /** + * Gateway-only: decide one request whose resources the Gateway resolved. + * The owner is allowed, and so is `remoteAccess/follow`: every Peer reads + * its own snapshot. Otherwise the action is allowed only when, on every + * listed resource, some binding of the principal's policy names that + * resource, has a role listing the action, and has no condition or a + * condition that allows. `bindings()` is called once per decision. + * @param request - the caller or recipient, the action, the resources it + * touches, and a call's arguments. + * @returns `allow`; or `deny` with `no-allow` when the principal kind has + * no policy, the first refusing condition's reason on the first resource + * no binding allows, else `explain()`'s reason, else `no-allow`; + * `policy-failed` when `bindings()`, a condition, or `explain()` throws, + * logged once per policy until the next `invalidate()`. The Gateway + * throws `remote-access/denied` for a denied call and drops a denied event. + */ + decide(request: RemoteAccessRequest): RemoteAccessDecision { + const { principal, action } = request + // oxlint-disable-next-line typescript/no-unnecessary-condition -- other packages merge non-owner kinds into PrincipalMap. + if (principal.kind === 'owner' || action === FOLLOW_ACTION) return ALLOW + const entry = this.policies.get(principal.kind) + if (entry === undefined) return NO_ALLOW + const bindings = this.bindingsOf(entry, principal, action) + if (bindings === undefined) return POLICY_FAILED + for (const resource of request.resources) { + const evaluation = this.evaluate(entry, bindings, action, resource, request.args) + switch (evaluation.kind) { + case 'allow': + continue + case 'failed': + return POLICY_FAILED + case 'refuse': + return evaluation.refusal ?? this.explain(entry, request) + /* v8 ignore next -- closed union. */ + default: + return assertNever(evaluation) + } + } + return ALLOW + } + + /** + * Filter items inside a Remote method. The invocation is read from the + * Context that accessed this service, so call it as + * `this.ctx.remoteAccess.allows(resource)` from the Remote method. Each + * check reads the policy's bindings again, so a long-running stream sees + * binding changes from the next check on. + * @param resource - resource of one result item. + * @returns true for the owner and outside a Remote call; otherwise whether + * the invocation's endpoint action is allowed on `resource` without call + * arguments, false when the policy is absent or fails. + */ + allows(resource: RemoteAccessResource): boolean { + const invocation = this.ctx.invocation + const principal = principalOf(invocation) + if (invocation === undefined || principal === undefined) return true + const action: RemoteAccessAction = `${invocation.request.namespace}/${invocation.request.method}` + const entry = this.policies.get(principal.kind) + if (entry === undefined) return false + const bindings = this.bindingsOf(entry, principal, action) + return bindings !== undefined && this.evaluate(entry, bindings, action, resource, undefined).kind === 'allow' + } + + /** + * The baseline a page opens with before its snapshot stream connects. A + * business package rendering a non-owner page assigns this value to + * {@link REMOTE_ACCESS_SNAPSHOT_KEY} after every index injection row, + * replacing the owner baseline the webServer index carries. + * @param principal - the page's caller identity. + * @returns the owner baseline for the owner; otherwise the principal's current grants. + */ + snapshotFor(principal: Principal): RemoteAccessBaseline { + // oxlint-disable-next-line typescript/no-unnecessary-condition -- other packages merge non-owner kinds into PrincipalMap. + if (principal.kind === 'owner') return OWNER_BASELINE + return { type: 'baseline', owner: false, grants: this.grantsFor(principal) } + } + + /** + * Stream the calling Peer's access snapshot: one baseline, then `upsert` + * and `remove` frames after each `invalidate()` and each policy + * registration or removal that changes a grant. The owner receives only + * `{ owner: true }`. The generation ends when `signal` aborts or this + * service unloads, so a reloaded service starts every reader from a fresh + * baseline. + * @param signal - generation cancellation. + * @returns the access frames of one generation. + */ + @Remote({ mode: 'stream' }) + async *follow(signal: AbortSignal): AsyncIterable { + signal.throwIfAborted() + const generation = AbortSignal.any([signal, this.lifetime.signal]) + const principal = principalOf(this.ctx.invocation) + if (principal === undefined) { + yield this.snapshotFor(OWNER) + await abortion(generation) + return + } + const follower = new AccessFollower(principal) + this.followers.add(follower) + try { + let sent = this.grantsFor(principal) + yield { type: 'baseline', owner: false, grants: sent } + while (await follower.changed(generation)) { + const next = this.grantsFor(principal) + const previous = sent + sent = next + for (const grant of next) { + const before = previous.find(other => sameResource(other.resource, grant.resource)) + if (before === undefined || !sameActions(before.actions, grant.actions)) yield { type: 'upsert', grant } + } + for (const grant of previous) { + if (!next.some(other => sameResource(other.resource, grant.resource))) yield { type: 'remove', resource: grant.resource } + } + } + } finally { + this.followers.delete(follower) + } + } + + /** One `bindings()` call; `undefined` after a logged failure. */ + private bindingsOf(entry: PolicyEntry, principal: Principal, purpose: string): readonly ErasedBinding[] | undefined { + try { + return entry.policy.bindings(principal) + } catch (error) { + // A policy failure is contained to this question, so one faulty rule cannot break a shared event stream. + this.report(entry, `list bindings for ${purpose}`, error) + return undefined + } + } + + /** Find a binding on `resource` whose role lists `action` and whose condition, if any, allows. */ + private evaluate( + entry: PolicyEntry, + bindings: readonly ErasedBinding[], + action: RemoteAccessAction, + resource: RemoteAccessResource, + args: Readonly> | undefined, + ): Evaluation { + let refusal: Refusal | undefined + for (const binding of bindings) { + if (!sameResource(binding.resource, resource) || !binding.role.actions.includes(action)) continue + if (binding.condition === undefined) return ALLOWED + let decision: RemoteAccessDecision + try { + decision = binding.condition(args === undefined ? { action, resource } : { action, resource, args }) + } catch (error) { + this.report(entry, `evaluate a condition for ${action}`, error) + return FAILED + } + if (decision.kind === 'allow') return ALLOWED + refusal ??= decision + } + return { kind: 'refuse', refusal } + } + + /** The refusal reason `explain()` names, else `no-allow`; `policy-failed` when it throws. */ + private explain(entry: PolicyEntry, request: RemoteAccessRequest): RemoteAccessDecision { + let reason: string | undefined + try { + reason = entry.policy.explain?.(request) + } catch (error) { + this.report(entry, `explain a refusal of ${request.action}`, error) + return POLICY_FAILED + } + return reason === undefined ? NO_ALLOW : { kind: 'deny', reason } + } + + /** + * One grant per bound resource, in first-binding order: the endpoint + * actions of the resource's bindings that `decide()` would allow there + * without call arguments. A resource with no such action gets no grant; + * event actions stay out of the snapshot. + */ + private grantsFor(principal: Principal): readonly RemoteAccessGrant[] { + const entry = this.policies.get(principal.kind) + if (entry === undefined) return [] + const bindings = this.bindingsOf(entry, principal, 'a snapshot') + if (bindings === undefined) return [] + const grants: RemoteAccessGrant[] = [] + const seen: RemoteAccessResource[] = [] + for (const { resource } of bindings) { + if (seen.some(other => sameResource(other, resource))) continue + seen.push(resource) + const candidates = new Set() + for (const binding of bindings) { + if (!sameResource(binding.resource, resource)) continue + for (const action of binding.role.actions) { + if (!action.startsWith('event:')) candidates.add(action) + } + } + const actions = [...candidates].filter(action => this.evaluate(entry, bindings, action, resource, undefined).kind === 'allow').sort() + if (actions.length > 0) grants.push(Object.freeze({ resource, actions: Object.freeze(actions) })) + } + return grants + } + + /** Log a policy failure once per policy until the next `invalidate()`; snapshots and lists ask many times per change. */ + private report(entry: PolicyEntry, action: string, error: unknown): void { + if (this.reported.has(entry)) return + this.reported.add(entry) + this.ctx.logger.warn( + `remote access: the ${JSON.stringify(entry.kind)} policy failed to ${action}; its further failures are not logged until access state changes`) + this.ctx.logger.warn(error) + } +} + +/* v8 ignore next 3 -- closed-union backstop. */ +function assertNever(value: never): never { + throw new Error(`remote access: unreachable value ${JSON.stringify(value)}`) +} + +/** Key-order-independent JSON text identifying one principal. */ +function canonicalJson(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]` + if (typeof value === 'object' && value !== null) { + // Own keys are unique, so no two entries compare equal. + const entries = Object.entries(value).sort(([left], [right]) => (left < right ? -1 : 1)) + return `{${entries.map(([key, item]) => `${JSON.stringify(key)}:${canonicalJson(item)}`).join(',')}}` + } + return JSON.stringify(value) +} + +/** Resources are equal when their kinds and ids are; `global` carries no id. */ +function sameResource( + left: { readonly kind: string; readonly id?: unknown }, + right: { readonly kind: string; readonly id?: unknown }, +): boolean { + return left.kind === right.kind && left.id === right.id +} + +function sameActions(left: readonly string[], right: readonly string[]): boolean { + return left.length === right.length && left.every((action, index) => action === right[index]) +} + +function abortion(signal: AbortSignal): Promise { + return new Promise((resolve) => { + if (signal.aborted) resolve() + else signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) +} + +export default RemoteAccessService diff --git a/packages/api/remote-access/src/snapshot.ts b/packages/api/remote-access/src/snapshot.ts new file mode 100644 index 0000000000..ad3a490205 --- /dev/null +++ b/packages/api/remote-access/src/snapshot.ts @@ -0,0 +1,8 @@ +/** + * The index global carrying a page's first access snapshot, shared by the + * Host row that writes it and the Client mount that reads it. + * @module @deepseek-ai/dsh-api-remote-access/src/snapshot + */ + +/** `globalThis` property an index injection row assigns to the page's opening baseline. */ +export const REMOTE_ACCESS_SNAPSHOT_KEY = '__DSH_REMOTE_ACCESS__' diff --git a/packages/api/remote-access/src/types.ts b/packages/api/remote-access/src/types.ts new file mode 100644 index 0000000000..c598ee60a2 --- /dev/null +++ b/packages/api/remote-access/src/types.ts @@ -0,0 +1,142 @@ +/** + * Method-level access control contracts: actions, roles, bindings, policies, + * decision requests, and the access snapshot stream. + * @module @deepseek-ai/dsh-api-remote-access/types + */ + +import type { + Principal, + RemoteAccessResource, + RemoteAccessResourceKind, + RemoteAccessResourceOf, +} from '@deepseek-ai/dsh-typert-protocol' + +export type { + RemoteAccessResource, + RemoteAccessResourceKind, + RemoteAccessResourceOf, + TypertResourceKind, + TypertResourceMap, +} from '@deepseek-ai/dsh-typert-protocol' + +/** Canonical `/` Remote endpoint; its segments never contain `:`. */ +export type RemoteAccessEndpoint = `${string}/${string}` + +/** A Remote endpoint, or `event:` for one forwarded event. */ +export type RemoteAccessAction = RemoteAccessEndpoint | `event:${string}` + +/** A named action set over one resource kind; a plain value the business package owns. */ +export interface RemoteAccessRole { + /** Name for diagnostics; decisions read only `resource` and `actions`. */ + readonly name: string + /** The resource kind every binding of this role names. */ + readonly resource: K + /** Actions the role allows on its binding's resource. */ + readonly actions: readonly RemoteAccessAction[] +} + +/** Answer of `decide()` and of a condition. */ +export type RemoteAccessDecision = + | { readonly kind: 'allow' } + | { readonly kind: 'deny'; readonly reason: string } + +/** + * Refines one binding for one action on its resource. Called synchronously, + * only for a binding whose resource matches and whose role lists the action. + * @param request - the action, the binding's resource, and the named + * arguments of a concrete call, validated against their Typert-compiled + * types with lookup parameters as wire identities; `args` is absent for + * events, snapshots, and list filtering, where the condition answers for + * the action alone. + * @returns allow, or deny with the reason a refused call reports. + */ +export type RemoteAccessCondition = (request: { + readonly action: RemoteAccessAction + readonly resource: RemoteAccessResourceOf + readonly args?: Readonly> +}) => RemoteAccessDecision + +/** One role on one resource of the role's kind; the resource type follows from the role. */ +export type RemoteAccessBinding = { + [K in RemoteAccessResourceKind]: { + readonly role: RemoteAccessRole + readonly resource: RemoteAccessResourceOf + /** Without a condition, the binding allows every action its role lists. */ + readonly condition?: RemoteAccessCondition + } +}[RemoteAccessResourceKind] + +/** One access question: may `principal` take `action` on `resources`. */ +export interface RemoteAccessRequest

{ + readonly principal: P + readonly action: RemoteAccessAction + /** + * Every resource the action touches, the global resource alone when it + * names none; allowed only when the action is allowed on every listed resource. + */ + readonly resources: readonly RemoteAccessResource[] + /** + * Named arguments of a concrete call, validated against their Typert-compiled types + * with lookup parameters as wire identities; absent for events, snapshots, and list filtering. + */ + readonly args?: Readonly> +} + +/** + * Business rules for one non-owner principal kind. Every member answers + * synchronously from state the business package holds in memory. + */ +export interface RemoteAccessPolicy

{ + /** + * List the principal's bindings. Called once per `decide()`, once per + * `allows()`, and once per snapshot computation; never cached. + * @param principal - non-owner caller of this policy's kind. + * @returns every binding the principal holds now. + */ + bindings(principal: P): readonly RemoteAccessBinding[] + /** + * Name the reason for a request no binding allows; called only after the + * decision is already a deny, so it never changes the decision. + * @param request - the refused request. + * @returns the reason a refused call reports, or `undefined` for `no-allow`. + */ + explain?(request: RemoteAccessRequest

): string | undefined +} + +/** Details of a refused non-owner call. */ +export interface RemoteAccessDeniedDetails { + /** Canonical `/` endpoint of the refused call. */ + readonly action: string + /** + * A condition's or `explain()`'s reason, `no-allow`, or `policy-failed`; + * the Gateway refuses a call whose resources are invalid with + * `resource-invalid` before `decide()` is asked. + */ + readonly reason: string +} + +declare module '@deepseek-ai/dsh-typert-protocol' { + interface RemoteErrorDetailsMap { + 'remote-access/denied': RemoteAccessDeniedDetails + } +} + +/** The endpoint actions one principal may call on one resource. */ +export interface RemoteAccessGrant { + readonly resource: RemoteAccessResource + /** + * Endpoint actions allowed on this resource without call arguments, sorted; + * plain strings because Typert generates no codec for template literal types. + */ + readonly actions: readonly string[] +} + +/** + * One frame of `remoteAccess/follow`. A generation opens with one baseline; + * an owner baseline is the only frame of its generation. + */ +export type RemoteAccessFrame = + | { readonly type: 'baseline'; readonly owner: true } + | { readonly type: 'baseline'; readonly owner: false; readonly grants: readonly RemoteAccessGrant[] } + | { readonly type: 'upsert'; readonly grant: RemoteAccessGrant } + | { readonly type: 'remove'; readonly resource: RemoteAccessResource } diff --git a/packages/api/remote-access/tests/client-access.client.spec.ts b/packages/api/remote-access/tests/client-access.client.spec.ts new file mode 100644 index 0000000000..b73944ec58 --- /dev/null +++ b/packages/api/remote-access/tests/client-access.client.spec.ts @@ -0,0 +1,291 @@ +import { describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import type { HostObservable, RootStandardSourceContribution } from '@deepseek-ai/dsh-client-ui-slots' +import type { TypertRemoteContribution, TypertResource } from '@deepseek-ai/dsh-typert-protocol' +import type { RemoteAccessFrame, RemoteAccessGrant } from '../src/types.ts' +import { ClientAccessState, parseInjectedBaseline, type Baseline } from '../src/client/state.ts' +import { mountAccess } from '../src/client/mount.ts' + +declare module '@deepseek-ai/dsh-typert-protocol' { + interface TypertResourceMap { + /** Test-only resource kinds; the Client face itself names no kind. */ + 'test-shelf': TypertResource + 'test-desk': TypertResource + } +} + +const GRANTED: Extract = { + type: 'baseline', + owner: false, + grants: [ + { resource: { kind: 'global' }, actions: ['session/list'] }, + { resource: { kind: 'test-shelf', id: 's1' }, actions: ['session/fork', 'session/prompt'] }, + { resource: { kind: 'test-desk', id: 'd1' }, actions: ['workspace/follow'] }, + ], +} + +describe('ClientAccessState', () => { + it('answers false without a baseline and after a non-owner stream fails, and true for the owner', () => { + const state = new ClientAccessState() + expect(state.global('session/create').getSnapshot()).toBe(false) + expect(state.resource('test-shelf', 's9', 'session/fork').getSnapshot()).toBe(false) + state.replace({ type: 'baseline', owner: true }) + expect(state.resource('test-desk', 'd9', 'workspace/follow').getSnapshot()).toBe(true) + state.unavailable() + expect(state.resource('test-desk', 'd9', 'workspace/follow').getSnapshot()).toBe(true) + state.replace(GRANTED) + expect(state.global('session/list').getSnapshot()).toBe(true) + state.unavailable() + expect(state.global('session/list').getSnapshot()).toBe(false) + }) + + it('starts from the injected baseline before any stream frame', () => { + expect(new ClientAccessState({ type: 'baseline', owner: true }).global('session/create').getSnapshot()).toBe(true) + const granted = new ClientAccessState(GRANTED) + expect(granted.global('session/list').getSnapshot()).toBe(true) + expect(granted.resource('test-shelf', 's1', 'session/fork').getSnapshot()).toBe(true) + expect(granted.global('session/create').getSnapshot()).toBe(false) + }) + + it('validates the injected baseline and refuses a malformed one', () => { + expect(parseInjectedBaseline(undefined)).toBeUndefined() + expect(parseInjectedBaseline({ type: 'baseline', owner: true, extra: 1 })).toEqual({ type: 'baseline', owner: true }) + expect(parseInjectedBaseline(JSON.parse(JSON.stringify(GRANTED)))).toEqual(GRANTED) + for (const malformed of [ + null, 'baseline', [], { type: 'upsert' }, + { type: 'baseline' }, { type: 'baseline', owner: false }, { type: 'baseline', owner: false, grants: [7] }, + { type: 'baseline', owner: false, grants: [{ resource: { kind: 'test-shelf' }, actions: [] }] }, + { type: 'baseline', owner: false, grants: [{ resource: { kind: 7 }, actions: [] }] }, + { type: 'baseline', owner: false, grants: [{ resource: { kind: 'global' }, actions: 'session/list' }] }, + { type: 'baseline', owner: false, grants: [{ resource: { kind: 'global' }, actions: [7] }] }, + { type: 'baseline', owner: false, grants: [{ resource: { kind: 'global' } }] }, + { type: 'baseline', owner: false, grants: [{ scope: { kind: 'page' }, endpoints: ['session/list'] }] }, + ]) { + expect(() => parseInjectedBaseline(malformed)).toThrow(TypeError) + } + }) + + it('answers each resource from its own grant without falling back to the global resource', () => { + const state = new ClientAccessState() + state.replace(GRANTED) + expect(state.global('session/list').getSnapshot()).toBe(true) + expect(state.global('session/fork').getSnapshot()).toBe(false) + expect(state.resource('test-shelf', 's1', 'session/fork').getSnapshot()).toBe(true) + expect(state.resource('test-shelf', 's1', 'session/list').getSnapshot()).toBe(false) + expect(state.resource('test-shelf', 's2', 'session/fork').getSnapshot()).toBe(false) + expect(state.resource('test-desk', 'd1', 'workspace/follow').getSnapshot()).toBe(true) + expect(state.resource('test-desk', 'd2', 'workspace/follow').getSnapshot()).toBe(false) + }) + + it('applies upserts and removals and notifies subscribers until they unsubscribe', () => { + const state = new ClientAccessState() + state.replace(GRANTED) + const fork = state.resource('test-shelf', 's1', 'session/fork') + const listener = vi.fn() + const unsubscribe = fork.subscribe(listener) + state.update({ type: 'upsert', grant: { resource: { kind: 'test-shelf', id: 's1' }, actions: ['session/prompt'] } }) + expect(fork.getSnapshot()).toBe(false) + expect(listener).toHaveBeenCalledOnce() + state.update({ type: 'upsert', grant: { resource: { kind: 'global' }, actions: ['session/create'] } }) + expect(state.global('session/create').getSnapshot()).toBe(true) + state.update({ type: 'upsert', grant: { resource: { kind: 'test-desk', id: 'd2' }, actions: ['workspace/follow'] } }) + expect(state.resource('test-desk', 'd2', 'workspace/follow').getSnapshot()).toBe(true) + state.update({ type: 'remove', resource: { kind: 'test-desk', id: 'd2' } }) + expect(state.resource('test-desk', 'd2', 'workspace/follow').getSnapshot()).toBe(false) + state.update({ type: 'remove', resource: { kind: 'test-shelf', id: 's1' } }) + expect(state.resource('test-shelf', 's1', 'session/prompt').getSnapshot()).toBe(false) + // Removing a kind that never had a grant leaves the other kinds intact. + state.update({ type: 'remove', resource: { kind: 'test-shelf', id: 'absent' } }) + unsubscribe() + state.update({ type: 'remove', resource: { kind: 'global' } }) + expect(listener).toHaveBeenCalledTimes(6) + expect(state.global('session/create').getSnapshot()).toBe(false) + }) + + it('applies a baseline of many grants of one kind in one pass and notifies once', () => { + const state = new ClientAccessState() + const first = state.resource('test-shelf', 's0', 'session/fork') + const listener = vi.fn() + first.subscribe(listener) + const grants: RemoteAccessGrant[] = Array.from({ length: 20_000 }, (_, index) => ({ + resource: { kind: 'test-shelf', id: `s${index}` }, + actions: ['session/fork'], + })) + // Copying the kind's grants once per grant takes seconds at this size and exceeds the spec timeout. + state.replace({ type: 'baseline', owner: false, grants }) + expect(listener).toHaveBeenCalledOnce() + expect(first.getSnapshot()).toBe(true) + expect(state.resource('test-shelf', 's19999', 'session/fork').getSnapshot()).toBe(true) + expect(state.resource('test-shelf', 's20000', 'session/fork').getSnapshot()).toBe(false) + }) + + it('ignores increments outside a non-owner generation and keeps source identity stable', () => { + const state = new ClientAccessState() + state.update({ type: 'upsert', grant: { resource: { kind: 'global' }, actions: ['session/list'] } }) + expect(state.global('session/list').getSnapshot()).toBe(false) + state.replace({ type: 'baseline', owner: true }) + state.update({ type: 'remove', resource: { kind: 'global' } }) + expect(state.global('session/list').getSnapshot()).toBe(true) + expect(state.global('session/list')).toBe(state.global('session/list')) + expect(state.resource('test-shelf', 's1', 'session/fork')).toBe(state.resource('test-shelf', 's1', 'session/fork')) + expect(state.resource('test-shelf', 's1', 'session/fork')).not.toBe(state.resource('test-desk', 's1', 'session/fork')) + }) +}) + +interface FakeStreamOptions { + readonly name: string + readonly open: (signal: AbortSignal) => unknown + readonly ended: (accepted: boolean) => Error +} + +/** One controllable generation of `remoteAccess/follow`, standing in for the Gateway-owned carrier. */ +class FakeAccessStream { + private readonly frames: RemoteAccessFrame[] = [] + private wake: (() => void) | undefined + private done = false + private failure: Error | undefined + + constructor(readonly options: FakeStreamOptions) {} + + push(frame: RemoteAccessFrame): void { + this.frames.push(frame) + this.wake?.() + } + + fail(error: Error): void { + this.failure = error + this.wake?.() + } + + restart(): void {} + + async dispose(): Promise { + this.done = true + this.wake?.() + } + + async *[Symbol.asyncIterator](): AsyncIterator<{ + readonly generation: number + readonly value: RemoteAccessFrame + readonly accept: () => void + }> { + while (!this.done) { + if (this.failure !== undefined) throw this.failure + const frame = this.frames.shift() + if (frame !== undefined) { + yield { generation: 1, value: frame, accept: () => {} } + continue + } + await new Promise((resolve) => { this.wake = resolve }) + } + } +} + +function value(source: HostObservable | undefined): unknown { + if (source === undefined) throw new Error('missing source') + return source.getSnapshot() +} + +async function mounted(initial?: Baseline): Promise<{ + readonly ctx: Context + readonly stream: () => FakeAccessStream + readonly global: (action: string) => HostObservable | undefined + readonly follow: ReturnType + readonly mount: ReturnType + readonly unmount: ReturnType + readonly contribution: TypertRemoteContribution + readonly dispose: () => Promise +}> { + const ctx = new Context() + let stream: FakeAccessStream | undefined + const follow = vi.fn() + const unmount = vi.fn(async () => {}) + const remote = { + $mount: vi.fn(async () => unmount), + $stream: (options: FakeStreamOptions) => { + stream = new FakeAccessStream(options) + return stream + }, + remoteAccess: { follow }, + } + let root: RootStandardSourceContribution | undefined + ctx.provide('remote', remote as never) + ctx.provide('slots', { + provideRoot: (contribution: RootStandardSourceContribution) => { + root = contribution + return () => {} + }, + } as never) + const contribution: TypertRemoteContribution = { package: '@deepseek-ai/dsh-api-remote-access', descriptors: [] } + let dispose: (() => Promise) | undefined + await ctx.plugin({ + inject: ['remote', 'slots'], + apply: async (pluginCtx: Context) => { dispose = await mountAccess(pluginCtx, contribution, initial) }, + }) + // The generated namespace arrives after the mount, as `$mount` provides it. + ctx.provide('remote.remoteAccess', remote.remoteAccess as never) + await vi.waitFor(() => { expect(stream).toBeDefined() }) + return { + ctx, + stream: () => stream as FakeAccessStream, + global: action => root?.keyedHooks?.['remoteAccess']?.(action), + follow, + mount: remote.$mount, + unmount, + contribution, + dispose: async () => { await dispose?.() }, + } +} + +describe('remote-access Client mount', () => { + it('provides the global-resource Hook and the remoteAccess service over the followed snapshot', async () => { + const access = await mounted() + expect(access.mount).toHaveBeenCalledWith(access.contribution) + const globalFork = access.global('session/fork') + const shelfFork = access.ctx.remoteAccess.source('test-shelf', 's1', 'session/fork') + const deskFollow = access.ctx.remoteAccess.source('test-desk', 'd1', 'workspace/follow') + expect(access.ctx.remoteAccess.source('test-shelf', 's1', 'session/fork')).toBe(shelfFork) + expect([value(globalFork), value(shelfFork), value(deskFollow)]).toEqual([false, false, false]) + + access.stream().push(GRANTED) + await vi.waitFor(() => { expect(value(shelfFork)).toBe(true) }) + expect(value(globalFork)).toBe(false) + expect(value(deskFollow)).toBe(true) + access.stream().push({ type: 'remove', resource: { kind: 'test-shelf', id: 's1' } }) + await vi.waitFor(() => { expect(value(shelfFork)).toBe(false) }) + // Reading an answer never issues a request; only the stream factory reaches the namespace. + expect(access.follow).not.toHaveBeenCalled() + access.stream().options.open(new AbortController().signal) + expect(access.follow).toHaveBeenCalledOnce() + expect(access.stream().options.ended(true).message).toMatch(/without a terminal result/) + expect(access.stream().options.ended(false).message).toMatch(/before its opening snapshot/) + + await access.dispose() + expect(access.unmount).toHaveBeenCalledOnce() + }) + + it('answers from the injected baseline at once and false once a non-owner stream fails terminally', async () => { + const access = await mounted(GRANTED) + const report = vi.spyOn(access.ctx.logger, 'error').mockImplementation(() => undefined) + const globalList = access.global('session/list') + expect(value(globalList)).toBe(true) + const failure = new Error('carrier closed for good') + access.stream().fail(failure) + await vi.waitFor(() => { expect(value(globalList)).toBe(false) }) + expect(report).toHaveBeenCalledWith('access snapshot stream failed; reload the page to reconnect', failure) + await access.dispose() + }) + + it('keeps the owner\'s answers when its stream fails terminally', async () => { + const access = await mounted({ type: 'baseline', owner: true }) + const report = vi.spyOn(access.ctx.logger, 'error').mockImplementation(() => undefined) + const globalFork = access.global('session/fork') + expect(value(globalFork)).toBe(true) + const failure = new Error('carrier closed for good') + access.stream().fail(failure) + await vi.waitFor(() => { expect(report).toHaveBeenCalledOnce() }) + expect(report).toHaveBeenCalledWith('access snapshot stream failed; owner access answers are unaffected', failure) + expect(value(globalFork)).toBe(true) + await access.dispose() + }) +}) diff --git a/packages/api/remote-access/tests/remote-access.host.spec.ts b/packages/api/remote-access/tests/remote-access.host.spec.ts new file mode 100644 index 0000000000..f29d4d1744 --- /dev/null +++ b/packages/api/remote-access/tests/remote-access.host.spec.ts @@ -0,0 +1,610 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import TypertGatewayService from '@deepseek-ai/dsh-api-gateway' +import { Peer } from '@deepseek-ai/dsh-client-connection' +import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver' +import { + Remote, + TypertRemoteService, + type PeerScope, + type Principal, + type PrincipalMap, + type RemoteInvocation, + type TypertResource, +} from '@deepseek-ai/dsh-typert-protocol' +import TypertRegistry from '@deepseek-ai/dsh-typert-registry' +import RemoteAccessService, { + REMOTE_ACCESS_SNAPSHOT_KEY, + type RemoteAccessBinding, + type RemoteAccessCondition, + type RemoteAccessFrame, + type RemoteAccessGrant, + type RemoteAccessPolicy, + type RemoteAccessRequest, + type RemoteAccessResource, + type RemoteAccessResourceOf, + type RemoteAccessRole, +} from '../src/index.ts' + +declare module '@deepseek-ai/dsh-typert-protocol' { + interface PrincipalMap { + /** Test-only non-owner caller shared by the access specs. */ + 'test-guest': { readonly kind: 'test-guest'; readonly guest: string } + /** Test-only non-owner caller whose identity carries a list. */ + 'test-crew': { readonly kind: 'test-crew'; readonly members: readonly string[] } + } + interface TypertResourceMap { + /** Test-only resource kinds; the service itself names no kind. */ + 'test-shelf': TypertResource + 'test-desk': TypertResource + } +} + +type Guest = PrincipalMap['test-guest'] + +const OWNER: Principal = { kind: 'owner' } +const GUEST: Guest = { kind: 'test-guest', guest: 'a' } +const OTHER: Guest = { kind: 'test-guest', guest: 'b' } +const CREW: Principal = { kind: 'test-crew', members: ['a', 'b'] } +const GLOBAL: RemoteAccessResourceOf<'global'> = { kind: 'global' } +const ROOM: RemoteAccessResourceOf<'test-shelf'> = { kind: 'test-shelf', id: 'r1' } +const ROOM2: RemoteAccessResourceOf<'test-shelf'> = { kind: 'test-shelf', id: 'r2' } +const DESK: RemoteAccessResourceOf<'test-desk'> = { kind: 'test-desk', id: 'd1' } + +const READER: RemoteAccessRole<'global'> = { + name: 'test:reader', + resource: 'global', + actions: ['notes/read', 'notes/rooms', 'notes/write', 'event:notes/changed'], +} +const SHELF_READER: RemoteAccessRole<'test-shelf'> = { + name: 'test:shelf-reader', + resource: 'test-shelf', + actions: ['notes/rooms', 'notes/read', 'event:notes/changed'], +} +const SHELF_WRITER: RemoteAccessRole<'test-shelf'> = { name: 'test:shelf-writer', resource: 'test-shelf', actions: ['notes/write'] } +const DESK_WRITER: RemoteAccessRole<'test-desk'> = { name: 'test:desk-writer', resource: 'test-desk', actions: ['notes/write'] } + +class Notes extends TypertRemoteService { + static inject = ['remoteAccess'] + + constructor(ctx: Context) { super(ctx, 'notes') } + + @Remote + read(): string { return 'read' } + + /** Resolves to the shelf its request names through the registered default field. */ + @Remote + write(request: { readonly shelfId: string; readonly text: string }): string { return request.text } + + @Remote + hidden(): string { return 'hidden' } + + /** A list method: a global call, then one `allows()` check per item. */ + @Remote + rooms(): string[] { + return ['r1', 'r2'].filter(id => this.ctx.remoteAccess.allows({ kind: 'test-shelf', id })) + } +} + +/** A policy whose bindings live in memory per guest, the way a business package preloads them. */ +class GuestPolicy implements RemoteAccessPolicy { + readonly held = new Map() + readonly bindings = vi.fn((guest: Guest): readonly RemoteAccessBinding[] => this.held.get(guest.guest) ?? []) + + hold(guest: Guest, ...bindings: RemoteAccessBinding[]): void { + this.held.set(guest.guest, bindings) + } +} + +const roots: Context[] = [] + +afterEach(async () => { + await Promise.all(roots.splice(0).map(ctx => ctx.fiber.dispose())) +}) + +async function setup(): Promise<{ readonly ctx: Context; readonly access: RemoteAccessService }> { + const ctx = new Context() + roots.push(ctx) + await ctx.plugin(TypertRegistry) + await ctx.plugin(RemoteAccessService) + await ctx.plugin(TypertGatewayService) + ctx.typert.resources.register('test-shelf', { field: 'shelfId' }) + await ctx.plugin(Notes) + return { ctx, access: ctx.remoteAccess } +} + +function call(ctx: Context, principal: Principal, method: string, args: Record = {}): Promise { + return ctx.typertGateway.invoke({ peer: new Peer(ctx, { principal }), namespace: 'notes', method, args }) +} + +/** A request by `principal` for `action` on `resources`. */ +function ask(principal: Principal, action: RemoteAccessRequest['action'], ...resources: RemoteAccessResource[]): RemoteAccessRequest { + return { principal, action, resources } +} + +const NEVER = new AbortController().signal + +/** The invocation a Gateway binds while dispatching `remoteAccess/follow` for one principal. */ +function invocationOf(ctx: Context, principal: Principal, method = 'follow', namespace = 'remoteAccess'): RemoteInvocation { + return { + request: { namespace, method, args: {} }, + service: namespace, + peer: new Peer(ctx, { principal }), + signal: NEVER, + uplink: (): AsyncIterable => ({ [Symbol.asyncIterator]: () => ({ next: async () => ({ done: true, value: undefined }) }) }), + } +} + +async function follow(ctx: Context, principal: Principal): Promise<{ + readonly frames: RemoteAccessFrame[] + readonly next: () => Promise + readonly stop: () => Promise +}> { + const abort = new AbortController() + const peer: PeerScope = new Peer(ctx, { principal }) + const stream = await ctx.typertGateway.stream({ peer, namespace: 'remoteAccess', method: 'follow', args: {}, signal: abort.signal }) + const iterator = stream[Symbol.asyncIterator]() + const frames: RemoteAccessFrame[] = [] + return { + frames, + next: async () => { + const result = await iterator.next() + if (result.done === true) throw new Error('access stream ended') + const frame = result.value as RemoteAccessFrame + frames.push(frame) + return frame + }, + stop: async () => { + abort.abort() + await iterator.return?.() + }, + } +} + +interface SpiedListenerMethod { + readonly mock: { readonly calls: readonly unknown[][]; readonly contexts: readonly unknown[] } +} + +/** `abort` registrations a spied `EventTarget` method received on AbortSignals. */ +function abortListenerCalls(spy: SpiedListenerMethod): number { + return spy.mock.calls.filter((args, index) => args[0] === 'abort' && spy.mock.contexts[index] instanceof AbortSignal).length +} + +describe('RemoteAccessService registrations', () => { + it('removes a policy with its disposer and with the plugin fiber that registered it', async () => { + const { ctx, access } = await setup() + const policy = new GuestPolicy() + policy.hold(GUEST, { role: READER, resource: GLOBAL }) + const dispose = access.registerPolicy('test-guest', policy) + await expect(call(ctx, GUEST, 'read')).resolves.toBe('read') + dispose() + await expect(call(ctx, GUEST, 'read')).rejects.toMatchObject({ code: 'remote-access/denied', details: { reason: 'no-allow' } }) + const fiber = ctx.plugin({ + inject: ['remoteAccess'], + apply: (pluginCtx: Context) => { pluginCtx.remoteAccess.registerPolicy('test-guest', policy) }, + }) + await fiber + await expect(call(ctx, GUEST, 'read')).resolves.toBe('read') + await fiber.dispose() + await expect(call(ctx, GUEST, 'read')).rejects.toMatchObject({ code: 'remote-access/denied', details: { reason: 'no-allow' } }) + }) + + it('accepts one policy per principal kind', async () => { + const { access } = await setup() + access.registerPolicy('test-guest', new GuestPolicy()) + expect(() => access.registerPolicy('test-guest', new GuestPolicy())) + .toThrow('remote access: principal kind "test-guest" already has a policy') + access.registerPolicy('test-crew', { bindings: () => [{ role: READER, resource: GLOBAL }] }) + expect(access.decide(ask(CREW, 'notes/read', GLOBAL))).toEqual({ kind: 'allow' }) + }) +}) + +describe('RemoteAccessService.decide', () => { + it('allows the owner without any policy and refuses a kind without a policy', async () => { + const { ctx, access } = await setup() + expect(access.decide(ask(OWNER, 'notes/hidden', ROOM))).toEqual({ kind: 'allow' }) + await expect(call(ctx, OWNER, 'hidden')).resolves.toBe('hidden') + await expect(call(ctx, GUEST, 'read')).rejects.toMatchObject({ + code: 'remote-access/denied', details: { action: 'notes/read', reason: 'no-allow' }, + }) + }) + + it('allows only actions a bound role lists on the resource the binding names', async () => { + const { ctx, access } = await setup() + const policy = new GuestPolicy() + access.registerPolicy('test-guest', policy) + policy.hold(GUEST, { role: READER, resource: GLOBAL }, { role: SHELF_WRITER, resource: ROOM }) + await expect(call(ctx, GUEST, 'read')).resolves.toBe('read') + await expect(call(ctx, GUEST, 'hidden')).rejects.toMatchObject({ details: { action: 'notes/hidden', reason: 'no-allow' } }) + await expect(call(ctx, GUEST, 'write', { request: { shelfId: 'r1', text: 'hi' } })).resolves.toBe('hi') + await expect(call(ctx, GUEST, 'write', { request: { shelfId: 'r2', text: 'hi' } })).rejects.toMatchObject({ details: { reason: 'no-allow' } }) + await expect(call(ctx, OTHER, 'read')).rejects.toMatchObject({ details: { reason: 'no-allow' } }) + // Resources are independent: a global grant implies nothing on a shelf, and a shelf grant nothing on global. + expect(access.decide(ask(GUEST, 'notes/read', ROOM))).toEqual({ kind: 'deny', reason: 'no-allow' }) + expect(access.decide(ask(GUEST, 'notes/write', GLOBAL))).toEqual({ kind: 'allow' }) + policy.hold(GUEST, { role: SHELF_WRITER, resource: ROOM }) + expect(access.decide(ask(GUEST, 'notes/write', GLOBAL))).toEqual({ kind: 'deny', reason: 'no-allow' }) + }) + + it('requires the action on every resource of the target', async () => { + const { access } = await setup() + const policy = new GuestPolicy() + access.registerPolicy('test-guest', policy) + const both = ask(GUEST, 'notes/write', ROOM, DESK) + policy.hold(GUEST, { role: SHELF_WRITER, resource: ROOM }) + expect(access.decide(both)).toEqual({ kind: 'deny', reason: 'no-allow' }) + policy.hold(GUEST, { role: SHELF_WRITER, resource: ROOM }, { role: DESK_WRITER, resource: DESK }) + expect(access.decide(both)).toEqual({ kind: 'allow' }) + expect(policy.bindings).toHaveBeenCalledTimes(2) + }) + + it('asks a matching binding\'s condition, which can refuse with its own reason', async () => { + const { ctx, access } = await setup() + const policy = new GuestPolicy() + access.registerPolicy('test-guest', policy) + const paused = vi.fn>(() => ({ kind: 'deny', reason: 'shelf-paused' })) + const locked = vi.fn>(() => ({ kind: 'deny', reason: 'shelf-locked' })) + const short = vi.fn>(({ args }) => { + const request: unknown = args?.['request'] + return typeof request === 'object' && request !== null && Reflect.get(request, 'text') === 'ok' + ? { kind: 'allow' } + : { kind: 'deny', reason: 'text-refused' } + }) + const unrelated = vi.fn>(() => ({ kind: 'allow' })) + policy.hold(GUEST, + { role: SHELF_READER, resource: ROOM, condition: unrelated }, + { role: SHELF_WRITER, resource: ROOM2, condition: unrelated }, + { role: SHELF_WRITER, resource: ROOM, condition: paused }, + { role: SHELF_WRITER, resource: ROOM, condition: locked }) + await expect(call(ctx, GUEST, 'write', { request: { shelfId: 'r1', text: 'x' } })).rejects.toMatchObject({ + code: 'remote-access/denied', details: { action: 'notes/write', reason: 'shelf-paused' }, + }) + expect(paused).toHaveBeenCalledWith({ action: 'notes/write', resource: ROOM, args: { request: { shelfId: 'r1', text: 'x' } } }) + expect(locked).toHaveBeenCalledOnce() + // A condition runs only for a binding whose resource matches and whose role lists the action. + expect(unrelated).not.toHaveBeenCalled() + + policy.hold(GUEST, { role: SHELF_WRITER, resource: ROOM, condition: short }) + await expect(call(ctx, GUEST, 'write', { request: { shelfId: 'r1', text: 'x' } })).rejects.toMatchObject({ details: { reason: 'text-refused' } }) + await expect(call(ctx, GUEST, 'write', { request: { shelfId: 'r1', text: 'ok' } })).resolves.toBe('ok') + + // Any binding without a condition, or with an allowing one, allows. + policy.hold(GUEST, { role: SHELF_WRITER, resource: ROOM, condition: paused }, { role: SHELF_WRITER, resource: ROOM }) + expect(access.decide(ask(GUEST, 'notes/write', ROOM))).toEqual({ kind: 'allow' }) + expect(paused).toHaveBeenLastCalledWith({ action: 'notes/write', resource: ROOM }) + }) + + it('names the reason explain() gives once no binding allows, without changing the decision', async () => { + const { access } = await setup() + const policy = new GuestPolicy() + const explain = vi.fn((request: { readonly action: string }) => (request.action === 'notes/write' ? 'share-not-writable' : undefined)) + access.registerPolicy('test-guest', { bindings: policy.bindings, explain }) + policy.hold(GUEST, { role: READER, resource: GLOBAL }) + expect(access.decide(ask(GUEST, 'notes/read', GLOBAL))).toEqual({ kind: 'allow' }) + expect(explain).not.toHaveBeenCalled() + const write = { ...ask(GUEST, 'notes/write', ROOM, DESK), args: { request: {} } } + expect(access.decide(write)).toEqual({ kind: 'deny', reason: 'share-not-writable' }) + expect(explain).toHaveBeenCalledWith({ principal: GUEST, action: 'notes/write', resources: [ROOM, DESK], args: { request: {} } }) + expect(access.decide(ask(GUEST, 'notes/hidden', GLOBAL))).toEqual({ kind: 'deny', reason: 'no-allow' }) + expect(explain).toHaveBeenLastCalledWith({ principal: GUEST, action: 'notes/hidden', resources: [GLOBAL] }) + // A refusing condition's reason comes first. + const paused: RemoteAccessCondition<'test-shelf'> = () => ({ kind: 'deny', reason: 'shelf-paused' }) + policy.hold(GUEST, { role: SHELF_WRITER, resource: ROOM, condition: paused }) + expect(access.decide(ask(GUEST, 'notes/write', ROOM))).toEqual({ kind: 'deny', reason: 'shelf-paused' }) + expect(explain).toHaveBeenCalledTimes(2) + }) + + it('refuses with policy-failed when bindings(), a condition, or explain() throws, logging each policy once per change', async () => { + const { ctx, access } = await setup() + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) + const failures = (): unknown[] => warn.mock.calls.flatMap(([value]) => (value instanceof Error ? [value.message] : [])) + let mode: 'bindings' | 'condition' | 'explain' = 'bindings' + access.registerPolicy('test-guest', { + bindings: () => { + if (mode === 'bindings') throw new Error('binding store unavailable') + return [{ role: SHELF_WRITER, resource: ROOM, condition: () => { throw new Error('condition failed') } }] + }, + explain: () => { throw new Error('explain failed') }, + }) + access.registerPolicy('test-crew', { bindings: () => { throw new Error('crew store unavailable') } }) + for (let round = 0; round < 3; round++) { + expect(access.decide(ask(GUEST, 'notes/write', ROOM))).toEqual({ kind: 'deny', reason: 'policy-failed' }) + expect(access.decide(ask(CREW, 'notes/write', ROOM))).toEqual({ kind: 'deny', reason: 'policy-failed' }) + } + await expect(call(ctx, GUEST, 'write', { request: { shelfId: 'r1', text: 'x' } })).rejects.toMatchObject({ + code: 'remote-access/denied', details: { action: 'notes/write', reason: 'policy-failed' }, + }) + expect(failures()).toEqual(['binding store unavailable', 'crew store unavailable']) + expect(warn).toHaveBeenCalledWith( + 'remote access: the "test-guest" policy failed to list bindings for notes/write; its further failures are not logged until access state changes') + + mode = 'condition' + access.invalidate(OTHER) + expect(access.decide(ask(GUEST, 'notes/write', ROOM))).toEqual({ kind: 'deny', reason: 'policy-failed' }) + expect(warn).toHaveBeenCalledWith( + 'remote access: the "test-guest" policy failed to evaluate a condition for notes/write; its further failures are not logged until access state changes') + + mode = 'explain' + access.invalidate() + expect(access.decide(ask(GUEST, 'notes/read', ROOM))).toEqual({ kind: 'deny', reason: 'policy-failed' }) + expect(warn).toHaveBeenCalledWith( + 'remote access: the "test-guest" policy failed to explain a refusal of notes/read; its further failures are not logged until access state changes') + expect(failures()).toEqual(['binding store unavailable', 'crew store unavailable', 'condition failed', 'explain failed']) + }) + + it('always lets a Peer open its own access snapshot', async () => { + const { ctx, access } = await setup() + access.registerPolicy('test-guest', { bindings: () => [] }) + const guest = await follow(ctx, GUEST) + await expect(guest.next()).resolves.toEqual({ type: 'baseline', owner: false, grants: [] }) + await guest.stop() + }) +}) + +describe('RemoteAccessService.allows', () => { + it('filters list items for non-owners on one resource without arguments', async () => { + const { ctx, access } = await setup() + const policy = new GuestPolicy() + access.registerPolicy('test-guest', policy) + const reader = vi.fn>(() => ({ kind: 'allow' })) + policy.hold(GUEST, { role: READER, resource: GLOBAL }, { role: SHELF_READER, resource: ROOM, condition: reader }) + await expect(call(ctx, GUEST, 'rooms')).resolves.toEqual(['r1']) + expect(reader).toHaveBeenCalledWith({ action: 'notes/rooms', resource: ROOM }) + // One call for the Gateway's decision and one per item check. + expect(policy.bindings).toHaveBeenCalledTimes(3) + policy.bindings.mockClear() + await expect(call(ctx, OWNER, 'rooms')).resolves.toEqual(['r1', 'r2']) + expect(access.allows(ROOM)).toBe(true) + expect(policy.bindings).not.toHaveBeenCalled() + }) + + it('reads the bindings on every check, so a running call sees binding changes from its next check on', async () => { + const { ctx, access } = await setup() + const policy = new GuestPolicy() + access.registerPolicy('test-guest', policy) + policy.hold(GUEST, { role: SHELF_READER, resource: ROOM }) + // One long-running call, such as a follow stream that filters every frame. + const stream = ctx.extend({ invocation: invocationOf(ctx, GUEST, 'rooms', 'notes') }).remoteAccess + expect(stream.allows(ROOM)).toBe(true) + expect(stream.allows(ROOM2)).toBe(false) + policy.hold(GUEST, { role: SHELF_READER, resource: ROOM2 }) + expect(stream.allows(ROOM)).toBe(false) + expect(stream.allows(ROOM2)).toBe(true) + }) + + it('allows nothing when the principal kind has no policy or the policy fails', async () => { + const { ctx, access } = await setup() + vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) + const crewCall = ctx.extend({ invocation: invocationOf(ctx, CREW, 'rooms', 'notes') }) + expect(crewCall.remoteAccess.allows(ROOM)).toBe(false) + const bindings = vi.fn((): readonly RemoteAccessBinding[] => { throw new Error('binding store unavailable') }) + access.registerPolicy('test-guest', { bindings }) + const guestCall = ctx.extend({ invocation: invocationOf(ctx, GUEST, 'rooms', 'notes') }) + expect(guestCall.remoteAccess.allows(ROOM)).toBe(false) + expect(guestCall.remoteAccess.allows(ROOM2)).toBe(false) + expect(bindings).toHaveBeenCalledTimes(2) + }) +}) + +describe('first snapshots', () => { + it('gives the owner the owner baseline and a non-owner its current grants', async () => { + const { access } = await setup() + const policy = new GuestPolicy() + policy.hold(GUEST, { role: READER, resource: GLOBAL }) + access.registerPolicy('test-guest', policy) + expect(access.snapshotFor(OWNER)).toEqual({ type: 'baseline', owner: true }) + expect(access.snapshotFor(GUEST)).toEqual({ + type: 'baseline', owner: false, grants: [{ resource: GLOBAL, actions: ['notes/read', 'notes/rooms', 'notes/write'] }], + }) + expect(access.snapshotFor(CREW)).toEqual({ type: 'baseline', owner: false, grants: [] }) + expect(REMOTE_ACCESS_SNAPSHOT_KEY).toBe('__DSH_REMOTE_ACCESS__') + }) + + it('injects the owner baseline into every index the webServer renders', async () => { + const { ctx } = await setup() + const render = (): IndexInjection[] => { + const table: IndexInjection[] = [] + ctx.emit('webserver/index-inject', table) + return table + } + expect(render()).toEqual([]) + ctx.provide('webServer', {} as Context['webServer']) + await vi.waitFor(() => { + expect(render()).toEqual([{ kind: 'global', name: '__DSH_REMOTE_ACCESS__', value: { type: 'baseline', owner: true } }]) + }) + }) + + it('lists per bound resource the endpoint actions decide() allows there without arguments, and no grant where none is allowed', async () => { + const { ctx, access } = await setup() + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) + const policy = new GuestPolicy() + access.registerPolicy('test-guest', policy) + const writeOnly: RemoteAccessCondition<'test-shelf'> = ({ action }) => + action === 'notes/write' ? { kind: 'allow' } : { kind: 'deny', reason: 'read-paused' } + policy.hold(GUEST, + { role: SHELF_READER, resource: ROOM, condition: writeOnly }, + { role: SHELF_WRITER, resource: ROOM }, + { role: DESK_WRITER, resource: DESK, condition: () => { throw new Error('condition failed') } }, + { role: READER, resource: GLOBAL }) + const grants: RemoteAccessGrant[] = [ + { resource: ROOM, actions: ['notes/write'] }, + { resource: GLOBAL, actions: ['notes/read', 'notes/rooms', 'notes/write'] }, + ] + expect(access.snapshotFor(GUEST)).toEqual({ type: 'baseline', owner: false, grants }) + for (const resource of [ROOM, GLOBAL]) { + for (const action of ['notes/read', 'notes/rooms', 'notes/write'] as const) { + const listed = grants.find(grant => grant.resource === resource)?.actions.includes(action) === true + expect(access.decide(ask(GUEST, action, resource)).kind).toBe(listed ? 'allow' : 'deny') + } + } + // Event actions are decided per notification and never enter the snapshot. + expect(access.decide(ask(GUEST, 'event:notes/changed', GLOBAL))).toEqual({ kind: 'allow' }) + expect(warn).toHaveBeenCalledWith(expect.objectContaining({ message: 'condition failed' })) + }) + + it('gives a principal whose bindings() throws no grant', async () => { + const { ctx, access } = await setup() + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) + access.registerPolicy('test-guest', { bindings: () => { throw new Error('binding store unavailable') } }) + expect(access.snapshotFor(GUEST)).toEqual({ type: 'baseline', owner: false, grants: [] }) + const guest = await follow(ctx, GUEST) + await expect(guest.next()).resolves.toEqual({ type: 'baseline', owner: false, grants: [] }) + expect(warn).toHaveBeenCalledWith( + 'remote access: the "test-guest" policy failed to list bindings for a snapshot; its further failures are not logged until access state changes') + await guest.stop() + }) +}) + +describe('remoteAccess/follow', () => { + it('sends the owner one owner baseline and nothing else', async () => { + const { ctx } = await setup() + const owner = await follow(ctx, OWNER) + await expect(owner.next()).resolves.toEqual({ type: 'baseline', owner: true }) + await owner.stop() + expect(owner.frames).toHaveLength(1) + + // Outside a Remote call the caller is trusted in-process code, answered as the owner. + for (const abortWhileWaiting of [false, true]) { + const abort = new AbortController() + const direct = ctx.remoteAccess.follow(abort.signal)[Symbol.asyncIterator]() + await expect(direct.next()).resolves.toEqual({ done: false, value: { type: 'baseline', owner: true } }) + if (!abortWhileWaiting) abort.abort() + const end = direct.next() + if (abortWhileWaiting) abort.abort() + await expect(end).resolves.toEqual({ done: true, value: undefined }) + } + }) + + it('pushes upserts and removals after invalidation and policy changes', async () => { + const { ctx, access } = await setup() + const policy = new GuestPolicy() + access.registerPolicy('test-guest', policy) + const guest = await follow(ctx, GUEST) + const other = await follow(ctx, OTHER) + const crew = await follow(ctx, CREW) + await expect(guest.next()).resolves.toEqual({ type: 'baseline', owner: false, grants: [] }) + await other.next() + await expect(crew.next()).resolves.toEqual({ type: 'baseline', owner: false, grants: [] }) + + policy.hold(GUEST, { role: SHELF_WRITER, resource: ROOM }) + access.invalidate(GUEST) + await expect(guest.next()).resolves.toEqual({ type: 'upsert', grant: { resource: ROOM, actions: ['notes/write'] } }) + + policy.hold(GUEST, { role: SHELF_WRITER, resource: ROOM }, { role: SHELF_READER, resource: ROOM }) + access.invalidate() + await expect(guest.next()).resolves.toEqual({ + type: 'upsert', grant: { resource: ROOM, actions: ['notes/read', 'notes/rooms', 'notes/write'] }, + }) + + policy.hold(GUEST, { role: READER, resource: GLOBAL }) + access.invalidate(GUEST) + await expect(guest.next()).resolves.toEqual({ + type: 'upsert', grant: { resource: GLOBAL, actions: ['notes/read', 'notes/rooms', 'notes/write'] }, + }) + await expect(guest.next()).resolves.toEqual({ type: 'remove', resource: ROOM }) + + // An unchanged grant is not pushed again. + policy.hold(GUEST, { role: READER, resource: GLOBAL }, { role: SHELF_WRITER, resource: ROOM2 }) + access.invalidate(GUEST) + await expect(guest.next()).resolves.toEqual({ type: 'upsert', grant: { resource: ROOM2, actions: ['notes/write'] } }) + policy.hold(GUEST, + { role: { ...READER, actions: ['notes/hidden', 'notes/read', 'notes/rooms'] }, resource: GLOBAL }, + { role: SHELF_WRITER, resource: ROOM2 }) + access.invalidate(GUEST) + await expect(guest.next()).resolves.toEqual({ + type: 'upsert', grant: { resource: GLOBAL, actions: ['notes/hidden', 'notes/read', 'notes/rooms'] }, + }) + // A resource on which no action stays allowed loses its grant. + const paused: RemoteAccessCondition<'test-shelf'> = () => ({ kind: 'deny', reason: 'shelf-paused' }) + policy.hold(GUEST, + { role: { ...READER, actions: ['notes/hidden', 'notes/read', 'notes/rooms'] }, resource: GLOBAL }, + { role: SHELF_WRITER, resource: ROOM2, condition: paused }) + access.invalidate(GUEST) + await expect(guest.next()).resolves.toEqual({ type: 'remove', resource: ROOM2 }) + + const disposeCrew = access.registerPolicy('test-crew', { bindings: () => [{ role: SHELF_WRITER, resource: ROOM2 }] }) + await expect(crew.next()).resolves.toEqual({ type: 'upsert', grant: { resource: ROOM2, actions: ['notes/write'] } }) + access.invalidate({ kind: 'test-crew', members: ['a', 'b'] }) + disposeCrew() + await expect(crew.next()).resolves.toEqual({ type: 'remove', resource: ROOM2 }) + + await guest.stop() + await crew.stop() + // Every recomputation for the other principal produced the same grants, so it received no increment. + expect(other.frames).toHaveLength(1) + await other.stop() + }) + + it('keeps no abort listener from a wait that a change ended', async () => { + const { ctx, access } = await setup() + const policy = new GuestPolicy() + access.registerPolicy('test-guest', policy) + const abort = new AbortController() + const stream = ctx.extend({ invocation: invocationOf(ctx, GUEST) }).remoteAccess.follow(abort.signal)[Symbol.asyncIterator]() + await stream.next() + const add = vi.spyOn(EventTarget.prototype, 'addEventListener') + const remove = vi.spyOn(EventTarget.prototype, 'removeEventListener') + try { + for (let round = 0; round < 30; round++) { + // The reader is already waiting when the change arrives. + const pending = stream.next() + policy.hold(GUEST, { role: round % 2 === 0 ? READER : { ...READER, actions: ['notes/read'] }, resource: GLOBAL }) + access.invalidate(GUEST) + await expect(pending).resolves.toMatchObject({ done: false, value: { type: 'upsert' } }) + } + expect(abortListenerCalls(add)).toBeGreaterThan(0) + expect(abortListenerCalls(add) - abortListenerCalls(remove)).toBe(0) + } finally { + add.mockRestore() + remove.mockRestore() + } + abort.abort() + await stream.return?.() + }) + + it('ends every open generation when the service unloads, so a reloaded service starts from a fresh baseline', async () => { + const ctx = new Context() + roots.push(ctx) + await ctx.plugin(TypertRegistry) + const service = ctx.plugin(RemoteAccessService) + await service + await ctx.plugin(TypertGatewayService) + await ctx.plugin(Notes) + const policy = new GuestPolicy() + policy.hold(GUEST, { role: READER, resource: GLOBAL }) + ctx.remoteAccess.registerPolicy('test-guest', policy) + const open = async (principal: Principal): Promise> => + (await ctx.typertGateway.stream({ + peer: new Peer(ctx, { principal }), namespace: 'remoteAccess', method: 'follow', args: {}, + }))[Symbol.asyncIterator]() + const guest = await open(GUEST) + const owner = await open(OWNER) + await expect(guest.next()).resolves.toMatchObject({ + value: { type: 'baseline', grants: [{ resource: GLOBAL, actions: ['notes/read', 'notes/rooms', 'notes/write'] }] }, + }) + await expect(owner.next()).resolves.toMatchObject({ value: { type: 'baseline', owner: true } }) + const guestEnd = guest.next() + await service.dispose() + await expect(guestEnd).resolves.toEqual({ done: true, value: undefined }) + await expect(owner.next()).resolves.toEqual({ done: true, value: undefined }) + + await ctx.plugin(RemoteAccessService) + await vi.waitFor(() => { expect(ctx.get('typertGateway')).toBeDefined() }) + const reopened = await follow(ctx, GUEST) + await expect(reopened.next()).resolves.toEqual({ type: 'baseline', owner: false, grants: [] }) + await reopened.stop() + }) + + it('stops a non-owner generation that is cancelled while waiting', async () => { + const { ctx, access } = await setup() + access.registerPolicy('test-guest', new GuestPolicy()) + const abort = new AbortController() + const stream = ctx.extend({ invocation: invocationOf(ctx, GUEST) }).remoteAccess.follow(abort.signal)[Symbol.asyncIterator]() + await expect(stream.next()).resolves.toMatchObject({ value: { type: 'baseline', owner: false } }) + const pending = stream.next() + abort.abort() + await expect(pending).resolves.toEqual({ done: true, value: undefined }) + // A cancelled generation no longer receives invalidations. + access.invalidate() + }) +}) diff --git a/packages/api/remote-access/tsconfig.client.json b/packages/api/remote-access/tsconfig.client.json new file mode 100644 index 0000000000..0ddeaeac30 --- /dev/null +++ b/packages/api/remote-access/tsconfig.client.json @@ -0,0 +1,22 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "tsBuildInfoFile": "lib/tsconfig.client.tsbuildinfo" + }, + "files": [ + "src/client/index.ts", + "src/client/mount.ts", + "src/client/state.ts", + "src/snapshot.ts", + "src/types.ts" + ], + "references": [ + { "path": "../../../vendor/cordis" }, + { "path": "../gateway/tsconfig.client.json" }, + { "path": "../../client/ui-renderer" }, + { "path": "../../client/ui-slots" }, + { "path": "../../typert/protocol" } + ] +} diff --git a/packages/api/remote-access/tsconfig.host.json b/packages/api/remote-access/tsconfig.host.json new file mode 100644 index 0000000000..0d9e8a79c8 --- /dev/null +++ b/packages/api/remote-access/tsconfig.host.json @@ -0,0 +1,18 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "tsBuildInfoFile": "lib/tsconfig.host.tsbuildinfo" + }, + "files": [ + "src/index.ts", + "src/snapshot.ts", + "src/types.ts" + ], + "references": [ + { "path": "../../../vendor/cordis" }, + { "path": "../../host/webserver" }, + { "path": "../../typert/protocol" } + ] +} diff --git a/packages/api/remote-access/tsconfig.json b/packages/api/remote-access/tsconfig.json new file mode 100644 index 0000000000..2a0b0e33f7 --- /dev/null +++ b/packages/api/remote-access/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.host.json" }, + { "path": "./tsconfig.client.json" } + ] +} diff --git a/packages/api/remote-access/tsdown.config.ts b/packages/api/remote-access/tsdown.config.ts new file mode 100644 index 0000000000..0e42a1163e --- /dev/null +++ b/packages/api/remote-access/tsdown.config.ts @@ -0,0 +1,7 @@ +import { clientBundle } from '../../client/tsdown.client.ts' + +export default clientBundle( + import.meta.dirname, + ['lib/types/index.js'], + { hostPhase: true }, +) diff --git a/packages/api/remotes/README.i18n.yaml b/packages/api/remotes/README.i18n.yaml index c8686dd4e2..3339260812 100644 --- a/packages/api/remotes/README.i18n.yaml +++ b/packages/api/remotes/README.i18n.yaml @@ -18,8 +18,8 @@ en: a80d44e0683768a5 zh: 379833ac180e5418 /deepseek-ai-dsh-api-remotes/forwarded-host-events: - en: d6d82bf0e55ae5e9 - zh: c3a0c80f8b115039 + en: 31bb9a2f50094b84 + zh: 226c055739d5def7 /deepseek-ai-dsh-api-remotes/build-boundary: en: 7a9934a3ba5b185f zh: 40457fd5707f57f0 diff --git a/packages/api/remotes/README.md b/packages/api/remotes/README.md index f479777030..bde4e4c93a 100644 --- a/packages/api/remotes/README.md +++ b/packages/api/remotes/README.md @@ -44,6 +44,8 @@ This package owns no physical transport or Host service discovery. It projects t The listener signature is not restated here. Each allowlisted event's Cordis `Events` declaration lives in its owner package's client-safe `./types` export, and both faces of this package pull those declarations in. The Host face additionally asserts every entry against `TypertForwardableEventEntry`: an `emit` entry must be a declared one-way event, while a `waterfall` entry must be a declared Agent-scoped waterfall whose final parameter is its same-result `next()` callback. +An `emit` entry may declare `resources`, readers keyed by `TypertResourceMap` kind and typed by the event's own parameters, such as `resources: { session: summary => summary.sessionId }` on `api-session/added`. The Host entry attaches them to each forwarded frame unchanged, and the Gateway forwards a notification to a non-owner Peer only when access control allows the event on the resources they name; a notification without `resources` touches the built-in `global` resource. `api-session/activity`, `api-session/added`, `api-session/error`, `api-session/removed`, and `api-session/status` declare their Session. A notification names one resource identity or none; collection changes travel on follow streams instead. + The Host entry registers an independent allowlist listener set and queue for each Client stream. It rejects non-JSON ordinary-event arguments before enqueueing. For a waterfall, it projects only the top-level Agent identity and JSON request fields; a Client result must also be lossless JSON, while `next()` delegates to the following Host listener. Each scoped waterfall request must carry its routed Agent directly as `request.agent`; the Host rejects a missing or mismatched identity before forwarding. The source attaches all listeners synchronously before `ctx.typertGateway.registerRemoteEvents()` exposes Gateway's internal `$events` logical stream, so its first `ready` item proves that incremental delivery is active and carries the Host home for Client path display. Withdrawing the registration aborts active streams. The payload-free `schedule/changed` event invalidates Schedule task queries; Clients re-read after a notification and reconnect. The payload-free `permission-presets/catalog-changed` event invalidates the process catalog. A Client subscribes before its first `permissionPresets.catalog()` read and re-reads the complete snapshot after a notification; the event carries no catalog state and changes no Session sequence. diff --git a/packages/api/remotes/README.zh.md b/packages/api/remotes/README.zh.md index b130e28b76..680e41e045 100644 --- a/packages/api/remotes/README.zh.md +++ b/packages/api/remotes/README.zh.md @@ -44,6 +44,8 @@ Client 组合挂载 Office 转换、Commands、凭据、settings、Goal、Schedu 监听器签名不在此处重写。名单内每条事件的 Cordis `Events` 声明都住在其 owner 包 client-safe 的 `./types` 导出,本包两个 face 都把那些声明纳入编译面。Host face 还会把每个条目断言给 `TypertForwardableEventEntry`:`emit` 条目必须是已声明的单向事件,`waterfall` 条目则必须是已声明的 agent-scoped waterfall,且其最后一个参数是返回相同结果类型的 `next()` 回调。 +`emit` 条目可以声明 `resources`:以 `TypertResourceMap` 种类为键、类型由该事件自身参数决定的读取函数,例如 `api-session/added` 上的 `resources: { session: summary => summary.sessionId }`。Host entry 把它们原样附在每个转发帧上;只有访问控制在它们给出的资源上允许该事件时,Gateway 才把通知转发给非 owner Peer,未声明 `resources` 的通知触及内置的 `global` 资源。`api-session/activity`、`api-session/added`、`api-session/error`、`api-session/removed` 与 `api-session/status` 声明其 Session。一个通知只携带一个资源身份或不携带;集合类变化改走 follow 类流。 + Host entry 为每条 Client 流独立注册一组 allowlist listener 和一个队列,并在普通事件入队前拒绝非 JSON 参数。对于 waterfall,它只投影顶层 agent 身份与 JSON 请求字段;Client 结果也必须能无损表示为 JSON,而 `next()` 会委托给后续 Host listener。每个作用域 waterfall 请求都必须以 `request.agent` 直接携带路由所用的 agent;Host 会在转发前拒绝缺失或不匹配的身份。该 source 在 `ctx.typertGateway.registerRemoteEvents()` 暴露 Gateway 内部的 `$events` 逻辑流前同步挂好所有 listener,因此首个 `ready` 项既能证明增量投递已就绪,也会携带供 Client 显示路径的 Host home。撤回注册会中止活动流。 无 payload 的 `schedule/changed` 事件使 Schedule 任务查询失效;Client 在收到通知及重连后重新读取。无 payload 的 `permission-presets/catalog-changed` 事件使进程目录失效。Client 在首次读取 `permissionPresets.catalog()` 前先订阅,并在通知后重新读取完整快照;该事件不携带目录状态,也不改变 Session 序号。 diff --git a/packages/api/remotes/package.json b/packages/api/remotes/package.json index 1ac78ce12f..c4db08b517 100644 --- a/packages/api/remotes/package.json +++ b/packages/api/remotes/package.json @@ -81,6 +81,7 @@ "@deepseek-ai/dsh-permission-presets": "workspace:*", "@deepseek-ai/dsh-schedule": "workspace:*", "@deepseek-ai/dsh-scope": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-session-reference": "workspace:*", "@deepseek-ai/dsh-settings": "workspace:*", "@deepseek-ai/dsh-subagent": "workspace:*", diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts index 49ed1e5392..68997853d6 100644 --- a/packages/api/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -34,7 +34,8 @@ export type { BundleInfo, BundleRowInfo, ChangeResult, IncompatiblePlugin, InspectOptions, InstallBundleOptions, InstallSpecKind, ManagementError, PackageResult, PluginChange, PluginEntryId, PluginInfo, PluginInspectProblem, PluginInstallCancellation, PluginInstallFailureKind, - PluginInstallLogChunk, PluginInstallProgress, PluginInstallRequestId, PluginRegistries, PluginSpecInspection, ReadOnlyReason, Registry, + PluginInstallLogChunk, PluginInstallProgress, PluginInstallRequestId, PluginRegistries, PluginSpecInspection, + ReadOnlyReason, Registry, RestartReason, } from '@deepseek-ai/dsh-plugin-manager/types' export type {} from '@deepseek-ai/dsh-plugin-manager/remote' export type {} from '@deepseek-ai/dsh-client-ui-plugin-manager/remote' @@ -144,7 +145,7 @@ export type { // Provider registry and discovery vocabulary for the llm namespace. export type { LlmConfigurableProvider, LlmDiscoveredModel, - LlmModelDiscoveryRequest, LlmProviderInfo, + LlmModelDiscoveryRequest, LlmNativeAuth, LlmProviderInfo, } from '@deepseek-ai/dsh-llm/types' // Reference-discovery result vocabulary for the fileReferences and // sessionReferenceResolver namespaces. diff --git a/packages/api/remotes/src/index.ts b/packages/api/remotes/src/index.ts index 781ffbb95a..d2fa2989f1 100644 --- a/packages/api/remotes/src/index.ts +++ b/packages/api/remotes/src/index.ts @@ -48,10 +48,12 @@ export function apply(ctx: Context): void { function remoteEventSource(ctx: Context): TypertRemoteEventSource { return (signal) => { const queue = new RemoteEventQueue() - const disposers = API_REMOTE_FORWARDED_EVENTS.map(({ event, mode }) => { + const disposers = API_REMOTE_FORWARDED_EVENTS.map((entry) => { + const { event, mode } = entry if (mode === 'emit') { + const resources = 'resources' in entry ? entry.resources : undefined return ctx.on(event as never, ((...args: unknown[]) => { - queue.push({ event, args: assertJsonArgs(event, args) }) + queue.push({ event, args: assertJsonArgs(event, args), ...resources === undefined ? {} : { resources } }) }) as never) } return ctx.on(event as never, (function ( diff --git a/packages/api/remotes/src/remote-events.ts b/packages/api/remotes/src/remote-events.ts index f57a5c6481..c59f72030c 100644 --- a/packages/api/remotes/src/remote-events.ts +++ b/packages/api/remotes/src/remote-events.ts @@ -7,6 +7,7 @@ */ import type {} from '@deepseek-ai/dsh-api-session-controller/remote-events' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import type {} from '@deepseek-ai/dsh-deepseek-account/types' import type {} from '@deepseek-ai/dsh-permission-presets/types' import type {} from '@deepseek-ai/dsh-plugin-manager/types' @@ -16,15 +17,19 @@ import type { TypertForwardableEventEntry } from '@deepseek-ai/dsh-typert-protoc /** * Host events this application forwards without renaming. The explicit mode is * both the Host dispatch strategy and the legal key set of `ctx.remote.$on`. + * An `emit` entry declares readers of the resources its payload carries, or + * none to touch the built-in `global` resource; it reaches a non-owner Peer + * only when access control allows it. Each notification names one resource + * identity or none, and collection changes travel on follow streams instead. */ export const API_REMOTE_FORWARDED_EVENTS = [ { event: 'agent-preset/selected', mode: 'emit' }, { event: 'approval/request', mode: 'waterfall' }, - { event: 'api-session/activity', mode: 'emit' }, - { event: 'api-session/added', mode: 'emit' }, - { event: 'api-session/error', mode: 'emit' }, - { event: 'api-session/removed', mode: 'emit' }, - { event: 'api-session/status', mode: 'emit' }, + { event: 'api-session/activity', mode: 'emit', resources: { session: (sessionId): SessionId => sessionId } }, + { event: 'api-session/added', mode: 'emit', resources: { session: (summary): SessionId => summary.sessionId } }, + { event: 'api-session/error', mode: 'emit', resources: { session: (sessionId): SessionId => sessionId } }, + { event: 'api-session/removed', mode: 'emit', resources: { session: (sessionId): SessionId => sessionId } }, + { event: 'api-session/status', mode: 'emit', resources: { session: (sessionId): SessionId => sessionId } }, { event: 'commands/change', mode: 'emit' }, { event: 'deepseek-account/session-expired', mode: 'emit' }, { event: 'deepseek-account/model-sign-in-required', mode: 'emit' }, diff --git a/packages/api/remotes/tests/built-lib.e2e.ts b/packages/api/remotes/tests/built-lib.e2e.ts index 991c1ac46e..5b8200c657 100644 --- a/packages/api/remotes/tests/built-lib.e2e.ts +++ b/packages/api/remotes/tests/built-lib.e2e.ts @@ -24,6 +24,7 @@ const requiredArtifacts = [ 'packages/goal/goal/lib/typert.host.js', 'packages/api/gateway/lib/client.js', 'packages/api/gateway/lib/index.js', + 'packages/api/remote-access/lib/index.js', 'packages/typert/registry/lib/client.js', 'packages/typert/registry/lib/index.js', 'packages/session/session-projection/lib/index.js', @@ -39,6 +40,7 @@ describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => { connectionHost: 'packages/client/connection/lib/index.js', goal: 'packages/goal/goal/lib/index.js', goalTypert: 'packages/goal/goal/lib/typert.host.js', + remoteAccess: 'packages/api/remote-access/lib/index.js', registryClient: 'packages/typert/registry/lib/client.js', registryHost: 'packages/typert/registry/lib/index.js', remotesClient: 'packages/api/remotes/lib/client.js', @@ -55,6 +57,7 @@ describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => { const { default: AgentRegistry } = await import(urls.agent) const connectionHost = await import(urls.connectionHost) const { default: TypertRemoteService } = await import(urls.apiGatewayHost) + const { default: RemoteAccessService } = await import(urls.remoteAccess) const { default: GoalService } = await import(urls.goal) const { default: SessionProjectionRegistry } = await import(urls.sessionProjections) const { TYPERT } = await import(urls.goalTypert) @@ -84,6 +87,7 @@ describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => { await host.plugin({ inject: connectionHost.inject, apply: connectionHost.apply }) await host.plugin(TypertRegistry) await host.plugin(AgentRegistry) + await host.plugin(RemoteAccessService) await host.plugin(TypertRemoteService) await host.plugin(SessionProjectionRegistry) await host.plugin(GoalService) diff --git a/packages/api/remotes/tests/remote-events.host.spec.ts b/packages/api/remotes/tests/remote-events.host.spec.ts index 80841acf6d..977257ee44 100644 --- a/packages/api/remotes/tests/remote-events.host.spec.ts +++ b/packages/api/remotes/tests/remote-events.host.spec.ts @@ -152,6 +152,33 @@ describe('Remote event Host source', () => { await ctx.fiber.dispose() }) + it('attaches each declared resource reader to its forwarded notification', async () => { + const { ctx, gateway, fiber } = await setup() + const abort = new AbortController() + const stream = sourceOf(gateway)(abort.signal)[Symbol.asyncIterator]() + const cases: readonly (readonly [string, readonly unknown[], string])[] = [ + ['api-session/activity', ['s-activity', 1], 's-activity'], + ['api-session/added', [{ sessionId: 's-added' }], 's-added'], + ['api-session/error', ['s-error', 'boom'], 's-error'], + ['api-session/removed', ['s-removed'], 's-removed'], + ['api-session/status', ['s-status', true], 's-status'], + ] + for (const [event, args, sessionId] of cases) { + emitRaw(ctx, event, args) + const next = await stream.next() + if (next.done === true || 'context' in next.value) throw new Error(`${event} was not forwarded as a notification`) + const frame = next.value + expect(frame).toMatchObject({ event, args }) + expect(frame.resources?.['session']?.(...frame.args)).toBe(sessionId) + } + // An event that declares no resources carries no readers, so access control sees the global resource. + emitRaw(ctx, 'commands/change', []) + await expect(stream.next()).resolves.toEqual({ done: false, value: { event: 'commands/change', args: [] } }) + abort.abort(new Error('Client disconnected')) + await fiber.dispose() + await ctx.fiber.dispose() + }) + it('rejects a non-JSON argument without poisoning the stream', async () => { const { ctx, gateway } = await setup() const abort = new AbortController() diff --git a/packages/api/remotes/tsconfig.client.json b/packages/api/remotes/tsconfig.client.json index 279c07025f..729bbd9e6b 100644 --- a/packages/api/remotes/tsconfig.client.json +++ b/packages/api/remotes/tsconfig.client.json @@ -33,6 +33,9 @@ { "path": "../../context/session-reference" }, + { + "path": "../../core/session" + }, { "path": "../../extensions/cordis-host-runner" }, diff --git a/packages/api/remotes/tsdown.config.ts b/packages/api/remotes/tsdown.config.ts index 29349148d6..0e42a1163e 100644 --- a/packages/api/remotes/tsdown.config.ts +++ b/packages/api/remotes/tsdown.config.ts @@ -1,7 +1,7 @@ import { clientBundle } from '../../client/tsdown.client.ts' export default clientBundle( - '@deepseek-ai/dsh-api-remotes', + import.meta.dirname, ['lib/types/index.js'], { hostPhase: true }, ) diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index 94dd4e518f..46f670802e 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/README.i18n.yaml @@ -15,8 +15,8 @@ en: 1fdf707322fb6996 zh: 0204f7e9d4aa3cce /session-controller/use-this-package: - en: d26a58a8f2406cd5 - zh: 82135eceb9adb628 + en: f9e4042fae88f801 + zh: f379f78966d57c2e /session-controller/client-references: en: 41bdda1ac32df558 zh: aa5d0e325924066b diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index ca922877f8..02dff176ba 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -33,13 +33,15 @@ The Client journal validates current Session event envelopes before publishing f Each endpoint states its activation policy. List reads only stored headers and projection-cache rows: it never calls per-session stat or opens a cold Session body. A current-format cache identity may supply every list hint; a lifecycle-matching predecessor cache may supply only its version-compatible title as a stale display fact, never as an authoritative fold seed. Search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Cancellation requires live state; queue mutation, model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Prompt rejects content with neither non-whitespace text nor an attachment before resolving the Agent or appending Session events; queue edits accept only non-empty text content. Prompt admission consumes opaque receipts from the injected [`fileUploads`](../../client/file-upload/README.md) Host service and resolves every same-Agent receipt before sending the complete ordered content list through `ctx.attachments`. Prompt retries whose `requestId` is already queued or logged return the original acceptance without inserting another message. Create and fork are the only operations that create a new Agent directly. The service applies one preset-aware resume policy and subagent ownership fence to its own methods and to the Typert Agent and Session lookups used by other Remote namespaces. Queue mutation has one narrow exception: a live child whose current projected identity is continuable and comes from its own non-seed suffix accepts the ordinary Edit, Remove, and QueueDock Steer actions across both inbox destinations. One-shot, missing, unknown, corrupt, seed-only, or cold children remain rejected without resume. The skill catalog uses a live Agent's effective working directory and scoped registry when present. Cold listing uses the projected current directory, falling back to the recorded origin and then the configured directory default. It retains the preset standing scope without activating an Agent. The authenticated delivery routes use `workspaceDesktop()` for the serving Host name and file-manager behavior. `openWorkspacePath({ path, action: "reveal" })` delegates file-manager navigation to the native adapter; omitting `action` opens the file-type association, including HTML and SVG. Both operations require the composed filesystem to map the requested Host path to the same canonical process path; unmapped remote paths are refused before a native command runs. `session.projections` normally reads an exact baseline, including current-format cold logs, and returns `kind: sequenced`. Only a read requiring migration returns `kind: migration-required` hints without `asOfSeq`; missing keys remain absent. It returns null for a missing Session. Fork callers may set `allowMigration: false` to receive `session/migration-required` instead of starting source migration; omission retains automatic migration. The Client exposes shared values and explicit-read state through `projectionsBySession`; domains select their own keys. Session-list summaries carry `agentAvailable`, updated through existing summary and status events independently of durable projections. Sequenced reads and live projection frames use the same sequence ordering; cached reads fill hints without replacing sequenced values. +Every Remote endpoint is access-controlled: a Peer whose principal is not the owner reaches an endpoint only when, on every resource the call touches, a binding of that principal kind's policy in [`dsh-api-remote-access`](../remote-access/README.md) grants a role listing the action ([Peer principals Agent Note](../../../.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md)). The controller injects `remoteAccess` and registers the `session` resource kind with the default field `sessionId` through `typert.resources`. `session/page` and `session/follow` declare a `session` reader in `@Remote({ resources })` that reads `request.address.sessionId` for an ordinary Session address and `request.address.childSessionId` for a subagent address; whether access to the parent Session covers its children is the policy's rule. `session/projections` reads the Session resource from `request.sessionId`, so a reconnecting reader reloads only the projections of Sessions it may read. `session/list`, `session/search`, and `session/control` read no resource, so their calls touch the `global` resource and need a `global` role; for a non-owner caller, `session/list` returns only the rows, `session/search` only the hits, and `session/control` only the baseline projections and projection frames, whose Session `ctx.remoteAccess.allows()` admits for the same action, so that action also belongs to a `session` role; owner results are unfiltered. `session/prompt` builds the durable `user-rpc` message source in its Remote method and records `principal` only for a non-owner caller, read through `principalOf(this.ctx.invocation)`; a source without `principal` belongs to the owner. + List rows carry `formatStatus` when available, so Clients can identify migration-required Sessions without requesting their projections. Host list generation yields between complete rows only when its configured work slice is exhausted; cheap remaining work returns without a forced yield. Request cancellation is checked per row and after each yield and rejects without partial results. Each row is synchronous; the list does not promise one cross-Session snapshot. A Session already queued as cold remains a cached row if it attaches during a yield: Client in-flight mutation replay corrects its availability, and sequenced projections supersede cached hints. Client list rows and resident Sessions use the current `sessionListMetadata` projection to reject stale blank-session hints; recency is the later of the summary timestamp and the projected last user prompt. When SessionManager creates an instance before its list row arrives, it reconciles blankness with metadata already retained for that Session. An opened conversation is therefore not reused by New Session even when an older list response still marks it blank. -Explicit-id `session.create` adopts a live Session or resumes a persisted Session while retaining its writer lock. Writer contention returns `session/writer-held`; callers may try another blank without suppressing unrelated failures. `session.list` includes persisted blanks using cached metadata, without opening cold log bodies. +`session.create` returns a Session for a new conversation, and its response names the Session actually used. A named `sessionId` is reused while it is blank (no turn has started), belongs to the requested directory, is an unarchived member of the requested Workspace, and is not written by another owner; a live Session is reused in place, and a persisted one is resumed while retaining its writer lock. The reused Session runs the requested `agentPreset`, or the configured default when the request names none: its recorded preset is not resolved, and a change is logged as `agent-preset/selected` before the Agent is published. A named Session that has started, fails the Workspace placement, or is written by another owner stays unchanged, and a new Session is created instead. A `sessionId` with no stored Session becomes the new Session's identity. A named Session in another directory fails with `session/conflict`, and one owned by subagent routing with `session/agent-busy`; both checks precede preset resolution. Creations naming one identity run in arrival order, including their preset resolution, and each response reports the preset its own request left the Session running. `session.list` includes persisted blanks using cached metadata, without opening cold log bodies. Client list refreshes retain unchanged row objects and reuse the items array when order and values match. Each row's `retainedBy` contains positive local reference-source counts; Host metadata refreshes cannot overwrite them. Cache membership checks use a per-refresh ID set, so reconciliation grows linearly with the current list and retained cache sizes. Host summary updates replace running and Agent availability; local create/fork responses only fill missing metadata on existing rows. Removed ordinary Sessions retain projection stores only when their catalogs contain children. diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index 585c4e0363..b01a99cc1f 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -33,13 +33,15 @@ Client journal 在发布 follow 快照、live entry 或历史页之前验证当 每个 endpoint 都声明自己的激活策略。列表只读取持久化 header 与 projection cache row,绝不调用逐 Session stat 或打开冷 Session body。当前格式 cache identity 可以提供全部列表 hint;生命周期匹配的 predecessor cache 只能提供版本兼容的 title,作为可能过时的展示事实,绝不能作为权威 fold seed。搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。取消要求 live 状态;queue 变更、模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。提示词会在解析 Agent 或追加 Session 事件前,拒绝既没有非空白文本也没有附件的 content;queue edit 只接受非空文本 content。prompt 准入从注入的 [`fileUploads`](../../client/file-upload/README.zh.md) Host 服务取得不透明凭证,在把完整有序内容列表交给 `ctx.attachments` 前解析每个属于同一 Agent 的凭证。`requestId` 已进入 queue 或日志时,prompt 重试直接返回原来的接受结果,不会重复插入消息。只有 create 与 fork 会直接创建新 Agent。该服务把同一套感知 preset 的恢复策略和 subagent ownership fence 同时用于自身方法,以及其他 Remote namespace 使用的 Typert Agent 与 Session lookup。Queue 变更只有一个狭窄例外:当前 projection identity 为 continuable 且来自自身非 seed suffix 的在线 child,可以在两个 inbox 目标上使用普通 Edit、Remove 与 QueueDock Steer action。One-shot、缺失、未知、损坏、仅含 seed identity 或冷 child 继续被拒绝,且不会恢复。skill 目录优先使用已有 live Agent 的有效工作目录与限定范围内的注册表。冷态列表使用投影中的当前目录,依次回退到记录的起始目录与配置的默认目录。它保留 preset 常驻 scope,不会激活 Agent。经过鉴权的文件交付路由通过 `workspaceDesktop()` 获取提供服务的 Host 名称和文件管理器行为。`openWorkspacePath({ path, action: "reveal" })` 将文件管理器导航委托给原生适配器;省略 `action` 时按文件类型关联打开,包括 HTML 和 SVG。两种操作都要求当前文件系统将请求的 Host 路径映射到同一个规范进程路径;无法映射的远端路径会在执行原生命令之前被拒绝。 `session.projections` 正常读取准确基线,包括当前格式的冷日志,返回 `kind: sequenced`。只有需要迁移时才返回不带 `asOfSeq` 的 `kind: migration-required` 提示,缺少的键保持缺席。Session 不存在时返回 null。Fork 调用者可设置 `allowMigration: false`,在源历史需要迁移时收到 `session/migration-required`;省略则保持自动迁移。Client 通过 `projectionsBySession` 暴露共享值和显式读取状态,由领域选择自身的 key。Session 列表摘要携带 `agentAvailable`,通过已有摘要与状态事件更新,与持久化 projection 相互独立。有序读取与实时 projection 帧使用相同的序号排序规则;缓存读取只补充提示,不替换有序值。 +每个 Remote 端点都受访问控制:principal 不是 owner 的 Peer 只有在本次调用访问的每个资源上,该 principal kind 在 [`dsh-api-remote-access`](../remote-access/README.zh.md) 中的 policy 都有某个 binding 授予列出该 action 的 role 时,才能调用该端点([Peer 身份属性 Agent Note](../../../.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md))。控制器注入 `remoteAccess`,并经 `typert.resources` 以默认字段 `sessionId` 登记 `session` 资源种类。`session/page` 与 `session/follow` 在 `@Remote({ resources })` 中声明 `session` 读取函数:对普通 Session 地址读 `request.address.sessionId`,对 subagent 地址读 `request.address.childSessionId`;父会话的访问权是否覆盖其子会话由 policy 决定。`session/projections` 从 `request.sessionId` 读取会话资源,因此重连的读者只会重新读取它可读的会话的 projections。`session/list`、`session/search` 与 `session/control` 不读取资源,其调用访问 `global` 资源,需要 `global` role;对非 owner 调用方,`session/list` 只返回其 Session 被 `ctx.remoteAccess.allows()` 按同一 action 允许的行,`session/search` 只返回这类 Session 的命中,`session/control` 也只返回这类 Session 的 baseline projection 与 projection 帧,因此该 action 也要列在 `session` role 中;owner 的结果不过滤。`session/prompt` 在其 Remote 方法内构造持久的 `user-rpc` 消息 source,且只为非 owner 调用方记录经 `principalOf(this.ctx.invocation)` 读取的 `principal`;不带 `principal` 的 source 属于 owner。 + 列表行在状态可用时携带 `formatStatus`,Client 无需请求其投影即可识别需要迁移的 Session。 Host 仅在配置的工作时间片耗尽时,于完整条目之间让出事件循环;剩余工作较少时直接返回,不强制让出。每行及每次让出之后检查请求取消,取消时拒绝返回部分结果。每行同步计算;列表不承诺跨 Session 的统一快照。已经进入冷态队列的 Session 若在让出期间接入,仍返回缓存行:Client 对进行中请求的变更重放会纠正其可用性,带序号的投影会覆盖缓存提示。 Client 列表行和驻留 Session 使用当前 `sessionListMetadata` 投影纠正过期的空白会话提示;最近活动时间取摘要时间戳与投影中最后一次用户提示词时间的较晚值。当 SessionManager 在列表行到达前创建实例时,会使用已保留的该 Session 元数据对账空白状态。因此,即使旧列表响应仍将已有对话标为空白,新会话操作也不会复用已经打开过的对话。 -显式 ID 的 `session.create` 会收养活动 Session,或恢复持久化 Session 并持续持有其写锁。写锁争用返回 `session/writer-held`;调用方可以尝试其他空白会话,同时保留其他失败。`session.list` 根据缓存元数据列出持久化空白会话,不打开冷日志正文。 +`session.create` 返回一个用于新对话的 Session,响应指明实际使用的 Session。请求指明的 `sessionId` 在满足以下条件时被复用:仍为空白(尚未开始任何轮次)、属于请求的目录、是请求的工作区中未归档的成员,且未被其他写者占用;活动 Session 原地复用,持久化 Session 被恢复并持续持有其写锁。被复用的 Session 运行请求的 `agentPreset`,请求未指明时运行配置的默认 preset:不解析它记录的 preset,变更在 Agent 发布前记录为 `agent-preset/selected`。指明的 Session 若已开始、不满足工作区归属,或被其他写者占用,则保持不变,改为新建 Session。没有对应已存储 Session 的 `sessionId` 成为新 Session 的标识。指明其他目录中的 Session 返回 `session/conflict`,指明由 subagent 路由拥有的 Session 返回 `session/agent-busy`;这两项检查先于 preset 解析。指明同一标识的创建请求按到达顺序执行,preset 解析也包含在内,每个响应报告其自身请求完成时该 Session 运行的 preset。`session.list` 根据缓存元数据列出持久化空白会话,不打开冷日志正文。 Client 列表刷新保留未变化的行对象,并在顺序和值均相同时复用条目数组。每行的 `retainedBy` 包含正数的本地引用来源计数,Host 元数据刷新不能覆盖它们。缓存成员检查使用每次刷新构建的 ID 集合,因此对账成本随当前列表和保留缓存的规模线性增长。 Host 摘要更新会替换运行状态与 Agent 可用性;本地 create/fork 响应只补充已有行缺失的元数据。普通 Session 被移除后,仅在目录仍有子项时保留其投影 store。 diff --git a/packages/api/session-controller/package.json b/packages/api/session-controller/package.json index 436b151bbb..1bcdd19004 100644 --- a/packages/api/session-controller/package.json +++ b/packages/api/session-controller/package.json @@ -122,6 +122,7 @@ } }, "devDependencies": { + "@deepseek-ai/dsh-api-remote-access": "workspace:*", "@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/dsh-agent": "workspace:*", "@deepseek-ai/dsh-agent-default-model": "workspace:*", diff --git a/packages/api/session-controller/src/agent.ts b/packages/api/session-controller/src/agent.ts index 3f0fa77944..aaacc886e7 100644 --- a/packages/api/session-controller/src/agent.ts +++ b/packages/api/session-controller/src/agent.ts @@ -1,5 +1,6 @@ /** Agent activation, composition, and model-selection policy owned by API Session. */ +import { randomUUID } from 'node:crypto' import { mkdir } from 'node:fs/promises' import type { Context } from '@deepseek-ai/cordis' import { installModelSelection } from '@deepseek-ai/dsh-agent' @@ -8,11 +9,12 @@ import type { } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-agent-default-model' import type {} from '@deepseek-ai/dsh-agent-preset-registry' +import { brandString } from '@deepseek-ai/dsh-brand' import { ReasoningEffortId } from '@deepseek-ai/dsh-llm' import type { Session, SessionId } from '@deepseek-ai/dsh-session' import type { SessionInspection } from '@deepseek-ai/dsh-session-persistence' import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query' -import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' +import { RemoteError, remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol' import type {} from '@deepseek-ai/dsh-typert-registry' import type { ModelSelection } from './types.ts' @@ -42,21 +44,49 @@ export class ApiSessionCwdConflict extends Error { } } -/** Explicit-id creation attempted to adopt a Session under another preset. */ +/** Reuse of a Session named an Agent preset while the deployment composes no preset roster. */ export class ApiSessionPresetConflict extends Error { constructor( readonly sessionId: SessionId, readonly requestedPreset: string, - readonly existingPreset: string | undefined, ) { - super( - existingPreset === undefined - ? `session "${sessionId}" records no agent preset and cannot be adopted under "${requestedPreset}"` - : `session "${sessionId}" runs agent preset "${existingPreset}", not "${requestedPreset}"`, - ) + super(`the deployment composes no agent preset roster, so session "${sessionId}" cannot run preset "${requestedPreset}"`) } } +/** A blank Session chosen for reuse started a turn before this Host acquired its writer. */ +class ApiSessionStartedSinceObserved extends Error {} + +/** Placement and Agent preset for one Session created for a new conversation. */ +export interface ApiSessionCreateRequest { + /** Blank Session to reuse, or the identity of a new Session when none is stored under it. */ + readonly sessionId?: SessionId + /** Directory the Session owns. */ + readonly cwd: string + /** Preset the Session runs; the configured default when omitted. */ + readonly agentPreset?: string + /** + * Placement rule the caller owns, such as Workspace membership and archive state. + * @param sessionId - existing Session named by the request. + * @returns whether that Session may be reused. + */ + readonly reusable: (sessionId: SessionId) => boolean +} + +/** The Session one creation request created or reused. */ +export interface ApiSessionCreated { + /** Live Agent of the Session. */ + readonly agent: Agent + /** Preset the Session ran when the request finished, before a later request for the same identity; absent without a roster. */ + readonly agentPreset: string | undefined +} + +/** Resolved preset and pre-publication Agent setup for one create or resume. */ +interface ApiSessionComposition { + readonly agentPreset?: string + readonly setup: AgentSetup +} + /** Failures produced while resolving one ordinary Session identity to its live Agent. */ export type ApiSessionAgentError = RemoteError<'session/not-found' | 'session/agent-busy' | 'session/writer-held' | 'gateway/internal'> @@ -139,7 +169,7 @@ export async function inspectApiSession( /** Owns every operation that may create, resume, or configure a Web Agent. */ export class ApiSessionAgentController { private readonly resumes = new Map>() - private readonly creations = new Map>() + private readonly claims = new Map>() private readonly selections = new WeakMap() private readonly imageAdmissionChains = new WeakMap>() @@ -229,50 +259,26 @@ export class ApiSessionAgentController { } /** - * Resolve one requested identity, creating or resuming it once. - * @param sessionId - requested Session identity. - * @param cwd - directory the Session must own. - * @param checkPersistedIdentity - whether to inspect a cold identity before creation. - * @param presetId - optional Agent preset the Session must own. - * @returns the matching live ordinary Agent. + * Create a Session for a new conversation, reusing the named blank Session + * while it is still reusable. A reused Session runs the requested preset, or + * the configured default when the request names none; the preset it recorded + * is not resolved, and a change is logged as `agent-preset/selected` before + * the Agent is published. A named Session that has started a turn, fails the + * caller's placement rule, or is written by another owner stays unchanged, + * and a new Session is created instead. Requests naming one identity run in + * arrival order, including their preset resolution, and check the named + * Session's directory and ownership before resolving the preset. + * @param request - optional Session to reuse, directory, preset, and placement rule. + * @returns the reused or created Session and the preset it ran when this request finished. + * @throws ApiSessionCwdConflict when the named Session belongs to another directory. + * @throws ApiSessionSubagentOwnership when subagent routing owns the named Session. + * @throws RemoteError `agent-preset/not-found` or `agent-preset/invalid` for the requested or default preset. + * @throws ApiSessionPresetConflict when no roster is composed and the request names a preset for a reused Session. */ - async ensureSession( - sessionId: SessionId, - cwd: string, - checkPersistedIdentity: boolean, - presetId?: string, - ): Promise { - let creation = this.creations.get(sessionId) - if (creation === undefined) { - creation = this.createOrAdopt(sessionId, cwd, checkPersistedIdentity, presetId) - .catch((error: unknown) => { - const live = this.ctx.agents.get(sessionId) - if (live !== undefined) { - if (hasApiSessionSubagentOwner(this.ctx, live.session, live)) { - throw new ApiSessionSubagentOwnership(sessionId) - } - return live - } - const attached = this.ctx.sessions.get(sessionId) - if (attached !== undefined && hasApiSessionSubagentOwner(this.ctx, attached, undefined)) { - throw new ApiSessionSubagentOwnership(sessionId) - } - throw error - }) - .finally(() => { this.creations.delete(sessionId) }) - this.creations.set(sessionId, creation) - } - const agent = await creation - if (hasApiSessionSubagentOwner(this.ctx, agent.session, agent)) { - throw new ApiSessionSubagentOwnership(sessionId) - } - if (presetId !== undefined) { - this.assertPresetUnchanged(sessionId, presetId, this.presetForSession(agent.session)) - } - if (agent.session.header.cwd !== cwd) { - throw new ApiSessionCwdConflict(sessionId, cwd, agent.session.header.cwd) - } - return agent + async createSession(request: ApiSessionCreateRequest): Promise { + const named = request.sessionId + if (named === undefined) return this.created(await this.createMinted(request.cwd, await this.composeAgent(request.agentPreset))) + return this.inOrder(named, async () => this.created(await this.claim(named, request))) } /** @@ -378,10 +384,7 @@ export class ApiSessionAgentController { * @param presetId - requested preset or the configured default when omitted. * @returns the resolved preset identity and Agent setup callback. */ - async composeAgent(presetId: string | undefined): Promise<{ - readonly agentPreset?: string - readonly setup: AgentSetup - }> { + async composeAgent(presetId: string | undefined): Promise { const presets = this.ctx.get('agentPresets') if (presets === undefined) { return { setup: (_agentCtx, agent) => { this.installSelection(agent) } } @@ -441,48 +444,128 @@ export class ApiSessionAgentController { })).agent } - private async createOrAdopt( - sessionId: SessionId, - cwd: string, - checkPersistedIdentity: boolean, - presetId: string | undefined, - ): Promise { + private async inOrder(sessionId: SessionId, operation: () => Promise): Promise { + const result = (this.claims.get(sessionId) ?? Promise.resolve()).then(operation) + const settled = result.then(() => undefined, () => undefined) + this.claims.set(sessionId, settled) + try { + return await result + } finally { + if (this.claims.get(sessionId) === settled) this.claims.delete(sessionId) + } + } + + /** @returns the reused Session, a new Session under `sessionId` when none is stored, or a new Session under a minted identity. */ + private async claim(sessionId: SessionId, request: ApiSessionCreateRequest): Promise { const attached = this.ctx.sessions.get(sessionId) const live = this.ctx.agents.get(sessionId) if (attached !== undefined && hasApiSessionSubagentOwner(this.ctx, attached, live)) { throw new ApiSessionSubagentOwnership(sessionId) } - if (live !== undefined) return live - - if (checkPersistedIdentity) { - try { - using observation = await this.ctx.sessionQuery.observeSession(sessionId) - if (hasApiSessionSubagentOwner(this.ctx, { header: observation.header }, undefined)) { - throw new ApiSessionSubagentOwnership(sessionId) - } - if (observation.header.cwd !== cwd) { - throw new ApiSessionCwdConflict(sessionId, cwd, observation.header.cwd) - } - const storedPreset = this.presetForObservation(observation) - this.assertPresetUnchanged(sessionId, presetId, storedPreset) - const composition = await this.composeAgent(storedPreset) - return (await this.ctx.agents.resume({ - resumeSessionId: sessionId, - agentOptions: this.agentOptions(), - setup: composition.setup, - })).agent - } catch (error: unknown) { - if (!(error instanceof SessionQueryError) - || error.code !== 'SESSION_QUERY_SESSION_NOT_FOUND') throw error + if (live !== undefined) { + if (live.session.header.cwd !== request.cwd) { + throw new ApiSessionCwdConflict(live.id, request.cwd, live.session.header.cwd) } + const composition = await this.composeAgent(request.agentPreset) + return await this.reuseLive(live, request, composition) ?? this.createMinted(request.cwd, composition) } + let observation: SessionObservation + try { + observation = await this.ctx.sessionQuery.observeSession(sessionId) + } catch (error: unknown) { + if (!(error instanceof SessionQueryError) || error.code !== 'SESSION_QUERY_SESSION_NOT_FOUND') throw error + return this.createAgent(sessionId, request.cwd, await this.composeAgent(request.agentPreset)) + } + let reusable: boolean + { + using observed = observation + if (hasApiSessionSubagentOwner(this.ctx, { header: observed.header }, undefined)) { + throw new ApiSessionSubagentOwnership(sessionId) + } + if (observed.header.cwd !== request.cwd) { + throw new ApiSessionCwdConflict(sessionId, request.cwd, observed.header.cwd) + } + reusable = request.reusable(sessionId) && !observed.events.some(event => event.type === 'turn/start') + } + const composition = await this.composeAgent(request.agentPreset) + if (!reusable) return this.createMinted(request.cwd, composition) + this.assertPresetApplicable(sessionId, request.agentPreset) + try { + return (await this.ctx.agents.resume({ + resumeSessionId: sessionId, + agentOptions: this.agentOptions(), + setup: this.reuseSetup(composition), + })).agent + } catch (error: unknown) { + if (error instanceof ApiSessionStartedSinceObserved) return this.createMinted(request.cwd, composition) + if (!(error instanceof Error) || error.name !== 'SessionAlreadyOwnedError') throw error + // Another resume in this process may hold the writer for the Agent it published. + return this.ctx.agents.get(sessionId) === undefined + ? this.createMinted(request.cwd, composition) + : this.claim(sessionId, request) + } + } + /** @returns the live blank Session under the requested preset, or undefined when it cannot be reused. */ + private async reuseLive( + live: Agent, + request: ApiSessionCreateRequest, + composition: ApiSessionComposition, + ): Promise { + if (!request.reusable(live.id) || !this.isBlank(live.session)) return undefined + this.assertPresetApplicable(live.id, request.agentPreset) + const current = this.presetForSession(live.session) + const presets = this.ctx.get('agentPresets') + const target = composition.agentPreset + if (presets === undefined || target === undefined || target === current) return live + try { + await presets.select(live, target) + } catch (error: unknown) { + if (remoteErrorOf(error)?.code !== 'agent-preset/locked') throw error + return undefined + } + return live + } + + /** Setup that re-checks blankness under the acquired writer, then records a preset change before publication. */ + private reuseSetup(composition: ApiSessionComposition): AgentSetup { + return async (agentCtx, agent) => { + if (!this.isBlank(agent.session)) throw new ApiSessionStartedSinceObserved() + const commit = await composition.setup(agentCtx, agent) + const target = composition.agentPreset + if (target !== undefined && this.presetForSession(agent.session) !== target) { + agent.session.append('agent-preset/selected', { agentPreset: target }) + } + return commit + } + } + + /** Whether no turn has started, by the predicate the preset registry's `select` enforces. */ + private isBlank(session: Session): boolean { + const boundary = this.ctx.sessionProjections.stateOf(session, 'turnBoundary') + return boundary === undefined || (boundary.openTurnStartSeq === null && boundary.lastTurn === 0) + } + + /** Without a roster, reuse cannot apply a named preset. */ + private assertPresetApplicable(sessionId: SessionId, requested: string | undefined): void { + if (requested === undefined || this.ctx.get('agentPresets') !== undefined) return + throw new ApiSessionPresetConflict(sessionId, requested) + } + + private created(agent: Agent): ApiSessionCreated { + return { agent, agentPreset: this.presetForSession(agent.session) } + } + + private createMinted(cwd: string, composition: ApiSessionComposition): Promise { + return this.createAgent(brandString(`session-${randomUUID()}`), cwd, composition) + } + + private async createAgent(sessionId: SessionId, cwd: string, composition: ApiSessionComposition): Promise { try { await mkdir(cwd, { recursive: true }) } catch (error: unknown) { throw new Error(`failed to ensure project directory "${cwd}": ${String(error)}`, { cause: error }) } - const composition = await this.composeAgent(presetId) return (await this.ctx.agents.create({ sessionId, agentOptions: this.agentOptions(), @@ -514,15 +597,6 @@ export class ApiSessionAgentController { } return observation.projections.values.agentPreset ?? undefined } - - private assertPresetUnchanged( - sessionId: SessionId, - requested: string | undefined, - existing: string | undefined, - ): void { - if (requested === undefined || requested === existing) return - throw new ApiSessionPresetConflict(sessionId, requested, existing) - } } function agentModelSelection(selection: ModelSelection): AgentModelSelection { diff --git a/packages/api/session-controller/src/client/contract/sessions.ts b/packages/api/session-controller/src/client/contract/sessions.ts index 08d6ad2367..a52d2c155a 100644 --- a/packages/api/session-controller/src/client/contract/sessions.ts +++ b/packages/api/session-controller/src/client/contract/sessions.ts @@ -79,14 +79,17 @@ export interface ISessions { */ readonly searchResultLimit: number /** - * Create or adopt a Session on the Host. - * @param opts - target workspace, directory, and optional preallocated identity. - * @returns the catalogued identity; retain it before borrowing its binding. + * Create a Session for a new conversation on the Host. The Host reuses the + * named blank Session while it is reusable and otherwise creates another; the + * Session runs the named preset, or the Host default when none is named. + * @param opts - target workspace or directory, an optional blank Session to reuse, and an optional Agent preset. + * @returns the catalogued identity the Host created or reused; retain it before borrowing its binding. */ create(opts?: { workspaceId?: WorkspaceId cwd?: string sessionId?: SessionId + agentPreset?: string }): Promise /** * Resolve an already discovered direct-parent address without opening it. diff --git a/packages/api/session-controller/src/client/sessions/manager.ts b/packages/api/session-controller/src/client/sessions/manager.ts index 9c2df4ba0e..b8bec9be8a 100644 --- a/packages/api/session-controller/src/client/sessions/manager.ts +++ b/packages/api/session-controller/src/client/sessions/manager.ts @@ -502,17 +502,21 @@ export class SessionManager { * Contract session.create; on success merge into summaries immediately (no * wait for the next refresh). A created session is blank by definition * (entity birth precedes the first message). - * @param opts - target workspace or working directory, plus an optional caller-owned id. - * @returns the create result. + * @param opts - target workspace or working directory, an optional blank Session to reuse, and an optional Agent preset. + * @returns the create result, naming the Session the Host created or reused. */ async create( opts: { workspaceId?: WorkspaceId cwd?: string sessionId?: SessionId + agentPreset?: string } = {}, ): Promise> { - const shared = opts.sessionId === undefined ? {} : { sessionId: opts.sessionId } + const shared = { + ...(opts.sessionId === undefined ? {} : { sessionId: opts.sessionId }), + ...(opts.agentPreset === undefined ? {} : { agentPreset: opts.agentPreset }), + } const payload = opts.workspaceId !== undefined ? { workspaceId: opts.workspaceId, ...shared } : { ...(opts.cwd === undefined ? {} : { cwd: opts.cwd }), ...shared } diff --git a/packages/api/session-controller/src/client/sessions/service.ts b/packages/api/session-controller/src/client/sessions/service.ts index dd86e7cac2..b992a34920 100644 --- a/packages/api/session-controller/src/client/sessions/service.ts +++ b/packages/api/session-controller/src/client/sessions/service.ts @@ -427,11 +427,16 @@ export class ClientSessions implements ISessions { /** * Create a Host Session and publish its catalog row before resolving. * Callers retain the returned identity before borrowing its binding. - * @param opts - target workspace or directory and an optional preallocated id. - * @returns the new session id. + * @param opts - target workspace or directory, an optional blank Session to reuse, and an optional Agent preset. + * @returns the created or reused session id. * @throws {SessionCreateError} with the requested id. */ - async create(opts: { workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId } = {}): Promise { + async create(opts: { + workspaceId?: WorkspaceId + cwd?: string + sessionId?: SessionId + agentPreset?: string + } = {}): Promise { const result = await this.manager.create(opts) if (!result.ok) throw new SessionCreateError(result.error, opts.sessionId) this.projectList() diff --git a/packages/api/session-controller/src/client/sessions/session.ts b/packages/api/session-controller/src/client/sessions/session.ts index 284abc5656..09494e01ab 100644 --- a/packages/api/session-controller/src/client/sessions/session.ts +++ b/packages/api/session-controller/src/client/sessions/session.ts @@ -289,7 +289,7 @@ export class Session implements SessionFace { // wire type is used; this array is not filtered or reordered. const routedContent = content as Exclude[] const routed = await this.remote.subagents.prompt({ - requestId: randomUUID() as SessionRequestId, + requestId: requestId ?? randomUUID() as SessionRequestId, parentSessionId: this.address.parentSessionId, childSessionId: this.address.childSessionId, mode: 'continuable', diff --git a/packages/api/session-controller/src/commands.ts b/packages/api/session-controller/src/commands.ts index 7e900b92ae..8dd38573c8 100644 --- a/packages/api/session-controller/src/commands.ts +++ b/packages/api/session-controller/src/commands.ts @@ -20,7 +20,6 @@ import { SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session' import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query' import { SessionTitleInvalidError } from '@deepseek-ai/dsh-session-title' -import { canonicalClientTimeZone } from '@deepseek-ai/dsh-util-time' import { assertNever } from '@deepseek-ai/dsh-util-values' import { RemoteError, remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol' import type { Workspace } from '@deepseek-ai/dsh-workspace' @@ -33,6 +32,7 @@ import { apiSessionSubagentOwnershipError, hasApiSessionSubagentOwner, inspectApiSession, + type ApiSessionCreated, } from './agent.ts' import type { SessionAttachmentRequest, @@ -64,7 +64,12 @@ type PromptContentCandidate = | SessionPromptRequest['content'][number] | Extract['content'][number] -function hasPromptContent(content: readonly PromptContentCandidate[]): boolean { +/** + * Whether prompt content carries non-whitespace text or an attachment. + * @param content - submitted or edited prompt parts. + * @returns false for content the Agent must not receive. + */ +export function hasPromptContent(content: readonly PromptContentCandidate[]): boolean { return content.some(part => part.type !== 'text' || part.text.trim().length > 0) } @@ -98,15 +103,15 @@ export class SessionCommandController { ) {} /** - * Create or idempotently adopt one ordinary Session. - * @param request - requested identity, location, and Agent preset. - * @returns the Session identity and resolved preset when configured. + * Create one ordinary Session for a new conversation, reusing the named + * blank Session while it is an unarchived member of the requested Workspace. + * @param request - optional blank Session to reuse, location, and Agent preset. + * @returns the created or reused Session identity and its preset when configured. */ async create(request: SessionCreateRequest): Promise { if (request.workspaceId !== undefined && request.cwd !== undefined) { throw new RemoteError('gateway/bad-request', 'session.create accepts workspaceId or cwd, not both', {}) } - const sessionId = request.sessionId ?? brandString(`session-${randomUUID()}`) let workspace: Workspace | undefined if (request.workspaceId !== undefined) { workspace = this.ctx.workspaceRegistry.get(request.workspaceId) @@ -117,17 +122,19 @@ export class SessionCommandController { } } const cwd = workspace?.path ?? request.cwd ?? this.defaultCwd - let adopted: Agent + let created: ApiSessionCreated try { - adopted = await this.agents.ensureSession( - sessionId, + created = await this.agents.createSession({ + ...(request.sessionId === undefined ? {} : { sessionId: request.sessionId }), cwd, - request.sessionId !== undefined, - request.agentPreset, - ) + ...(request.agentPreset === undefined ? {} : { agentPreset: request.agentPreset }), + reusable: sessionId => !this.ctx.workspaceRegistry.archivedSessionIds.includes(sessionId) + && (workspace === undefined || workspace.sessionIds.includes(sessionId)), + }) } catch (error) { - this.rejectCreation(sessionId, error) + this.rejectCreation(error) } + const sessionId = created.agent.id if (workspace !== undefined) { try { await workspace.attachSession(sessionId) @@ -139,8 +146,7 @@ export class SessionCommandController { ) } } - const agentPreset = this.agents.presetForSession(adopted.session) - return { sessionId, ...(agentPreset === undefined ? {} : { agentPreset }) } + return { sessionId, ...(created.agentPreset === undefined ? {} : { agentPreset: created.agentPreset }) } } /** @@ -304,35 +310,15 @@ export class SessionCommandController { } /** - * Reject empty content, then admit one prompt after Agent and attachment validation. - * @param request - Session identity, prompt content, source metadata, and delivery mode. + * Admit one prompt after Agent and attachment validation; the Remote method + * has already validated the content and built the durable message source. + * @param request - Session identity, prompt content, and delivery mode. + * @param source - the `user-rpc` source recorded on the accepted message. * @returns acknowledgement that the Agent accepted the prompt. */ - async prompt(request: SessionPromptRequest): Promise { - if (!hasPromptContent(request.content)) { - throw new RemoteError( - 'gateway/bad-request', - 'prompt content must include non-whitespace text or an attachment', - {}, - ) - } - const clientTimeZone = request.clientTimeZone === undefined - ? undefined - : canonicalClientTimeZone(request.clientTimeZone) - if (request.clientTimeZone !== undefined && clientTimeZone === undefined) { - throw new RemoteError( - 'session/invalid-time-zone', - 'clientTimeZone must be UTC or a valid IANA Area/Location name', - { value: request.clientTimeZone }, - ) - } + async prompt(request: SessionPromptRequest, source: MessageSource): Promise { const agent = await this.resolveAgent(request.sessionId) if (hasPromptRequest(agent, request.requestId)) return { accepted: true } - const source: MessageSource = { - kind: 'user', - rpcId: request.requestId, - ...(clientTimeZone === undefined ? {} : { clientTimeZone }), - } const hasImage = request.content.some(part => part.type === 'image') const admit = async (): Promise => { try { @@ -530,16 +516,12 @@ export class SessionCommandController { return found.agent } - private rejectCreation(sessionId: SessionId, error: unknown): never { + private rejectCreation(error: unknown): never { if (remoteErrorOf(error) !== undefined) throw error - if (error instanceof Error && error.name === 'SessionAlreadyOwnedError') { - throw new RemoteError('session/writer-held', error.message, { sessionId }) - } if (error instanceof ApiSessionPresetConflict) { throw new RemoteError('agent-preset/conflict', error.message, { sessionId: error.sessionId, requestedPreset: error.requestedPreset, - ...(error.existingPreset === undefined ? {} : { existingPreset: error.existingPreset }), }) } if (error instanceof ApiSessionCwdConflict) { @@ -552,7 +534,7 @@ export class SessionCommandController { if (error instanceof ApiSessionSubagentOwnership) { throw apiSessionSubagentOwnershipError(error.sessionId) } - throw new RemoteError('gateway/internal', `failed to create session "${sessionId}": ${String(error)}`, {}) + throw new RemoteError('gateway/internal', `failed to create session: ${String(error)}`, {}) } private async readSessionState(sessionId: SessionId): Promise { diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts index 0ba5bf8ff0..2362bce21c 100644 --- a/packages/api/session-controller/src/index.ts +++ b/packages/api/session-controller/src/index.ts @@ -6,20 +6,23 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-fs' import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' -import { errorChain, ReasoningEffortId } from '@deepseek-ai/dsh-llm' +import { errorChain, ReasoningEffortId, type MessageSource } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-client-file-upload' import { canOpenNativePath, nativeFileManager, nativeFileApplications, openNativeFileApplication, openNativeAssociatedPath, revealNativePath } from '@deepseek-ai/dsh-native-command' import type { SessionId } from '@deepseek-ai/dsh-session' import type { SessionInspection } from '@deepseek-ai/dsh-session-persistence' import type {} from '@deepseek-ai/dsh-session-projection-cache' import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query' -import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' +import { Remote, RemoteError, TypertRemoteService, principalOf } from '@deepseek-ai/dsh-typert-protocol' +import { canonicalClientTimeZone } from '@deepseek-ai/dsh-util-time' +import { assertNever } from '@deepseek-ai/dsh-util-values' +import type {} from '@deepseek-ai/dsh-api-remote-access' import { ApiSessionAgentController, inspectApiSession, type ApiSessionAgentResult, } from './agent.ts' -import { SessionCommandController } from './commands.ts' +import { SessionCommandController, hasPromptContent } from './commands.ts' import { SessionControlController } from './control.ts' import { SessionHistoryController } from './history.ts' import { SessionFileReferences } from './file-references.ts' @@ -32,6 +35,7 @@ import { ArchivedSessionGate } from './archived-session-gate.ts' import type { ModelCatalog, SessionWorkspacePathApplication, + SessionAddress, SessionAttachmentRequest, SessionAttachmentValue, SessionCancelRequest, @@ -76,6 +80,16 @@ declare module '@deepseek-ai/cordis' { } } +/** + * Read the Session resource a history address names: the Session itself, or + * the child Session whose transcript a subagent address reads. + * @param request - history request carrying the durable address. + * @returns the Session whose access the read requires. + */ +function addressedSession(request: { readonly address: SessionAddress }): SessionId { + return request.address.kind === 'session' ? request.address.sessionId : request.address.childSessionId +} + /** Session Controller deployment policy. */ export interface Config { /** Override platform desktop-opener detection. */ @@ -110,6 +124,7 @@ export class SessionController extends TypertRemoteService { 'fileUploads', 'fs', 'llm', + 'remoteAccess', 'sessions', 'sessionProjections', 'sessionQuery', @@ -141,6 +156,7 @@ export class SessionController extends TypertRemoteService { */ constructor(ctx: Context, config: Config, internals: SessionControllerInternals = {}) { super(ctx, 'sessionController', { namespace: 'session' }) + ctx.typert.resources.register('session', { field: 'sessionId' }) const resolved = SessionController.Config(config) installModelSelectionProjection(ctx) this.agents = new ApiSessionAgentController(ctx) @@ -258,24 +274,28 @@ export class SessionController extends TypertRemoteService { */ @Remote('list') async list(_request: SessionListRequest, signal: AbortSignal): Promise { - return { items: await this.listState.list(signal) } + const items = await this.listState.list(signal) + return { items: items.filter(item => this.ctx.remoteAccess.allows({ kind: 'session', id: item.sessionId })) } } /** - * Search visible Session content without resuming an Agent. + * Search visible Session content without resuming an Agent. A non-owner + * receives only the hits whose Session `allows()` admits; `hasMore` + * describes the bounded result before that filtering. * @param request - literal message-content query. * @param signal - cancellation for list and search reads. * @returns authorized bounded Session search results. */ @Remote('search') - search(request: SessionSearchRequest, signal: AbortSignal): Promise { - return this.listState.search(request.query, signal) + async search(request: SessionSearchRequest, signal: AbortSignal): Promise { + const result = await this.listState.search(request.query, signal) + return { ...result, items: result.items.filter(item => this.ctx.remoteAccess.allows({ kind: 'session', id: item.sessionId })) } } /** - * Create or idempotently adopt one ordinary Session. - * @param request - requested identity, location, and Agent preset. - * @returns the Session identity and resolved preset when configured. + * Create one ordinary Session for a new conversation, reusing the named blank Session while it is reusable. + * @param request - optional blank Session to reuse, location, and Agent preset. + * @returns the created or reused Session identity and its preset when configured. */ @Remote('create') create(request: SessionCreateRequest): Promise { @@ -439,9 +459,30 @@ export class SessionController extends TypertRemoteService { * @returns acknowledgement that the Agent accepted the prompt. */ @Remote('prompt') - prompt(request: SessionPromptRequest, signal: AbortSignal): Promise { + async prompt(request: SessionPromptRequest, signal: AbortSignal): Promise { signal.throwIfAborted() - return this.commands.prompt(request) + if (!hasPromptContent(request.content)) { + throw new RemoteError('gateway/bad-request', 'prompt content must include non-whitespace text or an attachment', {}) + } + const clientTimeZone = request.clientTimeZone === undefined + ? undefined + : canonicalClientTimeZone(request.clientTimeZone) + if (request.clientTimeZone !== undefined && clientTimeZone === undefined) { + throw new RemoteError( + 'session/invalid-time-zone', + 'clientTimeZone must be UTC or a valid IANA Area/Location name', + { value: request.clientTimeZone }, + ) + } + // The caller is recorded only when it is not the owner, so owner messages keep their exact source. + const principal = principalOf(this.ctx.invocation) + const source: MessageSource = { + kind: 'user', + rpcId: request.requestId, + ...(clientTimeZone === undefined ? {} : { clientTimeZone }), + ...(principal === undefined ? {} : { principal }), + } + return this.commands.prompt(request, source) } /** @@ -480,7 +521,7 @@ export class SessionController extends TypertRemoteService { * @param signal - cancellation for persistence reads. * @returns one chronological page. */ - @Remote('page') + @Remote({ name: 'page', resources: { session: addressedSession } }) page(request: SessionPageRequest, signal: AbortSignal): Promise { return this.history.page(request, signal) } @@ -492,7 +533,7 @@ export class SessionController extends TypertRemoteService { * @returns a complete opening snapshot followed by gap-free durable event * frames and optional cursorless assistant-stream frames. */ - @Remote({ mode: 'stream' }) + @Remote({ mode: 'stream', resources: { session: addressedSession } }) follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable { return this.history.follow(request, signal) } @@ -555,8 +596,23 @@ export class SessionController extends TypertRemoteService { * @returns one complete baseline followed by live replacement frames. */ @Remote({ mode: 'stream' }) - control(signal: AbortSignal): AsyncIterable { - return this.controlState.control(signal) + async *control(signal: AbortSignal): AsyncIterable { + for await (const frame of this.controlState.control(signal)) { + switch (frame.type) { + case 'baseline': { + const projections = Object.fromEntries(Object.entries(frame.value.projections) + .filter(([sessionId]) => this.ctx.remoteAccess.allows({ kind: 'session', id: sessionId as SessionId }))) + yield { type: 'baseline', value: { ...frame.value, projections } } + break + } + case 'projection': + if (this.ctx.remoteAccess.allows({ kind: 'session', id: frame.sessionId })) yield frame + break + /* v8 ignore next -- closed control-frame union. */ + default: + assertNever(frame) + } + } } diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts index 3455db1b9f..c0b2b9a573 100644 --- a/packages/api/session-controller/src/types.ts +++ b/packages/api/session-controller/src/types.ts @@ -9,6 +9,7 @@ import type { LlmAttemptId, MessageId } from '@deepseek-ai/dsh-llm/brand' import type { TextBlock } from '@deepseek-ai/dsh-llm' import type { SessionId, SessionSeqCursor } from '@deepseek-ai/dsh-session/types' import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types' +import type { Principal } from '@deepseek-ai/dsh-typert-protocol' import type { JsonValue } from '@deepseek-ai/dsh-util-values' import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' @@ -219,7 +220,6 @@ declare module '@deepseek-ai/dsh-typert-protocol' { 'agent-preset/conflict': { readonly sessionId: SessionId readonly requestedPreset: string - readonly existingPreset?: string } 'session/attachment-invalid': { readonly reason: string } 'session/queue-item-not-found': { readonly itemId: MessageId } @@ -284,17 +284,23 @@ export interface SessionSearchValue { readonly hasMore: boolean } -/** Session creation or explicit-id adoption request. */ +/** Request for a Session for a new conversation. */ export interface SessionCreateRequest { + /** Workspace the Session joins; exclusive with `cwd`. */ readonly workspaceId?: WorkspaceId + /** Directory the Session owns when no Workspace is named. */ readonly cwd?: string + /** Blank Session to reuse while it is reusable, or the identity of a new Session when none is stored under it. */ readonly sessionId?: SessionId + /** Preset the Session runs, replacing a reused Session's recorded preset; the configured default when omitted. */ readonly agentPreset?: string } /** Session creation response value. */ export interface SessionCreateValue { + /** The created or reused Session, which may differ from the requested `sessionId`. */ readonly sessionId: SessionId + /** Preset the Session runs, when the deployment composes a preset roster. */ readonly agentPreset?: string } @@ -404,8 +410,12 @@ export type SessionRequestId = Branded<'session-request-id'> declare module '@deepseek-ai/dsh-llm' { interface MessageSourceMap { - /** Browser prompt correlation and optional Host-validated time zone. */ - 'user-rpc': { kind: 'user'; rpcId: SessionRequestId; clientTimeZone?: string } + /** + * Browser prompt correlation, optional Host-validated time zone, and the + * caller that submitted the prompt. `principal` is written only for a + * caller other than the owner, so an absent `principal` means the owner. + */ + 'user-rpc': { kind: 'user'; rpcId: SessionRequestId; clientTimeZone?: string; principal?: Principal } } } diff --git a/packages/api/session-controller/tests/agent.host.spec.ts b/packages/api/session-controller/tests/agent.host.spec.ts index 4756ae45b0..40dceeefb9 100644 --- a/packages/api/session-controller/tests/agent.host.spec.ts +++ b/packages/api/session-controller/tests/agent.host.spec.ts @@ -6,7 +6,7 @@ import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-preset-registry' import SessionStore, { SESSION_FORMAT_VERSION, SessionLogOffset, SessionId } from '@deepseek-ai/dsh-session' -import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' +import type { SessionHeader } from '@deepseek-ai/dsh-session' import { SessionAlreadyOwnedError } from '@deepseek-ai/dsh-session-persistence' import type { SessionObservation } from '@deepseek-ai/dsh-session-query' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' @@ -15,7 +15,6 @@ import { ApiSessionAgentController, ApiSessionCwdConflict, ApiSessionNotFound, - ApiSessionSubagentOwnership, inspectApiSession, } from '../src/agent.ts' import { installModelSelectionProjection } from '../src/model-selection-projection.ts' @@ -329,123 +328,8 @@ describe('ApiSession model selection', () => { }) }) -describe('ApiSession create or adoption', () => { - it('shares one in-flight creation between concurrent callers', async () => { - const { ctx, agents } = await harness() - const cwd = mkdtempSync(join(tmpdir(), 'dsh-session-controller-concurrent-')) - tempDirs.push(cwd) - const meta = header('concurrent-create', cwd) - const created = unpublishedAgent(ctx, meta) - let release!: () => void - const gate = new Promise((resolve) => { release = resolve }) - const create = vi.spyOn(ctx.agents, 'create').mockImplementation(async () => { - await gate - return { agent: created, dispose: () => Promise.resolve() } - }) - - const first = agents.ensureSession(meta.id, cwd, false) - const second = agents.ensureSession(meta.id, cwd, false) - release() - - await expect(Promise.all([first, second])).resolves.toEqual([created, created]) - expect(create).toHaveBeenCalledOnce() - }) - - it('accepts a raced ordinary creation and rejects a raced attached child', async () => { - const ordinary = await harness() - const cwd = mkdtempSync(join(tmpdir(), 'dsh-session-controller-create-')) - tempDirs.push(cwd) - const ordinaryMeta = header('create-race', cwd) - const winner = agent(ordinary.ctx, ordinaryMeta) - vi.spyOn(ordinary.ctx.agents, 'create').mockImplementation(async () => { - await ordinary.ctx.agents.register(winner) - throw new Error('raced creation') - }) - await expect(ordinary.agents.ensureSession(ordinaryMeta.id, cwd, false)) - .resolves.toBe(winner) - - const child = await harness() - const childCwd = mkdtempSync(join(tmpdir(), 'dsh-session-controller-child-')) - tempDirs.push(childCwd) - const childId = SessionId('create-child-race') - vi.spyOn(child.ctx.agents, 'create').mockImplementation(async () => { - child.ctx.sessions.create(childId, { - meta: { cwd: childCwd, parentSession: SessionId('parent'), origin: 'subagent' }, - }) - throw new Error('raced child creation') - }) - await expect(child.agents.ensureSession(childId, childCwd, false)) - .rejects.toBeInstanceOf(ApiSessionSubagentOwnership) - }) - - it('validates ownership and cwd on the Agent returned by creation', async () => { - const child = await harness() - const childCwd = mkdtempSync(join(tmpdir(), 'dsh-session-controller-returned-child-')) - tempDirs.push(childCwd) - const childMeta = { - ...header('returned-child', childCwd), - parentSession: SessionId('parent'), - origin: 'subagent' as const, - } - const childAgent = unpublishedAgent(child.ctx, childMeta) - vi.spyOn(child.ctx.agents, 'create').mockResolvedValue({ - agent: childAgent, - dispose: () => Promise.resolve(), - }) - await expect(child.agents.ensureSession(childMeta.id, childCwd, false)) - .rejects.toBeInstanceOf(ApiSessionSubagentOwnership) - - const wrong = await harness() - const requestedCwd = mkdtempSync(join(tmpdir(), 'dsh-session-controller-wrong-cwd-')) - tempDirs.push(requestedCwd) - const wrongAgent = unpublishedAgent(wrong.ctx, header('wrong-returned-cwd', '/other')) - vi.spyOn(wrong.ctx.agents, 'create').mockResolvedValue({ - agent: wrongAgent, - dispose: () => Promise.resolve(), - }) - await expect(wrong.agents.ensureSession(wrongAgent.id, requestedCwd, false)) - .rejects.toBeInstanceOf(ApiSessionCwdConflict) - }) - - it('resumes a matching persisted identity and preserves its selected preset', async () => { - const { ctx, agents } = await harness() - const meta = { ...header('stored'), agentPreset: 'minimal' } - const events = [{ - type: 'agent-preset/selected', - seq: 0, - time: 1, - data: { agentPreset: 'minimal' }, - }] as SessionEvent[] - providePersistence(ctx, { - list: () => Promise.resolve([meta]), - inspect: () => Promise.resolve({ meta, events }), - }) - ctx.provide('agentPresets', { - resolve: (id?: string) => Promise.resolve({ id: id ?? 'minimal' }), - mount: () => Promise.resolve(), - } as never) - const resumed = { - id: meta.id, - session: { - id: meta.id, - header: meta, - snapshotEvents: () => events, - eventAt: (seq: number) => events[seq], - seq: events.length, - }, - status: 'idle', - ctx, - } as unknown as Agent - const resume = vi.spyOn(ctx.agents, 'resume').mockResolvedValue({ - agent: resumed, - dispose: () => Promise.resolve(), - }) - - await expect(agents.ensureSession(meta.id, '/workspace', true, 'minimal')).resolves.toBe(resumed) - expect(resume).toHaveBeenCalledWith(expect.objectContaining({ resumeSessionId: meta.id })) - }) - - it('rejects an ownership race before resume and a persisted cwd conflict', async () => { +describe('ApiSession creation', () => { + it('rejects a resume that subagent routing adopts before publication', async () => { const child = await harness() const childMeta = header('resume-child-race') providePersistence(child.ctx, { @@ -464,15 +348,6 @@ describe('ApiSession create or adoption', () => { await expect(child.agents.resolveAgent(childMeta.id)).resolves.toMatchObject({ error: { code: 'session/agent-busy' }, }) - - const conflict = await harness() - const stored = header('stored-cwd-conflict', '/stored') - providePersistence(conflict.ctx, { - list: () => Promise.resolve([stored]), - inspect: () => Promise.resolve({ meta: stored, events: [] }), - }) - await expect(conflict.agents.ensureSession(stored.id, '/requested', true)) - .rejects.toBeInstanceOf(ApiSessionCwdConflict) }) it('surfaces directory creation failure', async () => { @@ -481,7 +356,7 @@ describe('ApiSession create or adoption', () => { tempDirs.push(parent) const file = join(parent, 'file') writeFileSync(file, 'not a directory') - await expect(agents.ensureSession(SessionId('mkdir-failure'), join(file, 'child'), false)) + await expect(agents.createSession({ cwd: join(file, 'child'), reusable: () => true })) .rejects.toThrow('failed to ensure project directory') }) }) diff --git a/packages/api/session-controller/tests/commands-create-fork.host.spec.ts b/packages/api/session-controller/tests/commands-create-fork.host.spec.ts index 87bdc5a399..f43ce3f116 100644 --- a/packages/api/session-controller/tests/commands-create-fork.host.spec.ts +++ b/packages/api/session-controller/tests/commands-create-fork.host.spec.ts @@ -5,11 +5,14 @@ import type {} from '@deepseek-ai/dsh-agent-preset-registry' import { createUserMessage } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' +import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import type { Workspace, WorkspaceId } from '@deepseek-ai/dsh-workspace' import { describe, expect, it, vi } from 'vitest' import { ApiSessionAgentController, ApiSessionCwdConflict, + type ApiSessionCreated, + type ApiSessionCreateRequest, } from '../src/agent.ts' import { SessionCommandController } from '../src/commands.ts' import { installSessionReadTestServices, testSessionPersistence } from './test-remote.ts' @@ -18,18 +21,31 @@ async function expectFailure(operation: Promise, code: string): Promise await expect(operation).rejects.toMatchObject({ code }) } -function controllerAgents(overrides: object = {}): ApiSessionAgentController { - return { - ensureSession: () => Promise.resolve(), - composeAgent: () => Promise.resolve({ setup: () => {} }), - presetForSession: () => undefined, - presetForObservation: () => undefined, - ...overrides, - } as unknown as ApiSessionAgentController +/** The Agent controller with a fixed Agent composition, and optionally a fixed creation outcome. */ +function controllerAgents( + ctx: Context, + fixed: { + readonly composeAgent?: ApiSessionAgentController['composeAgent'] + readonly createSession?: ApiSessionAgentController['createSession'] + } = {}, +): ApiSessionAgentController { + const agents = new ApiSessionAgentController(ctx) + vi.spyOn(agents, 'composeAgent').mockImplementation(fixed.composeAgent ?? (() => Promise.resolve({ setup: () => {} }))) + if (fixed.createSession !== undefined) vi.spyOn(agents, 'createSession').mockImplementation(fixed.createSession) + return agents +} + +/** A creation outcome that publishes `sessionId` in `cwd` as a stand-in Agent. */ +function publishedSession(ctx: Context, sessionId: SessionId) { + return (request: ApiSessionCreateRequest): Promise => { + const session = ctx.sessions.create(sessionId, { meta: { cwd: request.cwd } }) + return Promise.resolve({ agent: { id: session.id, session, status: 'idle', ctx } as Agent, agentPreset: undefined }) + } } async function baseContext(): Promise { const ctx = new Context() + await ctx.plugin(TypertRegistry) await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) installSessionReadTestServices(ctx) @@ -41,29 +57,19 @@ async function baseContext(): Promise { } describe('Session creation failures', () => { - it('mints an identity with the default cwd when no explicit target is supplied', async () => { + it('creates in the default cwd without a named identity when no explicit target is supplied', async () => { const ctx = await baseContext() - ctx.provide('workspaceRegistry', { get: () => undefined, list: () => [] } as never) - const ensureSession = vi.fn((sessionId: SessionId, cwd: string) => { - const session = ctx.sessions.create(sessionId, { meta: { cwd } }) - return Promise.resolve({ id: sessionId, session } as Agent) - }) - const controller = new SessionCommandController( - ctx, - controllerAgents({ ensureSession }), - '/default-workspace', - ) + ctx.provide('workspaceRegistry', { get: () => undefined, list: () => [], archivedSessionIds: [] } as never) + const createSession = vi.fn(publishedSession(ctx, SessionId('session-minted'))) + const controller = new SessionCommandController(ctx, controllerAgents(ctx, { createSession }), '/default-workspace') const created = await controller.create({}) - expect(created.sessionId).toMatch(/^session-/) - expect(created).not.toHaveProperty('agentPreset') - expect(ensureSession).toHaveBeenCalledWith( - created.sessionId, - '/default-workspace', - false, - undefined, - ) + expect(created).toEqual({ sessionId: 'session-minted' }) + expect(createSession).toHaveBeenCalledOnce() + const request = createSession.mock.calls[0]?.[0] + expect(request?.cwd).toBe('/default-workspace') + expect(Object.keys(request ?? {}).sort()).toEqual(['cwd', 'reusable']) await ctx.fiber.dispose() }) @@ -72,7 +78,7 @@ describe('Session creation failures', () => { missing.provide('workspaceRegistry', { get: () => undefined, list: () => [] } as never) const missingController = new SessionCommandController( missing, - controllerAgents(), + controllerAgents(missing), '/default', ) await expectFailure(missingController.create({ @@ -92,7 +98,7 @@ describe('Session creation failures', () => { } as never) const failedController = new SessionCommandController( failed, - controllerAgents(), + controllerAgents(failed, { createSession: publishedSession(failed, SessionId('workspace-session')) }), '/default', ) await expectFailure(failedController.create({ @@ -119,10 +125,6 @@ describe('Session creation failures', () => { error: new ApiSessionCwdConflict(SessionId('wrong-cwd'), '/requested', '/stored'), code: 'session/conflict', }, - { - error: Object.assign(new Error('writer already held'), { name: 'SessionAlreadyOwnedError' }), - code: 'session/writer-held', - }, { error: new Error('factory unavailable'), code: 'gateway/internal', @@ -132,7 +134,7 @@ describe('Session creation failures', () => { ctx.provide('workspaceRegistry', { get: () => undefined, list: () => [] } as never) const controller = new SessionCommandController( ctx, - controllerAgents({ ensureSession: () => Promise.reject(error) }), + controllerAgents(ctx, { createSession: () => Promise.reject(error) }), '/default', ) @@ -144,7 +146,7 @@ describe('Session creation failures', () => { it('rejects contradictory create targets', async () => { const ctx = await baseContext() - const controller = new SessionCommandController(ctx, controllerAgents(), '/default') + const controller = new SessionCommandController(ctx, controllerAgents(ctx), '/default') await expectFailure(controller.create({ workspaceId: 'workspace-1' as WorkspaceId, @@ -184,7 +186,7 @@ describe('Session fork failures', () => { const withoutPersistence = await baseContext() withoutPersistence.provide('workspaceRegistry', { list: () => [] } as never) const unavailableController = new SessionCommandController( - withoutPersistence, controllerAgents(), '/default', + withoutPersistence, controllerAgents(withoutPersistence), '/default', ) await expectFailure(unavailableController.fork({ sessionId: SessionId('missing'), @@ -197,7 +199,7 @@ describe('Session fork failures', () => { list: () => Promise.resolve([]), inspect: vi.fn(), }) as never) - const missingController = new SessionCommandController(missing, controllerAgents(), '/default') + const missingController = new SessionCommandController(missing, controllerAgents(missing), '/default') await expectFailure(missingController.fork({ sessionId: SessionId('missing'), }), 'session/not-found') @@ -208,7 +210,7 @@ describe('Session fork failures', () => { const ctx = await baseContext() ctx.provide('workspaceRegistry', { list: () => [] } as never) vi.spyOn(ctx.sessionQuery, 'observeSession').mockRejectedValue(new Error('storage offline')) - const controller = new SessionCommandController(ctx, controllerAgents(), '/default') + const controller = new SessionCommandController(ctx, controllerAgents(ctx), '/default') await expectFailure(controller.fork({ sessionId: SessionId('unreadable') }), 'gateway/internal') await ctx.fiber.dispose() @@ -218,7 +220,7 @@ describe('Session fork failures', () => { const ctx = await baseContext() ctx.provide('workspaceRegistry', { list: () => [] } as never) const source = ctx.sessions.create(SessionId('empty-source')) - const controller = new SessionCommandController(ctx, controllerAgents(), '/default') + const controller = new SessionCommandController(ctx, controllerAgents(ctx), '/default') await expectFailure(controller.fork({ sessionId: source.id }), 'session/fork-unavailable') await expect(controller.fork({ sessionId: source.id, atSeq: 0 })).rejects.toMatchObject({ @@ -237,7 +239,7 @@ describe('Session fork failures', () => { parentSession: SessionId('parent'), origin: 'subagent', }) - const lineageController = new SessionCommandController(lineage, controllerAgents(), '/default') + const lineageController = new SessionCommandController(lineage, controllerAgents(lineage), '/default') await expectFailure(lineageController.fork({ sessionId: child.id }), 'gateway/internal') await lineage.fiber.dispose() @@ -245,7 +247,7 @@ describe('Session fork failures', () => { creation.provide('workspaceRegistry', { list: () => [] } as never) const source = completedSession(creation, 'creation-source', '/workspace') vi.spyOn(creation.agents, 'create').mockRejectedValue(new Error('factory failed')) - const creationController = new SessionCommandController(creation, controllerAgents(), '/default') + const creationController = new SessionCommandController(creation, controllerAgents(creation), '/default') await expectFailure(creationController.fork({ sessionId: source.id }), 'gateway/internal') await creation.fiber.dispose() }) @@ -262,7 +264,7 @@ describe('Session fork failures', () => { const create = vi.spyOn(ctx.agents, 'create').mockImplementation( (options: CreateAgentOptions) => Promise.resolve(resolvedHandle(ctx, options.sessionId)), ) - const controller = new SessionCommandController(ctx, controllerAgents(), '/default') + const controller = new SessionCommandController(ctx, controllerAgents(ctx), '/default') await expectFailure(controller.fork({ sessionId: source.id }), 'session/workspace-attach-failed') const options = create.mock.calls[0]?.[0] @@ -279,7 +281,7 @@ describe('Session fork failures', () => { const create = vi.spyOn(ctx.agents, 'create').mockImplementation( (options: CreateAgentOptions) => Promise.resolve(resolvedHandle(ctx, options.sessionId)), ) - const controller = new SessionCommandController(ctx, controllerAgents({ + const controller = new SessionCommandController(ctx, controllerAgents(ctx, { composeAgent: () => Promise.resolve({ agentPreset: 'minimal', setup: () => {} }), }), '/default') diff --git a/packages/api/session-controller/tests/commands-upload-file.host.spec.ts b/packages/api/session-controller/tests/commands-upload-file.host.spec.ts index 53d1137628..4c7bc2ed5a 100644 --- a/packages/api/session-controller/tests/commands-upload-file.host.spec.ts +++ b/packages/api/session-controller/tests/commands-upload-file.host.spec.ts @@ -7,7 +7,7 @@ import type { FileAttachmentRef, ImageAttachmentRef, SaveFileAttachment, SaveFileStreamAttachment, } from '@deepseek-ai/dsh-attachment' import { createUserMessage } from '@deepseek-ai/dsh-llm' -import type { UserMessage } from '@deepseek-ai/dsh-llm' +import type { MessageSource, UserMessage } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import CommandRuntime from '@deepseek-ai/dsh-commands' import { createScope } from '@deepseek-ai/dsh-scope' @@ -113,6 +113,9 @@ async function uploadHarness(origin?: 'subagent'): Promise<{ } } +/** The owner source `SessionController.prompt()` builds for {@link promptRequest}. */ +const USER_SOURCE: MessageSource = { kind: 'user', rpcId: 'req-1' as SessionRequestId } + function promptRequest(content: Parameters[0]['content']) { return { requestId: 'req-1' as SessionRequestId, @@ -163,7 +166,7 @@ describe('Session file uploads', () => { { type: 'file', receiptId: receipt.receiptId }, { type: 'image', mediaType: 'image/png', data: 'AAAA' }, { type: 'text', text: 'read it' }, - ])) + ]), USER_SOURCE) expect(followup).toHaveBeenCalledTimes(1) const message = followup.mock.calls[0]?.[0] as UserMessage expect(message.content).toEqual([ @@ -190,7 +193,7 @@ describe('Session file uploads', () => { name: 'huge.bin', })) expect(receipt.file).toMatchObject({ name: 'huge.bin', bytes: 5 }) - await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }])) + await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]), USER_SOURCE) expect((followup.mock.calls[0]?.[0] as UserMessage).content).toEqual([ { type: 'file', attachment: receipt.file }, ]) @@ -217,7 +220,7 @@ describe('Session file uploads', () => { await expect(controller.prompt(promptRequest([ { type: 'image', mediaType: 'image/png', data: 'AAAA' }, { type: 'file', receiptId: 'missing-receipt' as FileUploadReceiptId }, - ]))).rejects.toMatchObject({ code: 'session/attachment-invalid', details: { reason: 'FILE_NOT_STAGED' } }) + ]), USER_SOURCE)).rejects.toMatchObject({ code: 'session/attachment-invalid', details: { reason: 'FILE_NOT_STAGED' } }) expect(followup).not.toHaveBeenCalled() expect(saveImages).not.toHaveBeenCalled() }) @@ -302,7 +305,7 @@ describe('Session file uploads', () => { const { controller, uploads, agent } = await uploadHarness() uploads.retirePrompt(agent, 'not-staged') const receipt = await uploads.upload(agent, { data: 'AAAA' }, new AbortController().signal) - await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }])) + await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]), USER_SOURCE) expect(uploads.resolve(agent, receipt.receiptId)).toEqual(receipt.file) uploads.retirePrompt(agent, 'other-request') expect(uploads.resolve(agent, receipt.receiptId)).toEqual(receipt.file) @@ -317,7 +320,7 @@ describe('Session file uploads', () => { { data: 'AAAA' }, new AbortController().signal, ) - await first.controller.prompt(promptRequest([{ type: 'file', receiptId: observed.receiptId }])) + await first.controller.prompt(promptRequest([{ type: 'file', receiptId: observed.receiptId }]), USER_SOURCE) first.ctx.emit('session/event', first.agent.session, { type: 'user/message', data: createUserMessage({ @@ -345,9 +348,9 @@ describe('Session file uploads', () => { it('deduplicates a retried rpcId already present in the Agent inbox', async () => { const { controller, agent, followup } = await uploadHarness() const request = promptRequest([{ type: 'text', text: 'once' }]) - await controller.prompt(request) + await controller.prompt(request, USER_SOURCE) agent.inbox.append('next-turn', followup.mock.calls[0]?.[0] as UserMessage) - await expect(controller.prompt(request)).resolves.toEqual({ accepted: true }) + await expect(controller.prompt(request, USER_SOURCE)).resolves.toEqual({ accepted: true }) expect(followup).toHaveBeenCalledOnce() }) @@ -364,7 +367,7 @@ describe('Session file uploads', () => { source: { kind: 'user', rpcId: request.requestId }, }), { surfaceOp: 'append' }) - await expect(controller.prompt(request)).resolves.toEqual({ accepted: true }) + await expect(controller.prompt(request, USER_SOURCE)).resolves.toEqual({ accepted: true }) expect(followup).not.toHaveBeenCalled() }) @@ -374,7 +377,7 @@ describe('Session file uploads', () => { saveImages.mockReturnValueOnce(admitted.promise) const prompting = controller.prompt(promptRequest([ { type: 'image', mediaType: 'image/png', data: 'AAAA' }, - ])) + ]), USER_SOURCE) await vi.waitFor(() => { expect(saveImages).toHaveBeenCalledOnce() }) await disposeAgent() admitted.resolve([{ @@ -386,7 +389,7 @@ describe('Session file uploads', () => { it('rejects when a previously bound receipt retires during image admission', async () => { const { controller, uploads, agent, saveImages } = await uploadHarness() const receipt = await uploads.upload(agent, { data: 'AAAA' }, new AbortController().signal) - await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }])) + await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]), USER_SOURCE) const admitted = Promise.withResolvers() saveImages.mockReturnValueOnce(admitted.promise) const prompting = controller.prompt({ @@ -395,7 +398,7 @@ describe('Session file uploads', () => { { type: 'image', mediaType: 'image/png', data: 'AAAA' }, ]), requestId: 'req-2' as SessionRequestId, - }) + }, USER_SOURCE) await vi.waitFor(() => { expect(saveImages).toHaveBeenCalledOnce() }) uploads.retirePrompt(agent, 'req-1') admitted.resolve([{ @@ -409,12 +412,12 @@ describe('Session file uploads', () => { it('keeps the prior receipt binding when a later prompt attempt fails', async () => { const { controller, uploads, agent, followup } = await uploadHarness() const receipt = await uploads.upload(agent, { data: 'AAAA' }, new AbortController().signal) - await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }])) + await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]), USER_SOURCE) followup.mockImplementationOnce(() => { throw new Error('busy') }) await expect(controller.prompt({ ...promptRequest([{ type: 'file', receiptId: receipt.receiptId }]), requestId: 'req-2' as SessionRequestId, - })).rejects.toMatchObject({ code: 'session/agent-busy' }) + }, USER_SOURCE)).rejects.toMatchObject({ code: 'session/agent-busy' }) uploads.retirePrompt(agent, 'req-1') expect(uploads.resolve(agent, receipt.receiptId)).toBeUndefined() }) @@ -425,7 +428,7 @@ describe('Session file uploads', () => { followup.mockImplementationOnce(() => { throw new Error('busy') }) await expect(controller.prompt(promptRequest([ { type: 'file', receiptId: receipt.receiptId }, - ]))).rejects.toMatchObject({ code: 'session/agent-busy' }) + ]), USER_SOURCE)).rejects.toMatchObject({ code: 'session/agent-busy' }) uploads.retirePrompt(agent, 'req-1') expect(uploads.resolve(agent, receipt.receiptId)).toEqual(receipt.file) }) @@ -433,7 +436,7 @@ describe('Session file uploads', () => { it('retires a prompt-bound receipt when its queued occurrence is removed', async () => { const { controller, uploads, agent, followup } = await uploadHarness() const receipt = await uploads.upload(agent, { data: 'AAAA' }, new AbortController().signal) - await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }])) + await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]), USER_SOURCE) const queued = followup.mock.calls[0]?.[0] as UserMessage agent.inbox.append('next-turn', queued) expect(await controller.updateQueue({ @@ -454,7 +457,7 @@ describe('Session file uploads', () => { await controller.prompt(promptRequest([ { type: 'file', receiptId: first.receiptId }, { type: 'file', receiptId: second.receiptId }, - ])) + ]), USER_SOURCE) const message = followup.mock.calls[0]?.[0] as UserMessage expect(message.content).toEqual([ diff --git a/packages/api/session-controller/tests/list-scheduling.host.spec.ts b/packages/api/session-controller/tests/list-scheduling.host.spec.ts index eefea76713..b3e412ec9f 100644 --- a/packages/api/session-controller/tests/list-scheduling.host.spec.ts +++ b/packages/api/session-controller/tests/list-scheduling.host.spec.ts @@ -8,6 +8,7 @@ import type { SessionRecord } from '@deepseek-ai/dsh-session-query' import { resolve } from 'node:path' import { performance } from 'node:perf_hooks' import { scheduler } from 'node:timers/promises' +import RemoteAccessService from '@deepseek-ai/dsh-api-remote-access' import SessionController from '../src/index.ts' import { afterEach, describe, expect, it, vi } from 'vitest' import { ApiSessionList } from '../src/list.ts' @@ -181,10 +182,15 @@ describe('Session-list work slices', () => { it('routes an overridden budget through the public Session Controller', async () => { const h = await harness([{ id: 'first', phase: 'cold' }, { id: 'middle', phase: 'cold' }, { id: 'last', phase: 'cold' }], 4) const dispose = (): void => {} - h.ctx.provide('typert', { lookups: { configure: () => dispose }, contexts: { configureHost: () => dispose } } as never) + h.ctx.provide('typert', { + lookups: { configure: () => dispose }, + contexts: { configureHost: () => dispose }, + resources: { register: () => dispose }, + } as never) h.ctx.provide('fileUploads', { registerAgentResolver: () => dispose } as never) h.ctx.provide('agentDefaultModel', { currentSelection: () => ({ provider: 'test', model: 'test' }) } as never) h.ctx.provide('workspaceRegistry', { list: () => [], archivedSessionIds: [] } as never) + new RemoteAccessService(h.ctx) const controller = new SessionController(h.ctx, { nativeOpen: false, listWorkSliceMs: 4 }, { canOpenPath: () => false }) const yieldWork = vi.spyOn(scheduler, 'yield') const result = await controller.list({}, new AbortController().signal) diff --git a/packages/api/session-controller/tests/manager.client.spec.ts b/packages/api/session-controller/tests/manager.client.spec.ts index 2097854412..bcf4a033be 100644 --- a/packages/api/session-controller/tests/manager.client.spec.ts +++ b/packages/api/session-controller/tests/manager.client.spec.ts @@ -1194,6 +1194,17 @@ describe('remaining branches', () => { expect(await manager.create()).toMatchObject({ ok: false }) }) + it('create forwards the requested Agent preset and catalogs the identity the Host returns', async ({ mock, remote }) => { + remote.session.create.mockResolvedValue(ok({ sessionId: S2, agentPreset: 'minimal' })) + const manager = makeManager(mock, remote) + + const result = await manager.create({ workspaceId: 'w1' as never, sessionId: S1, agentPreset: 'minimal' }) + + expect(remote.session.create).toHaveBeenCalledExactlyOnceWith({ workspaceId: 'w1', sessionId: S1, agentPreset: 'minimal' }) + expect(result).toMatchObject({ ok: true, value: { sessionId: S2 } }) + expect(manager.getListSnapshot().items.map(item => item.sessionId)).toEqual([S2]) + }) + it.for(['create', 'fork'] as const)('preserves pushed running state when a %s response arrives later', async (operation, { mock, remote }) => { const response = Promise.withResolvers>>() remote.session[operation].mockImplementation(() => response.promise) diff --git a/packages/api/session-controller/tests/projection-discovery.host.spec.ts b/packages/api/session-controller/tests/projection-discovery.host.spec.ts index 0a14c90f0a..fcfe1a4820 100644 --- a/packages/api/session-controller/tests/projection-discovery.host.spec.ts +++ b/packages/api/session-controller/tests/projection-discovery.host.spec.ts @@ -9,7 +9,7 @@ import AgentRegistry from '@deepseek-ai/dsh-agent' import SessionStore, { SESSION_FORMAT_VERSION, SessionId, SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session' import type { SessionHeader } from '@deepseek-ai/dsh-session' import type { SessionPersistenceSnapshot } from '@deepseek-ai/dsh-session-persistence' -import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' +import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SessionProjectionCache, { checkpointRecord, diff --git a/packages/api/session-controller/tests/remote-access.host.spec.ts b/packages/api/session-controller/tests/remote-access.host.spec.ts new file mode 100644 index 0000000000..bd39bc13f1 --- /dev/null +++ b/packages/api/session-controller/tests/remote-access.host.spec.ts @@ -0,0 +1,222 @@ +/** + * Session Controller under non-owner callers: the Session resource kind it + * registers, the Session readers of `session/page` and `session/follow`, + * `session/list`, `session/search`, and `session/control` called through a + * `global` role and filtered per Session through `remoteAccess.allows()`, the caller recorded + * on prompt sources, and `session/projections` admitted per Session through + * the Gateway. + */ + +import { Context } from '@deepseek-ai/cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { mountAgentLoopTestDependencies, mountAgentLoopTestHarness } from '@deepseek-ai/dsh-agent-loop-testkit' +import TypertGatewayService from '@deepseek-ai/dsh-api-gateway' +import type { RemoteAccessBinding, RemoteAccessRole } from '@deepseek-ai/dsh-api-remote-access' +import { createUserMessage } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' +import { remoteMethods, type PeerId, type PeerScope, type Principal, type RemoteInvocation } from '@deepseek-ai/dsh-typert-protocol' +import TypertRegistry from '@deepseek-ai/dsh-typert-registry' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type SessionController from '../src/index.ts' +import { ApiSessionList } from '../src/list.ts' +import type { SessionControlFrame, SessionRequestId, SessionSearchValue } from '../src/types.ts' +import { createSessionTestController } from './test-remote.ts' + +declare module '@deepseek-ai/dsh-typert-protocol' { + interface PrincipalMap { + /** Test-only non-owner caller shared by the access specs. */ + 'test-guest': { readonly kind: 'test-guest'; readonly guest: string } + } +} + +const OWNER: Principal = { kind: 'owner' } +const GUEST: Principal = { kind: 'test-guest', guest: 'session-controller' } +const SHARED = SessionId('shared-session') +const PRIVATE = SessionId('private-session') + +/** Calls the Session list and control methods; each item still needs a Session role. */ +const LISTER: RemoteAccessRole<'global'> = { name: 'test:session-lister', resource: 'global', actions: ['session/list', 'session/control'] } +/** Reads one Session: its list row, its control projections, and its stored projections. */ +const READER: RemoteAccessRole<'session'> = { + name: 'test:session-reader', resource: 'session', actions: ['session/list', 'session/control', 'session/projections'], +} +const LIST_CALLS: RemoteAccessBinding = { role: LISTER, resource: { kind: 'global' } } +const READS_SHARED: RemoteAccessBinding = { role: READER, resource: { kind: 'session', id: SHARED } } +const SEARCH_CALLS: RemoteAccessBinding = { + role: { name: 'test:session-searcher', resource: 'global', actions: ['session/search'] }, + resource: { kind: 'global' }, +} +const SEARCHES_SHARED: RemoteAccessBinding = { + role: { name: 'test:session-search-reader', resource: 'session', actions: ['session/search'] }, + resource: { kind: 'session', id: SHARED }, +} + +const owned = new Set() +afterEach(async () => { + await Promise.all([...owned].map(ctx => ctx.fiber.dispose())) + owned.clear() +}) + +/** + * Register the guest policy. + * @returns the bindings the policy answers with; edits apply from the next Remote call. + */ +function guestPolicy(ctx: Context, ...bindings: RemoteAccessBinding[]): RemoteAccessBinding[] { + const held = [...bindings] + ctx.remoteAccess.registerPolicy('test-guest', { bindings: () => held }) + return held +} + +/** Invoke `session/` as the guest through the Gateway. */ +function guestCall(ctx: Context, method: string, args: Readonly>): Promise { + const peer: PeerScope = { id: 'session-access-peer' as PeerId, ctx, principal: GUEST, signal: new AbortController().signal, dispose: async () => {} } + return ctx.typertGateway.invoke({ peer, namespace: 'session', method, args }) +} + +async function harness(): Promise<{ + readonly ctx: Context + readonly controller: SessionController + readonly shared: Agent + readonly hidden: Agent + /** The Context a Gateway dispatch of `session/` binds for `principal`. */ + readonly as: (principal: Principal, method: string) => Context +}> { + const ctx = new Context() + owned.add(ctx) + await ctx.plugin(TypertRegistry) + await mountAgentLoopTestDependencies(ctx) + const loop = await mountAgentLoopTestHarness(ctx) + const shared = await loop.create(SHARED) + const hidden = await loop.create(PRIVATE) + const controller = createSessionTestController(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + const as = (principal: Principal, method: string): Context => { + const invocation: RemoteInvocation = { + request: { namespace: 'session', method, args: {} }, + service: 'sessionController', + peer: { id: 'session-access-peer' as PeerId, ctx, principal, signal: new AbortController().signal, dispose: async () => {} }, + signal: new AbortController().signal, + uplink: (): AsyncIterable => ({ [Symbol.asyncIterator]: () => ({ next: async () => ({ done: true, value: undefined }) }) }), + } + return ctx.extend({ invocation }) + } + return { ctx, controller, shared, hidden, as } +} + +function message(text: string) { + return createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } }) +} + +describe('Session Controller access', () => { + it('owns the Session resource kind and its default sessionId field', async () => { + const { ctx } = await harness() + expect(() => ctx.typert.resources.register('session', { field: 'sessionId' })).toThrow(/already registered/) + }) + + it('reads the Session resource of page and follow requests from the addressed Session', async () => { + const { controller } = await harness() + for (const method of ['page', 'follow']) { + const reader = remoteMethods(controller).find(marker => marker.method === method)?.resources?.['session'] + expect(reader?.({ address: { kind: 'session', sessionId: SHARED } })).toBe(SHARED) + // A subagent address reads the child transcript; whether the parent's access covers it is the policy's rule. + expect(reader?.({ address: { kind: 'subagent', parentSessionId: SHARED, childSessionId: PRIVATE } })).toBe(PRIVATE) + } + }) + + it('lets a global role call session/list and lists only the Sessions a Session role admits', async () => { + const { ctx, as } = await harness() + await ctx.plugin(TypertGatewayService) + const held = guestPolicy(ctx, LIST_CALLS, READS_SHARED) + const rows = async (): Promise => { + const value = await guestCall(ctx, 'list', { _request: {} }) as { readonly items: readonly { readonly sessionId: SessionId }[] } + return value.items.map(item => item.sessionId) + } + await expect(rows()).resolves.toEqual([SHARED]) + // The global role admits the call, not any row. + held.splice(0, held.length, LIST_CALLS) + await expect(rows()).resolves.toEqual([]) + // A Session role alone does not admit the call: the global resource does not inherit from it. + held.splice(0, held.length, READS_SHARED) + await expect(rows()).rejects.toMatchObject({ + code: 'remote-access/denied', details: { action: 'session/list', reason: 'no-allow' }, + }) + const owner = await as(OWNER, 'list').sessionController.list({}, new AbortController().signal) + expect(owner.items.map(item => item.sessionId).sort()).toEqual([PRIVATE, SHARED].sort()) + }) + + it('returns a non-owner only the session/search hits whose Session a Session role admits', async () => { + const { ctx, as } = await harness() + await ctx.plugin(TypertGatewayService) + guestPolicy(ctx, SEARCH_CALLS, SEARCHES_SHARED) + const hits: SessionSearchValue = { + items: [{ sessionId: PRIVATE, snippet: 'private hit' }, { sessionId: SHARED, snippet: 'shared hit' }], + hasMore: true, + } + vi.spyOn(ApiSessionList.prototype, 'search').mockResolvedValue(hits) + await expect(guestCall(ctx, 'search', { request: { query: 'hit' } })).resolves.toEqual({ + items: [{ sessionId: SHARED, snippet: 'shared hit' }], + hasMore: true, + }) + await expect(as(OWNER, 'search').sessionController.search({ query: 'hit' }, new AbortController().signal)).resolves.toEqual(hits) + }) + + it('filters session/control baselines and projection frames for a non-owner', async () => { + const { ctx, shared, hidden, as } = await harness() + guestPolicy(ctx, LIST_CALLS, READS_SHARED) + const abort = new AbortController() + const guest = as(GUEST, 'control').sessionController.control(abort.signal)[Symbol.asyncIterator]() + const owner = as(OWNER, 'control').sessionController.control(abort.signal)[Symbol.asyncIterator]() + const baselineSessions = async (iterator: AsyncIterator): Promise => { + const next = await iterator.next() + if (next.done === true || next.value.type !== 'baseline') throw new Error('control stream opened without a baseline') + return Object.keys(next.value.value.projections).sort() + } + expect(await baselineSessions(guest)).toEqual([SHARED]) + expect(await baselineSessions(owner)).toEqual([PRIVATE, SHARED].sort()) + + hidden.inbox.append('next-turn', message('private work')) + shared.inbox.append('next-turn', message('shared work')) + const projected = async (iterator: AsyncIterator): Promise => { + for (;;) { + const next = await iterator.next() + if (next.done === true) throw new Error('control stream ended') + if (next.value.type === 'projection' && next.value.key === 'inbox') return next.value.sessionId + } + } + // The owner sees the private Session's frame first; the guest's first Inbox frame is the shared one. + expect(await projected(owner)).toBe(PRIVATE) + expect(await projected(guest)).toBe(SHARED) + abort.abort() + await Promise.all([guest.return?.(), owner.return?.()]) + }) + + it('records a non-owner caller on the prompt source and leaves owner sources unchanged', async () => { + const { shared, as } = await harness() + const signal = new AbortController().signal + const prompt = (principal: Principal, requestId: string) => as(principal, 'prompt').sessionController.prompt({ + sessionId: SHARED, + requestId: requestId as SessionRequestId, + mode: 'queue', + content: [{ type: 'text', text: `from ${principal.kind}` }], + }, signal) + await expect(prompt(GUEST, 'guest-1')).resolves.toEqual({ accepted: true }) + await expect(prompt(OWNER, 'owner-1')).resolves.toEqual({ accepted: true }) + // The durable Inbox splice records each accepted message with its source. + const logged = shared.session.snapshotEvents() + .flatMap(event => event.type === 'agent/inbox/spliced' ? event.data.inserted.map(item => item.source) : []) + expect(logged).toEqual([ + { kind: 'user', rpcId: 'guest-1', principal: GUEST }, + { kind: 'user', rpcId: 'owner-1' }, + ]) + }) + + it('lets a non-owner read projections only in a Session its Session role names', async () => { + const { ctx } = await harness() + await ctx.plugin(TypertGatewayService) + guestPolicy(ctx, READS_SHARED) + const projections = (sessionId: SessionId): Promise => guestCall(ctx, 'projections', { request: { sessionId } }) + await expect(projections(SHARED)).resolves.toMatchObject({ kind: 'sequenced' }) + await expect(projections(PRIVATE)).rejects.toMatchObject({ + code: 'remote-access/denied', details: { action: 'session/projections', reason: 'no-allow' }, + }) + }) +}) diff --git a/packages/api/session-controller/tests/session-presets.host.spec.ts b/packages/api/session-controller/tests/session-presets.host.spec.ts index 5406d235f8..0fc3132fe7 100644 --- a/packages/api/session-controller/tests/session-presets.host.spec.ts +++ b/packages/api/session-controller/tests/session-presets.host.spec.ts @@ -1,4 +1,4 @@ -/** Session creation and adoption rules for Agent preset identity. */ +/** Session creation, blank-Session reuse, and Agent preset identity through `session.create`. */ import { mkdtempSync, realpathSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' @@ -6,12 +6,14 @@ import { join } from 'node:path' import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent, AgentFactory } from '@deepseek-ai/dsh-agent' +import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop' import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-preset-registry' -import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' -import type { Session } from '@deepseek-ai/dsh-session' +import SessionStore, { SESSION_FORMAT_VERSION, SessionId, SessionSeq } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' -import { afterEach, describe, expect, it } from 'vitest' -import { createSessionTestRemote } from './test-remote.ts' +import type { WorkspaceId } from '@deepseek-ai/dsh-workspace' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createSessionTestRemote, testSessionPersistence } from './test-remote.ts' /** Booted contexts and their temp roots, torn down after each test. */ const contexts: Context[] = [] @@ -25,53 +27,137 @@ function stubAgent(session: Session): Agent { return { id: session.id, session, status: 'idle' } as unknown as Agent } -function roster(ids: readonly string[]): unknown { - const presetOf = (id: string): object => ({ - id, - trust: 'system', - }) - return { - defaultId: ids[0], - resolve: (id?: string) => { - const wanted = id ?? ids[0] ?? '' - if (!ids.includes(wanted)) { - return Promise.reject(new RemoteError( - 'agent-preset/not-found', - `agent-presets: preset "${wanted}" not found (available: ${ids.join(', ') || 'none'})`, - { agentPreset: wanted, available: ids }, - )) - } - return Promise.resolve(presetOf(wanted)) - }, - mount: (_ctx: Context, id?: string) => Promise.resolve(presetOf(id ?? ids[0] ?? '')), - } +/** A stored Session the persistence double serves until an Agent resumes it. */ +interface StoredSession { + readonly header: SessionHeader + readonly events: readonly SessionEvent[] } -async function harness(presets?: readonly string[]) { +interface HarnessOptions { + /** Declared presets; the first is the default. Omitted, no roster is composed. */ + readonly presets?: readonly string[] + /** Stored Sessions keyed by id. */ + readonly stored?: ReadonlyMap + /** Stored Sessions whose writer another owner holds. */ + readonly held?: ReadonlySet + /** Stored Sessions another resume in this process publishes while this one waits for the writer. */ + readonly publishedByOtherResume?: ReadonlySet + /** Stored Sessions whose storage fails to open for writing. */ + readonly unreadable?: ReadonlySet + /** Events a resume finds beyond the stored log, as when another Host wrote after the observation. */ + readonly writtenSinceObservation?: ReadonlyMap + /** Live Sessions whose preset switch the roster refuses as already started. */ + readonly locked?: ReadonlySet + /** Declared presets whose activation failed. */ + readonly broken?: ReadonlySet + /** Requested presets whose resolution waits for the paired promise. */ + readonly resolving?: ReadonlyMap> + /** Workspace placement served to `workspaceId` requests. */ + readonly workspace?: { readonly members: readonly SessionId[]; readonly archived: readonly SessionId[] } +} + +const WORKSPACE = 'workspace-1' as WorkspaceId + +async function harness(options: HarnessOptions = {}) { const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-'))) tempDirs.push(cwd) const ctx = new Context() contexts.push(ctx) await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) + const presets = options.presets + const selections: { sessionId: SessionId; agentPreset: string }[] = [] + const resumed: SessionId[] = [] if (presets !== undefined) { - ctx.provide('agentPresets', roster(presets) as never) + const known = (id?: string): string => { + const wanted = id ?? presets[0] ?? '' + if (!presets.includes(wanted)) { + throw new RemoteError('agent-preset/not-found', `Unknown agent preset: ${wanted}`, { + agentPreset: wanted, available: [...presets], + }) + } + return wanted + } + const usable = (id?: string): string => { + const wanted = known(id) + if (options.broken?.has(wanted) === true) { + throw new RemoteError('agent-preset/invalid', 'activation failed', { agentPreset: wanted, reason: 'activation failed' }) + } + return wanted + } + ctx.provide('agentPresets', { + defaultId: presets[0], + resolve: (id?: string) => Promise.resolve(id === undefined ? undefined : options.resolving?.get(id)) + .then(() => ({ id: known(id) })), + mount: (_ctx: Context, id?: string) => Promise.resolve().then(() => ({ id: usable(id) })), + select: async (agent: Agent, agentPreset: string) => { + usable(agentPreset) + if (options.locked?.has(agent.id) === true) { + throw new RemoteError('agent-preset/locked', 'This session has already started', { + sessionId: agent.id, agentPreset, + }) + } + selections.push({ sessionId: agent.id, agentPreset }) + agent.session.append('agent-preset/selected', { agentPreset }) + return agentPreset + }, + } as never) } - - const factory: AgentFactory = { - async createAgent(_ownerCtx, options) { - const session = ctx.sessions.create( - options.sessionId, - options.meta === undefined ? {} : { meta: options.meta }, - ) - const agent = stubAgent(session) - ;(agent as { ctx?: Context }).ctx = ctx - await options.setup?.(ctx, agent) - const unregister = await ctx.agents.register(agent) - return { agent, dispose: async () => { await unregister() } } + const stored = options.stored ?? new Map() + ctx.provide('sessionPersistence', testSessionPersistence(ctx, { + list: () => Promise.resolve([...stored.values()].map(record => record.header)), + inspect: (id: SessionId) => { + const record = stored.get(id) + return Promise.resolve(record === undefined ? undefined : { meta: record.header, events: [...record.events] }) }, - async resume() { - throw new Error('test harness has no persisted sessions') + }) as never) + const { members, archived } = options.workspace ?? { members: [], archived: [] } + const workspace = { id: WORKSPACE, path: cwd, sessionIds: members, attachSession: () => Promise.resolve() } + ctx.provide('workspaceRegistry', { + get: (id: WorkspaceId) => id === WORKSPACE ? workspace : undefined, + list: () => [workspace], + archivedSessionIds: archived, + } as never) + + /** Run setup on the unpublished Session, then publish it and its Agent, as the Agent factory does. */ + const publish = async ( + session: Session, + setup: Parameters[1]['setup'], + ): Promise<{ agent: Agent; dispose: () => Promise }> => { + const agent = stubAgent(session) + ;(agent as { ctx?: Context }).ctx = ctx + await setup?.(ctx, agent) + ctx.effect(function* () { + yield ctx.sessions.enter(session) + ctx.sessions.announce(session) + }, 'test: published Session') + const unregister = await ctx.agents.register(agent) + return { agent, dispose: async () => { await unregister() } } + } + const factory: AgentFactory = { + async createAgent(_ownerCtx, create) { + const session = ctx.sessions.prepare(create.sessionId, create.meta === undefined ? {} : { meta: create.meta }) + return publish(session, create.setup) + }, + async resume(_ownerCtx, resume) { + const id = resume.resumeSessionId + if (options.held?.has(id) === true) { + throw Object.assign(new Error(`session "${id}" is written by another owner`), { name: 'SessionAlreadyOwnedError' }) + } + const record = stored.get(id) + if (record === undefined || options.unreadable?.has(id) === true) { + throw new Error(`test persistence cannot open Session "${id}" for writing`) + } + if (options.publishedByOtherResume?.has(id) === true) { + await publish(ctx.sessions.prepare(id, { meta: record.header, seed: [...record.events] }), undefined) + throw Object.assign(new Error(`session "${id}" is written by this process`), { name: 'SessionAlreadyOwnedError' }) + } + resumed.push(id) + const session = ctx.sessions.prepare(id, { + meta: record.header, + seed: [...record.events, ...options.writtenSinceObservation?.get(id) ?? []], + }) + return publish(session, resume.setup) }, } ctx.agents.setFactory(factory) @@ -79,13 +165,43 @@ async function harness(presets?: readonly string[]) { defaultModelSelection: () => ({ provider: 'test', model: 'test-model' }), cwd, }) + ctx.sessionProjections.register(turnBoundaryProjectionDefinition) if (presets !== undefined) ctx.sessionProjections.register(agentPresetProjectionDefinition) - return { ctx, remote } + const presetOf = (id: SessionId): string | null | undefined => { + const session = ctx.sessions.get(id) + return session === undefined ? undefined : ctx.sessionProjections.stateOf(session, 'agentPreset') + } + const selectedEvents = (id: SessionId): unknown[] => { + const session = ctx.sessions.get(id) + return session === undefined ? [] : session.snapshotEvents() + .filter(event => event.type === 'agent-preset/selected') + .map(event => event.data) + } + return { ctx, remote, cwd, selections, resumed, presetOf, selectedEvents } +} + +function storedHeader(id: SessionId, cwd: string, extra: Partial = {}): SessionHeader { + return { version: SESSION_FORMAT_VERSION, id, createdAt: 1, isSeeded: false, cwd, ...extra } +} + +function modelSelection(seq: number): SessionEvent { + return { type: 'model/selection', seq: SessionSeq(seq), time: 1, data: { provider: 'test', model: 'test-model' } } +} + +function presetSelection(seq: number, agentPreset: string): SessionEvent { + return { type: 'agent-preset/selected', seq: SessionSeq(seq), time: 1, data: { agentPreset } } +} + +function completedTurn(seq: number): SessionEvent[] { + return [ + { type: 'turn/start', seq: SessionSeq(seq), time: 1, data: { turn: 1 } }, + { type: 'turn/end', seq: SessionSeq(seq + 1), time: 2, data: { turn: 1, reason: { kind: 'completed' } } }, + ] } describe('session.create Agent preset identity', () => { it('records the requested preset on the Session header', async () => { - const { ctx, remote } = await harness(['standard', 'minimal']) + const { ctx, remote } = await harness({ presets: ['standard', 'minimal'] }) const created = await remote.create({ sessionId: SessionId('s1'), agentPreset: 'minimal' }) @@ -94,7 +210,7 @@ describe('session.create Agent preset identity', () => { }) it('records the roster default when the caller names no preset', async () => { - const { ctx, remote } = await harness(['standard', 'minimal']) + const { ctx, remote } = await harness({ presets: ['standard', 'minimal'] }) await remote.create({ sessionId: SessionId('s2') }) @@ -102,55 +218,13 @@ describe('session.create Agent preset identity', () => { }) it('rejects an unknown preset', async () => { - const { remote } = await harness(['standard']) + const { remote } = await harness({ presets: ['standard'] }) const response = await remote.create({ sessionId: SessionId('s3'), agentPreset: 'nope' }) expect(response).toMatchObject({ ok: false, error: { code: 'agent-preset/not-found' } }) }) - it('refuses to adopt a live Session under a different preset', async () => { - const { remote } = await harness(['standard', 'minimal']) - await remote.create({ sessionId: SessionId('s4'), agentPreset: 'minimal' }) - - const response = await remote.create({ sessionId: SessionId('s4'), agentPreset: 'standard' }) - - expect(response).toMatchObject({ - ok: false, - error: { - code: 'agent-preset/conflict', - details: { - sessionId: 's4', - requestedPreset: 'standard', - existingPreset: 'minimal', - }, - }, - }) - }) - - it('adopts a live Session under the preset selected in its log', async () => { - const { ctx, remote } = await harness(['standard', 'minimal']) - await remote.create({ sessionId: SessionId('s4b'), agentPreset: 'standard' }) - ctx.sessions.get(SessionId('s4b'))?.append('agent-preset/selected', { agentPreset: 'minimal' }) - - const adopted = await remote.create({ sessionId: SessionId('s4b'), agentPreset: 'minimal' }) - const stale = await remote.create({ sessionId: SessionId('s4b'), agentPreset: 'standard' }) - - expect(adopted).toMatchObject({ ok: true, value: { agentPreset: 'minimal' } }) - expect(stale).toMatchObject({ - ok: false, - error: { details: { existingPreset: 'minimal' } }, - }) - }) - - it('adopts a live Session unchanged when the caller names no preset', async () => { - const { remote } = await harness(['standard', 'minimal']) - await remote.create({ sessionId: SessionId('s5'), agentPreset: 'minimal' }) - - await expect(remote.create({ sessionId: SessionId('s5') })) - .resolves.toMatchObject({ ok: true }) - }) - it('leaves the header preset-less when no roster is composed', async () => { const { ctx, remote } = await harness() @@ -158,6 +232,195 @@ describe('session.create Agent preset identity', () => { expect(ctx.sessions.get(SessionId('s6'))?.header.agentPreset).toBeUndefined() }) +}) + +describe('session.create reuse of a live blank Session', () => { + it('recomposes a blank Session under the default when the caller names no preset', async () => { + const { remote, selections, presetOf } = await harness({ presets: ['standard', 'minimal'] }) + await remote.create({ sessionId: SessionId('draft'), agentPreset: 'minimal' }) + + const reused = await remote.create({ sessionId: SessionId('draft') }) + + expect(reused).toMatchObject({ ok: true, value: { sessionId: 'draft', agentPreset: 'standard' } }) + expect(selections).toEqual([{ sessionId: 'draft', agentPreset: 'standard' }]) + expect(presetOf(SessionId('draft'))).toBe('standard') + }) + + it('recomposes a blank Session under the named preset', async () => { + const { remote, selections } = await harness({ presets: ['standard', 'minimal'] }) + await remote.create({ sessionId: SessionId('draft') }) + + const reused = await remote.create({ sessionId: SessionId('draft'), agentPreset: 'minimal' }) + + expect(reused).toMatchObject({ ok: true, value: { sessionId: 'draft', agentPreset: 'minimal' } }) + expect(selections).toEqual([{ sessionId: 'draft', agentPreset: 'minimal' }]) + }) + + it('keeps a blank Session that already runs the requested preset', async () => { + const { ctx, remote, selections } = await harness({ presets: ['standard', 'minimal'] }) + await remote.create({ sessionId: SessionId('draft') }) + ctx.sessions.get(SessionId('draft'))?.append('agent-preset/selected', { agentPreset: 'minimal' }) + + const reused = await remote.create({ sessionId: SessionId('draft'), agentPreset: 'minimal' }) + + expect(reused).toMatchObject({ ok: true, value: { sessionId: 'draft', agentPreset: 'minimal' } }) + expect(selections).toEqual([]) + }) + + it('validates the requested preset before touching the blank Session', async () => { + const { remote, selections, presetOf } = await harness({ presets: ['standard', 'minimal'] }) + await remote.create({ sessionId: SessionId('draft'), agentPreset: 'minimal' }) + + const response = await remote.create({ sessionId: SessionId('draft'), agentPreset: 'deleted' }) + + expect(response).toMatchObject({ ok: false, error: { code: 'agent-preset/not-found' } }) + expect(selections).toEqual([]) + expect(presetOf(SessionId('draft'))).toBe('minimal') + }) + + it('keeps the blank Session unchanged when the requested preset fails to activate', async () => { + const { remote, selections, presetOf } = await harness({ + presets: ['standard', 'broken'], + broken: new Set(['broken']), + }) + await remote.create({ sessionId: SessionId('draft') }) + + const response = await remote.create({ sessionId: SessionId('draft'), agentPreset: 'broken' }) + + expect(response).toMatchObject({ ok: false, error: { code: 'agent-preset/invalid' } }) + expect(selections).toEqual([]) + expect(presetOf(SessionId('draft'))).toBe('standard') + }) + + it('creates a new Session instead of reusing one that has started', async () => { + const { ctx, remote, selections, presetOf } = await harness({ presets: ['standard', 'minimal'] }) + await remote.create({ sessionId: SessionId('talk'), agentPreset: 'minimal' }) + const talk = ctx.sessions.get(SessionId('talk')) + talk?.append('turn/start', { turn: 1 }) + talk?.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + + const created = await remote.create({ sessionId: SessionId('talk') }) + + if (!created.ok) throw new Error(`create failed: ${created.error.message}`) + expect(created.value.sessionId).not.toBe('talk') + expect(created.value.agentPreset).toBe('standard') + expect(selections).toEqual([]) + expect(presetOf(SessionId('talk'))).toBe('minimal') + }) + + it('creates a new Session when a turn starts before the preset switch', async () => { + const { remote } = await harness({ + presets: ['standard', 'minimal'], + locked: new Set([SessionId('racing')]), + }) + await remote.create({ sessionId: SessionId('racing'), agentPreset: 'minimal' }) + + const created = await remote.create({ sessionId: SessionId('racing') }) + + if (!created.ok) throw new Error(`create failed: ${created.error.message}`) + expect(created.value.sessionId).not.toBe('racing') + }) + + it('serializes concurrent reuse of one Session in request order', async () => { + const { remote, selections, presetOf } = await harness({ presets: ['standard', 'minimal'] }) + await remote.create({ sessionId: SessionId('draft') }) + + const [first, second] = await Promise.all([ + remote.create({ sessionId: SessionId('draft'), agentPreset: 'minimal' }), + remote.create({ sessionId: SessionId('draft') }), + ]) + + expect(first).toMatchObject({ ok: true, value: { sessionId: 'draft' } }) + expect(second).toMatchObject({ ok: true, value: { sessionId: 'draft', agentPreset: 'standard' } }) + expect(selections).toEqual([ + { sessionId: 'draft', agentPreset: 'minimal' }, + { sessionId: 'draft', agentPreset: 'standard' }, + ]) + expect(presetOf(SessionId('draft'))).toBe('standard') + }) + + it('claims one Session in arrival order when an earlier preset resolves later', async () => { + const minimal = Promise.withResolvers() + const { remote, selections, presetOf } = await harness({ + presets: ['standard', 'minimal'], resolving: new Map([['minimal', minimal.promise]]), + }) + await remote.create({ sessionId: SessionId('draft') }) + + const first = remote.create({ sessionId: SessionId('draft'), agentPreset: 'minimal' }) + const second = remote.create({ sessionId: SessionId('draft') }) + await new Promise(resolve => setTimeout(resolve, 0)) + minimal.resolve(undefined) + + expect(await first).toMatchObject({ ok: true, value: { sessionId: 'draft', agentPreset: 'minimal' } }) + expect(await second).toMatchObject({ ok: true, value: { sessionId: 'draft', agentPreset: 'standard' } }) + expect(selections).toEqual([ + { sessionId: 'draft', agentPreset: 'minimal' }, + { sessionId: 'draft', agentPreset: 'standard' }, + ]) + expect(presetOf(SessionId('draft'))).toBe('standard') + }) + + it('shares one creation between concurrent callers of a new identity', async () => { + const { ctx, remote } = await harness({ presets: ['standard'] }) + + const results = await Promise.all([ + remote.create({ sessionId: SessionId('fresh') }), + remote.create({ sessionId: SessionId('fresh') }), + ]) + + expect(results).toMatchObject([ + { ok: true, value: { sessionId: 'fresh' } }, + { ok: true, value: { sessionId: 'fresh' } }, + ]) + expect(ctx.agents.list().map(agent => agent.id)).toEqual(['fresh']) + }) + + it('refuses a Session in another directory', async () => { + const { ctx, remote } = await harness({ presets: ['standard'] }) + const elsewhere = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-elsewhere-'))) + tempDirs.push(elsewhere) + await remote.create({ sessionId: SessionId('other'), cwd: elsewhere }) + + const response = await remote.create({ sessionId: SessionId('other') }) + + expect(response).toMatchObject({ + ok: false, + error: { code: 'session/conflict', details: { sessionId: 'other', existingCwd: elsewhere } }, + }) + expect(ctx.agents.list().map(agent => agent.id)).toEqual(['other']) + }) + + it('refuses a Session in another directory before resolving the requested preset', async () => { + const { remote } = await harness({ presets: ['standard'] }) + const elsewhere = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-elsewhere-'))) + tempDirs.push(elsewhere) + await remote.create({ sessionId: SessionId('other'), cwd: elsewhere }) + + const response = await remote.create({ sessionId: SessionId('other'), agentPreset: 'nope' }) + + expect(response).toMatchObject({ ok: false, error: { code: 'session/conflict', details: { sessionId: 'other' } } }) + }) + + it('leaves a subagent identity to subagent routing', async () => { + const { ctx, remote, cwd } = await harness({ presets: ['standard'] }) + ctx.sessions.create(SessionId('child'), { + meta: { cwd, parentSession: SessionId('parent'), origin: 'subagent' }, + }) + + const response = await remote.create({ sessionId: SessionId('child') }) + + expect(response).toMatchObject({ ok: false, error: { code: 'session/agent-busy' } }) + }) + + it('reuses a preset-less blank Session unchanged when no roster is composed', async () => { + const { ctx, remote } = await harness() + await remote.create({ sessionId: SessionId('s5') }) + + const reused = await remote.create({ sessionId: SessionId('s5') }) + + expect(reused).toMatchObject({ ok: true, value: { sessionId: 's5' } }) + expect(ctx.sessions.get(SessionId('s5'))?.header.agentPreset).toBeUndefined() + }) it('explains why a preset-less Session cannot be adopted under one', async () => { const { remote } = await harness() @@ -177,6 +440,235 @@ describe('session.create Agent preset identity', () => { }) if (response.ok) throw new Error('unreachable') expect('existingPreset' in response.error.details).toBe(false) - expect(response.error.message).toContain('records no agent preset') + expect(response.error.message).toBe('the deployment composes no agent preset roster, so session "s7" cannot run preset "standard"') + }) +}) + +describe('session.create reuse of a stored blank Session', () => { + it('resumes a blank Session whose recorded preset is no longer declared under the default', async () => { + const draft = SessionId('stale-draft') + const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-stored-'))) + tempDirs.push(cwd) + const { remote, resumed, presetOf, selectedEvents } = await harness({ + presets: ['standard', 'minimal'], + stored: new Map([[draft, { header: storedHeader(draft, cwd, { agentPreset: 'liangshen' }), events: [modelSelection(0)] }]]), + }) + + const reused = await remote.create({ sessionId: draft, cwd }) + + expect(reused).toMatchObject({ ok: true, value: { sessionId: 'stale-draft', agentPreset: 'standard' } }) + expect(resumed).toEqual(['stale-draft']) + expect(presetOf(draft)).toBe('standard') + expect(selectedEvents(draft)).toEqual([{ agentPreset: 'standard' }]) + }) + + it('resumes a blank Session under the default even when its previous choice is still declared', async () => { + const draft = SessionId('picked-draft') + const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-stored-'))) + tempDirs.push(cwd) + const { remote, presetOf, selectedEvents } = await harness({ + presets: ['standard', 'minimal'], + stored: new Map([[draft, { header: storedHeader(draft, cwd, { agentPreset: 'standard' }), events: [presetSelection(0, 'minimal')] }]]), + }) + + const reused = await remote.create({ sessionId: draft, cwd }) + + expect(reused).toMatchObject({ ok: true, value: { sessionId: 'picked-draft', agentPreset: 'standard' } }) + expect(presetOf(draft)).toBe('standard') + expect(selectedEvents(draft)).toEqual([{ agentPreset: 'minimal' }, { agentPreset: 'standard' }]) + }) + + it('resumes a blank Session under its recorded preset when the caller names it', async () => { + const draft = SessionId('restored-draft') + const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-stored-'))) + tempDirs.push(cwd) + const { remote, presetOf, selectedEvents } = await harness({ + presets: ['standard', 'minimal'], + stored: new Map([[draft, { header: storedHeader(draft, cwd, { agentPreset: 'standard' }), events: [presetSelection(0, 'minimal')] }]]), + }) + + const reused = await remote.create({ sessionId: draft, cwd, agentPreset: 'minimal' }) + + expect(reused).toMatchObject({ ok: true, value: { sessionId: 'restored-draft', agentPreset: 'minimal' } }) + expect(presetOf(draft)).toBe('minimal') + expect(selectedEvents(draft)).toEqual([{ agentPreset: 'minimal' }]) + }) + + it('rejects a named preset that is no longer declared without resuming the Session', async () => { + const draft = SessionId('stale-draft') + const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-stored-'))) + tempDirs.push(cwd) + const { ctx, remote, resumed } = await harness({ + presets: ['standard'], + stored: new Map([[draft, { header: storedHeader(draft, cwd, { agentPreset: 'liangshen' }), events: [modelSelection(0)] }]]), + }) + + const response = await remote.create({ sessionId: draft, cwd, agentPreset: 'liangshen' }) + + expect(response).toMatchObject({ + ok: false, + error: { code: 'agent-preset/not-found', details: { agentPreset: 'liangshen', available: ['standard'] } }, + }) + expect(resumed).toEqual([]) + expect(ctx.agents.list()).toEqual([]) + }) + + it('creates a new Session instead of resuming one that has started', async () => { + const talk = SessionId('stored-talk') + const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-stored-'))) + tempDirs.push(cwd) + const { remote, resumed } = await harness({ + presets: ['standard'], + stored: new Map([[talk, { header: storedHeader(talk, cwd, { agentPreset: 'liangshen' }), events: completedTurn(0) }]]), + }) + + const created = await remote.create({ sessionId: talk, cwd }) + + if (!created.ok) throw new Error(`create failed: ${created.error.message}`) + expect(created.value.sessionId).not.toBe('stored-talk') + expect(created.value.agentPreset).toBe('standard') + expect(resumed).toEqual([]) + }) + + it('creates a new Session when another owner holds the blank Session', async () => { + const draft = SessionId('held-draft') + const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-stored-'))) + tempDirs.push(cwd) + const { ctx, remote } = await harness({ + presets: ['standard'], + stored: new Map([[draft, { header: storedHeader(draft, cwd, { agentPreset: 'standard' }), events: [modelSelection(0)] }]]), + held: new Set([draft]), + }) + + const created = await remote.create({ sessionId: draft, cwd }) + + if (!created.ok) throw new Error(`create failed: ${created.error.message}`) + expect(created.value.sessionId).not.toBe('held-draft') + expect(ctx.sessions.get(draft)).toBeUndefined() + }) + + it('reuses the Agent another resume published while holding the writer', async () => { + const draft = SessionId('contended-draft') + const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-stored-'))) + tempDirs.push(cwd) + const { remote, selections, presetOf } = await harness({ + presets: ['standard', 'minimal'], + stored: new Map([[draft, { header: storedHeader(draft, cwd, { agentPreset: 'minimal' }), events: [modelSelection(0)] }]]), + publishedByOtherResume: new Set([draft]), + }) + + const reused = await remote.create({ sessionId: draft, cwd }) + + expect(reused).toMatchObject({ ok: true, value: { sessionId: 'contended-draft', agentPreset: 'standard' } }) + expect(selections).toEqual([{ sessionId: 'contended-draft', agentPreset: 'standard' }]) + expect(presetOf(draft)).toBe('standard') + }) + + it('reports an unreadable Session instead of creating another one', async () => { + const { ctx, remote } = await harness({ presets: ['standard'] }) + vi.spyOn(ctx.sessionQuery, 'observeSession').mockRejectedValue(new Error('storage offline')) + + const response = await remote.create({ sessionId: SessionId('unreadable') }) + + expect(response).toMatchObject({ ok: false, error: { code: 'gateway/internal' } }) + expect(ctx.agents.list()).toEqual([]) + }) + + it('reports a storage failure instead of creating another Session', async () => { + const draft = SessionId('unreadable-draft') + const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-stored-'))) + tempDirs.push(cwd) + const { ctx, remote } = await harness({ + presets: ['standard'], + stored: new Map([[draft, { header: storedHeader(draft, cwd, { agentPreset: 'standard' }), events: [modelSelection(0)] }]]), + unreadable: new Set([draft]), + }) + + const response = await remote.create({ sessionId: draft, cwd }) + + expect(response).toMatchObject({ ok: false, error: { code: 'gateway/internal' } }) + expect(ctx.agents.list()).toEqual([]) + }) + + it('creates a new Session when the blank Session started after it was observed', async () => { + const draft = SessionId('overtaken-draft') + const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-stored-'))) + tempDirs.push(cwd) + const { ctx, remote, resumed } = await harness({ + presets: ['standard', 'minimal'], + stored: new Map([[draft, { header: storedHeader(draft, cwd, { agentPreset: 'minimal' }), events: [modelSelection(0)] }]]), + writtenSinceObservation: new Map([[draft, completedTurn(1)]]), + }) + + const created = await remote.create({ sessionId: draft, cwd }) + + if (!created.ok) throw new Error(`create failed: ${created.error.message}`) + expect(created.value.sessionId).not.toBe('overtaken-draft') + expect(resumed).toEqual(['overtaken-draft']) + expect(ctx.sessions.get(draft)).toBeUndefined() + }) + + it('refuses a stored Session in another directory', async () => { + const other = SessionId('stored-other') + const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-stored-'))) + tempDirs.push(cwd) + const { remote, resumed } = await harness({ + presets: ['standard'], + stored: new Map([[other, { header: storedHeader(other, '/elsewhere'), events: [modelSelection(0)] }]]), + }) + + const response = await remote.create({ sessionId: other, cwd }) + + expect(response).toMatchObject({ + ok: false, + error: { code: 'session/conflict', details: { sessionId: 'stored-other', existingCwd: '/elsewhere' } }, + }) + expect(resumed).toEqual([]) + }) + + it('leaves a stored subagent identity to subagent routing', async () => { + const child = SessionId('stored-child') + const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-stored-'))) + tempDirs.push(cwd) + const { remote, resumed } = await harness({ + presets: ['standard'], + stored: new Map([[child, { + header: storedHeader(child, cwd, { parentSession: SessionId('parent'), origin: 'subagent' }), + events: [modelSelection(0)], + }]]), + }) + + const response = await remote.create({ sessionId: child, cwd }) + + expect(response).toMatchObject({ ok: false, error: { code: 'session/agent-busy' } }) + expect(resumed).toEqual([]) + }) +}) + +describe('session.create Workspace placement', () => { + it('reuses a blank member of the requested Workspace', async () => { + const draft = SessionId('member-draft') + const { remote } = await harness({ presets: ['standard'], workspace: { members: [draft], archived: [] } }) + await remote.create({ sessionId: draft }) + + const reused = await remote.create({ sessionId: draft, workspaceId: WORKSPACE }) + + expect(reused).toMatchObject({ ok: true, value: { sessionId: 'member-draft' } }) + }) + + it.each([ + { name: 'outside the Workspace', members: [], archived: [] }, + { name: 'archived', members: [SessionId('placed-draft')], archived: [SessionId('placed-draft')] }, + ])('creates a new Session instead of reusing a blank Session $name', async ({ members, archived }) => { + const draft = SessionId('placed-draft') + const { remote, selections, presetOf } = await harness({ presets: ['standard', 'minimal'], workspace: { members, archived } }) + await remote.create({ sessionId: draft, agentPreset: 'minimal' }) + + const created = await remote.create({ sessionId: draft, workspaceId: WORKSPACE }) + + if (!created.ok) throw new Error(`create failed: ${created.error.message}`) + expect(created.value.sessionId).not.toBe('placed-draft') + expect(selections).toEqual([]) + expect(presetOf(draft)).toBe('minimal') }) }) diff --git a/packages/api/session-controller/tests/session.client.spec.ts b/packages/api/session-controller/tests/session.client.spec.ts index ccba6d3a6a..8d1838e40f 100644 --- a/packages/api/session-controller/tests/session.client.spec.ts +++ b/packages/api/session-controller/tests/session.client.spec.ts @@ -513,6 +513,19 @@ describe('prompt and cancel errors', () => { expect(session.getSnapshot().subagent).toEqual({ address: CHILD, parentAvailable: true }) }) + it('sends an addressed child continuation under its echo request id and retires a refused echo', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID, { address: CHILD, parentAvailable: true }) + await session.open() + const accepted = session.beginSubmission({ mode: 'queue', text: '继续', attachments: [] }) + await session.prompt([{ type: 'text', text: '继续' }], 'queue', undefined, accepted.requestId) + expect(mock.log.requests('subagents/prompt')).toMatchObject([{ requestId: accepted.requestId, ...CHILD }]) + expect(session.getSnapshot().pendingSubmissions.map(echo => echo.requestId)).toEqual([accepted.requestId]) + + const refused = session.beginSubmission({ mode: 'queue', text: '附件', attachments: [] }) + await session.prompt([{ type: 'file', receiptId: 'receipt' as never }], 'queue', undefined, refused.requestId) + expect(session.getSnapshot().pendingSubmissions.map(echo => echo.requestId)).toEqual([accepted.requestId]) + }) + it('forwards continuation image parts to the subagent prompt Remote unstripped', async ({ mock, start }) => { const session = await sessionBench(mock, start, SID, { address: CHILD, parentAvailable: true }) await session.open() diff --git a/packages/api/session-controller/tests/test-remote.ts b/packages/api/session-controller/tests/test-remote.ts index 71db06b22b..586afcde7b 100644 --- a/packages/api/session-controller/tests/test-remote.ts +++ b/packages/api/session-controller/tests/test-remote.ts @@ -31,6 +31,7 @@ import { remoteErrorOf, type RemoteResult, } from '@deepseek-ai/dsh-typert-protocol' +import RemoteAccessService from '@deepseek-ai/dsh-api-remote-access' import SessionController from '../src/index.ts' import type { ModelCatalog, @@ -243,6 +244,7 @@ function installControllers( ctx.provide('typert', { lookups: { configure: () => dispose }, contexts: { configureHost: () => dispose }, + resources: { register: () => dispose }, } as never) } if (ctx.get('agentDefaultModel') === undefined) { @@ -289,6 +291,7 @@ function installControllers( } as never) } installSessionReadTestServices(ctx) + if (ctx.get('remoteAccess') === undefined) new RemoteAccessService(ctx) const cwd = vi.spyOn(process, 'cwd').mockReturnValue(defaults.cwd) let controller: SessionController try { diff --git a/packages/api/session-controller/tsconfig.host.json b/packages/api/session-controller/tsconfig.host.json index 6376b437ca..8f90ca7a6a 100644 --- a/packages/api/session-controller/tsconfig.host.json +++ b/packages/api/session-controller/tsconfig.host.json @@ -104,6 +104,9 @@ { "path": "../../typert/protocol" }, + { + "path": "../remote-access/tsconfig.host.json" + }, { "path": "../../typert/registry" }, diff --git a/packages/api/session-controller/tsdown.config.ts b/packages/api/session-controller/tsdown.config.ts index bc2abfa538..0e42a1163e 100644 --- a/packages/api/session-controller/tsdown.config.ts +++ b/packages/api/session-controller/tsdown.config.ts @@ -1,7 +1,7 @@ import { clientBundle } from '../../client/tsdown.client.ts' export default clientBundle( - '@deepseek-ai/dsh-api-session-controller', + import.meta.dirname, ['lib/types/index.js'], { hostPhase: true }, ) diff --git a/packages/api/settings-controller/src/credentials.ts b/packages/api/settings-controller/src/credentials.ts index 99c42a4cf0..3889669048 100644 --- a/packages/api/settings-controller/src/credentials.ts +++ b/packages/api/settings-controller/src/credentials.ts @@ -132,11 +132,12 @@ export class CredentialsController extends TypertRemoteService { /** * Run one remote write and report every refusal as `credential/rejected` - * carrying the seam's own message: a read-only source shadowing the reference - * is what a configuration surface must show verbatim. Callers brand the - * reference before entering, so a name outside the grammar never reaches this - * path and fails the same way it does on the read side. The details name only - * the reference, so no failure path can carry the value back out. + * carrying the seam's own message: a provider refusal, such as a stored + * document that no longer parses, is what a settings page must show + * verbatim. Callers brand the reference before entering, so a name outside + * the grammar never reaches this path and fails the same way it does on the + * read side. The details name only the reference, so no failure path can + * carry the value back out. */ private async write(ref: string, write: () => Promise): Promise { try { diff --git a/packages/api/settings-controller/src/types.ts b/packages/api/settings-controller/src/types.ts index 2110184363..3d11c6ab0c 100644 --- a/packages/api/settings-controller/src/types.ts +++ b/packages/api/settings-controller/src/types.ts @@ -20,9 +20,9 @@ declare module '@deepseek-ai/dsh-typert-protocol' { */ 'settings/conflict': { readonly ns: string; readonly expected: number; readonly actual: number } /** - * The provider refused a valid credential write, for example because a - * read-only source shadows the reference. The details name only the - * reference, never the value. + * The provider refused a valid credential write, for example because the + * stored document no longer parses. The details name only the reference, + * never the value. */ 'credential/rejected': { readonly ref: string } } diff --git a/packages/api/settings-controller/tests/credentials-controller.host.spec.ts b/packages/api/settings-controller/tests/credentials-controller.host.spec.ts index 9ac14d9eb8..5eeb190385 100644 --- a/packages/api/settings-controller/tests/credentials-controller.host.spec.ts +++ b/packages/api/settings-controller/tests/credentials-controller.host.spec.ts @@ -24,7 +24,7 @@ class LiteralRejectingCredentials extends MemoryCredentials { /** A store whose provider-owned policy rejects an otherwise valid write. */ class RejectingCredentials extends MemoryCredentials { override set(): Promise { - return Promise.reject(new Error('a read-only source shadows this reference')) + return Promise.reject(new Error('the stored document no longer parses')) } } @@ -120,7 +120,7 @@ describe('the credentials Remote namespace a configuration surface calls', () => const failure = await controller.set('DEEPSEEK_API_KEY', 'sk-live').catch((error: unknown) => error) const { code, message, details } = remoteErrorOf(failure) ?? {} expect(code).toBe('credential/rejected') - expect(message).toContain('read-only source') + expect(message).toContain('no longer parses') expect(details).toEqual({ ref: 'DEEPSEEK_API_KEY' }) }) diff --git a/packages/api/terminal-controller/README.i18n.yaml b/packages/api/terminal-controller/README.i18n.yaml index 167264cbde..e25664e9e3 100644 --- a/packages/api/terminal-controller/README.i18n.yaml +++ b/packages/api/terminal-controller/README.i18n.yaml @@ -15,11 +15,11 @@ en: d152484eb41ac6b4 zh: 1fa9a72d1ee045cc /deepseek-ai-dsh-api-terminal-controller/use-this-package: - en: abda92ff99530baa - zh: 67aa726392ae8e05 + en: 056c8b23819be51f + zh: d4525149ad4c32ce /deepseek-ai-dsh-api-terminal-controller/understand-the-implementation: - en: 94dcd1835c55acc2 - zh: cbfeb2da8cb60395 + en: b34684c82aa5fe5f + zh: 07638715c8708546 /deepseek-ai-dsh-api-terminal-controller/further-exploration: en: b1b5d2a2bfb8cc6a zh: aa0ea568aa63fda5 diff --git a/packages/api/terminal-controller/README.md b/packages/api/terminal-controller/README.md index 2709674163..856ebc96de 100644 --- a/packages/api/terminal-controller/README.md +++ b/packages/api/terminal-controller/README.md @@ -25,7 +25,7 @@ Open the execution environment's default shell in the Session's current working ## Use this package -The Web bundle mounts this package with the subprocess provider, working-directory service, sandbox policy and Typert Gateway. `remote.terminal` exposes `environment`, `shells`, `list`, `create`, `retain`, `follow`, `write`, `resize`, `rename` and `close`; each operation is scoped by Session identity. Listing reads retained Host terminals directly, so viewing an offline Session neither activates an Agent nor produces a recovery error. +The Web bundle mounts this package with the subprocess provider, working-directory service, sandbox policy and Typert Gateway. `remote.terminal` exposes `environment`, `shells`, `list`, `create`, `retain`, `attach`, `rename` and `close`; each operation is scoped by Session identity. Listing reads retained Host terminals directly, so viewing an offline Session neither activates an Agent nor produces a recovery error. Shell discovery lists the execution environment's declared default shell first. Only when the provider omits that default does resolution use `/bin/sh` on POSIX or `cmd.exe` on Windows. An optional `shell` profile overrides that choice with executable `path`, display `name` and `args` (default `[]`). The selector also probes `shellCandidates` through the execution provider and omits confirmed lookup misses. It lists each executable name once, ignoring letter case and `.exe`, and keeps the earliest entry, because PATH lookup can reach the default through another directory, such as `/usr/bin/bash` for `/bin/bash` on merged-`/usr` systems. Creation accepts a discovered `shellPath` and verifies it again; resolution or transport failure is reported without launching a different shell. Environment lookup reads the current directory and limits without filesystem validation or shell resolution, so unavailable directories or default shells do not prevent reattaching to an existing process. Automatic POSIX profiles start interactively, and PowerShell uses `-NoLogo`, so completion and startup configuration remain shell-owned. New terminals validate the current directory through the [working-directory service](../../session/working-directory/README.md) before spawning, including its recovery behavior. Existing terminals keep their process directories. User terminals run with the execution environment’s system-user permissions, independently of the Agent’s sandbox mode and approval policy. Operating-system and container restrictions still apply; DSH does not elevate the user. The subprocess provider retains its credential-environment scrubbing. @@ -36,8 +36,8 @@ Shell discovery lists the execution environment's declared default shell first. | `maxTerminals` | `8` | Retained terminals and pending allocations per Session | | `maxCols`, `maxRows` | `500`, `200` | Maximum PTY dimensions | | `scrollback` | `1000` | Retained screen history rows | -| `maxBufferedBytes` | `2097152` | Output queued for one follower | -| `maxInputBytes` | `65536` | Maximum input request bytes | +| `maxBufferedBytes` | `2097152` | Encoded output per follower and buffered Client input | +| `maxInputBytes` | `65536` | Maximum raw UTF-8 bytes per input command and across unacknowledged Client input | | `disposeGraceMs` | `1000` | Provider termination grace in milliseconds | | `unattendedTimeoutMs` | `7200000` | Continuous confirmed idle time without window holds before cleanup; `0` disables automatic reclamation | | `activityPollIntervalMs` | `30000` | Interval between unattended activity observations | @@ -51,11 +51,13 @@ An open tab in any connected window retains its terminal, including hidden tabs

Implementation internals — click to expand -The Host uses `ctx.subprocess.spawnTerminal` with `TERM=xterm-256color`; it never launches a desktop terminal application. Streaming UTF-8 decoding preserves split characters and leading BOMs, and replaces incomplete trailing bytes at EOF. Unary control uses the Gateway, and `follow` uses its multiplexed Remote stream transport. Headless xterm and its serializer produce each opening screen after all preceding output writes, then monotone output sequences identify subsequent frames. Slow followers fail explicitly; a new attachment restores the current screen. +The Host uses `ctx.subprocess.spawnTerminal` with `TERM=xterm-256color`; it never launches a desktop terminal application. Streaming UTF-8 decoding preserves split characters and leading BOMs, and replaces incomplete trailing bytes at EOF. The duplex `attach` Remote stream carries output and ordered `write`/`resize` commands on the Gateway mux. Each command has a consecutive attachment-local sequence; an `ack` confirms provider acceptance or reports control refusal. Snapshots and state frames carry the receiving attachment's `writable` flag. Headless xterm and its serializer produce each opening screen after all preceding output writes, then monotone output sequences identify subsequent frames. Slow followers fail explicitly; a new attachment restores the current screen. -The latest attachment owns input and resize. Detachment releases input control without killing the process. Explicit close awaits process cleanup and final output; cleanup failure retains the resource for retry. The Session remembers closed identities and rejects their delayed or repeated creation, including creation already in progress when close arrives. A new terminal uses a new identity. Pending allocations remain owned even if cancellation and cleanup both fail. Session owner disposal and controller disposal also terminate owned processes. Changing the Session’s sandbox mode leaves user terminals running with the same permissions. Input or resize refused after control transfer or process exit leaves the output attachment intact and disables input; rejected input is not replayed. +The latest attachment owns input and resize. Detachment releases input control without killing the process. Explicit close awaits process cleanup and final output; cleanup failure retains the resource for retry. The Session remembers closed identities and rejects their delayed or repeated creation, including creation already in progress when close arrives. A new terminal uses a new identity. Pending allocations remain owned even if cancellation and cleanup both fail. Session owner disposal and controller disposal also terminate owned processes. Changing the Session’s sandbox mode leaves user terminals running with the same permissions. Input or resize refused after control transfer or process exit leaves the output attachment intact and disables input; rejected input is not replayed. Provider completion disables input before output drainage finishes, and late input failures cannot discard a gracefully finished follower's final frames. -The Client saves each Session/content-to-terminal association before allocation under its own `dsh.terminal.binding.v1.*` localStorage key. The content identity is globally unique; layout-local tab ids only identify live view occurrences. Independent record writes and deletes preserve other windows' bindings. Restored views reuse that identity; the sidebar terminal provider restores its views before querying unrepresented Host terminals. A new view may create a process, while a recovered view reports a missing target without creating a replacement. Explicit close removes the association after saving its cleanup request. The Host supplies current process metadata and screen contents; neither is saved in the browser. The Client model acknowledges screen writes after the browser emulator processes them, serializes input and ignores stale attachment responses. Client-owned errors carry locale keys. Plugin disposal awaits active and previously detached output streams without closing Host processes. +The opening snapshot advertises the carrier's complete-frame `inputWindow`; in-process carriers without an inbox limit omit it. The Client counts JSON escaping, command fields, and the carrier envelope for both writes and resizes. Commands wait locally while unacknowledged frames fill that window; provider acknowledgements release capacity. `maxInputBytes` bounds raw UTF-8 input, while `maxBufferedBytes` bounds all queued and unacknowledged encoded input frames. A single frame that cannot fit the carrier window, or an exhausted Client buffer, reports `inputFull` and cancels the attachment without replaying queued commands. + +The Client saves each Session/content-to-terminal association before allocation under its own `dsh.terminal.binding.v1.*` localStorage key. The content identity is globally unique; layout-local tab ids only identify live view occurrences. Independent record writes and deletes preserve other windows' bindings. Restored views reuse that identity; the sidebar terminal provider restores its views before querying unrepresented Host terminals. A new view may create a process, while a recovered view reports a missing target without creating a replacement. Explicit close removes the association after saving its cleanup request. The Host supplies current process metadata and screen contents; neither is saved in the browser. The Client model acknowledges screen writes after the browser emulator processes them, pipelines input within its unacknowledged UTF-8 byte budget and ignores stale attachment responses. Reconnection creates a fresh command sequence and never replays unacknowledged input. Provider input failures end the attachment; its process and recovery screen remain available for explicit reconnection. Client-owned errors carry locale keys. Plugin disposal awaits active and previously detached output streams without closing Host processes. The separate `retain(sessionId, id, signal)` Remote stream acknowledges a window hold without Agent activation, screen output, input transfer or process creation. The terminal provider supplies the sidebar's complete open-tab inventory, and the Client intersects it with its own saved identities. Duplicate occurrences share one hold per window; stale associations alone retain nothing. Restored output attachments wait for an acknowledged current hold. Transport cancellation releases exactly that physical generation; plugin disposal joins all hold streams. Failed cleanup retains ownership and retries without reopening admission or restarting the idle grace. diff --git a/packages/api/terminal-controller/README.zh.md b/packages/api/terminal-controller/README.zh.md index 49fc7ab343..220d88190d 100644 --- a/packages/api/terminal-controller/README.zh.md +++ b/packages/api/terminal-controller/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用此包 -Web bundle 将此包与 subprocess provider、working-directory 服务、sandbox policy 和 Typert Gateway 一起挂载。`remote.terminal` 提供 `environment`、`shells`、`list`、`create`、`retain`、`follow`、`write`、`resize`、`rename` 和 `close`;每个操作均按 Session 标识限定范围。列表直接读取 Host 保留的终端,因此查看离线 Session 不会激活 Agent,也不会产生恢复错误。 +Web bundle 将此包与 subprocess provider、working-directory 服务、sandbox policy 和 Typert Gateway 一起挂载。`remote.terminal` 提供 `environment`、`shells`、`list`、`create`、`retain`、`attach`、`rename` 和 `close`;每个操作均按 Session 标识限定范围。列表直接读取 Host 保留的终端,因此查看离线 Session 不会激活 Agent,也不会产生恢复错误。 Shell 探测结果首先列出执行环境声明的默认 shell。仅当 provider 未声明默认值时,才在 POSIX 使用 `/bin/sh`,在 Windows 使用 `cmd.exe`。可选的 `shell` profile 通过可执行路径 `path`、显示名称 `name` 和参数 `args`(默认 `[]`)覆盖这一选择。选择器还会通过执行 provider 探测 `shellCandidates`,并省略确定未找到的候选。同一可执行文件名(不区分大小写,忽略 `.exe`)只列出最早的一项,因为 PATH 查找可能经由其他目录找到默认 shell,例如 merged-`/usr` 系统上 `/bin/bash` 对应的 `/usr/bin/bash`。创建请求接受探测返回的 `shellPath` 并再次验证;解析或传输失败会直接报告,不启动其他 shell。环境查询读取当前目录和限制,不校验文件系统或解析 shell,因此目录或默认 shell 不可用时仍可重新连接已有进程。POSIX 自动 profile 以交互模式启动,PowerShell 使用 `-NoLogo`,补全和启动配置仍由 shell 提供。新终端启动前通过[工作目录服务](../../session/working-directory/README.zh.md)校验当前目录,并采用该服务的恢复行为。已有终端保留其进程目录。用户终端使用执行环境中系统用户的权限,独立于 Agent 的沙箱模式和审批策略。操作系统和容器的限制仍然生效;DSH 不提升用户权限。Subprocess provider 继续清除环境中的凭据变量。 @@ -36,8 +36,8 @@ Shell 探测结果首先列出执行环境声明的默认 shell。仅当 provide | `maxTerminals` | `8` | 每个 Session 保留的终端和创建请求上限 | | `maxCols`、`maxRows` | `500`、`200` | PTY 最大尺寸 | | `scrollback` | `1000` | 屏幕历史行数 | -| `maxBufferedBytes` | `2097152` | 单个订阅者的输出排队上限 | -| `maxInputBytes` | `65536` | 单次输入请求的字节上限 | +| `maxBufferedBytes` | `2097152` | 单个订阅者输出及 Client 输入帧的缓存上限 | +| `maxInputBytes` | `65536` | 每条输入命令及 Client 未确认输入总量的原始 UTF-8 字节上限 | | `disposeGraceMs` | `1000` | provider 终止宽限期,单位毫秒 | | `unattendedTimeoutMs` | `7200000` | 无窗口持有且持续确认空闲后开始清理的时长;`0` 禁用自动回收 | | `activityPollIntervalMs` | `30000` | 无窗口持有时的活动观察间隔 | @@ -51,11 +51,13 @@ Shell 探测结果首先列出执行环境声明的默认 shell。仅当 provide
实现细节 -Host 通过 `ctx.subprocess.spawnTerminal` 创建 `TERM=xterm-256color` 的终端,不启动桌面终端应用。流式 UTF-8 解码保留跨块字符和开头的 BOM,并在 EOF 将不完整的尾部字节替换为替代字符。控制请求走 Gateway,`follow` 使用其复用的 Remote stream。Headless xterm 和序列化 addon 在此前输出写入后生成初始屏幕,后续增量携带单调序号。过慢的订阅者明确失败;重新连接恢复当前屏幕。 +Host 通过 `ctx.subprocess.spawnTerminal` 创建 `TERM=xterm-256color` 的终端,不启动桌面终端应用。流式 UTF-8 解码保留跨块字符和开头的 BOM,并在 EOF 将不完整的尾部字节替换为替代字符。双工 `attach` Remote 流通过 Gateway mux 传输输出和有序的 `write`/`resize` 命令。每条命令使用附件内连续递增的序号;`ack` 确认提供者已接受操作,或报告控制权拒绝。快照和状态帧携带接收附件的 `writable` 标记。Headless xterm 和序列化 addon 在此前输出写入后生成初始屏幕,后续增量携带单调序号。过慢的订阅者明确失败;重新连接恢复当前屏幕。 -最新连接持有输入和尺寸控制权。断开连接只释放输入权,不结束进程。显式关闭等待进程清理和最后输出;清理失败时保留资源以便重试。Session 记住已关闭的标识并拒绝迟到或重复的创建请求,包括关闭到达时仍在进行的创建。新终端使用新标识。取消创建且清理失败时,已分配的进程仍有所有者。Session owner 和 controller 卸载也会终止所拥有的进程。改变 Session 的沙箱模式时,用户终端继续以原有权限运行。控制权转移或进程退出后被拒绝的输入和尺寸请求保留输出连接并禁用输入,不重发被拒绝的输入。 +最新连接持有输入和尺寸控制权。断开连接只释放输入权,不结束进程。显式关闭等待进程清理和最后输出;清理失败时保留资源以便重试。Session 记住已关闭的标识并拒绝迟到或重复的创建请求,包括关闭到达时仍在进行的创建。新终端使用新标识。取消创建且清理失败时,已分配的进程仍有所有者。Session owner 和 controller 卸载也会终止所拥有的进程。改变 Session 的沙箱模式时,用户终端继续以原有权限运行。控制权转移或进程退出后被拒绝的输入和尺寸请求保留输出连接并禁用输入,不重发被拒绝的输入。提供者完成时即禁用输入,无须等待输出排空;迟到的输入失败不能丢弃已正常结束的订阅者所保留的尾部帧。 -Client 在分配前将每条 Session/内容与终端身份的关联保存到独立的 localStorage key `dsh.terminal.binding.v1.*`。内容身份全局唯一;布局内的 tab id 只标识活动视图 occurrence。逐条记录的写入和删除会保留其他窗口的关联。恢复视图复用该身份;侧栏 terminal provider 先恢复自己的视图,再查询尚无视图的 Host 终端。新视图可以创建进程,恢复视图在目标缺失时显示错误,不创建替代进程。显式关闭先保存清理请求,再删除关联。当前进程元数据和屏幕内容由 Host 提供,不保存在浏览器中。Client 模型在浏览器完成屏幕解析后确认帧,按序发送输入,并忽略旧连接迟到的响应。Client 自产错误携带本地化键。插件卸载等待活跃及先前断开的输出流结束,不关闭 Host 进程。 +初始快照公布载体的完整帧 `inputWindow`;没有 inbox 限额的进程内载体省略该字段。Client 对输入和尺寸调整都计入 JSON 转义、命令字段及载体封装的开销。未确认帧占满窗口时,命令在本地等待;提供者确认后释放额度。`maxInputBytes` 限制原始 UTF-8 输入,`maxBufferedBytes` 限制全部排队及未确认的编码输入帧。单帧无法容纳于载体窗口或 Client 缓存耗尽时,报告 `inputFull` 并取消附件,不重放排队命令。 + +Client 在分配前将每条 Session/内容与终端身份的关联保存到独立的 localStorage key `dsh.terminal.binding.v1.*`。内容身份全局唯一;布局内的 tab id 只标识活动视图 occurrence。逐条记录的写入和删除会保留其他窗口的关联。恢复视图复用该身份;侧栏 terminal provider 先恢复自己的视图,再查询尚无视图的 Host 终端。新视图可以创建进程,恢复视图在目标缺失时显示错误,不创建替代进程。显式关闭先保存清理请求,再删除关联。当前进程元数据和屏幕内容由 Host 提供,不保存在浏览器中。Client 模型在浏览器完成屏幕解析后确认帧,在未确认 UTF-8 字节预算内连续发送输入,并忽略旧连接迟到的响应。重连建立新的命令序列,绝不重放未确认输入。提供者输入失败会结束附件;进程和恢复屏幕仍可供显式重连使用。Client 自产错误携带本地化键。插件卸载等待活跃及先前断开的输出流结束,不关闭 Host 进程。 独立的 `retain(sessionId, id, signal)` Remote 流确认窗口持有关系,不激活 Agent、发送屏幕输出、转移输入权或创建进程。终端 provider 提供侧栏的完整打开标签清单,Client 将其与自己保存的身份取交集。同一窗口中的重复 occurrence 共用一个持有流;孤立的旧关联不会保活任何终端。恢复输出连接前必须等待当前持有关系确认。传输取消只释放对应的物理流代次;插件卸载等待全部持有流结束。清理失败时保留所有权并重试,不重新接受连接,也不重新计算空闲宽限期。 diff --git a/packages/api/terminal-controller/package.json b/packages/api/terminal-controller/package.json index d9ade0d77a..56f8ef8671 100644 --- a/packages/api/terminal-controller/package.json +++ b/packages/api/terminal-controller/package.json @@ -58,9 +58,10 @@ "@deepseek-ai/dsh-deque": "workspace:*", "@deepseek-ai/dsh-lazy-require": "workspace:*", "@deepseek-ai/dsh-typert-protocol": "workspace:*", + "@deepseek-ai/dsh-util-values": "workspace:*", "@deepseek-ai/schemastery": "workspace:~", - "@xterm/headless": "^6.0.0", "@xterm/addon-serialize": "^0.14.0", + "@xterm/headless": "^6.0.0", "zod": "4.4.3" }, "peerDependencies": { diff --git a/packages/api/terminal-controller/src/client/model.ts b/packages/api/terminal-controller/src/client/model.ts index 64bea4906e..d143e9c57a 100644 --- a/packages/api/terminal-controller/src/client/model.ts +++ b/packages/api/terminal-controller/src/client/model.ts @@ -1,13 +1,13 @@ /** React-free browser terminal state and reconnecting Remote-stream ownership. */ import { preferredShell, rememberShell } from './shell-preference.ts' -import { randomUUID } from '@deepseek-ai/dsh-util-crypto' import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' +import { Deque } from '@deepseek-ai/dsh-deque' import { RemoteStreamCarrierError, type ClientRemote, type RemoteStream } from '@deepseek-ai/dsh-api-gateway/client' -import { RemoteError, remoteErrorOf, type RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import { RemoteError, remoteErrorOf, type RemoteResult, type RemoteStreamHandle, type RemoteUplinkWindow } from '@deepseek-ai/dsh-typert-protocol' import type {} from '@deepseek-ai/dsh-api-terminal-controller/remote' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { - TerminalAttachmentId, TerminalEnvironment, TerminalFrame, + TerminalInput, TerminalEnvironment, TerminalFrame, WebTerminalId, WebTerminalInfo, } from '../types.ts' @@ -46,6 +46,24 @@ export interface TerminalViewState { readonly issue?: TerminalViewIssue | undefined } +interface InputCost { + readonly inputBytes: number + readonly frameBytes: number +} + +interface TerminalInputAttachment { + readonly handle: RemoteStreamHandle + readonly signal: AbortSignal + sequence: number + acknowledged: number + readonly pending: Map + readonly queued: Deque + window: RemoteUplinkWindow | undefined + bytes: number + bufferedBytes: number + inFlightBytes: number +} + /** A view survives DOM unmount; its process only ends on explicit close. */ export class TerminalView { /** Observable controls, process metadata and the next screen update awaiting acknowledgement. */ @@ -53,14 +71,12 @@ export class TerminalView { private readonly lifetime = new AbortController() private stream: RemoteStream | undefined private mounted = false - private attachmentId: TerminalAttachmentId | undefined + private attachment: TerminalInputAttachment | undefined private pendingRender: { revision: number; resolve: () => void } | undefined private revision = 0 private creation: Promise | undefined private loading: Promise | undefined private closing: Promise | undefined - private writes: Promise = Promise.resolve() - private queuedInput = 0 private readonly detaching = new Set>() /** @@ -164,9 +180,20 @@ export class TerminalView { open: async function* (this: TerminalView, signal: AbortSignal) { await this.retain?.(signal) signal.throwIfAborted() - const attachmentId = randomUUID() as TerminalAttachmentId - this.attachmentId = attachmentId - yield* this.remote.follow(this.sessionId, info.id, attachmentId, signal) + const handle = this.remote.attach(this.sessionId, info.id, signal) + const attachment: TerminalInputAttachment = { + handle, signal, sequence: 0, acknowledged: 0, + pending: new Map(), queued: new Deque(), window: undefined, + bytes: 0, bufferedBytes: 0, inFlightBytes: 0, + } + this.attachment = attachment + try { + for await (const frame of handle) { + if (frame.type === 'snapshot') attachment.window = frame.inputWindow + yield frame + } + } + finally { if (this.attachment === attachment) this.attachment = undefined } }.bind(this), ended: () => new TerminalViewError('attachmentEnded'), carrierFailed: () => { if (this.stream === stream) this.patch({ phase: 'disconnected', writable: false }) }, @@ -187,40 +214,66 @@ export class TerminalView { } /** - * Serialize raw input so concurrent RPC requests cannot reorder keystrokes. - * @param data - input from the terminal emulator. + * Queue input within the raw UTF-8 and encoded-frame budgets, waiting for carrier capacity. + * @param data - input from the terminal emulator; never replayed after disconnection. */ write(data: string): void { const state = this.state.getSnapshot() - const attachmentId = this.attachmentId - if (!state.writable || state.info === undefined || attachmentId === undefined) return + const attachment = this.attachment + if (!state.writable || state.info === undefined || attachment === undefined || attachment.signal.aborted) return + if (data.length === 0) return const bytes = new TextEncoder().encode(data).byteLength - if (this.queuedInput + bytes > (state.environment?.maxInputBytes ?? 0)) { this.fail(new TerminalViewError('inputFull')); return } - this.queuedInput += bytes - const id = state.info.id - this.writes = this.writes.then(async () => { - if (this.attachmentId !== attachmentId || !this.state.getSnapshot().writable) return - valueOf(await this.remote.write(this.sessionId, id, attachmentId, data)) - }).catch((error: unknown) => { if (this.attachmentId === attachmentId) this.fail(error) }).finally(() => { this.queuedInput -= bytes }) + if (attachment.bytes + bytes > (state.environment?.maxInputBytes ?? 0)) { this.failInput(new TerminalViewError('inputFull')); return } + this.send(attachment, { type: 'write', sequence: attachment.sequence + 1, data }, bytes) } /** - * Resize only from the currently writable view. + * Resize only from the currently writable view, ordered with keyboard input. * @param cols - measured column count. * @param rows - measured row count. */ resize(cols: number, rows: number): void { const state = this.state.getSnapshot() - const attachmentId = this.attachmentId - if (!state.writable || state.info === undefined || attachmentId === undefined) return + const attachment = this.attachment + if (!state.writable || state.info === undefined || attachment === undefined || attachment.signal.aborted) return if (state.info.cols === cols && state.info.rows === rows) return - const id = state.info.id cols = Math.min(cols, state.environment?.maxCols ?? cols) rows = Math.min(rows, state.environment?.maxRows ?? rows) - this.writes = this.writes.then(async () => { - if (this.attachmentId !== attachmentId || !this.state.getSnapshot().writable) return - valueOf(await this.remote.resize(this.sessionId, id, attachmentId, cols, rows)) - }).catch((error: unknown) => { if (this.attachmentId === attachmentId) this.fail(error) }) + this.send(attachment, { type: 'resize', sequence: attachment.sequence + 1, cols, rows }, 0) + } + + private send(attachment: TerminalInputAttachment, command: TerminalInput, bytes: number): void { + const frameBytes = new TextEncoder().encode(JSON.stringify(command)).byteLength + (attachment.window?.frameOverheadBytes ?? 0) + if (attachment.bufferedBytes + frameBytes > (this.state.getSnapshot().environment?.maxBufferedBytes ?? 0) + || (attachment.window !== undefined && frameBytes > attachment.window.maxBytes)) { + this.failInput(new TerminalViewError('inputFull')) + return + } + attachment.queued.pushBack({ command, inputBytes: bytes, frameBytes }) + attachment.sequence = command.sequence + attachment.bytes += bytes + attachment.bufferedBytes += frameBytes + this.flushInput(attachment) + } + + private flushInput(attachment: TerminalInputAttachment): void { + while (this.attachment === attachment && !attachment.signal.aborted && this.state.getSnapshot().writable) { + const next = attachment.queued.popFront() + if (next === undefined) return + if (attachment.window !== undefined && attachment.inFlightBytes + next.frameBytes > attachment.window.maxBytes) { + attachment.queued.pushFront(next) + return + } + try { attachment.handle.send(next.command) } + catch (error) { this.failInput(error); return } + attachment.pending.set(next.command.sequence, { inputBytes: next.inputBytes, frameBytes: next.frameBytes }) + attachment.inFlightBytes += next.frameBytes + } + } + + private failInput(error: unknown): void { + this.fail(error) + this.detach() } /** @@ -269,7 +322,7 @@ export class TerminalView { private detach(): void { const previous = this.stream this.stream = undefined - this.attachmentId = undefined + this.attachment = undefined this.pendingRender?.resolve() this.pendingRender = undefined if (previous !== undefined) { @@ -294,8 +347,23 @@ export class TerminalView { if (frame.sequence !== sequence + 1) throw new TerminalViewError('invalidOutput', 'Terminal output sequence has a gap') sequence = frame.sequence } else if (frame.type === 'snapshot') throw new TerminalViewError('invalidOutput', 'Unexpected terminal screen snapshot') + if (frame.type === 'ack') { + const attachment = this.attachment + /* v8 ignore next -- the physical opener retains its handle until this consumer advances. */ + if (attachment === undefined) throw new TerminalViewError('invalidOutput', 'Terminal acknowledgement has no attachment') + const pending = attachment.pending.get(frame.sequence) + if (frame.sequence !== attachment.acknowledged + 1 || pending === undefined) throw new TerminalViewError('invalidOutput', 'Terminal input acknowledgement is out of order') + attachment.acknowledged = frame.sequence + attachment.bytes -= pending.inputBytes + attachment.bufferedBytes -= pending.frameBytes + attachment.inFlightBytes -= pending.frameBytes + attachment.pending.delete(frame.sequence) + if (frame.refusal !== undefined) this.patch({ writable: false }) + this.flushInput(attachment) + continue + } if (frame.type !== 'output') { - this.patch({ info: frame.info, title: frame.info.title, phase: 'connected', writable: frame.info.state === 'running' && frame.info.controllerId === this.attachmentId }) + this.patch({ info: frame.info, title: frame.info.title, phase: 'connected', writable: frame.info.state === 'running' && frame.writable }) } if (frame.type !== 'state') { const revision = ++this.revision @@ -324,10 +392,6 @@ export class TerminalView { private fail(error: unknown): void { const failure = remoteErrorOf(error) - if (failure?.code === 'terminal/control-unavailable') { - this.patch({ writable: false, error: undefined, issue: undefined }) - return - } const issue = failure?.code === 'terminal/view' ? failure.details.issue : failure?.code === 'terminal/limit-reached' ? 'terminalLimit' : failure?.code === 'terminal/unavailable' ? 'missingTerminal' : undefined this.patch({ phase: error instanceof RemoteStreamCarrierError ? 'disconnected' : 'failed', writable: false, issue, error: error instanceof Error ? error.message : String(error) }) } diff --git a/packages/api/terminal-controller/src/index.ts b/packages/api/terminal-controller/src/index.ts index 953c34a9fc..75f454d77d 100644 --- a/packages/api/terminal-controller/src/index.ts +++ b/packages/api/terminal-controller/src/index.ts @@ -5,12 +5,12 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import type { SessionId } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-sandbox-policy' import type {} from '@deepseek-ai/dsh-working-directory' -import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' +import { Remote, RemoteError, TypertRemoteService, type RemoteStream } from '@deepseek-ai/dsh-typert-protocol' import { discoverShells, resolveShell } from './shells.ts' import { BrowserTerminal } from './terminal.ts' import { TerminalRetention } from './retention.ts' import type { - TerminalShell, TerminalAttachmentId, TerminalCreateRequest, TerminalEnvironment, TerminalFrame, TerminalRetentionFrame, + TerminalShell, TerminalInput, TerminalCreateRequest, TerminalEnvironment, TerminalFrame, TerminalRetentionFrame, WebTerminalId, WebTerminalInfo, } from './types.ts' @@ -44,9 +44,9 @@ export interface Config { readonly maxRows: number /** Screen history rows retained for reconnecting clients. */ readonly scrollback: number - /** Maximum queued UTF-8 frame bytes per output follower before disconnection. */ + /** Maximum queued UTF-8 frame bytes per output follower and in the Client input buffer. */ readonly maxBufferedBytes: number - /** Maximum UTF-8 bytes in one input request. */ + /** Maximum raw UTF-8 bytes per input command and across unacknowledged Client input. */ readonly maxInputBytes: number /** Provider process-termination grace period in milliseconds. */ readonly disposeGraceMs: number @@ -120,7 +120,7 @@ export class TerminalController extends TypertRemoteService { signal.throwIfAborted() const { workingDirectory } = this.execution(agent) return { cwd: workingDirectory.get(agent.session), - maxInputBytes: this.config.maxInputBytes, maxCols: this.config.maxCols, + maxInputBytes: this.config.maxInputBytes, maxBufferedBytes: this.config.maxBufferedBytes, maxCols: this.config.maxCols, maxRows: this.config.maxRows, scrollback: this.config.scrollback } } @@ -206,46 +206,17 @@ export class TerminalController extends TypertRemoteService { } /** - * Attach to a terminal without binding its process lifetime to the transport. + * Attach terminal input and output to one physical Remote stream. * @param agent - Session owner supplied by the Gateway. * @param id - terminal identity. - * @param attachmentId - new exclusive input attachment. - * @param signal - physical stream cancellation. - * @returns screen recovery followed by output and metadata changes. + * @param signal - attachment cancellation; the process remains alive and input is never replayed. + * @returns screen recovery, output, writable state and provider-acceptance acknowledgements. */ @Remote({ mode: 'stream' }) - follow(agent: Agent, id: WebTerminalId, attachmentId: TerminalAttachmentId, signal: AbortSignal): AsyncIterable { - if (!/^[\w-]{1,128}$/u.test(attachmentId)) throw new Error('Invalid terminal attachment identity') - return this.terminal(agent, id).follow(attachmentId, signal) - } - - /** - * Deliver raw input, including Tab completion and control characters. - * @param agent - Session owner supplied by the Gateway. - * @param id - terminal identity. - * @param attachmentId - current writable attachment. - * @param data - input bytes represented as UTF-8 text. - * @returns after provider input acceptance. - */ - @Remote - async write(agent: Agent, id: WebTerminalId, attachmentId: TerminalAttachmentId, data: string): Promise { - if (Buffer.byteLength(data, 'utf8') > this.config.maxInputBytes) throw new Error('Terminal input exceeds the configured limit') - await this.terminal(agent, id).write(attachmentId, data) - } - - /** - * Update the dimensions of the PTY and recovery screen. - * @param agent - Session owner supplied by the Gateway. - * @param id - terminal identity. - * @param attachmentId - current writable attachment. - * @param cols - column count. - * @param rows - row count. - * @returns after the resize completes. - */ - @Remote - async resize(agent: Agent, id: WebTerminalId, attachmentId: TerminalAttachmentId, cols: number, rows: number): Promise { - this.dimensions(cols, rows) - await this.terminal(agent, id).resize(attachmentId, cols, rows) + attach(agent: Agent, id: WebTerminalId, signal: AbortSignal): RemoteStream { + const invocation = this.ctx.invocation + if (invocation === undefined) throw new Error('Terminal attachment requires a Remote invocation') + return this.terminal(agent, id).attach(invocation.uplink(), this.config, signal, invocation.uplinkWindow) } /** diff --git a/packages/api/terminal-controller/src/stream.ts b/packages/api/terminal-controller/src/stream.ts index 4ee89f254f..d3b33dc922 100644 --- a/packages/api/terminal-controller/src/stream.ts +++ b/packages/api/terminal-controller/src/stream.ts @@ -11,6 +11,9 @@ export class TerminalFollower { private finished = false private failure: Error | undefined + /** Whether this attachment still accepts input and output. */ + get active(): boolean { return !this.closed && !this.finished } + /** @param maxBytes - maximum queued UTF-8 bytes for this follower. */ constructor(private readonly maxBytes: number) {} @@ -22,8 +25,7 @@ export class TerminalFollower { if (this.closed || this.finished) return const bytes = Buffer.byteLength(JSON.stringify(frame), 'utf8') if (this.bytes + bytes > this.maxBytes) { - this.failure = new Error('Terminal output consumer exceeded its buffer; reconnect to recover the current screen') - this.close() + this.fail(new Error('Terminal output consumer exceeded its buffer; reconnect to recover the current screen')) return } this.queue.pushBack({ frame, bytes }) @@ -31,6 +33,16 @@ export class TerminalFollower { this.wake?.() } + /** + * Fail this attachment without stopping its terminal; failures after finish or close are ignored. + * @param error - input or output failure reported to the attached consumer. + */ + fail(error: Error): void { + if (!this.active) return + this.failure = error + this.close() + } + /** Finish after delivering every queued frame, including the final exit state. */ finish(): void { this.finished = true diff --git a/packages/api/terminal-controller/src/terminal.ts b/packages/api/terminal-controller/src/terminal.ts index b4f6623037..7268092b97 100644 --- a/packages/api/terminal-controller/src/terminal.ts +++ b/packages/api/terminal-controller/src/terminal.ts @@ -1,12 +1,13 @@ /** One PTY, a bounded terminal emulator and its detachable browser followers. */ -import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' +import { assertNever } from '@deepseek-ai/dsh-util-values' +import { RemoteError, remoteErrorOf, type RemoteUplinkWindow } from '@deepseek-ai/dsh-typert-protocol' import type { Terminal as HeadlessTerminal } from '@xterm/headless' import type { SerializeAddon as Serializer } from '@xterm/addon-serialize' import type { SubprocessTerminalHandle } from '@deepseek-ai/dsh-subprocess' import { createLazyRequire } from '@deepseek-ai/dsh-lazy-require' import { TerminalFollower } from './stream.ts' import { TerminalRetention, type TerminalRetentionPolicy } from './retention.ts' -import type { TerminalAttachmentId, TerminalFrame, TerminalRetentionFrame, WebTerminalInfo } from './types.ts' +import type { TerminalEnvironment, TerminalInput, TerminalFrame, TerminalRetentionFrame, WebTerminalInfo } from './types.ts' const requireHeadless = createLazyRequire('@xterm/headless', import.meta.url) const requireSerialize = createLazyRequire('@xterm/addon-serialize', import.meta.url) @@ -19,9 +20,11 @@ export class BrowserTerminal { private sequence = 0 private operations: Promise = Promise.resolve() private readonly drained: Promise + /** Provider completion precedes output drainage and the published exit state. */ + private exited = false private closing: Promise | undefined private retention: TerminalRetention | undefined - private controller: { id: TerminalAttachmentId; follower: TerminalFollower } | undefined + private controller: TerminalFollower | undefined /** * @param handle - allocated terminal process range. @@ -69,64 +72,91 @@ export class BrowserTerminal { } /** - * Attach with exclusive input control; an older attachment becomes read-only. - * @param id - browser attachment identity. + * Attach with exclusive input control; older attachments keep receiving output. + * @param input - Gateway-decoded commands; returning its iterator must interrupt pending reads. + * @param limits - input byte and PTY dimension limits validated at the Remote boundary. * @param signal - attachment cancellation; never terminates the process. - * @returns a consistent screen followed by ordered output and state changes. + * @param inputWindow - carrier frame capacity; absent for a carrier without an encoded-frame inbox limit. + * @returns a screen baseline, output, per-attachment state and ordered command acknowledgements. */ - async *follow(id: TerminalAttachmentId, signal: AbortSignal): AsyncIterable { + async *attach(input: AsyncIterable, limits: Pick, signal: AbortSignal, inputWindow?: RemoteUplinkWindow): AsyncIterable { signal.throwIfAborted() const follower = new TerminalFollower(this.maxBufferedBytes) const baseline = await this.enqueue(() => { signal.throwIfAborted() - this.controller = { id, follower } - this.info = { ...this.info, controllerId: id } - this.broadcast({ type: 'state', info: this.info }) - const snapshot: TerminalFrame = { type: 'snapshot', sequence: this.sequence, screen: this.serializer.serialize(), info: this.info } + this.controller = follower + this.broadcastState() + const snapshot: TerminalFrame = { type: 'snapshot', sequence: this.sequence, screen: this.serializer.serialize(), info: this.info, writable: this.writable(follower), ...inputWindow === undefined ? {} : { inputWindow } } this.followers.add(follower) return snapshot }) + const uplink = input[Symbol.asyncIterator]() + const commands = this.consumeInput(uplink, follower, limits, signal).catch((error: unknown) => { + follower.fail(error instanceof Error ? error : new Error(String(error))) + }) try { yield baseline yield* follower.read(signal) } finally { this.followers.delete(follower) follower.close() - if (this.controller?.follower === follower) { + if (this.controller === follower) { this.controller = undefined - const { controllerId: _controllerId, ...info } = this.info - this.info = info - this.broadcast({ type: 'state', info }) + this.broadcastState() + } + // Gateway's uplink return wakes pending reads before attachment cleanup joins the input pump. + await uplink.return?.() + await commands + } + } + + private async consumeInput(input: AsyncIterator, follower: TerminalFollower, limits: Pick, signal: AbortSignal): Promise { + let sequence = 0 + while (true) { + const next = await input.next() + if (next.done === true || signal.aborted || !follower.active) return + const command = next.value + if (!Number.isSafeInteger(command.sequence) || command.sequence !== ++sequence) throw new Error('Terminal input sequence has a gap') + try { + await this.enqueue(async () => { + signal.throwIfAborted() + this.requireController(follower) + switch (command.type) { + case 'write': + if (Buffer.byteLength(command.data, 'utf8') > limits.maxInputBytes) throw new Error('Terminal input exceeds the configured limit') + this.retention?.invalidate() + await this.handle.write(command.data) + break + case 'resize': + if (!Number.isSafeInteger(command.cols) || command.cols < 2 || command.cols > limits.maxCols + || !Number.isSafeInteger(command.rows) || command.rows < 1 || command.rows > limits.maxRows) throw new Error('Terminal dimensions exceed the configured limits') + await this.handle.resize(command.cols, command.rows) + this.screen.resize(command.cols, command.rows) + this.info = { ...this.info, cols: command.cols, rows: command.rows } + this.broadcastState() + break + /* v8 ignore next -- the Gateway codec validates this closed union. */ + default: assertNever(command) + } + }) + follower.push({ type: 'ack', sequence }) + } catch (error) { + const failure = remoteErrorOf(error) + if (failure?.code === 'terminal/control-unavailable') { + follower.push({ type: 'ack', sequence, refusal: failure.details.reason }) + } else if (this.exited) { + follower.push({ type: 'ack', sequence, refusal: 'not-running' }) + } else throw error } } } - /** - * Send raw terminal input without command interpretation. - * @param id - current writable attachment. - * @param data - UTF-8 input, including shell completion/control keys. - * @returns when the provider accepts the input. - */ - write(id: TerminalAttachmentId, data: string): Promise { - this.retention?.invalidate() - return this.enqueue(async () => { this.requireController(id); await this.handle.write(data) }) + private writable(follower: TerminalFollower): boolean { + return !this.exited && this.closing === undefined && this.info.state === 'running' && this.controller === follower } - /** - * Resize the PTY and recovery screen in the same operation order as output. - * @param id - current writable attachment. - * @param cols - validated column count. - * @param rows - validated row count. - * @returns when the provider and emulator use the new dimensions. - */ - resize(id: TerminalAttachmentId, cols: number, rows: number): Promise { - return this.enqueue(async () => { - this.requireController(id) - await this.handle.resize(cols, rows) - this.screen.resize(cols, rows) - this.info = { ...this.info, cols, rows } - this.broadcast({ type: 'state', info: this.info }) - }) + private broadcastState(): void { + for (const follower of this.followers) follower.push({ type: 'state', info: this.info, writable: this.writable(follower) }) } /** @@ -135,7 +165,7 @@ export class BrowserTerminal { */ rename(title: string): void { this.info = { ...this.info, title } - this.broadcast({ type: 'state', info: this.info }) + this.broadcastState() } /** @@ -164,9 +194,9 @@ export class BrowserTerminal { return this.closing } - private requireController(id: TerminalAttachmentId): void { - if (this.closing !== undefined || this.info.state !== 'running') throw new RemoteError('terminal/control-unavailable', 'Terminal is not running', { reason: 'not-running' }) - if (this.controller?.id !== id) throw new RemoteError('terminal/control-unavailable', 'Terminal input is controlled by another attachment', { reason: 'read-only' }) + private requireController(follower: TerminalFollower): void { + if (this.exited || this.closing !== undefined || this.info.state !== 'running') throw new RemoteError('terminal/control-unavailable', 'Terminal is not running', { reason: 'not-running' }) + if (this.controller !== follower || !follower.active) throw new RemoteError('terminal/control-unavailable', 'Terminal input is controlled by another attachment', { reason: 'read-only' }) } private broadcast(frame: TerminalFrame): void { @@ -181,7 +211,7 @@ export class BrowserTerminal { private async consume(): Promise { const decoder = new TextDecoder('utf-8', { ignoreBOM: true }) - const outcome = this.handle.done.then(value => ({ value }), (error: unknown) => ({ error })) + const outcome = this.handle.done.then((value) => { this.exited = true; return { value } }, (error: unknown) => ({ error })) try { for await (const chunk of this.handle.output) { // Node Readable's iterator is untyped; this provider explicitly emits Buffer chunks. @@ -195,7 +225,7 @@ export class BrowserTerminal { } catch (error) { this.info = { ...this.info, state: 'failed', error: error instanceof Error ? error.message : String(error) } } - this.broadcast({ type: 'state', info: this.info }) + this.broadcastState() } private async output(data: string): Promise { diff --git a/packages/api/terminal-controller/src/types.ts b/packages/api/terminal-controller/src/types.ts index 8490ea1a7f..7c0eba8ff2 100644 --- a/packages/api/terminal-controller/src/types.ts +++ b/packages/api/terminal-controller/src/types.ts @@ -1,6 +1,6 @@ /** Browser terminal identities, metadata and screen-stream frames. */ import type { Branded } from '@deepseek-ai/dsh-brand' -import type {} from '@deepseek-ai/dsh-typert-protocol' +import type { RemoteUplinkWindow } from '@deepseek-ai/dsh-typert-protocol' declare module '@deepseek-ai/dsh-typert-protocol' { interface RemoteErrorDetailsMap { @@ -15,8 +15,6 @@ declare module '@deepseek-ai/dsh-typert-protocol' { /** A terminal identity scoped to one Session and one Host lifetime. */ export type WebTerminalId = Branded<'WebTerminalId'> -/** An attachment allowed to write and resize one terminal. */ -export type TerminalAttachmentId = Branded<'TerminalAttachmentId'> /** Acknowledges one physical window hold without taking screen or input control. */ export interface TerminalRetentionFrame { @@ -35,6 +33,8 @@ export interface TerminalEnvironment { /** Logged current directory, read without filesystem validation. */ readonly cwd: string readonly maxInputBytes: number + /** Maximum complete encoded Client input bytes retained until acknowledgement. */ + readonly maxBufferedBytes: number readonly maxCols: number readonly maxRows: number readonly scrollback: number @@ -52,7 +52,6 @@ export interface WebTerminalInfo { readonly state: 'running' | 'exited' | 'failed' readonly exitCode: number | null readonly error?: string - readonly controllerId?: TerminalAttachmentId } /** Create is idempotent for an open identity; closed identities cannot be recreated. */ @@ -64,8 +63,17 @@ export interface TerminalCreateRequest { readonly rows: number } -/** Every attachment begins with a complete bounded screen, then ordered output. */ +/** Commands are numbered consecutively from one within each attachment; acknowledgements never authorize replay. */ +export type TerminalInput = + | { readonly type: 'write'; readonly sequence: number; readonly data: string } + | { readonly type: 'resize'; readonly sequence: number; readonly cols: number; readonly rows: number } + +/** + * Every attachment begins with a complete bounded screen, then ordered output. + * inputWindow is absent when the carrier has no encoded-frame inbox limit. + */ export type TerminalFrame = - | { readonly type: 'snapshot'; readonly sequence: number; readonly screen: string; readonly info: WebTerminalInfo } + | { readonly type: 'snapshot'; readonly sequence: number; readonly screen: string; readonly info: WebTerminalInfo; readonly writable: boolean; readonly inputWindow?: RemoteUplinkWindow } | { readonly type: 'output'; readonly sequence: number; readonly data: string } - | { readonly type: 'state'; readonly info: WebTerminalInfo } + | { readonly type: 'state'; readonly info: WebTerminalInfo; readonly writable: boolean } + | { readonly type: 'ack'; readonly sequence: number; readonly refusal?: 'read-only' | 'not-running' } diff --git a/packages/api/terminal-controller/tests/controller.spec.ts b/packages/api/terminal-controller/tests/controller.spec.ts index d9f7ee273f..dd86c0be82 100644 --- a/packages/api/terminal-controller/tests/controller.spec.ts +++ b/packages/api/terminal-controller/tests/controller.spec.ts @@ -11,8 +11,10 @@ import { SubprocessExecutableNotFoundError, type SubprocessRuntime, type Subproc import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' import { afterEach, describe, expect, it, vi } from 'vitest' import { TerminalController, type Config } from '../src/index.ts' +import { inputChannel } from './input.ts' +import type { RemoteInvocation, PeerId } from '@deepseek-ai/dsh-typert-protocol' import { resolveShell } from '../src/shells.ts' -import type { TerminalAttachmentId, WebTerminalId } from '../src/types.ts' +import type { TerminalInput, WebTerminalId } from '../src/types.ts' const roots: Context[] = [] afterEach(async () => { await Promise.all(roots.splice(0).map(ctx => ctx.fiber.dispose())) }) @@ -70,7 +72,7 @@ describe('TerminalController', () => { it('creates the environment default shell and keeps an existing identity when that default changes', async () => { const { controller, agent, subprocess } = fixture({ shell: undefined }) subprocess.terminalEnvironment.mockResolvedValue({ platform: 'posix', defaultShell: '/usr/local/bin/zsh' }) - expect(controller.environment(agent, signal())).toEqual({ cwd: '/workspace', maxCols: 200, maxRows: 100, maxInputBytes: 1000, scrollback: 100 }) + expect(controller.environment(agent, signal())).toEqual({ cwd: '/workspace', maxCols: 200, maxRows: 100, maxInputBytes: 1000, maxBufferedBytes: 100_000, scrollback: 100 }) expect(subprocess.terminalEnvironment).not.toHaveBeenCalled() const created = await controller.create(agent, request, signal()) expect(created.shell.path).toBe('/usr/local/bin/zsh') @@ -87,7 +89,7 @@ describe('TerminalController', () => { subprocess.resolveExecutable.mockRejectedValue(new SubprocessExecutableNotFoundError('default shell was removed')) expect(controller.environment(agent, signal())).toMatchObject({ cwd: '/workspace', maxInputBytes: 1000 }) expect(controller.list(agent.id)).toMatchObject([{ id, state: 'running' }]) - const stream = controller.follow(agent, id, 'restored' as TerminalAttachmentId, signal())[Symbol.asyncIterator]() + const stream = attachController(agent, id, signal())[Symbol.asyncIterator]() try { expect(await stream.next()).toMatchObject({ done: false, value: { type: 'snapshot', info: { id, state: 'running' } } }) expect(subprocess.resolveExecutable).toHaveBeenCalledOnce() @@ -181,9 +183,9 @@ describe('TerminalController', () => { expect(subprocess.spawnTerminal).toHaveBeenCalledOnce() expect(subprocess.spawnTerminal).toHaveBeenCalledWith(expect.objectContaining({ terminalType: 'xterm-256color', cwd: '/workspace' })) expect(controller.list('other' as SessionId)).toEqual([]) - expect(() => controller.follow(owner(ctx, 'other'), id, 'writer' as TerminalAttachmentId, signal())).toThrow('no longer exists') + expect(() => attachController(owner(ctx, 'other'), id, signal())).toThrow('no longer exists') const abort = new AbortController() - const stream = controller.follow(agent, id, 'writer' as TerminalAttachmentId, abort.signal)[Symbol.asyncIterator]() + const stream = attachController(agent, id, abort.signal)[Symbol.asyncIterator]() const firstFrame = await stream.next() if (firstFrame.done === true) throw new Error('Terminal stream ended before its snapshot') expect(firstFrame.value.type).toBe('snapshot') @@ -266,13 +268,13 @@ describe('TerminalController', () => { expect(controller.list(agent.id)).toHaveLength(0) }) - it('rejects invalid dimensions, unavailable shells and oversized input before executing them', async () => { + it('rejects invalid dimensions and unavailable shells before allocating a process', async () => { const { controller, agent, subprocess } = fixture() await expect(controller.create(agent, { ...request, cols: 1 }, signal())).rejects.toThrow('dimensions') subprocess.resolveExecutable.mockRejectedValueOnce(new SubprocessExecutableNotFoundError('shell unavailable')) await expect(controller.create(agent, request, signal())).rejects.toThrow('unavailable') expect(subprocess.spawnTerminal).not.toHaveBeenCalled() - await expect(controller.write(agent, id, 'writer' as TerminalAttachmentId, 'x'.repeat(1001))).rejects.toThrow('input') + }) it('keeps the committed identity on repeated creation and counts pending reservations toward the Session limit', async () => { @@ -319,27 +321,32 @@ describe('TerminalController', () => { expect(controller.list(agent.id)).toEqual([]) }) - it('routes input, resize and trimmed names through the active attachment', async () => { + it('routes ordered input and resize through the current Remote attachment', async () => { const { controller, agent, handle } = fixture({ maxInputBytes: 6 }) await controller.create(agent, request, signal()) - const attachmentId = 'writer' as TerminalAttachmentId - const stream = controller.follow(agent, id, attachmentId, signal())[Symbol.asyncIterator]() + const input = inputChannel() + const stream = attachController(agent, id, signal(), input.source)[Symbol.asyncIterator]() await stream.next() try { - await controller.write(agent, id, attachmentId, '终端') + input.push({ type: 'write', sequence: 1, data: '终端' }) + expect((await stream.next()).value).toMatchObject({ type: 'ack', sequence: 1 }) expect(handle.write).toHaveBeenCalledWith('终端') - await expect(controller.write(agent, id, attachmentId, '终端!')).rejects.toThrow('input exceeds') - await controller.resize(agent, id, attachmentId, 200, 100) + input.push({ type: 'resize', sequence: 2, cols: 200, rows: 100 }) + expect((await stream.next()).value).toMatchObject({ type: 'state', info: { cols: 200, rows: 100 } }) + expect((await stream.next()).value).toMatchObject({ type: 'ack', sequence: 2 }) expect(handle.resize).toHaveBeenCalledWith(200, 100) controller.rename(agent, id, ' server logs ') expect(controller.list(agent.id)).toMatchObject([{ title: 'server logs', cols: 200, rows: 100 }]) + input.push({ type: 'write', sequence: 3, data: '终端!' }) + await stream.next() + await expect(stream.next()).rejects.toThrow('input exceeds') } finally { await stream.return?.() } }) - it.each([{ cols: 1.5, rows: 24 }, { cols: 201, rows: 24 }, { cols: 80, rows: 1.5 }, { cols: 80, rows: 0 }, { cols: 80, rows: 101 }])('rejects dimensions $cols × $rows before allocation or resize', async (dimensions) => { + it.each([{ cols: 1.5, rows: 24 }, { cols: 201, rows: 24 }, { cols: 80, rows: 1.5 }, { cols: 80, rows: 0 }, { cols: 80, rows: 101 }])('rejects dimensions $cols × $rows before allocation', async (dimensions) => { const { controller, agent, subprocess, handle } = fixture() await expect(controller.create(agent, { ...request, ...dimensions }, signal())).rejects.toThrow('dimensions') - await expect(controller.resize(agent, id, 'writer' as TerminalAttachmentId, dimensions.cols, dimensions.rows)).rejects.toThrow('dimensions') + expect(subprocess.spawnTerminal).not.toHaveBeenCalled() expect(handle.resize).not.toHaveBeenCalled() }) @@ -347,7 +354,7 @@ describe('TerminalController', () => { it('rejects invalid wire identities and names without changing retained terminals', async () => { const { controller, agent } = fixture() await expect(controller.create(agent, { ...request, id: '../terminal' as WebTerminalId }, signal())).rejects.toThrow('Invalid terminal identity') - expect(() => controller.follow(agent, id, '' as TerminalAttachmentId, signal())).toThrow('attachment identity') + expect(() => controller.attach(agent, id, signal())).toThrow('Remote invocation') expect(() => { controller.rename(agent, id, ' ') }).toThrow('1–120') expect(() => { controller.rename(agent, id, 'x'.repeat(121)) }).toThrow('1–120') await controller.close(agent, id) @@ -699,12 +706,40 @@ it.each(['allocation', 'committed'] as const)('retains failed %s cleanup, report it('classifies missing and closing terminal identities for localized recovery actions', async () => { const h = fixture() - expect(() => h.controller.follow(h.agent, id, 'view' as TerminalAttachmentId, signal())) + expect(() => attachController(h.agent, id, signal())) .toThrow(expect.objectContaining({ code: 'terminal/unavailable' })) await h.controller.create(h.agent, request, signal()) h.handle.terminate.mockRejectedValueOnce(new Error('cleanup pending')) await expect(h.controller.close(h.agent, id)).rejects.toThrow('cleanup pending') - expect(() => h.controller.follow(h.agent, id, 'view' as TerminalAttachmentId, signal())) + expect(() => attachController(h.agent, id, signal())) .toThrow(expect.objectContaining({ code: 'terminal/unavailable' })) await expect(h.controller.create(h.agent, request, signal())).rejects.toMatchObject({ code: 'terminal/unavailable' }) }) + +it('publishes the carrier input window in the attachment snapshot', async () => { + const { controller, agent } = fixture() + await controller.create(agent, request, signal()) + const abort = new AbortController() + const window = { maxBytes: 512, frameOverheadBytes: 76 } + const frames = attachController(agent, id, abort.signal, inputChannel().source, window)[Symbol.asyncIterator]() + try { + expect(await frames.next()).toMatchObject({ value: { type: 'snapshot', inputWindow: window } }) + } finally { + abort.abort() + await frames.return?.() + } +}) + +function attachController( + agent: Agent, terminalId: WebTerminalId, signal: AbortSignal, + input: AsyncIterable = inputChannel().source, + uplinkWindow?: RemoteInvocation['uplinkWindow'], +) { + const invocation: RemoteInvocation = { + request: { namespace: 'terminal', method: 'attach', args: {} }, service: 'terminalController', signal, + peer: { id: 'test-peer' as PeerId, ctx: agent.ctx, principal: { kind: 'owner' }, signal, dispose: async () => {} }, + uplink: () => input as AsyncIterable, + uplinkWindow, + } + return agent.ctx.extend({ invocation }).terminalController.attach(agent, terminalId, signal) +} diff --git a/packages/api/terminal-controller/tests/input.ts b/packages/api/terminal-controller/tests/input.ts new file mode 100644 index 0000000000..ae7a75c559 --- /dev/null +++ b/packages/api/terminal-controller/tests/input.ts @@ -0,0 +1,16 @@ +/** A typed uplink whose return interrupts a pending read, matching Gateway invocation cleanup. */ +export function inputChannel() { + let controller!: ReadableStreamDefaultController + const stream = new ReadableStream({ start(value) { controller = value } }) + const reader = stream.getReader() + const source: AsyncIterable = { + [Symbol.asyncIterator]: () => ({ + async next() { + const result = await reader.read() + return result.done ? { done: true, value: undefined } : { done: false, value: result.value } + }, + async return() { await reader.cancel(); return { done: true, value: undefined } }, + }), + } + return { source, push: (value: T) => { controller.enqueue(value) }, end: () => { controller.close() } } +} diff --git a/packages/api/terminal-controller/tests/model.client.spec.ts b/packages/api/terminal-controller/tests/model.client.spec.ts index 2c34b9c54b..fbae792001 100644 --- a/packages/api/terminal-controller/tests/model.client.spec.ts +++ b/packages/api/terminal-controller/tests/model.client.spec.ts @@ -1,19 +1,18 @@ /** Browser view ownership across slow RPCs, remounts and transport generations. */ -import { setImmediate } from 'node:timers/promises' import { afterEach, expect, it, vi } from 'vitest' import { RemoteStream, RemoteStreamCarrierError, type ClientRemote, type RemoteStreamOptions } from '@deepseek-ai/dsh-api-gateway/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' -import { RemoteError, type RemoteResult } from '@deepseek-ai/dsh-typert-protocol' -import { streamMethod } from '@deepseek-ai/dsh-remote-mock' +import { RemoteError, type RemoteResult, type RemoteUplinkWindow } from '@deepseek-ai/dsh-typert-protocol' +import { RemoteMock, streamMethod, type StreamHandle } from '@deepseek-ai/dsh-remote-mock' import type {} from '@deepseek-ai/dsh-api-terminal-controller/remote' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { TerminalView, type TerminalRemote } from '../src/client/model.ts' -import type { TerminalEnvironment, TerminalFrame, WebTerminalId, WebTerminalInfo } from '../src/types.ts' +import type { TerminalInput, TerminalEnvironment, TerminalFrame, WebTerminalId, WebTerminalInfo } from '../src/types.ts' const sessionId = 'session' as SessionId const info: WebTerminalInfo = { id: 'terminal' as WebTerminalId, shell: { name: 'bash', path: '/bin/bash', args: ['-i'] }, title: 'bash', cwd: '/workspace', rows: 24, cols: 80, state: 'running', exitCode: null } const environment: TerminalEnvironment = { - cwd: info.cwd, maxInputBytes: 1000, maxCols: 200, maxRows: 100, scrollback: 100, + cwd: info.cwd, maxInputBytes: 1000, maxBufferedBytes: 100_000, maxCols: 200, maxRows: 100, scrollback: 100, } const success = (value: T): RemoteResult => ({ ok: true, value }) const cleanups: (() => void | Promise)[] = [] @@ -30,7 +29,16 @@ function failure(message: string): RemoteResult { return { ok: false, error: new RemoteError('gateway/bad-request', message, {}) } } -function fixture(prepareStream?: (stream: RemoteStream) => void) { +function fixture(prepareStream?: (stream: RemoteStream) => void, inputWindow?: RemoteUplinkWindow) { + const attachments: StreamHandle[] = [] + const sent: TerminalInput[][] = [] + const mock = RemoteMock.create().stream('terminal/attach', async (args, stream) => { + attachments.push(stream) + const commands: TerminalInput[] = [] + sent.push(commands) + stream.push({ type: 'snapshot', sequence: 0, screen: 'ready', info: { ...info, id: args[1] }, writable: true, ...inputWindow === undefined ? {} : { inputWindow } }) + for await (const command of stream.uplink) commands.push(command as TerminalInput) + }) const remote: TerminalRemote = { retain: vi.fn(streamMethod(async function* (_session, _id, signal) { yield { type: 'retained' } @@ -43,11 +51,7 @@ function fixture(prepareStream?: (stream: RemoteStream) => void) { environment: vi.fn(async () => success(environment)), list: vi.fn(async () => success([])), create: vi.fn(async (_sessionId, request) => success({ ...info, id: request.id })), close: vi.fn(async () => success(undefined)), rename: vi.fn(async () => success(undefined)), - write: vi.fn(async () => success(undefined)), resize: vi.fn(async () => success(undefined)), - follow: vi.fn(streamMethod(async function* (_sessionId, id, attachmentId, signal) { - yield { type: 'snapshot', sequence: 0, screen: 'ready', info: { ...info, id, controllerId: attachmentId } } - await untilAborted(signal) - })), + attach: mock.remote.terminal.attach, } const options: RemoteStreamOptions[] = [] const streams: { dispose(): Promise; restart(): void }[] = [] @@ -64,7 +68,7 @@ function fixture(prepareStream?: (stream: RemoteStream) => void) { cleanups.push(async () => { await Promise.all(streams.map(stream => stream.dispose())) }) const model = new TerminalView(sessionId, remote, gateway, info.id) cleanups.push(() => model.dispose()) - return { model, remote, gateway, options, streams, generation } + return { model, remote, gateway, options, streams, generation, attachments, sent } } async function mount(model: TerminalView) { @@ -84,29 +88,11 @@ async function connected(model: TerminalView): Promise { acknowledge(model) } -it('does not let a late input failure downgrade a newer connection', async () => { - const { model, remote } = fixture() - await mount(model) - await expect.poll(() => model.state.getSnapshot().writable).toBe(true) - acknowledge(model) - const input = Promise.withResolvers>() - vi.mocked(remote.write).mockReturnValueOnce(input.promise) - model.write('x') - await expect.poll(() => vi.mocked(remote.write).mock.calls.length).toBe(1) - model.connect() - await expect.poll(() => vi.mocked(remote.follow).mock.calls.length).toBe(2) - await expect.poll(() => model.state.getSnapshot().writable).toBe(true) - model.write('current input') - input.reject(new Error('old attachment was replaced')) - await expect.poll(() => vi.mocked(remote.write).mock.calls.some(call => call[3] === 'current input')).toBe(true) - expect(model.state.getSnapshot()).toMatchObject({ phase: 'connected', writable: true, error: undefined }) -}) - it('waits for render acknowledgement before publishing subsequent output', async () => { const { model, remote } = fixture() - vi.mocked(remote.follow).mockImplementation(streamMethod(async function* (_sessionId, id, controllerId, signal) { + vi.mocked(remote.attach).mockImplementation(streamMethod(async function* (_sessionId, id, signal) { const frames: TerminalFrame[] = [ - { type: 'snapshot', sequence: 0, screen: '', info: { ...info, id, controllerId } }, + { type: 'snapshot', writable: true, sequence: 0, screen: '', info: { ...info, id } }, { type: 'output', sequence: 1, data: 'first' }, { type: 'output', sequence: 2, data: 'second' }, ] @@ -121,77 +107,6 @@ it('waits for render acknowledgement before publishing subsequent output', async await expect.poll(() => model.state.getSnapshot().render?.frame).toMatchObject({ type: 'output', sequence: 2 }) }) -it('serializes input and resizes, clamps geometry, and suppresses unchanged sizes', async () => { - const { model, remote } = fixture() - await connected(model) - const input = Promise.withResolvers>() - vi.mocked(remote.write).mockReturnValueOnce(input.promise) - model.resize(80, 24) - model.write('first') - model.write('second') - model.resize(500, 300) - await expect.poll(() => vi.mocked(remote.write).mock.calls.length).toBe(1) - expect(remote.resize).not.toHaveBeenCalled() - input.resolve(success(undefined)) - await expect.poll(() => vi.mocked(remote.resize).mock.calls.length).toBe(1) - const attachmentId = vi.mocked(remote.follow).mock.calls[0]![2] - const id = model.state.getSnapshot().info!.id - expect(vi.mocked(remote.write).mock.calls.map(call => call[3])).toEqual(['first', 'second']) - expect(remote.resize).toHaveBeenCalledWith(sessionId, id, attachmentId, 200, 100) - model.resize(90, 24) - await expect.poll(() => vi.mocked(remote.resize).mock.calls.length).toBe(2) -}) - -it('bounds queued input by UTF-8 bytes and releases the byte budget after settlement', async () => { - const { model, remote } = fixture() - vi.mocked(remote.environment).mockResolvedValue(success({ ...environment, maxInputBytes: 6 })) - await connected(model) - const input = Promise.withResolvers>() - vi.mocked(remote.write).mockReturnValueOnce(input.promise) - model.write('界界') - await expect.poll(() => vi.mocked(remote.write).mock.calls.length).toBe(1) - model.write('a') - expect(model.state.getSnapshot()).toMatchObject({ phase: 'failed', issue: 'inputFull' }) - input.resolve(success(undefined)) - model.connect() - await expect.poll(() => model.state.getSnapshot().writable).toBe(true) - model.write('界界') - await expect.poll(() => vi.mocked(remote.write).mock.calls.length).toBe(2) - expect(vi.mocked(remote.write).mock.calls.map(call => call[3])).toEqual(['界界', '界界']) -}) - -it('drops queued commands from a detached attachment and ignores its late resize failure', async () => { - const { model, remote } = fixture() - await connected(model) - const input = Promise.withResolvers>() - vi.mocked(remote.resize).mockReturnValueOnce(input.promise) - model.resize(100, 30) - await expect.poll(() => vi.mocked(remote.resize).mock.calls.length).toBe(1) - model.write('stale') - model.resize(110, 35) - model.connect() - await expect.poll(() => model.state.getSnapshot().writable).toBe(true) - model.write('current') - input.resolve(failure('old attachment')) - await expect.poll(() => vi.mocked(remote.write).mock.calls.length).toBe(1) - expect(vi.mocked(remote.write).mock.calls[0]?.[3]).toBe('current') - expect(remote.resize).toHaveBeenCalledOnce() - expect(model.state.getSnapshot().phase).toBe('connected') -}) - -it('publishes current input and resize failures and can reconnect for another attempt', async () => { - const { model, remote } = fixture() - await connected(model) - vi.mocked(remote.write).mockResolvedValueOnce(failure('input refused')) - model.write('x') - await expect.poll(() => model.state.getSnapshot().error).toBe('input refused') - model.connect() - await expect.poll(() => model.state.getSnapshot().writable).toBe(true) - vi.mocked(remote.resize).mockResolvedValueOnce(failure('resize refused')) - model.resize(100, 30) - await expect.poll(() => model.state.getSnapshot().error).toBe('resize refused') -}) - it('treats carrier loss as disconnected only for the active attachment', async () => { const { model, options } = fixture() await connected(model) @@ -206,11 +121,11 @@ it('treats carrier loss as disconnected only for the active attachment', async ( it.each([ ['output before snapshot', [{ type: 'output', sequence: 1, data: 'orphan' }], 'missing its screen snapshot'], - ['sequence gap', [{ type: 'snapshot', sequence: 4, screen: '', info }, { type: 'output', sequence: 6, data: 'gap' }], 'sequence has a gap'], - ['repeated snapshot', [{ type: 'snapshot', sequence: 4, screen: '', info }, { type: 'snapshot', sequence: 4, screen: '', info }], 'Unexpected terminal screen snapshot'], + ['sequence gap', [{ type: 'snapshot', writable: true, sequence: 4, screen: '', info }, { type: 'output', sequence: 6, data: 'gap' }], 'sequence has a gap'], + ['repeated snapshot', [{ type: 'snapshot', writable: true, sequence: 4, screen: '', info }, { type: 'snapshot', writable: true, sequence: 4, screen: '', info }], 'Unexpected terminal screen snapshot'], ] satisfies readonly (readonly [string, readonly TerminalFrame[], string])[])('refuses a malformed output stream: %s', async (_name, frames, message) => { const { model, remote } = fixture() - vi.mocked(remote.follow).mockImplementation(streamMethod(async function* () { yield* frames })) + vi.mocked(remote.attach).mockImplementation(streamMethod(async function* () { yield* frames })) await mount(model) if (frames[0]?.type === 'snapshot') { await expect.poll(() => model.state.getSnapshot().render).toBeDefined() @@ -222,9 +137,9 @@ it.each([ it('retains an exited screen and closes controls when its stream ends', async () => { const { model, remote } = fixture() - vi.mocked(remote.follow).mockImplementation(streamMethod(async function* (_sessionId, id, controllerId) { - yield { type: 'snapshot', sequence: 0, screen: 'last screen', info: { ...info, id, controllerId } } - yield { type: 'state', info: { ...info, id, controllerId, state: 'exited', exitCode: 3 } } + vi.mocked(remote.attach).mockImplementation(streamMethod(async function* (_sessionId, id) { + yield { type: 'snapshot', writable: true, sequence: 0, screen: 'last screen', info: { ...info, id } } + yield { type: 'state', writable: true, info: { ...info, id, state: 'exited', exitCode: 3 } } })) await connected(model) await expect.poll(() => model.state.getSnapshot().phase).toBe('closed') @@ -233,8 +148,8 @@ it('retains an exited screen and closes controls when its stream ends', async () it('reports an attachment ending while the shell is still running and permits manual reconnect', async () => { const { model, remote } = fixture() - vi.mocked(remote.follow).mockImplementationOnce(streamMethod(async function* (_sessionId, id, controllerId) { - yield { type: 'snapshot', sequence: 0, screen: '', info: { ...info, id, controllerId } } + vi.mocked(remote.attach).mockImplementationOnce(streamMethod(async function* (_sessionId, id) { + yield { type: 'snapshot', writable: true, sequence: 0, screen: '', info: { ...info, id } } })) await connected(model) await expect.poll(() => model.state.getSnapshot().issue).toBe('attachmentEnded') @@ -244,14 +159,14 @@ it('reports an attachment ending while the shell is still running and permits ma it('publishes remote control transfer without replacing the retained screen', async () => { const { model, remote } = fixture() - vi.mocked(remote.follow).mockImplementation(streamMethod(async function* (_sessionId, id, controllerId, signal) { - yield { type: 'snapshot', sequence: 0, screen: 'retained', info: { ...info, id, controllerId } } - yield { type: 'state', info: { ...info, id } } + vi.mocked(remote.attach).mockImplementation(streamMethod(async function* (_sessionId, id, signal) { + yield { type: 'snapshot', writable: true, sequence: 0, screen: 'retained', info: { ...info, id } } + yield { type: 'state', writable: false, info: { ...info, id } } await untilAborted(signal) })) await connected(model) await expect.poll(() => model.state.getSnapshot().writable).toBe(false) - expect(model.state.getSnapshot().render?.frame).toMatchObject({ type: 'snapshot', screen: 'retained' }) + expect(model.state.getSnapshot().render?.frame).toMatchObject({ type: 'snapshot', writable: true, screen: 'retained' }) }) /** Pause delivery after the real Gateway iterator settles, before the model observes it. */ @@ -293,13 +208,13 @@ it('ignores an already delivered screen when its attachment is replaced before t barrier.release() await model.rename('settled') expect(model.state.getSnapshot().render).toBe(current.render) - expect(remote.follow).toHaveBeenCalledTimes(2) + expect(remote.attach).toHaveBeenCalledTimes(2) }) it('ignores an already settled stream error after a newer attachment has become writable', async () => { const barrier = deliveryBarrier() const { model, remote } = fixture(barrier.prepare) - vi.mocked(remote.follow).mockImplementationOnce(streamMethod(async function* () { + vi.mocked(remote.attach).mockImplementationOnce(streamMethod(async function* () { yield* [] throw new Error('retired attachment failed') })) @@ -319,7 +234,7 @@ it('releases a pending screen render on carrier generation cancellation and acce const revision = model.state.getSnapshot().render!.revision streams[0]!.restart() await expect.poll(() => model.state.getSnapshot().render?.revision).toBe(revision + 1) - expect(remote.follow).toHaveBeenCalledTimes(2) + expect(remote.attach).toHaveBeenCalledTimes(2) expect(model.state.getSnapshot().writable).toBe(true) }) @@ -331,96 +246,10 @@ it('does not wait for the DOM callback of a screen whose generation was already streams[0]!.restart() barrier.release() await expect.poll(() => model.state.getSnapshot().render?.revision).toBe(2) - expect(remote.follow).toHaveBeenCalledTimes(2) + expect(remote.attach).toHaveBeenCalledTimes(2) expect(model.state.getSnapshot().writable).toBe(true) }) -it.each(['write', 'resize'] as const)('keeps a fresh attachment writable when a pending %s fails after automatic transport recovery', async (operation) => { - const { model, remote, generation } = fixture() - generation.set({ id: 1, host: { home: '/home/fixture' } }) - const disconnected = Promise.withResolvers() - vi.mocked(remote.follow).mockImplementationOnce(streamMethod(async function* (_sessionId, id, controllerId, signal) { - yield { type: 'snapshot', sequence: 0, screen: 'before disconnect', info: { ...info, id, controllerId } } - await Promise.race([disconnected.promise, untilAborted(signal)]) - if (signal?.aborted) return - throw new RemoteStreamCarrierError('connection lost') - })) - await connected(model) - const pending = Promise.withResolvers>() - if (operation === 'write') { - vi.mocked(remote.write).mockReturnValueOnce(pending.promise) - model.write('old input') - } else { - vi.mocked(remote.resize).mockReturnValueOnce(pending.promise) - model.resize(100, 30) - } - await expect.poll(() => vi.mocked(remote[operation]).mock.calls.length).toBe(1) - const oldAttachmentId = vi.mocked(remote.follow).mock.calls[0]![2] - generation.set(undefined) - disconnected.resolve(undefined) - await expect.poll(() => model.state.getSnapshot().phase).toBe('disconnected') - expect(model.state.getSnapshot().writable).toBe(false) - expect(remote.follow).toHaveBeenCalledOnce() - - generation.set({ id: 2, host: { home: '/home/fixture' } }) - await expect.poll(() => model.state.getSnapshot().writable).toBe(true) - expect(remote.follow).toHaveBeenCalledTimes(2) - const attachmentId = vi.mocked(remote.follow).mock.calls[1]![2] - expect(attachmentId).not.toBe(oldAttachmentId) - expect(model.state.getSnapshot().info?.controllerId).toBe(attachmentId) - expect(model.state.getSnapshot().render?.frame).toMatchObject({ type: 'snapshot', screen: 'ready' }) - - model.write('fresh input') - pending.reject(new Error('old attachment was replaced')) - await expect.poll(() => vi.mocked(remote.write).mock.calls.some(call => call[3] === 'fresh input')).toBe(true) - expect(model.state.getSnapshot()).toMatchObject({ phase: 'connected', writable: true, error: undefined }) - expect(vi.mocked(remote.write).mock.calls.at(-1)?.[2]).toBe(attachmentId) - expect(remote.create).toHaveBeenCalledOnce() - expect(remote.close).not.toHaveBeenCalled() -}) - -it('ignores controls before discovery and reconnects an existing process after remount', async () => { - const { model, remote } = fixture() - model.connect() - model.write('early') - model.resize(100, 30) - model.acknowledge(100) - expect(remote.follow).not.toHaveBeenCalled() - expect(remote.write).not.toHaveBeenCalled() - expect(remote.resize).not.toHaveBeenCalled() - const unmount = await mount(model) - await expect.poll(() => model.state.getSnapshot().writable).toBe(true) - unmount() - model.connect() - model.write('detached') - model.resize(100, 30) - expect(remote.follow).toHaveBeenCalledOnce() - expect(remote.write).not.toHaveBeenCalled() - expect(remote.resize).not.toHaveBeenCalled() - model.mount() - await expect.poll(() => remote.follow).toHaveBeenCalledTimes(2) - await expect.poll(() => model.state.getSnapshot().writable).toBe(true) - expect(remote.create).toHaveBeenCalledOnce() -}) - -it('keeps controls inert when process metadata or the attachment is unavailable', async () => { - const { model, remote } = fixture() - await connected(model) - const state = model.state.getSnapshot() - model.state.set({ ...state, info: undefined }) - model.write('missing metadata') - model.resize(100, 30) - expect(remote.write).not.toHaveBeenCalled() - expect(remote.resize).not.toHaveBeenCalled() - model.state.set({ ...state, environment: undefined }) - model.resize(100, 30) - await expect.poll(() => remote.resize).toHaveBeenCalledOnce() - expect(vi.mocked(remote.resize).mock.calls[0]?.slice(-2)).toEqual([100, 30]) - model.write('unknown budget') - expect(model.state.getSnapshot().issue).toBe('inputFull') - expect(remote.write).not.toHaveBeenCalled() -}) - it('renames an existing terminal, skips unchanged names, and exposes rename failures', async () => { const { model, remote } = fixture() await connected(model) @@ -462,7 +291,7 @@ it('retains state when disposal overtakes successful allocation or a failed disc else discovery.reject(new Error('late discovery failure')) await loading expect(model.state.getSnapshot()).toBe(before) - expect(remote.follow).not.toHaveBeenCalled() + expect(remote.attach).not.toHaveBeenCalled() } }) @@ -487,7 +316,7 @@ it('allows retry after failed process cleanup and never reconnects while close i const first = model.close() expect(model.close()).toBe(first) model.connect() - expect(remote.follow).toHaveBeenCalledOnce() + expect(remote.attach).toHaveBeenCalledOnce() closing.resolve(failure('close refused')) await expect(first).rejects.toThrow('close refused') expect(model.state.getSnapshot()).toMatchObject({ phase: 'failed', error: 'close refused' }) @@ -509,55 +338,7 @@ it('does not publish a close result or reconnect after disposal', async () => { model.mount() model.connect() expect(model.state.getSnapshot()).toBe(before) - expect(remote.follow).toHaveBeenCalledOnce() -}) - -it.each(['write', 'resize'] as const)('keeps the output connection when %s loses control before its state frame arrives', async (operation) => { - const { model, remote } = fixture() - const transfer = Promise.withResolvers() - const response = Promise.withResolvers>() - cleanups.push(() => { transfer.resolve(undefined); response.resolve(success(undefined)) }) - vi.mocked(remote.follow).mockImplementation(streamMethod(async function* (_session, id, controllerId, signal) { - yield { type: 'snapshot', sequence: 0, screen: 'retained screen', info: { ...info, id, controllerId } } - await transfer.promise - yield { type: 'state', info: { ...info, id } } - await untilAborted(signal) - })) - await connected(model) - vi.mocked(remote[operation]).mockReturnValueOnce(response.promise) - if (operation === 'write') model.write('before transfer') - else model.resize(100, 30) - model.write('queued before transfer') - await expect.poll(() => vi.mocked(remote[operation]).mock.calls.length).toBe(1) - response.resolve({ ok: false, error: new RemoteError('terminal/control-unavailable', 'Another window owns input', { reason: 'read-only' }) }) - await expect.poll(() => model.state.getSnapshot().writable).toBe(false) - expect(model.state.getSnapshot()).toMatchObject({ phase: 'connected', error: undefined, issue: undefined, render: { frame: { screen: 'retained screen' } } }) - transfer.resolve(undefined) - await expect.poll(() => model.state.getSnapshot().info?.controllerId).toBeUndefined() - expect(remote.follow).toHaveBeenCalledOnce() - expect(vi.mocked(remote.write).mock.calls.some(call => call[3] === 'queued before transfer')).toBe(false) -}) - -it('keeps an exited screen when a pending input is refused after the exit state arrives', async () => { - const { model, remote } = fixture() - const exit = Promise.withResolvers() - const response = Promise.withResolvers>() - cleanups.push(() => { exit.resolve(undefined); response.resolve(success(undefined)) }) - vi.mocked(remote.follow).mockImplementation(streamMethod(async function* (_session, id, controllerId, signal) { - yield { type: 'snapshot', sequence: 0, screen: 'final screen', info: { ...info, id, controllerId } } - await exit.promise - yield { type: 'state', info: { ...info, id, state: 'exited', exitCode: 0 } } - await untilAborted(signal) - })) - await connected(model) - vi.mocked(remote.write).mockReturnValueOnce(response.promise) - model.write('exit race') - await expect.poll(() => vi.mocked(remote.write).mock.calls.length).toBe(1) - exit.resolve(undefined) - await expect.poll(() => model.state.getSnapshot().info?.state).toBe('exited') - response.resolve({ ok: false, error: new RemoteError('terminal/control-unavailable', 'Terminal is not running', { reason: 'not-running' }) }) - await setImmediate() - expect(model.state.getSnapshot()).toMatchObject({ phase: 'connected', writable: false, error: undefined, issue: undefined, render: { frame: { screen: 'final screen' } } }) + expect(remote.attach).toHaveBeenCalledOnce() }) it('exposes a localized quota error and clears it after a successful retry', async () => { @@ -568,3 +349,208 @@ it('exposes a localized quota error and clears it after a successful retry', asy await connected(model) expect(model.state.getSnapshot()).toMatchObject({ phase: 'connected', issue: undefined, error: undefined }) }) + +it('sends input and clamped resize in order without waiting for acknowledgements', async () => { + const h = fixture() + await connected(h.model) + h.model.resize(80, 24) + h.model.write('first') + h.model.write('second') + h.model.resize(500, 300) + await expect.poll(() => h.sent[0]?.length).toBe(3) + expect(h.sent[0]).toEqual([ + { type: 'write', sequence: 1, data: 'first' }, + { type: 'write', sequence: 2, data: 'second' }, + { type: 'resize', sequence: 3, cols: 200, rows: 100 }, + ]) +}) + +it('releases the UTF-8 input budget only on acknowledgement', async () => { + const h = fixture() + vi.mocked(h.remote.environment).mockResolvedValue(success({ ...environment, maxInputBytes: 6 })) + await connected(h.model) + h.model.write('界界') + await expect.poll(() => h.sent[0]?.length).toBe(1) + h.attachments[0]!.push({ type: 'ack', sequence: 1 }) + h.attachments[0]!.push({ type: 'state', info: { ...info, cols: 90 }, writable: true }) + await expect.poll(() => h.model.state.getSnapshot().info?.cols).toBe(90) + h.model.write('界界') + await expect.poll(() => h.sent[0]?.length).toBe(2) + h.model.write('a') + expect(h.model.state.getSnapshot()).toMatchObject({ phase: 'failed', issue: 'inputFull' }) +}) + +it('keeps output attached after an input refusal and stops further input', async () => { + const h = fixture() + await connected(h.model) + h.model.write('before transfer') + h.attachments[0]!.push({ type: 'ack', sequence: 1, refusal: 'read-only' }) + await expect.poll(() => h.model.state.getSnapshot().writable).toBe(false) + h.model.write('refused') + h.model.resize(100, 30) + h.attachments[0]!.push({ type: 'output', sequence: 1, data: 'still following' }) + await expect.poll(() => h.model.state.getSnapshot().render?.frame).toMatchObject({ data: 'still following' }) + expect(h.model.state.getSnapshot()).toMatchObject({ phase: 'connected', error: undefined }) + expect(h.remote.attach).toHaveBeenCalledOnce() + expect(h.sent[0]).toHaveLength(1) +}) + +it('discards the old generation budget and never replays unacknowledged input after reconnect', async () => { + const h = fixture() + h.generation.set({ id: 1, host: { home: '/home/fixture' } }) + await connected(h.model) + h.model.write('unconfirmed') + await expect.poll(() => h.sent[0]?.length).toBe(1) + h.generation.set(undefined) + h.attachments[0]!.fail(new RemoteStreamCarrierError('connection lost')) + await expect.poll(() => h.model.state.getSnapshot().phase).toBe('disconnected') + h.model.write('offline') + h.generation.set({ id: 2, host: { home: '/home/fixture' } }) + await expect.poll(() => h.attachments.length).toBe(2) + await expect.poll(() => h.model.state.getSnapshot().writable).toBe(true) + acknowledge(h.model) + h.model.write('fresh') + await expect.poll(() => h.sent[1]?.length).toBe(1) + expect(h.sent[1]).toEqual([{ type: 'write', sequence: 1, data: 'fresh' }]) + h.attachments[0]!.push({ type: 'ack', sequence: 1, refusal: 'read-only' }) + expect(h.model.state.getSnapshot().writable).toBe(true) + expect(h.remote.create).toHaveBeenCalledOnce() +}) + +it('ignores controls before mount and after detach, then reuses the process on remount', async () => { + const h = fixture() + h.model.write('early') + h.model.resize(100, 30) + const unmount = await mount(h.model) + await expect.poll(() => h.model.state.getSnapshot().writable).toBe(true) + unmount() + h.model.write('detached') + h.model.resize(100, 30) + expect(h.sent[0]).toEqual([]) + h.model.mount() + await expect.poll(() => h.remote.attach).toHaveBeenCalledTimes(2) + expect(h.remote.create).toHaveBeenCalledOnce() +}) + +it('rejects an out-of-order acknowledgement and reports synchronous send failures', async () => { + const h = fixture() + await connected(h.model) + h.model.write('input') + h.attachments[0]!.push({ type: 'ack', sequence: 2 }) + await expect.poll(() => h.model.state.getSnapshot().issue).toBe('invalidOutput') + h.model.connect() + await expect.poll(() => h.model.state.getSnapshot().writable).toBe(true) + const handle = vi.mocked(h.remote.attach).mock.results.at(-1)!.value as ReturnType + vi.spyOn(handle, 'send').mockImplementation(() => { throw new Error('send failed') }) + h.model.write('next') + expect(h.model.state.getSnapshot()).toMatchObject({ phase: 'failed', error: 'send failed' }) +}) + +it.each(['write', 'resize'] as const)('ignores empty input and stale render acknowledgements and refuses %s without limits', async (operation) => { + const h = fixture() + await connected(h.model) + h.model.acknowledge(-1) + h.model.write('') + const state = h.model.state.getSnapshot() + h.model.state.set({ ...state, info: undefined }) + h.model.write('missing') + h.model.resize(100, 30) + expect(h.sent[0]).toEqual([]) + h.model.state.set({ ...state, environment: undefined }) + if (operation === 'write') h.model.write('no budget') + else h.model.resize(100, 30) + expect(h.sent[0]).toEqual([]) + expect(h.model.state.getSnapshot().issue).toBe('inputFull') +}) + +function encodedFrame(command: TerminalInput): number { + return new TextEncoder().encode(JSON.stringify({ type: 'item', streamId: '00000000-0000-0000-0000-000000000000', value: command })).byteLength +} + +function inputWindow(maxBytes: number): RemoteUplinkWindow { + const command: TerminalInput = { type: 'write', sequence: 1, data: 'a' } + return { maxBytes, frameOverheadBytes: encodedFrame(command) - new TextEncoder().encode(JSON.stringify(command)).byteLength } +} + +it('waits for complete-frame capacity and sends queued write and resize commands in order', async () => { + const commands: TerminalInput[] = [ + { type: 'write', sequence: 1, data: '\u0001界' }, + { type: 'resize', sequence: 2, cols: 90, rows: 25 }, + { type: 'write', sequence: 3, data: 'last' }, + ] + const h = fixture(undefined, inputWindow(Math.max(...commands.map(encodedFrame)))) + await connected(h.model) + h.model.write('\u0001界') + h.model.resize(90, 25) + h.model.write('last') + await expect.poll(() => h.sent[0]).toEqual(commands.slice(0, 1)) + h.attachments[0]!.push({ type: 'ack', sequence: 1 }) + await expect.poll(() => h.sent[0]).toEqual(commands.slice(0, 2)) + h.attachments[0]!.push({ type: 'ack', sequence: 2 }) + await expect.poll(() => h.sent[0]).toEqual(commands) + expect(h.model.state.getSnapshot()).toMatchObject({ phase: 'connected', issue: undefined }) +}) + +it('keeps a burst of short commands within the advertised inbox and resumes on acknowledgements', async () => { + const window = inputWindow(262_144) + const h = fixture(undefined, window) + vi.mocked(h.remote.environment).mockResolvedValue(success({ ...environment, maxInputBytes: 65_536, maxBufferedBytes: 2_097_152 })) + await connected(h.model) + for (let index = 0; index < 3000; index++) h.model.write('a') + await expect.poll(() => h.sent[0]?.length).toBe(2250) + const first = h.sent[0]! + expect(first.reduce((bytes, command) => bytes + encodedFrame(command), 0)).toBeLessThanOrEqual(window.maxBytes) + for (let sequence = 1; sequence <= 1000; sequence++) h.attachments[0]!.push({ type: 'ack', sequence }) + await expect.poll(() => h.sent[0]?.length).toBe(3000) + expect(h.sent[0]!.slice(1000).reduce((bytes, command) => bytes + encodedFrame(command), 0)).toBeLessThanOrEqual(window.maxBytes) + expect(h.model.state.getSnapshot()).toMatchObject({ phase: 'connected', issue: undefined }) +}) + +it('counts queued resize frames against the Client buffer and cancels unsent commands on overflow', async () => { + const command: TerminalInput = { type: 'resize', sequence: 1, cols: 90, rows: 25 } + const bytes = encodedFrame(command) + const h = fixture(undefined, inputWindow(bytes)) + vi.mocked(h.remote.environment).mockResolvedValue(success({ ...environment, maxBufferedBytes: 2 * bytes })) + await connected(h.model) + h.model.resize(90, 25) + h.model.resize(91, 25) + await expect.poll(() => h.sent[0]?.length).toBe(1) + h.model.resize(92, 25) + expect(h.model.state.getSnapshot()).toMatchObject({ phase: 'failed', issue: 'inputFull' }) + expect(h.sent[0]).toEqual([command]) +}) + +it('refuses a single escaped input frame larger than the carrier window before sending it', async () => { + const h = fixture(undefined, inputWindow(150)) + await connected(h.model) + h.model.write('\u0001'.repeat(20)) + expect(h.model.state.getSnapshot()).toMatchObject({ phase: 'failed', issue: 'inputFull' }) + expect(h.sent[0]).toEqual([]) +}) + +it('rejects acknowledgements for commands still waiting in the Client queue', async () => { + const h = fixture(undefined, inputWindow(120)) + await connected(h.model) + h.model.write('a') + h.model.write('b') + await expect.poll(() => h.sent[0]?.length).toBe(1) + h.attachments[0]!.push({ type: 'ack', sequence: 2 }) + await expect.poll(() => h.model.state.getSnapshot().issue).toBe('invalidOutput') +}) + +it('drops waiting commands when input is refused and never replays them on a new attachment', async () => { + const h = fixture(undefined, inputWindow(120)) + await connected(h.model) + h.model.write('a') + h.model.write('b') + await expect.poll(() => h.sent[0]?.length).toBe(1) + h.attachments[0]!.push({ type: 'ack', sequence: 1, refusal: 'read-only' }) + await expect.poll(() => h.model.state.getSnapshot().writable).toBe(false) + expect(h.sent[0]).toHaveLength(1) + h.model.connect() + await expect.poll(() => h.attachments.length).toBe(2) + await expect.poll(() => h.model.state.getSnapshot().writable).toBe(true) + acknowledge(h.model) + h.model.write('c') + await expect.poll(() => h.sent[1]).toEqual([{ type: 'write', sequence: 1, data: 'c' }]) +}) diff --git a/packages/api/terminal-controller/tests/recovery.client.spec.ts b/packages/api/terminal-controller/tests/recovery.client.spec.ts index 690dd9bf0d..a3104bd55b 100644 --- a/packages/api/terminal-controller/tests/recovery.client.spec.ts +++ b/packages/api/terminal-controller/tests/recovery.client.spec.ts @@ -16,7 +16,9 @@ import * as TerminalClient from '../src/client/index.ts' const sessionId = 'session' as SessionId const info: WebTerminalInfo = { id: 'terminal' as WebTerminalId, shell: { name: 'zsh', path: '/bin/zsh', args: ['-i'] }, title: 'zsh', cwd: '/workspace', rows: 24, cols: 80, state: 'running', exitCode: null } -const environment: TerminalEnvironment = { cwd: info.cwd, maxInputBytes: 1000, maxCols: 200, maxRows: 100, scrollback: 100 } +const environment: TerminalEnvironment = { + cwd: info.cwd, maxInputBytes: 1000, maxBufferedBytes: 100_000, maxCols: 200, maxRows: 100, scrollback: 100, +} const success = (value: T): RemoteResult => ({ ok: true, value }) const failure = (message: string): RemoteResult => ({ ok: false, error: new RemoteError('gateway/bad-request', message, {}) }) const cleanups: (() => void | Promise)[] = [] @@ -41,9 +43,8 @@ function fixture() { environment: vi.fn(async () => success(environment)), list: vi.fn(async () => success([])), create: vi.fn(async (_session, request) => success({ ...info, id: request.id })), close: vi.fn(async () => success(undefined)), rename: vi.fn(async () => success(undefined)), - write: vi.fn(async () => success(undefined)), resize: vi.fn(async () => success(undefined)), - follow: vi.fn(streamMethod(async function* (_session, id, controllerId, signal) { - yield { type: 'snapshot', sequence: 0, screen: 'retained', info: { ...info, id, controllerId } } + attach: vi.fn(streamMethod(async function* (_session, id, signal) { + yield { type: 'snapshot', writable: true, sequence: 0, screen: 'retained', info: { ...info, id } } await new Promise((resolve) => { if (signal?.aborted) resolve() else signal?.addEventListener('abort', () => { resolve() }, { once: true }) @@ -93,8 +94,8 @@ it('recovers a listed process without creating another, including its title and model.mount() await model.refresh() expect(h.remote.create).not.toHaveBeenCalled() - await expect.poll(() => model.state.getSnapshot().render?.frame).toMatchObject({ type: 'snapshot', screen: 'retained' }) - expect(h.remote.follow).toHaveBeenCalledWith(sessionId, info.id, expect.any(String), expect.any(AbortSignal)) + await expect.poll(() => model.state.getSnapshot().render?.frame).toMatchObject({ type: 'snapshot', writable: true, screen: 'retained' }) + expect(h.remote.attach).toHaveBeenCalledWith(sessionId, info.id, expect.any(AbortSignal)) }) it('does not recreate a recovered terminal that disappeared after the recovery list was shown', async () => { @@ -140,7 +141,7 @@ it('waits for an in-flight creation while close detaches immediately and prevent await starting await closing expect(h.remote.close).toHaveBeenCalledWith(sessionId, info.id) - expect(h.remote.follow).not.toHaveBeenCalled() + expect(h.remote.attach).not.toHaveBeenCalled() }) it('does not allocate after close or disposal overtakes discovery', async () => { @@ -361,7 +362,7 @@ it('restores the same terminal in the same occurrence after reload without openi await restored.refresh() expect(restored.id).toBe(original.id) expect(h.remote.create).toHaveBeenCalledOnce() - await expect.poll(() => restored.state.getSnapshot().render?.frame).toMatchObject({ type: 'snapshot', screen: 'retained' }) + await expect.poll(() => restored.state.getSnapshot().render?.frame).toMatchObject({ type: 'snapshot', writable: true, screen: 'retained' }) }) it('offers retained processes when their saved occurrence is absent from the restored layout', async () => { @@ -490,10 +491,10 @@ it('waits for both active and detached stream finalizers during plugin disposal const finished = [Promise.withResolvers(), Promise.withResolvers()] cleanups.push(() => { for (const barrier of release) barrier.resolve(undefined) }) let index = 0 - vi.mocked(h.remote.follow).mockImplementation(streamMethod(async function* (_session, id, controllerId, signal) { + vi.mocked(h.remote.attach).mockImplementation(streamMethod(async function* (_session, id, signal) { const current = index++ try { - yield { type: 'snapshot', sequence: 0, screen: 'screen', info: { ...info, id, controllerId } } + yield { type: 'snapshot', writable: true, sequence: 0, screen: 'screen', info: { ...info, id } } await new Promise((resolve) => { if (signal?.aborted) resolve() else signal?.addEventListener('abort', () => { resolve() }, { once: true }) @@ -611,7 +612,7 @@ it('retains only open saved occurrences across inactive Sessions and deduplicate expect(h.remote.environment).not.toHaveBeenCalled() expect(h.remote.create).not.toHaveBeenCalled() expect(h.remote.list).not.toHaveBeenCalled() - expect(h.remote.follow).not.toHaveBeenCalled() + expect(h.remote.attach).not.toHaveBeenCalled() const calls = vi.mocked(h.remote.retain).mock.calls service.retainTabs([{ sessionId: otherSession, tabId: 'b', contentId: 'b' }]) await expect.poll(() => calls.find(call => call[0] === sessionId)?.[2]?.aborted).toBe(true) @@ -639,7 +640,7 @@ it('waits for the window hold acknowledgement before restoring an output attachm const model = service.view(sessionId, 'restored', 'restored') model.mount() await model.refresh() - expect(h.remote.follow).not.toHaveBeenCalled() + expect(h.remote.attach).not.toHaveBeenCalled() acknowledge.resolve(undefined) await expect.poll(() => model.state.getSnapshot().writable).toBe(true) expect(h.remote.retain).toHaveBeenCalledOnce() @@ -657,7 +658,7 @@ it('shows a missing terminal when retention loses the race to Host cleanup, with const model = service.view(sessionId, 'restored', 'restored') model.mount() await expect.poll(() => model.state.getSnapshot().issue).toBe('missingTerminal') - expect(h.remote.follow).not.toHaveBeenCalled() + expect(h.remote.attach).not.toHaveBeenCalled() expect(h.remote.create).not.toHaveBeenCalled() }) diff --git a/packages/api/terminal-controller/tests/stream.spec.ts b/packages/api/terminal-controller/tests/stream.spec.ts index a5fd9357ff..8a928378b7 100644 --- a/packages/api/terminal-controller/tests/stream.spec.ts +++ b/packages/api/terminal-controller/tests/stream.spec.ts @@ -78,3 +78,20 @@ describe('TerminalFollower', () => { expect(await follower.read(signal())[Symbol.asyncIterator]().next()).toEqual({ done: true, value: undefined }) }) }) + +it('does not replace an ended attachment with a late input failure', async () => { + const follower = new TerminalFollower(1000) + follower.close() + follower.fail(new Error('late write failed')) + expect(await follower.read(signal())[Symbol.asyncIterator]().next()).toEqual({ done: true, value: undefined }) +}) + +it('drains final frames when an input failure arrives after graceful finish', async () => { + const follower = new TerminalFollower(1000) + follower.push(frame) + follower.finish() + follower.fail(new Error('late write failed')) + const received: TerminalFrame[] = [] + for await (const item of follower.read(signal())) received.push(item) + expect(received).toEqual([frame]) +}) diff --git a/packages/api/terminal-controller/tests/terminal.spec.ts b/packages/api/terminal-controller/tests/terminal.spec.ts index 9735701b81..8881f18774 100644 --- a/packages/api/terminal-controller/tests/terminal.spec.ts +++ b/packages/api/terminal-controller/tests/terminal.spec.ts @@ -3,11 +3,11 @@ import { PassThrough } from 'node:stream' import { afterEach, describe, expect, it, vi } from 'vitest' import type { SubprocessOutcome, SubprocessTerminalHandle } from '@deepseek-ai/dsh-subprocess' import { BrowserTerminal } from '../src/terminal.ts' +import { inputChannel } from './input.ts' import { TerminalFollower } from '../src/stream.ts' -import type { TerminalAttachmentId, TerminalFrame, WebTerminalId, WebTerminalInfo } from '../src/types.ts' +import type { TerminalInput, TerminalFrame, WebTerminalId, WebTerminalInfo } from '../src/types.ts' const info: WebTerminalInfo = { id: 'terminal-test' as WebTerminalId, title: 'bash', shell: { path: '/bin/bash', name: 'bash', args: ['-i'] }, cwd: '/workspace', cols: 80, rows: 24, state: 'running', exitCode: null } -const attachment = (id: string): TerminalAttachmentId => id as TerminalAttachmentId const cleanups: (() => Promise)[] = [] afterEach(async () => { await Promise.all(cleanups.splice(0).map(close => close())) }) @@ -26,12 +26,14 @@ function fixture() { return { terminal, output, outcome, handle } } -async function attach(terminal: BrowserTerminal, id = 'first') { +async function attach(terminal: BrowserTerminal) { const controller = new AbortController() - const iterator = terminal.follow(attachment(id), controller.signal)[Symbol.asyncIterator]() + const input = inputChannel() + const frames = terminal.attach(input.source, { maxInputBytes: 1000, maxCols: 200, maxRows: 100 }, controller.signal) + const iterator = frames[Symbol.asyncIterator]() const baseline = await readFrame(iterator) cleanups.push(async () => { controller.abort(); await iterator.return?.() }) - return { controller, iterator, baseline } + return { controller, iterator, baseline, input } } describe('BrowserTerminal', () => { @@ -46,7 +48,7 @@ describe('BrowserTerminal', () => { expect(handle.terminate).not.toHaveBeenCalled() output.write(Buffer.from('world')) await expect.poll(() => terminal.info.state).toBe('running') - const second = await attach(terminal, 'second') + const second = await attach(terminal) // The baseline and its output queue share the same ordered screen write queue. const frames = [second.baseline] if (second.baseline.type === 'snapshot' && !second.baseline.screen.includes('world')) frames.push(await readFrame(second.iterator)) @@ -55,26 +57,6 @@ describe('BrowserTerminal', () => { expect(handle.terminate).not.toHaveBeenCalled() }) - it('preserves split UTF-8, gives the newest attachment input, and resizes both PTY and recovery screen', async () => { - const { terminal, output, handle } = fixture() - const first = await attach(terminal) - const encoded = Buffer.from('终端') - output.write(encoded.subarray(0, 2)) - await expect.poll(() => output.readableLength).toBe(0) - output.write(encoded.subarray(2)) - expect(await readFrame(first.iterator)).toMatchObject({ type: 'output', data: '终端' }) - const second = await attach(terminal, 'second') - await expect(terminal.write(attachment('first'), 'ignored')).rejects.toMatchObject({ code: 'terminal/control-unavailable', details: { reason: 'read-only' } }) - await terminal.write(attachment('second'), '\t') - expect(handle.write).toHaveBeenCalledWith('\t') - await terminal.resize(attachment('second'), 100, 30) - expect(handle.resize).toHaveBeenCalledWith(100, 30) - expect(terminal.info).toMatchObject({ cols: 100, rows: 30 }) - second.controller.abort() - await second.iterator.return?.() - expect(terminal.info.controllerId).toBeUndefined() - }) - it('keeps exit facts and screen until explicit cleanup and never starts a replacement process', async () => { const { terminal, output, outcome, handle } = fixture() const first = await attach(terminal) @@ -83,7 +65,7 @@ describe('BrowserTerminal', () => { expect(await readFrame(first.iterator)).toMatchObject({ type: 'output', data: 'done' }) expect(await readFrame(first.iterator)).toMatchObject({ type: 'state', info: { state: 'exited', exitCode: 7 } }) expect(handle.terminate).not.toHaveBeenCalled() - const second = await attach(terminal, 'second') + const second = await attach(terminal) expect(second.baseline).toMatchObject({ type: 'snapshot', info: { state: 'exited', exitCode: 7 } }) await terminal.close() expect(handle.terminate).toHaveBeenCalledOnce() @@ -102,72 +84,6 @@ describe('BrowserTerminal', () => { expect((await first.iterator.next()).done).toBe(true) }) - it('rejects input before attachment, during close and after process exit', async () => { - const { terminal, handle, output, outcome } = fixture() - await expect(terminal.write(attachment('first'), 'ignored')).rejects.toMatchObject({ code: 'terminal/control-unavailable', details: { reason: 'read-only' } }) - const first = await attach(terminal) - output.end() - outcome.resolve({ exitCode: 0, signal: null }) - expect(await readFrame(first.iterator)).toMatchObject({ type: 'state', info: { state: 'exited' } }) - await expect(terminal.write(attachment('first'), 'ignored')).rejects.toMatchObject({ code: 'terminal/control-unavailable', details: { reason: 'not-running' } }) - await expect(terminal.resize(attachment('first'), 100, 30)).rejects.toMatchObject({ code: 'terminal/control-unavailable', details: { reason: 'not-running' } }) - const closing = terminal.close() - await expect(terminal.write(attachment('first'), 'ignored')).rejects.toMatchObject({ code: 'terminal/control-unavailable', details: { reason: 'not-running' } }) - await closing - expect(handle.write).not.toHaveBeenCalled() - expect(handle.resize).not.toHaveBeenCalled() - }) - - it('preserves the input controller when an older follower detaches', async () => { - const { terminal, handle } = fixture() - const first = await attach(terminal) - const second = await attach(terminal, 'second') - first.controller.abort() - await first.iterator.return?.() - expect(terminal.info.controllerId).toBe(attachment('second')) - terminal.rename('build output') - expect(await readFrame(second.iterator)).toMatchObject({ type: 'state', info: { title: 'build output', controllerId: 'second' } }) - await terminal.write(attachment('second'), 'pwd\r') - expect(handle.write).toHaveBeenCalledWith('pwd\r') - }) - - it('does not grant input to attachments cancelled before their snapshot is ready', async () => { - const { terminal, handle } = fixture() - const aborted = new AbortController() - aborted.abort(new Error('already detached')) - await expect(terminal.follow(attachment('cancelled'), aborted.signal)[Symbol.asyncIterator]().next()).rejects.toThrow('already detached') - await attach(terminal) - const writing = Promise.withResolvers() - const written = Promise.withResolvers() - handle.write.mockImplementationOnce(async () => { writing.resolve(undefined); await written.promise }) - const pendingWrite = terminal.write(attachment('first'), 'pwd\r') - try { - await writing.promise - const abort = new AbortController() - const pendingAttachment = terminal.follow(attachment('late'), abort.signal)[Symbol.asyncIterator]().next() - const rejected = expect(pendingAttachment).rejects.toThrow('detached while waiting') - abort.abort(new Error('detached while waiting')) - written.resolve(undefined) - await pendingWrite - await rejected - expect(terminal.info.controllerId).toBe(attachment('first')) - } finally { written.resolve(undefined) } - }) - - it('continues accepting operations after a provider write or resize fails', async () => { - const { terminal, handle } = fixture() - await attach(terminal) - handle.write.mockRejectedValueOnce(new Error('input transport failed')) - handle.resize.mockRejectedValueOnce(new Error('resize transport failed')) - await expect(terminal.write(attachment('first'), 'failed')).rejects.toThrow('input transport failed') - await expect(terminal.resize(attachment('first'), 100, 30)).rejects.toThrow('resize transport failed') - expect(terminal.info).toMatchObject({ cols: 80, rows: 24 }) - await terminal.write(attachment('first'), 'accepted') - await terminal.resize(attachment('first'), 90, 25) - expect(handle.write).toHaveBeenLastCalledWith('accepted') - expect(terminal.info).toMatchObject({ cols: 90, rows: 25 }) - }) - it.each([new Error('process wait failed'), 'remote process wait failed'])('publishes a failed process outcome and retains its recovery screen: %s', async (failure) => { const { terminal, output, outcome } = fixture() const first = await attach(terminal) @@ -175,7 +91,7 @@ describe('BrowserTerminal', () => { outcome.reject(failure) expect(await readFrame(first.iterator)).toMatchObject({ type: 'output', data: 'last output' }) expect(await readFrame(first.iterator)).toMatchObject({ type: 'state', info: { state: 'failed', error: failure instanceof Error ? failure.message : failure } }) - const second = await attach(terminal, 'second') + const second = await attach(terminal) expect(second.baseline).toMatchObject({ type: 'snapshot', screen: 'last output', info: { state: 'failed' } }) }) @@ -205,12 +121,13 @@ describe('BrowserTerminal', () => { it('shares concurrent close attempts and permits retry after termination fails', async () => { const { terminal, handle } = fixture() - await attach(terminal) + const view = await attach(terminal) handle.terminate.mockRejectedValueOnce(new Error('process range remains alive')) const first = terminal.close() expect(terminal.close()).toBe(first) await expect(first).rejects.toThrow('remains alive') - await terminal.write(attachment('first'), 'retry cleanup next') + view.input.push({ type: 'write', sequence: 1, data: 'retry cleanup next' }) + expect(await readFrame(view.iterator)).toMatchObject({ type: 'ack', sequence: 1 }) await terminal.close() expect(handle.terminate).toHaveBeenCalledTimes(2) }) @@ -234,3 +151,181 @@ it('refuses retention before allocation commit and disposes an uncommitted scree await terminal.dispose() expect(handle.terminate).toHaveBeenCalledOnce() }) + +it('preserves split UTF-8 and gives input control only to the newest attachment', async () => { + const { terminal, output, handle } = fixture() + const first = await attach(terminal) + const encoded = Buffer.from('终端') + output.write(encoded.subarray(0, 2)) + await expect.poll(() => output.readableLength).toBe(0) + output.write(encoded.subarray(2)) + expect(await readFrame(first.iterator)).toMatchObject({ type: 'output', data: '终端' }) + const second = await attach(terminal) + expect(second.baseline).toMatchObject({ writable: true }) + expect(await readFrame(first.iterator)).toMatchObject({ type: 'state', writable: false }) + first.input.push({ type: 'write', sequence: 1, data: 'refused' }) + expect(await readFrame(first.iterator)).toEqual({ type: 'ack', sequence: 1, refusal: 'read-only' }) + second.input.push({ type: 'write', sequence: 1, data: '\t' }) + expect(await readFrame(second.iterator)).toEqual({ type: 'ack', sequence: 1 }) + expect(handle.write).toHaveBeenCalledExactlyOnceWith('\t') + first.controller.abort() + await first.iterator.return?.() + second.input.push({ type: 'resize', sequence: 2, cols: 100, rows: 30 }) + expect(await readFrame(second.iterator)).toMatchObject({ type: 'state', writable: true, info: { cols: 100, rows: 30 } }) + expect(await readFrame(second.iterator)).toEqual({ type: 'ack', sequence: 2 }) + expect(handle.resize).toHaveBeenCalledWith(100, 30) +}) + +it('acknowledges provider acceptance in order and joins in-flight input on detach', async () => { + const { terminal, handle } = fixture() + const view = await attach(terminal) + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + handle.write.mockImplementationOnce(async () => { entered.resolve(undefined); await release.promise }) + cleanups.push(async () => { release.resolve(undefined) }) + view.input.push({ type: 'write', sequence: 1, data: 'accepted' }) + view.input.push({ type: 'write', sequence: 2, data: 'cancelled' }) + view.input.push({ type: 'resize', sequence: 3, cols: 100, rows: 30 }) + await entered.promise + expect(handle.write).toHaveBeenCalledOnce() + expect(handle.resize).not.toHaveBeenCalled() + view.controller.abort() + let detached = false + const closing = view.iterator.return?.().then(() => { detached = true }) + await Promise.resolve() + expect(detached).toBe(false) + release.resolve(undefined) + await closing + expect(handle.write).toHaveBeenCalledExactlyOnceWith('accepted') + expect(handle.resize).not.toHaveBeenCalled() + expect(handle.terminate).not.toHaveBeenCalled() +}) + +it('half-closes input while continuing to publish output and exit state', async () => { + const { terminal, output, outcome } = fixture() + const view = await attach(terminal) + view.input.push({ type: 'write', sequence: 1, data: 'exit\r' }) + view.input.end() + expect(await readFrame(view.iterator)).toEqual({ type: 'ack', sequence: 1 }) + output.end('last output') + outcome.resolve({ exitCode: 3, signal: null }) + expect(await readFrame(view.iterator)).toMatchObject({ type: 'output', data: 'last output' }) + expect(await readFrame(view.iterator)).toMatchObject({ type: 'state', writable: false, info: { exitCode: 3 } }) +}) + +it.each([ + { type: 'write', sequence: 2, data: 'gap' }, + { type: 'write', sequence: 1, data: '界'.repeat(334) }, + { type: 'resize', sequence: 1, cols: 201, rows: 24 }, + { type: 'resize', sequence: 1, cols: 80, rows: 0 }, + { type: 'resize', sequence: 1, cols: 1.5, rows: 24 }, +] satisfies TerminalInput[])('rejects invalid input before executing it: %j', async (command) => { + const { terminal, handle } = fixture() + const view = await attach(terminal) + view.input.push(command) + await expect(view.iterator.next()).rejects.toThrow() + expect(handle.write).not.toHaveBeenCalled() + expect(handle.resize).not.toHaveBeenCalled() + expect(handle.terminate).not.toHaveBeenCalled() +}) + +it.each(['write', 'resize'] as const)('fails the attachment on a provider %s error and permits fresh attachment recovery', async (operation) => { + const { terminal, handle } = fixture() + const first = await attach(terminal) + handle[operation].mockRejectedValueOnce(new Error('provider failed')) + first.input.push(operation === 'write' ? { type: 'write', sequence: 1, data: 'failed' } : { type: 'resize', sequence: 1, cols: 90, rows: 25 }) + await expect(first.iterator.next()).rejects.toThrow('provider failed') + expect(terminal.info).toMatchObject({ cols: 80, rows: 24 }) + const second = await attach(terminal) + second.input.push({ type: 'write', sequence: 1, data: 'fresh' }) + expect(await readFrame(second.iterator)).toEqual({ type: 'ack', sequence: 1 }) + expect(handle.write).toHaveBeenLastCalledWith('fresh') +}) + +it.each(['write', 'resize'] as const)('preserves final output when an in-flight %s is refused during process exit', async (operation) => { + const { terminal, handle, output, outcome } = fixture() + const view = await attach(terminal) + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + cleanups.push(async () => { release.resolve(undefined) }) + handle[operation].mockImplementationOnce(async () => { entered.resolve(undefined); await release.promise }) + view.input.push(operation === 'write' ? { type: 'write', sequence: 1, data: 'late' } : { type: 'resize', sequence: 1, cols: 90, rows: 25 }) + await entered.promise + outcome.resolve({ exitCode: 7, signal: null }) + output.end('FINAL OUTPUT') + release.reject(new Error('terminal process has exited')) + const frames = [await readFrame(view.iterator), await readFrame(view.iterator), await readFrame(view.iterator)] + expect(frames).toEqual(expect.arrayContaining([ + { type: 'ack', sequence: 1, refusal: 'not-running' }, + expect.objectContaining({ type: 'output', data: 'FINAL OUTPUT' }), + ])) + expect(frames.find(frame => frame.type === 'state')).toMatchObject({ writable: false, info: { state: 'exited', exitCode: 7 } }) +}) + +it('refuses queued input once the process completes while output is still draining', async () => { + const { terminal, handle, output, outcome } = fixture() + const view = await attach(terminal) + outcome.resolve({ exitCode: 0, signal: null }) + await outcome.promise + expect(terminal.info.state).toBe('running') + view.input.push({ type: 'write', sequence: 1, data: 'late' }) + expect(await readFrame(view.iterator)).toEqual({ type: 'ack', sequence: 1, refusal: 'not-running' }) + expect(handle.write).not.toHaveBeenCalled() + output.end('FINAL OUTPUT') + expect(await readFrame(view.iterator)).toMatchObject({ type: 'output', data: 'FINAL OUTPUT' }) + expect(await readFrame(view.iterator)).toMatchObject({ type: 'state', writable: false, info: { state: 'exited' } }) +}) + +it('refuses input after exit without ending the screen attachment', async () => { + const { terminal, output, outcome, handle } = fixture() + const view = await attach(terminal) + output.end() + outcome.resolve({ exitCode: 0, signal: null }) + expect(await readFrame(view.iterator)).toMatchObject({ type: 'state', writable: false }) + view.input.push({ type: 'write', sequence: 1, data: 'late' }) + expect(await readFrame(view.iterator)).toEqual({ type: 'ack', sequence: 1, refusal: 'not-running' }) + terminal.rename('finished') + expect(await readFrame(view.iterator)).toMatchObject({ type: 'state', info: { title: 'finished' } }) + expect(handle.write).not.toHaveBeenCalled() +}) + +it('does not grant input to an attachment cancelled before its screen baseline', async () => { + const { terminal } = fixture() + const input = inputChannel() + const abort = new AbortController() + abort.abort(new Error('already detached')) + await expect(terminal.attach(input.source, { maxInputBytes: 1000, maxCols: 200, maxRows: 100 }, abort.signal)[Symbol.asyncIterator]().next()).rejects.toThrow('already detached') +}) + +it('reports a non-Error provider input rejection through the attachment', async () => { + const { terminal, handle } = fixture() + const view = await attach(terminal) + handle.write.mockRejectedValueOnce('remote input failed') + view.input.push({ type: 'write', sequence: 1, data: 'input' }) + await expect(view.iterator.next()).rejects.toThrow('remote input failed') +}) + +it('preserves the current controller when a queued attachment is cancelled during an input write', async () => { + const { terminal, handle } = fixture() + const first = await attach(terminal) + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + cleanups.push(async () => { release.resolve(undefined) }) + handle.write.mockImplementationOnce(async () => { entered.resolve(undefined); await release.promise }) + first.input.push({ type: 'write', sequence: 1, data: 'first' }) + await entered.promise + const abort = new AbortController() + const input = inputChannel() + const iterator = terminal.attach(input.source, { maxInputBytes: 1000, maxCols: 200, maxRows: 100 }, abort.signal)[Symbol.asyncIterator]() + cleanups.push(async () => { abort.abort(); await iterator.return?.() }) + const failed = expect(iterator.next()).rejects.toThrow('cancelled while queued') + abort.abort(new Error('cancelled while queued')) + release.resolve(undefined) + await failed + expect(await readFrame(first.iterator)).toEqual({ type: 'ack', sequence: 1 }) + terminal.rename('retained control') + expect(await readFrame(first.iterator)).toMatchObject({ type: 'state', writable: true }) + first.input.push({ type: 'write', sequence: 2, data: 'still writable' }) + expect(await readFrame(first.iterator)).toEqual({ type: 'ack', sequence: 2 }) + expect(handle.write).toHaveBeenLastCalledWith('still writable') +}) diff --git a/packages/api/terminal-controller/tsconfig.client.json b/packages/api/terminal-controller/tsconfig.client.json index cdb554988a..055e471ad7 100644 --- a/packages/api/terminal-controller/tsconfig.client.json +++ b/packages/api/terminal-controller/tsconfig.client.json @@ -35,6 +35,9 @@ }, { "path": "../../util/crypto" + }, + { + "path": "../../util/deque" } ] } diff --git a/packages/api/terminal-controller/tsconfig.host.json b/packages/api/terminal-controller/tsconfig.host.json index 68bc1a559e..952e7df4b2 100644 --- a/packages/api/terminal-controller/tsconfig.host.json +++ b/packages/api/terminal-controller/tsconfig.host.json @@ -55,6 +55,9 @@ }, { "path": "../../util/lazy-require" + }, + { + "path": "../../util/values" } ] } diff --git a/packages/api/terminal-controller/tsdown.config.ts b/packages/api/terminal-controller/tsdown.config.ts index cb9d252eb2..0e42a1163e 100644 --- a/packages/api/terminal-controller/tsdown.config.ts +++ b/packages/api/terminal-controller/tsdown.config.ts @@ -1,7 +1,7 @@ import { clientBundle } from '../../client/tsdown.client.ts' export default clientBundle( - '@deepseek-ai/dsh-api-terminal-controller', + import.meta.dirname, ['lib/types/index.js'], { hostPhase: true }, ) diff --git a/packages/api/workspace-controller/README.i18n.yaml b/packages/api/workspace-controller/README.i18n.yaml index d804399b83..e3cb08a719 100644 --- a/packages/api/workspace-controller/README.i18n.yaml +++ b/packages/api/workspace-controller/README.i18n.yaml @@ -15,8 +15,8 @@ en: 4e53164159b5ce41 zh: 89582dc0294d0776 /workspace-controller/use-this-package: - en: aa0d93826ab685fe - zh: 144e48d49020f5a4 + en: 8276c5edb7d0db7d + zh: cda0c91f8c3776e6 /workspace-controller/use-this-package/first-use-workspace: en: 61032e060e7213f0 zh: 437eb982a8738539 diff --git a/packages/api/workspace-controller/README.md b/packages/api/workspace-controller/README.md index b24053384f..27c26c7d9b 100644 --- a/packages/api/workspace-controller/README.md +++ b/packages/api/workspace-controller/README.md @@ -24,6 +24,8 @@ English | [中文](README.zh.md) The Host controller serializes mutations whose correctness depends on current registry state and throws `RemoteError` with a stable error code for expected failures. Its `follow()` stream synchronously attaches to durable Workspace changes, emits one complete baseline first, then emits ordered `upsert`, `remove`, `order`, `archived`, and `pinned` increments. Archive and pin sets are Session id arrays, with the most recently pinned id first in the pin array. A reconnect starts another generation with a replacement baseline, so consumers do not depend on receiving every increment while disconnected. `archiveSession` without `stopActivity` refuses a Session with running work as `workspace/session-active`, whose details list that work by family (`turn`, `subagent`, `job`, `schedule`) with item ids and labels; with `stopActivity: true` the registry's providers stop the work first and the response arrives once the archive set is durable, while the stops settle in the background. +The controller injects `remoteAccess` and registers the `workspace` resource kind with the default field `workspaceId` through `typert.resources`. `workspace/follow` reads no resource, so its call touches the `global` resource: a Peer whose principal is not the owner may follow it only when its principal kind's policy in [`dsh-api-remote-access`](../remote-access/README.md) binds a `global` role listing the action. For that caller, the baseline, `upsert` frames, and `order` ids keep only the Workspaces `ctx.remoteAccess.allows()` admits as `workspace` resources, and only the Session ids it admits as `session` resources, within each Workspace and in the archived and pinned sets, so `workspace` and `session` roles list the same action. The stream tracks the Workspaces it delivered. When one is no longer admitted at its next change or in a new order, the stream sends a `replace` increment carrying the complete filtered state instead of a `remove`, because a removed id is never resurrected on the Client; `replace` is an ordinary increment that the Client applies like an opening baseline, not the start of a new generation, so it neither reports an error nor reconnects; once access returns, the Workspace reappears through an ordinary `upsert`. A `remove` frame reports a deletion and reaches only a caller that received that Workspace. Owner frames are unfiltered. + The Client entry provides `ClientWorkspaceModel` and `createWorkspaceStateStream()`. The model owns Workspace rows, registry order, archived and pinned Session identities, unary mutation echoes, and stream/unary race resolution. A newer Host row wins by `updatedAt`; a committed stream order outranks an older unary response; a removed Workspace id cannot be resurrected by delayed data. Pin snapshots change only when their identities or order change. The package exposes framework-neutral snapshots and subscriptions, leaving navigation policy and React hooks to the UI owner. `WorkspaceController.archiveSession(sessionId, { stopActivity })` throws `WorkspaceArchiveError` with the Host's `rpcError`, so a surface can tell the running-work refusal from a missing Session or a carrier fault and offer to stop the work. diff --git a/packages/api/workspace-controller/README.zh.md b/packages/api/workspace-controller/README.zh.md index dc240950cc..4772d0c9b1 100644 --- a/packages/api/workspace-controller/README.zh.md +++ b/packages/api/workspace-controller/README.zh.md @@ -24,6 +24,8 @@ kind: "package-reference" Host 控制器会串行执行正确性取决于当前注册表状态的变更,并为预期失败抛出带有稳定错误码的 `RemoteError`。它的 `follow()` 流会同步订阅持久 Workspace 变更,先发出一份完整 baseline,再按顺序发出 `upsert`、`remove`、`order`、`archived` 和 `pinned` 增量。归档与置顶集合都是会话 id 数组,置顶数组把最近置顶的 id 放在前面。重连会以替换 baseline 开始新一代,因此消费方不依赖收到断线期间的每个增量。不带 `stopActivity` 的 `archiveSession` 会以 `workspace/session-active` 拒绝仍有工作在跑的会话,其 details 按族(`turn`、`subagent`、`job`、`schedule`)列出这些工作及各项的 id 与名称;带 `stopActivity: true` 时注册表的提供方先停止这些工作,归档集合持久化后即返回响应,停止在后台收敛。 +控制器注入 `remoteAccess`,并经 `typert.resources` 以默认字段 `workspaceId` 登记 `workspace` 资源种类。`workspace/follow` 不读取资源,其调用访问 `global` 资源:principal 不是 owner 的 Peer 只有在其 principal kind 于 [`dsh-api-remote-access`](../remote-access/README.zh.md) 中的 policy 绑定了列出该 action 的 `global` role 时才能订阅它。对这类调用方,baseline、`upsert` 帧与 `order` 中的 id 只保留 `ctx.remoteAccess.allows()` 作为 `workspace` 资源允许的 Workspace,以及它作为 `session` 资源允许的会话 id(各 Workspace 内的会话以及归档与置顶集合),因此 `workspace` role 与 `session` role 也列出同一 action。流记录已下发的 Workspace。某个 Workspace 在下一次变化或新顺序中已不被允许时,流发送一个携带完整过滤后状态的 `replace` 增量帧而不是 `remove`,因为 Client 不会复活已移除的 id;`replace` 是普通增量帧,Client 像开场 baseline 一样整体替换状态,但它不开启新的一代,因此既不报告错误也不重连;访问恢复后,该 Workspace 经普通 `upsert` 重新出现。`remove` 帧表示删除,只发给收到过该 Workspace 的调用方。owner 的帧不过滤。 + Client 入口提供 `ClientWorkspaceModel` 和 `createWorkspaceStateStream()`。该模型拥有 Workspace 行、registry 顺序、归档与置顶会话身份、一元变更回显,以及流与一元调用的竞态处理。较新的 Host 行按 `updatedAt` 获胜;已提交的流顺序优先于较旧的一元响应;已经移除的 Workspace id 不会被延迟数据复活。置顶快照仅在会话身份或顺序变化时更新。该包公开与框架无关的快照和订阅,把导航策略与 React 钩子留给 UI owner。`WorkspaceController.archiveSession(sessionId, { stopActivity })` 抛出携带 Host `rpcError` 的 `WorkspaceArchiveError`,界面因此能区分"仍有工作在跑"的拒绝与会话缺失或载体故障,并提议停止这些工作。 diff --git a/packages/api/workspace-controller/package.json b/packages/api/workspace-controller/package.json index ac9d45a3e1..a6105a5c1c 100644 --- a/packages/api/workspace-controller/package.json +++ b/packages/api/workspace-controller/package.json @@ -89,6 +89,7 @@ "devDependencies": { "@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/dsh-api-gateway": "workspace:*", + "@deepseek-ai/dsh-api-remote-access": "workspace:*", "@deepseek-ai/dsh-client-connection": "workspace:*", "@deepseek-ai/dsh-client-store": "workspace:*", "@deepseek-ai/dsh-client-test-runtime": "workspace:*", diff --git a/packages/api/workspace-controller/src/client/index.ts b/packages/api/workspace-controller/src/client/index.ts index c3182102a7..3bbf4670a0 100644 --- a/packages/api/workspace-controller/src/client/index.ts +++ b/packages/api/workspace-controller/src/client/index.ts @@ -98,6 +98,9 @@ export function createWorkspaceStateStream( function acceptIncrement(accept: WorkspaceFollowSink, frame: WorkspaceFollowIncrement): void { switch (frame.type) { + case 'replace': + accept.replaceBaseline(frame.value) + return case 'upsert': accept.upsertView(frame.workspace) return diff --git a/packages/api/workspace-controller/src/feed.ts b/packages/api/workspace-controller/src/feed.ts index 709f17fa5d..be1cbdb21c 100644 --- a/packages/api/workspace-controller/src/feed.ts +++ b/packages/api/workspace-controller/src/feed.ts @@ -15,6 +15,9 @@ import type { WorkspaceView, } from './types.ts' +/** The frames the feed produces: every follow frame except `replace`, which only access filtering sends. */ +type WorkspaceFeedFrame = Exclude + /** * Project one authoritative Workspace entity into its Remote value. * @param workspace - authoritative registry entity. @@ -82,7 +85,7 @@ export class WorkspaceFeed { * @param signal - generation cancellation. * @returns baseline followed by ordered Workspace increments. */ - async *follow(signal: AbortSignal): AsyncIterable { + async *follow(signal: AbortSignal): AsyncIterable { signal.throwIfAborted() const follower = new WorkspaceFollower() this.followers.add(follower) @@ -138,7 +141,7 @@ export class WorkspaceFeed { }) } - private publish(frame: Exclude): void { + private publish(frame: Exclude): void { for (const follower of this.followers) follower.push(frame) } } @@ -148,11 +151,11 @@ function sameStrings(left: readonly string[], right: readonly string[]): boolean } class WorkspaceFollower { - private readonly frames = new Deque() + private readonly frames = new Deque() private waiting: (() => void) | undefined private closed = false - push(frame: WorkspaceFollowFrame): void { + push(frame: WorkspaceFeedFrame): void { /* v8 ignore next -- closed followers are removed before later publication can reach them. */ if (this.closed) return this.frames.pushBack(frame) @@ -165,7 +168,7 @@ class WorkspaceFollower { this.waiting?.() } - async *read(signal: AbortSignal): AsyncIterable { + async *read(signal: AbortSignal): AsyncIterable { while (!this.closed && !signal.aborted) { const frame = this.frames.popFront() if (frame !== undefined) { diff --git a/packages/api/workspace-controller/src/index.ts b/packages/api/workspace-controller/src/index.ts index 0b23517921..f275eb3d12 100644 --- a/packages/api/workspace-controller/src/index.ts +++ b/packages/api/workspace-controller/src/index.ts @@ -3,6 +3,8 @@ import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' +import type {} from '@deepseek-ai/dsh-api-remote-access' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import { WorkspaceCommands } from './commands.ts' import { DirectoryPickerController } from './directory-picker.ts' import { WorkspaceFeed, workspaceView } from './feed.ts' @@ -10,11 +12,13 @@ import { defaultWorkspaceDirectory, validateDocumentsDirectory } from './default import type { WorkspaceArchiveSessionRequest, WorkspaceArchiveValue, + WorkspaceBaseline, WorkspaceCreateRequest, WorkspaceCreateValue, WorkspaceDeleteRequest, WorkspaceDeleteValue, WorkspaceFollowFrame, + WorkspaceId, WorkspaceInsertBeforeRequest, WorkspaceInsertSessionBeforeRequest, WorkspaceOrderValue, @@ -49,7 +53,7 @@ declare module '@deepseek-ai/cordis' { /** Host service backing the generated `ctx.remote.workspace` namespace. */ export class WorkspaceController extends TypertRemoteService { - static inject = ['typert', 'workspaceRegistry'] + static inject = ['remoteAccess', 'typert', 'workspaceRegistry'] static Config: z = z.object({ documentsDirectory: z.string(), @@ -66,6 +70,7 @@ export class WorkspaceController extends TypertRemoteService { */ constructor(ctx: Context, config: Config = {}) { super(ctx, 'workspaceController', { namespace: 'workspace' }) + ctx.typert.resources.register('workspace', { field: 'workspaceId' }) this.config = WorkspaceController.Config(config) if (this.config.documentsDirectory !== undefined) validateDocumentsDirectory(this.config.documentsDirectory) this.commands = new WorkspaceCommands(ctx) @@ -187,14 +192,77 @@ export class WorkspaceController extends TypertRemoteService { } /** - * Stream a complete Workspace baseline followed by ordered increments. + * Stream a complete Workspace baseline followed by ordered increments. A + * non-owner receives only the Workspaces and Sessions `allows()` admits. + * When a delivered Workspace is no longer admitted at its next change or + * the next order change, the stream sends a `replace` frame with the + * complete filtered state instead of a `remove`, because a Client never + * resurrects a removed Workspace; a `remove` frame reports a deletion and + * reaches only a Client that received that Workspace. * @param signal - generation cancellation. * @returns baseline followed by ordered Workspace increments. */ @Remote({ mode: 'stream' }) - follow(signal: AbortSignal): AsyncIterable { - return this.feed.follow(signal) + async *follow(signal: AbortSignal): AsyncIterable { + const access = this.ctx.remoteAccess + const visible = (workspaceId: WorkspaceId): boolean => access.allows({ kind: 'workspace', id: workspaceId }) + const sessions = (sessionIds: readonly SessionId[]): SessionId[] => + sessionIds.filter(sessionId => access.allows({ kind: 'session', id: sessionId })) + /** Workspaces this generation has delivered and not yet removed. */ + const delivered = new Set() + /** The admitted part of `value`; it becomes the delivered set. */ + const filtered = (value: WorkspaceBaseline): WorkspaceBaseline => { + const items = value.items.filter(item => visible(item.workspaceId)) + delivered.clear() + for (const item of items) delivered.add(item.workspaceId) + return { + items: items.map(item => ({ ...item, sessionIds: sessions(item.sessionIds) })), + archivedSessionIds: sessions(value.archivedSessionIds), + pinnedSessionIds: sessions(value.pinnedSessionIds), + } + } + for await (const frame of this.feed.follow(signal)) { + switch (frame.type) { + case 'baseline': + yield { type: 'baseline', value: filtered(frame.value) } + break + case 'upsert': { + const workspaceId = frame.workspace.workspaceId + if (visible(workspaceId)) { + delivered.add(workspaceId) + yield { type: 'upsert', workspace: { ...frame.workspace, sessionIds: sessions(frame.workspace.sessionIds) } } + } else if (delivered.has(workspaceId)) { + yield { type: 'replace', value: filtered(this.feed.baseline()) } + } + break + } + case 'remove': + if (delivered.delete(frame.workspaceId)) yield frame + break + case 'order': + if (frame.workspaceIds.some(workspaceId => delivered.has(workspaceId) && !visible(workspaceId))) { + yield { type: 'replace', value: filtered(this.feed.baseline()) } + } else { + yield { type: 'order', workspaceIds: frame.workspaceIds.filter(visible) } + } + break + case 'archived': + yield { type: 'archived', archivedSessionIds: sessions(frame.archivedSessionIds) } + break + case 'pinned': + yield { type: 'pinned', pinnedSessionIds: sessions(frame.pinnedSessionIds) } + break + /* v8 ignore next -- closed follow-frame union. */ + default: + assertNever(frame) + } + } } } +/* v8 ignore next 3 -- closed-union backstop. */ +function assertNever(value: never): never { + throw new Error(`unreachable Workspace follow frame: ${JSON.stringify(value)}`) +} + export default WorkspaceController diff --git a/packages/api/workspace-controller/src/types.ts b/packages/api/workspace-controller/src/types.ts index d5b98746be..cdf9adf073 100644 --- a/packages/api/workspace-controller/src/types.ts +++ b/packages/api/workspace-controller/src/types.ts @@ -157,8 +157,13 @@ export interface WorkspaceBaseline { readonly pinnedSessionIds: readonly SessionId[] } -/** One ordered Workspace change after a generation's baseline. */ +/** + * One ordered Workspace change after a generation's baseline. `replace` + * carries the complete state that supersedes everything delivered before it + * in the generation; it is a change, not a new generation's opening frame. + */ export type WorkspaceFollowIncrement = + | { readonly type: 'replace'; readonly value: WorkspaceBaseline } | { readonly type: 'upsert'; readonly workspace: WorkspaceView } | { readonly type: 'remove'; readonly workspaceId: WorkspaceId } | { readonly type: 'order'; readonly workspaceIds: readonly WorkspaceId[] } diff --git a/packages/api/workspace-controller/tests/transport.client.spec.ts b/packages/api/workspace-controller/tests/transport.client.spec.ts index b74ddfb394..b1d6ca2a9b 100644 --- a/packages/api/workspace-controller/tests/transport.client.spec.ts +++ b/packages/api/workspace-controller/tests/transport.client.spec.ts @@ -183,6 +183,38 @@ describe('Workspace state stream', () => { expect(streamStates(mock)).toEqual(['cancelled']) }) + it('applies a replace increment in place, so a Workspace hidden and shown again never errors or reconnects', async ({ mock, start }) => { + const { remote } = await gatewayClient(mock, start) + mock.stream(FOLLOW, openStream([baseline('one', 'two')])) + const model = new ClientWorkspaceModel(remote.workspace) + const carrierFailed = vi.fn(() => { model.handleCarrierFailure() }) + const failed = vi.fn((error: unknown) => { model.handleStreamFailure(error) }) + const stream = createWorkspaceStateStream(remote, { accept: model, carrierFailed, failed }) + const states = new Set() + model.subscribe(() => { states.add(model.getSnapshot().state) }) + const ids = (): string[] => model.getSnapshot().items.map(item => item.workspaceId).sort() + const replace = (...workspaceIds: string[]): WorkspaceFollowFrame => ({ type: 'replace', value: baseline(...workspaceIds).value }) + + stream.start() + await vi.waitFor(() => { expect(ids()).toEqual(['one', 'two']) }) + // Access to `two` is revoked, restored through a replace, revoked again, and restored through an upsert. + mock.streams.push(FOLLOW, replace('one')) + await vi.waitFor(() => { expect(ids()).toEqual(['one']) }) + mock.streams.push(FOLLOW, replace('one', 'two')) + await vi.waitFor(() => { expect(ids()).toEqual(['one', 'two']) }) + mock.streams.push(FOLLOW, replace('one')) + await vi.waitFor(() => { expect(ids()).toEqual(['one']) }) + mock.streams.push(FOLLOW, { type: 'upsert', workspace: workspace('two') }) + await vi.waitFor(() => { expect(ids()).toEqual(['one', 'two']) }) + + expect(states).not.toContain('error') + expect(model.getSnapshot()).toMatchObject({ phase: 'ready', state: 'idle', error: null }) + expect(carrierFailed).not.toHaveBeenCalled() + expect(failed).not.toHaveBeenCalled() + expect(streamStates(mock)).toEqual(['open']) + await stream.dispose() + }) + it('retains the old state across carrier loss and applies the replacement baseline', async ({ mock, start }) => { const { remote } = await gatewayClient(mock, start) const carrier = new RemoteStreamCarrierError('socket lost') diff --git a/packages/api/workspace-controller/tests/workspace-controller.host.spec.ts b/packages/api/workspace-controller/tests/workspace-controller.host.spec.ts index 35158490f2..f284f35155 100644 --- a/packages/api/workspace-controller/tests/workspace-controller.host.spec.ts +++ b/packages/api/workspace-controller/tests/workspace-controller.host.spec.ts @@ -6,9 +6,10 @@ import { Context } from '@deepseek-ai/cordis' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import Storage from '@deepseek-ai/dsh-storage' import { DomainFacility } from '@deepseek-ai/dsh-storage-domain' -import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' +import { RemoteError, type PeerId, type Principal, type RemoteInvocation } from '@deepseek-ai/dsh-typert-protocol' import WorkspaceRegistry from '@deepseek-ai/dsh-workspace' import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' +import RemoteAccessService, { type RemoteAccessCondition, type RemoteAccessRole } from '@deepseek-ai/dsh-api-remote-access' import WorkspaceController from '../src/index.ts' import { DEFAULT_WORKSPACE_DIRECTORY } from '../src/default-workspace.ts' import { WorkspaceFeed } from '../src/feed.ts' @@ -67,11 +68,39 @@ async function harness(options: { systemDocuments?: boolean } = {}) { ctx.provide('typert', { lookups: { configure: () => dispose }, contexts: { configureHost: () => dispose }, + resources: { register: vi.fn(() => dispose) }, } as never) + new RemoteAccessService(ctx) const controller = new WorkspaceController(ctx, options.systemDocuments === true ? {} : { documentsDirectory: root }) return { controller, ctx, root, storageDomain } } +declare module '@deepseek-ai/dsh-typert-protocol' { + interface PrincipalMap { + /** Test-only non-owner caller shared by the access specs. */ + 'test-guest': { readonly kind: 'test-guest'; readonly guest: string } + } +} + +/** Opens the Workspace follow stream; each Workspace and Session in it still needs its own role. */ +const FOLLOWER: RemoteAccessRole<'global'> = { name: 'test:workspace-follower', resource: 'global', actions: ['workspace/follow'] } +/** Shows one Workspace in the follow stream. */ +const WORKSPACE_VIEWER: RemoteAccessRole<'workspace'> = { name: 'test:workspace-viewer', resource: 'workspace', actions: ['workspace/follow'] } +/** Shows one Session id in the follow stream. */ +const SESSION_VIEWER: RemoteAccessRole<'session'> = { name: 'test:session-viewer', resource: 'session', actions: ['workspace/follow'] } + +/** The Context a Gateway dispatch of `workspace/follow` binds for `principal`. */ +function followerContext(ctx: Context, principal: Principal): Context { + const invocation: RemoteInvocation = { + request: { namespace: 'workspace', method: 'follow', args: {} }, + service: 'workspaceController', + peer: { id: 'workspace-access-peer' as PeerId, ctx, principal, signal: new AbortController().signal, dispose: async () => {} }, + signal: new AbortController().signal, + uplink: (): AsyncIterable => ({ [Symbol.asyncIterator]: () => ({ next: async () => ({ done: true, value: undefined }) }) }), + } + return ctx.extend({ invocation }) +} + function stageDir(root: string, name: string): string { const path = join(root, name) mkdirSync(path, { recursive: true }) @@ -416,6 +445,110 @@ describe('WorkspaceController follow', () => { await expect(iterator.next()).resolves.toEqual({ done: true, value: undefined }) }) + it('follows only the Workspaces and Sessions a non-owner may follow', async () => { + const { controller, ctx, root } = await harness() + // oxlint-disable-next-line typescript/unbound-method -- the assertion inspects the spy and never calls it. + expect(ctx.typert.resources.register).toHaveBeenCalledWith('workspace', { field: 'workspaceId' }) + const shown = await controller.create({ path: stageDir(root, 'shown') }) + const hiddenWorkspace = await controller.create({ path: stageDir(root, 'hidden') }) + const shownId = shown.workspace.workspaceId + const hiddenId = hiddenWorkspace.workspace.workspaceId + const shared = ctx.sessions.create(SessionId('shared'), { meta: { cwd: shown.workspace.path } }) + const secret = ctx.sessions.create(SessionId('secret'), { meta: { cwd: shown.workspace.path } }) + const workspace = ctx.workspaceRegistry.get(shownId) + if (workspace === undefined) throw new Error('fixture Workspace disappeared') + await workspace.attachSession(shared.id) + await workspace.attachSession(secret.id) + await controller.pinSession({ sessionId: shared.id }) + await controller.pinSession({ sessionId: secret.id }) + ctx.remoteAccess.registerPolicy('test-guest', { + bindings: () => [ + { role: FOLLOWER, resource: { kind: 'global' } }, + { role: WORKSPACE_VIEWER, resource: { kind: 'workspace', id: shownId } }, + { role: SESSION_VIEWER, resource: { kind: 'session', id: shared.id } }, + ], + }) + const guest: Principal = { kind: 'test-guest', guest: 'workspace-controller' } + const abort = new AbortController() + const iterator = followerContext(ctx, guest).workspaceController.follow(abort.signal)[Symbol.asyncIterator]() + await expect(nextFrame(iterator)).resolves.toEqual({ + type: 'baseline', + value: { + items: [expect.objectContaining({ workspaceId: shownId, sessionIds: [shared.id] })], + archivedSessionIds: [], + pinnedSessionIds: [shared.id], + }, + }) + + await controller.rename({ workspaceId: hiddenId, title: 'still hidden' }) + await controller.rename({ workspaceId: shownId, title: 'renamed' }) + await expect(nextFrame(iterator)).resolves.toMatchObject({ + type: 'upsert', workspace: { workspaceId: shownId, title: 'renamed', sessionIds: [shared.id] }, + }) + await controller.unpinSession({ sessionId: secret.id }) + await expect(nextFrame(iterator)).resolves.toEqual({ type: 'pinned', pinnedSessionIds: [shared.id] }) + await controller.unpinSession({ sessionId: shared.id }) + await controller.archiveSession({ sessionId: secret.id }) + await controller.archiveSession({ sessionId: shared.id }) + await expect(nextFrame(iterator)).resolves.toEqual({ type: 'pinned', pinnedSessionIds: [] }) + await expect(nextFrame(iterator)).resolves.toEqual({ type: 'archived', archivedSessionIds: [] }) + await expect(nextFrame(iterator)).resolves.toEqual({ type: 'archived', archivedSessionIds: [shared.id] }) + await controller.delete({ workspaceId: hiddenId }) + await expect(nextFrame(iterator)).resolves.toEqual({ type: 'order', workspaceIds: [shownId] }) + await controller.delete({ workspaceId: shownId }) + await expect(nextFrame(iterator)).resolves.toEqual({ type: 'order', workspaceIds: [] }) + await expect(nextFrame(iterator)).resolves.toEqual({ type: 'remove', workspaceId: shownId }) + + abort.abort() + await expect(iterator.next()).resolves.toEqual({ done: true, value: undefined }) + }) + + it('sends a non-owner a replace increment when a delivered Workspace loses visibility, so it can reappear', async () => { + const { controller, ctx, root } = await harness() + const first = (await controller.create({ path: stageDir(root, 'first') })).workspace.workspaceId + const second = (await controller.create({ path: stageDir(root, 'second') })).workspace.workspaceId + // One Remote call reads its bindings once, so visibility changes during the stream come from the condition. + const allowedWorkspaces = new Set([first, second]) + const stillShared: RemoteAccessCondition<'workspace'> = ({ resource }) => + allowedWorkspaces.has(resource.id) ? { kind: 'allow' } : { kind: 'deny', reason: 'test:unshared' } + ctx.remoteAccess.registerPolicy('test-guest', { + bindings: () => [ + { role: FOLLOWER, resource: { kind: 'global' } }, + ...[first, second].map(id => ({ role: WORKSPACE_VIEWER, resource: { kind: 'workspace', id } as const, condition: stillShared })), + ], + }) + const abort = new AbortController() + const iterator = followerContext(ctx, { kind: 'test-guest', guest: 'withdrawn' }).workspaceController.follow(abort.signal)[Symbol.asyncIterator]() + const ids = (frame: WorkspaceFollowFrame): string[] => + frame.type === 'baseline' || frame.type === 'replace' ? frame.value.items.map(item => item.workspaceId).sort() : [] + const opening = await nextFrame(iterator) + expect(opening.type).toBe('baseline') + expect(ids(opening)).toEqual([first, second].sort()) + + // Revoked: the next change replaces the state instead of sending a `remove` the Client would never undo. + allowedWorkspaces.delete(first) + await controller.rename({ workspaceId: first, title: 'revoked' }) + const withoutFirst = await nextFrame(iterator) + expect(withoutFirst.type).toBe('replace') + expect(ids(withoutFirst)).toEqual([second]) + allowedWorkspaces.delete(second) + const third = (await controller.create({ path: stageDir(root, 'third') })).workspace.workspaceId + const empty = await nextFrame(iterator) + expect(empty.type).toBe('replace') + expect(ids(empty)).toEqual([]) + // A deletion reaches only a Client that received the Workspace. + await controller.delete({ workspaceId: third }) + await expect(nextFrame(iterator)).resolves.toEqual({ type: 'order', workspaceIds: [] }) + + // Restored: the Workspace reappears through an ordinary upsert. + allowedWorkspaces.add(first) + await controller.rename({ workspaceId: first, title: 'restored' }) + await expect(nextFrame(iterator)).resolves.toMatchObject({ type: 'upsert', workspace: { workspaceId: first, title: 'restored' } }) + + abort.abort() + await expect(iterator.next()).resolves.toEqual({ done: true, value: undefined }) + }) + it('ignores unrelated domain writes and closes active followers on disposal', async () => { const { controller, ctx, root } = await harness() const abort = new AbortController() diff --git a/packages/api/workspace-controller/tsconfig.host.json b/packages/api/workspace-controller/tsconfig.host.json index ff1ca31080..e9d5104e98 100644 --- a/packages/api/workspace-controller/tsconfig.host.json +++ b/packages/api/workspace-controller/tsconfig.host.json @@ -21,6 +21,7 @@ { "path": "../../host/directory-picker" }, { "path": "../../storage/storage-domain" }, { "path": "../../typert/protocol" }, + { "path": "../remote-access/tsconfig.host.json" }, { "path": "../../util/deque" }, { "path": "../../util/native-command" }, { "path": "../../workspace/workspace" } diff --git a/packages/api/workspace-controller/tsdown.config.ts b/packages/api/workspace-controller/tsdown.config.ts index a79662aebc..0e42a1163e 100644 --- a/packages/api/workspace-controller/tsdown.config.ts +++ b/packages/api/workspace-controller/tsdown.config.ts @@ -1,7 +1,7 @@ import { clientBundle } from '../../client/tsdown.client.ts' export default clientBundle( - '@deepseek-ai/dsh-api-workspace-controller', + import.meta.dirname, ['lib/types/index.js'], { hostPhase: true }, ) diff --git a/packages/api/workspace-files/package.json b/packages/api/workspace-files/package.json index f77a965ad0..5aebc36735 100644 --- a/packages/api/workspace-files/package.json +++ b/packages/api/workspace-files/package.json @@ -64,6 +64,7 @@ "devDependencies": { "@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/dsh-api-gateway": "workspace:*", + "@deepseek-ai/dsh-api-remote-access": "workspace:*", "@deepseek-ai/dsh-client-connection": "workspace:*", "@deepseek-ai/dsh-client-resources": "workspace:*", "@deepseek-ai/dsh-client-ui-slots": "workspace:*", diff --git a/packages/api/workspace-files/src/index.ts b/packages/api/workspace-files/src/index.ts index 1c1c652f1e..d6ecbc68c1 100644 --- a/packages/api/workspace-files/src/index.ts +++ b/packages/api/workspace-files/src/index.ts @@ -203,6 +203,7 @@ export class WorkspaceFiles extends TypertRemoteService { scope.typert.lookups.register('workspaceFileScope', { parameter: 'workspaceFileScope', wire: 'workspaceFileScopeId', + resource: 'session', hostTypeSymbol: '@deepseek-ai/dsh-api-workspace-files#WorkspaceFileScope', wireTypeSymbol: '@deepseek-ai/dsh-session/types#SessionId', resolve: async (sessionId) => { diff --git a/packages/api/workspace-files/tests/binary-rpc.spec.ts b/packages/api/workspace-files/tests/binary-rpc.spec.ts index 35058d58a1..dc42bfbe02 100644 --- a/packages/api/workspace-files/tests/binary-rpc.spec.ts +++ b/packages/api/workspace-files/tests/binary-rpc.spec.ts @@ -5,6 +5,7 @@ import { beforeEach, afterEach, expect, it, vi } from 'vitest' import SessionStore from '@deepseek-ai/dsh-session' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import TypertGateway from '@deepseek-ai/dsh-api-gateway' +import RemoteAccessService from '@deepseek-ai/dsh-api-remote-access' import { HostConnectionService } from '@deepseek-ai/dsh-client-connection/src/rpc-host.ts' import type { BrowserAuth } from '@deepseek-ai/dsh-client-connection/src/browser-auth.ts' import { createWebConnectionRpc } from '@deepseek-ai/dsh-client-connection/src/client/rpc.ts' @@ -19,12 +20,14 @@ beforeEach(async () => { const ctx = harness.ctx await ctx.plugin(SessionStore) await ctx.plugin(TypertRegistry) + await ctx.plugin(RemoteAccessService) await ctx.plugin(TypertGateway) await ctx.plugin((scope) => { new HostConnectionService(scope, [], {} as BrowserAuth) }) await ctx.plugin(WorkspaceFiles, { maxBytes: 8, maxFileBytes: 4, maxLines: 100, maxEntries: 100 }) ctx.sessions.create(harness.scope.sessionId, { meta: { cwd: harness.workspace, origin: 'subagent' } }) - const handler = (ctx.get('connection') as HostConnectionService).createSharedFetchHandler('/api') - rpc = createWebConnectionRpc(async (path, init) => handler.fetch(new Request(new URL(path, 'http://host'), init))) + const connection = ctx.get('connection') as HostConnectionService + const handler = connection.createSharedFetchHandler('/api') + rpc = createWebConnectionRpc(async (path, init) => handler.fetch(new Request(new URL(path, 'http://host'), init), connection.browserPeer)) }) afterEach(async () => { diff --git a/packages/api/workspace-files/tests/scope.spec.ts b/packages/api/workspace-files/tests/scope.spec.ts index 2d9cd097b2..d954063bfd 100644 --- a/packages/api/workspace-files/tests/scope.spec.ts +++ b/packages/api/workspace-files/tests/scope.spec.ts @@ -52,6 +52,8 @@ describe('Workspace Files Session scope lookup', () => { expect(lookup).toMatchObject({ parameter: 'workspaceFileScope', wire: 'workspaceFileScopeId', + // A method taking a scope reads the scope's Session as its access-control resource. + resource: 'session', hostTypeSymbol: '@deepseek-ai/dsh-api-workspace-files#WorkspaceFileScope', wireTypeSymbol: '@deepseek-ai/dsh-session/types#SessionId', }) diff --git a/packages/api/workspace-files/tsdown.config.ts b/packages/api/workspace-files/tsdown.config.ts index 6bf04a7409..0e42a1163e 100644 --- a/packages/api/workspace-files/tsdown.config.ts +++ b/packages/api/workspace-files/tsdown.config.ts @@ -1,7 +1,7 @@ import { clientBundle } from '../../client/tsdown.client.ts' export default clientBundle( - '@deepseek-ai/dsh-api-workspace-files', + import.meta.dirname, ['lib/types/index.js'], { hostPhase: true }, ) diff --git a/packages/boot/app-boot/README.i18n.yaml b/packages/boot/app-boot/README.i18n.yaml index 20dc764eec..12b9f227e6 100644 --- a/packages/boot/app-boot/README.i18n.yaml +++ b/packages/boot/app-boot/README.i18n.yaml @@ -36,35 +36,32 @@ en: a9afb263b03d112c zh: 31f5e797cd953c15 /deepseek-ai-dsh-app-boot/use-this-package/startup-and-reload-failures: - en: 09890e218ab49c84 - zh: e3d86597e7fb1adb -/deepseek-ai-dsh-app-boot/use-this-package/telling-the-agent-where-the-harness-lives: - en: 07c459f26f4f8517 - zh: 210f1e4894731b1a + en: dc13dfa1f79292e4 + zh: d51682ced7a18ac2 /deepseek-ai-dsh-app-boot/understand-the-implementation: en: f4a2695cb4c8a570 zh: 89837235d2f376cf /deepseek-ai-dsh-app-boot/understand-the-implementation/design-notes: - en: 25a0d1b46fd66d68 - zh: 5cd8b4ac382dccdc + en: 9ecb744556705fd1 + zh: d83aaf2ebb91d0fc /deepseek-ai-dsh-app-boot/understand-the-implementation/helper-behavior: - en: f7bc3a8dd4b00bdd - zh: 30098428aa769ad0 + en: 6fa850d1bb000d0e + zh: aa83a40a9d3db295 /deepseek-ai-dsh-app-boot/understand-the-implementation/source-map: - en: 3da01827e3354655 - zh: 06ccfaed83f65a76 + en: 541700d5c39e0ab4 + zh: 7522dbd9cd68c930 /deepseek-ai-dsh-app-boot/further-exploration: en: 401269cc5ea8593e zh: cd337c356b320565 /deepseek-ai-dsh-app-boot/model-experience: - en: 769777f1cb1a9408 - zh: f02b2834179df50d + en: d2d0a21502d88c50 + zh: 82a60c7a490a6834 /deepseek-ai-dsh-app-boot/model-experience/kv-cache-effect: - en: c4d49630b68ae896 - zh: 6f450ec6ba39bb81 + en: 5a1d9344c8fdc790 + zh: 0b32579f658b2ca1 /deepseek-ai-dsh-app-boot/known-limitations-and-deferred-work: - en: bf1a49013cad8e9c - zh: 41b2b010f8650ec3 + en: 1400c4f22a79b59a + zh: 269f6c91584aec3e /deepseek-ai-dsh-app-boot/known-limitations-and-deferred-work/dev-note: en: d2f0d68fe29fb750 zh: 323d21ee73a9e3f6 diff --git a/packages/boot/app-boot/README.md b/packages/boot/app-boot/README.md index 093c79252b..5e4d3b460a 100644 --- a/packages/boot/app-boot/README.md +++ b/packages/boot/app-boot/README.md @@ -91,7 +91,7 @@ Use `readPluginMeta(specifier, parentURL)` or `ctx.pluginPackages.metaOf(specifi ### Startup and reload failures -Profile reconciliation returns diagnostics for unchanged inactive entries without failing an unrelated mutation. A new inactive entry, a changed configuration or fiber, or a changed diagnostic fails reconciliation; removed fibers must still finish disposal. Explicit enablement targets must activate even when their failure predates the operation. Successful reconciliation emits `app-boot/config-reload` after lifecycle settlement and diagnostic checks, including programmatic updates without HMR. The event carries no diff or parsed config. Successful reconciliation returns after lifecycle settlement and diagnostic checks; volatile-only entry changes are committed by Loader during the update. +Profile reconciliation returns diagnostics for unchanged inactive entries without failing an unrelated mutation. A new inactive entry, a changed configuration or fiber, or a changed diagnostic fails reconciliation; removed fibers must still finish disposal. Explicit enablement targets must activate even when their failure predates the operation. `recomposeProfile(ctx, patches, binName, { requiredIds, running })` runs one recomposition together with the runtime resolution: it removes the rows the generation no longer lists and waits for their disposal while their packages are still mapped, publishes the latest generation of a recomputable resolution, runs the compatibility preflight against the published packages, applies the generation, starts the `running` entries and entries whose import failed that the update left without a running fiber, and judges activation. `running` lists the entries that ran before the caller stopped some of them; each that failed afterwards counts as a new failure. A failure before the generation applies puts the removed rows back at their places, which starts them again, and starts the `running` entries again. An entry waiting for a service is reported as a warning unless it is required, so it starts once a provider appears; `reconcileProfilePatches()` judges it the same way. A publication the resolution rejects is such a failure and reports `package-moved`. `reconcileProfilePatches()`, which the Config Editor uses, applies and judges the patches without publishing the resolution or retrying failed imports. An entry whose import failed is reported with the error Loader's import returns again, so judging it evaluates the module when the import now succeeds; the state recorded before the change imports nothing, so an entry whose import failed before counts as the same failure whatever its error now is. Successful reconciliation emits `app-boot/config-reload` after lifecycle settlement and diagnostic checks, including programmatic updates without HMR. The event carries no diff or parsed config. Successful reconciliation returns after lifecycle settlement and diagnostic checks; volatile-only entry changes are committed by Loader during the update. After the Loader settles, app-boot warns when only optional entries are inactive. If an enabled required entry cannot activate, `boot()` rejects with `StartupError` after disposal. An independently owned logger exporter retains warning and error records through asynchronous disposal and is released before `boot()` settles. Its message groups all failed plugins and pending services, marks required entries, and retains original stacks, nested causes, and aggregate members. The CLI prints that message once and saves [full startup diagnostics](../../../apps/cli/reference/README.md#startup-diagnostics) before exiting with code 1; unrelated exceptions retain their normal stack output. In the table, stopping startup means disposing any mounted plugins and exiting nonzero without reporting readiness; continuing keeps successful plugins running. Later configuration HMR does not repeat the required-startup audit and does not roll back the whole update. @@ -116,10 +116,6 @@ The [Web process matrix](../../../apps/cli/tests/profiles/web/tests/web-failure- If your app owns the terminal, it can hand the terminal back before the process exits, so your shell is never left in raw mode. The handoff is bounded: a stuck cleanup delays the fatal exit but never cancels it. -### Telling the agent where the harness lives - -When your app boots a model-backed agent, you can tell the agent where the DSH implementation checkout lives: it learns that path and that it must not infer the working directory from it — it should use `pwd`. The instruction appears once near the top of the system prompt. Apps without a system prompt service skip it; in development, reloading the system prompt drops it until the next boot. - ----- @@ -139,8 +135,8 @@ This section explains how the outcomes above are realized and points at the code - **Package metadata.** `ctx.pluginPackages.packageOf` locates the owning package without loading code or requiring an exported `package.json`; a subpath selects its package without validating that file. The installed runtime resolution owns selection even for a miss. Low-level embedders that install the service without one retain native lookup. Display metadata uses the separate entry-aware reader described above. - **Two Loader builtins.** `mountRootInclude` registers `cordis:include` and `cordis:group` as Loader builtins: a group row gives one `isolate` realm to a provider and its consumers together, and an agent preset outside this workspace cannot resolve `@deepseek-ai/cordis-plugin-group` by name. Both load through the ambient module pipeline rather than the included tree's own specifier resolution. - **Consumer-owned strictness.** Ordinary Loader groups keep successful siblings. App-boot applies the global required-entry policy after initial settlement; agent presets and dynamic multi-entry compositions own and dispose their separate Loader subtree when they require all-or-nothing setup. App-boot reads failed fibers to report their recorded errors and coalesces duplicate Loader rejection notifications through one process checkpoint. -- **One runtime resolution.** The installation-first and ordered-bundle breadth-first traversal produces the runtime table. Runtime resolution creates no links; runtime resolution entries occupy their package names at `$DSH_HOME/profiles/node_modules`, and every other name sees that directory as an ordinary ancestor. Profile load removes the `.dsh-module-fallback` projections that link-backend releases wrote into a profile; pnpm-installed packages stay. External bare targets selected by package `imports` use the same package order, while Node retains mapping, conditions, and exact target resolution. A complete successor may add package names, remove profile-scoped mappings and profile-local names, and update linked-root membership atomically; retained mappings keep their normalized directory, version, and scope. Installation anchors remain fixed; a profile mapping may change its declarer. [Plugin Manager](../plugin-manager/README.md) publishes successors after package operations ([decision](../../../.agents/notes/implemented/architecture/2026-09-30-profile-package-refresh-and-manifest-invalidation.md)). -- **Removing linked interception.** A successor may remove a linked root without restart. Directories outside all remaining roots use native lookup for subsequent requests, which may find a development copy or report a missing package. Existing module references and Node caches stay unchanged. Re-adding the same link name and target is allowed; pointing a previously published name at a different target is rejected even after removal ([generation rules](../../../.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.md#immutable-generations)). +- **One runtime resolution.** The installation-first and ordered-bundle breadth-first traversal produces the runtime table. Runtime resolution creates no links; runtime resolution entries occupy their package names at `$DSH_HOME/profiles/node_modules`, and every other name sees that directory as an ordinary ancestor. Profile load removes the `.dsh-module-fallback` projections that link-backend releases wrote into a profile; pnpm-installed packages stay. External bare targets selected by package `imports` use the same package order, while Node retains mapping, conditions, and exact target resolution. A complete successor may add package names, remove profile-scoped mappings and profile-local names, and update linked-root membership atomically; retained mappings keep their normalized directory and scope. Installation mappings keep their version and anchor; a profile mapping may change its declarer and, for a package upgraded in place, its version. [Plugin Manager](../plugin-manager/README.md) publishes successors after package operations ([decision](../../../.agents/notes/implemented/architecture/2026-09-30-profile-package-refresh-and-manifest-invalidation.md)). +- **Removing linked interception.** A successor may remove a linked root without restart. Directories outside all remaining roots use native lookup for subsequent requests, which may find a development copy or report a missing package. Existing module references and Node caches stay unchanged. A link name that is no profile mapping may point to another target, also after removal, and so may one whose mapping was removed first; Node's cached real paths of the previous target are relocated ([generation rules](../../../.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.md#immutable-generations)). - **Application-owned profiles.** Application-owned profiles use the same runtime resolution. Links within the active profile directory, including pnpm store links, are not external roots even when the profile is outside the shared profiles tree. Resolution does not modify their `node_modules`; pnpm owns installed packages. - **Owned Workers.** Worker build banners import `@deepseek-ai/dsh-app-boot/worker/profile-resolution-bootstrap` before bundled business code. Each Worker installs the structured-cloned runtime resolution in its own isolate. The bootstrap bundle has no static package imports. Source Worker entries retain their self-contained dependency closure, and third-party Workers receive no injection. - **Update completion.** App boot observes restart failures through the `internal/update` waterfall. Live patch reloads wait for the tree's fibers before auditing activation; `Fiber.update()` and `Entry.update()` alone do not establish restart success. @@ -151,13 +147,13 @@ The startup error also retains inactive-entry metadata and raw startup warning/e ### Helper behavior -The exports each own one stage of the boot: config resolution and snapshot replay, layered environment loading, fail-loud reporting, activation auditing, patch parsing, root-include mounting, config dump rendering, profile composition, and the harness-source section. Per-export contracts live in the code, not this README — see [`src/index.ts`](src/index.ts) and [`src/profile.ts`](src/profile.ts). +The exports each own one stage of the boot: config resolution and snapshot replay, layered environment loading, fail-loud reporting, activation auditing, patch parsing, root-include mounting, config dump rendering, and profile composition. Per-export contracts live in the code, not this README — see [`src/index.ts`](src/index.ts) and [`src/profile.ts`](src/profile.ts). ### Source map | File | Role | |---|---| -| [`src/index.ts`](src/index.ts) | Boot helpers: config resolution, environment loading, fail-loud guard, activation audit, patch parsing, config dump, harness-source section | +| [`src/index.ts`](src/index.ts) | Boot helpers: config resolution, environment loading, fail-loud guard, activation audit, patch parsing, config dump | | [`src/profile.ts`](src/profile.ts) | Profile discovery, initialization, bundle resolution, runtime resolution construction | | [`src/profile-plugins.ts`](src/profile-plugins.ts) | Installed dependencies, bundle activation policy, and manifest updates | | [`src/profile-sanitize.ts`](src/profile-sanitize.ts) | Profile patch backup and recovery bundle activation | @@ -187,11 +183,11 @@ Read these pages when the package-level contract is not enough. They move from t ## Model Experience -Indirectly, through the loaded plugin tree, which alone contributes model context; the one export that adds model-visible text, `addHarnessSourceSection`, does so only when a consumer calls it after boot. +Indirectly, through the loaded plugin tree, which alone contributes model context; no export adds model-visible text. #### KV Cache effect -Boot itself changes no request prefix. `addHarnessSourceSection` places its source path after first-party reusable instructions, so different checkouts leave those preceding bytes unchanged when tools and configuration match. Provider cache reuse is not guaranteed. +Boot itself changes no request prefix. ## Known Limitations and Deferred Work @@ -200,7 +196,7 @@ Boot itself changes no request prefix. `addHarnessSourceSection` places its sour These limits describe when this boot library is a poor fit or needs special care. They are current package constraints, not a task backlog. - **Runtime resolution depends on Node internals** — supported Node versions require the native builtin-access addon and executable compatibility coverage. Only built Harness-owned Workers receive the runtime resolution bootstrap; third-party Workers and custom `vm` linkers keep native resolution. -- **Relinking a profile package needs a restart** — Node caches real paths, so changing the target of a profile link or a dependency link requires a process restart. +- **Moving a profile package needs its removal first** — `replace()` rejects a retained profile mapping that moved to another directory or link target with `ResolutionRestartRequired`; removing the package and installing it again maps the new directory. A linked root may point to another directory once its profile mapping was removed. Node keeps the real path of every link it resolved through; once a link points elsewhere, or its name is installed again as a directory, a native lookup that selects a file below its previous target, for an importer outside it, selects the same file below the current one when that exists, including an explicit absolute path into the previous target. Installation mappings and local overrides still require a restart. - **Linked scope follows recorded real directories** — a hoisted dependency outside every linked root uses native Node. A peer read never invalidates Node caches or changes which files are watched. - **Source launches use an ESM-only hook** — CommonJS requests still need the JavaScript files selected by package exports; resolution does not supply missing build outputs. - **Snapshot replay swapping is basename-specific** — only a config ending in `cordis.yml` or `cordis.yaml` maps to the sibling `cordis.snapshot.yml`; custom config names require caller-managed selection. diff --git a/packages/boot/app-boot/README.zh.md b/packages/boot/app-boot/README.zh.md index 379d602176..31fdebcb15 100644 --- a/packages/boot/app-boot/README.zh.md +++ b/packages/boot/app-boot/README.zh.md @@ -91,7 +91,7 @@ profile 导入插件前,DSH 会检查其 `peerDependencies` 中对 `@deepseek- ### 启动与重载失败 -profile 重载返回未变化的已有故障诊断,不让无关修改因此失败。新增未激活条目、配置或 fiber 变化、诊断变化都会使重载失败;被移除的 fiber 仍须完成释放。显式启用的目标必须成功激活,即使它的故障早于本次操作。成功重载在生命周期结束及诊断检查通过后发出 `app-boot/config-reload`,包括未启用 HMR 时的程序化更新。事件不携带 diff 或解析后的配置。 成功重载在生命周期结束及诊断检查通过后返回;仅 volatile 的条目变化由 Loader 在更新过程中提交。 +profile 重载返回未变化的已有故障诊断,不让无关修改因此失败。新增未激活条目、配置或 fiber 变化、诊断变化都会使重载失败;被移除的 fiber 仍须完成释放。显式启用的目标必须成功激活,即使它的故障早于本次操作。`recomposeProfile(ctx, patches, binName, { requiredIds, running })` 与 runtime resolution 一起执行一次重新组合:它移除新一代组合不再列出的行,并在其包仍有映射时等待这些行释放完成,发布可重新计算的 resolution 的最新一代,使兼容性预检读取已发布的包,应用新一代组合,启动更新后没有运行中 fiber 的 `running` 条目和导入失败的条目,然后判定激活结果。`running` 列出调用方停止其中部分条目之前正在运行的条目;之后每个失败的条目都计为新的故障。新一代组合应用之前的失败会把移除的行放回原位置,从而重新启动它们,并重新启动 `running` 条目。等待服务的条目报告为警告(required 条目除外),因此出现提供方后它即启动;`reconcileProfilePatches()` 以同样方式判定它。resolution 拒绝的发布属于这种失败,并报告 `package-moved`。Config Editor 使用的 `reconcileProfilePatches()` 应用并判定 patch,不发布 resolution,也不重试失败的导入。导入失败的条目以 Loader 再次导入时返回的错误报告,因此若导入现在成功,判定该条目时会对模块求值;变更前记录的激活状态不执行任何导入,因此之前导入失败的条目无论现在的错误是什么,都计为同一个故障。成功重载在生命周期结束及诊断检查通过后发出 `app-boot/config-reload`,包括未启用 HMR 时的程序化更新。事件不携带 diff 或解析后的配置。成功重载在生命周期结束及诊断检查通过后返回;仅 volatile 的条目变化由 Loader 在更新过程中提交。 Loader 结算后,app-boot 在仅 optional 条目未激活时输出警告。如果已启用的 required 条目无法激活,`boot()` 会在释放资源后以 `StartupError` 拒绝。独立管理生命周期的 logger exporter 会保留异步资源释放期间的警告和错误记录,并在 `boot()` 结算前释放。其消息分组列出所有失败插件和等待的服务,标记 required 条目,并保留原始堆栈、嵌套原因和聚合错误成员。CLI 仅输出该消息一次,并在保存[完整启动诊断](../../../apps/cli/reference/README.zh.md#startup-diagnostics)后以退出码 1 结束;其他异常保留正常堆栈输出。表中的“终止启动”指释放已挂载插件并以非零码退出,不报告就绪;“继续”指保留成功运行的插件。后续配置 HMR 不会再次执行 required 启动审计,也不会回滚整个更新。 @@ -116,10 +116,6 @@ Loader 结算后,app-boot 在仅 optional 条目未激活时输出警告。如 如果你的应用持有终端,它可以在进程退出前把终端交还,你的 shell 绝不会残留在 raw 模式。交还过程有界:卡住的清理只会延迟致命退出,而不会取消它。 -### 告诉 agent(智能体)harness 所在位置 - -当你的应用启动模型驱动的 agent 时,你可以告诉 agent DSH 实现代码 checkout 的位置:它得知该路径,也知道不得据此推断工作目录——它应使用 `pwd`。这条指示在系统提示词靠前位置出现一次。没有系统提示词服务的应用会跳过;开发环境中,重新加载系统提示词后它会消失,直至下次启动。 - ----- @@ -139,8 +135,8 @@ Loader 结算后,app-boot 在仅 optional 条目未激活时输出警告。如 - **包元数据。** `ctx.pluginPackages.packageOf` 定位所属包,不加载代码,也不要求导出 `package.json`;子路径选择其所属包,不校验该文件。安装 runtime resolution 后,即使查询未命中也以其选包规则为准。仅安装服务而不提供 runtime resolution 的底层嵌入方保留原生查询。展示元数据使用上文另述的入口感知读取器。 - **两个 Loader builtin。** `mountRootInclude` 把 `cordis:include` 与 `cordis:group` 注册为 Loader builtin:group 行能把一个提供方与它的消费方放进同一个 `isolate` realm,而位于本工作区之外的 agent preset 无法按名称解析 `@deepseek-ai/cordis-plugin-group`。两者都通过宿主的模块管线加载,而非被包含树自身的说明符解析。 - **由 consumer 持有严格语义。** 普通 Loader group 保留成功 sibling。App-boot 在首次结算后应用全局 required-entry policy;agent preset 与动态多 entry 组合在需要 all-or-nothing setup 时,持有并拆卸各自的独立 Loader 子树。App-boot 读取 failed fiber 来报告已记录的错误,并在一个进程检查点内合并 Loader 重复的 rejection 通知。 -- **唯一 runtime resolution。** 安装优先、有序 bundle 逐根 breadth-first 遍历生成运行时表。runtime 解析不创建链接;runtime resolution 条目占据 `$DSH_HOME/profiles/node_modules` 上各自的包名位置,其余包名把该目录当作普通祖先。profile 加载时删除 Link 后端发布版写进 profile 的 `.dsh-module-fallback` 投影;pnpm 安装的包保留。package `imports` 选中的外部 bare target 使用相同的选包顺序,映射、conditions 和精确 target 解析仍由 Node 负责。完整后继 runtime resolution 可以原子地增加 package name、移除 profile 范围的映射和 profile 本地包名、更新 linked root 集合;保留映射的规范化目录、版本和作用域不变。安装范围的锚点不变;profile 映射可以更换声明来源。[插件管理器](../plugin-manager/README.zh.md)在包操作之后发布后继代([决策](../../../.agents/notes/implemented/architecture/2026-09-30-profile-package-refresh-and-manifest-invalidation.zh.md))。 -- **移除链接拦截。** 后继 generation 可以移除 linked root,无需重启。目录不再被任何剩余 root 覆盖时,后续请求使用原生查询,可能找到开发副本,也可能报告缺包。已有模块引用和 Node 缓存保持不变。同名、同目标可以重新加入;曾发布的名称改指向不同目标时,即使中间移除过也会被拒绝([generation 规则](../../../.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.zh.md#immutable-generations))。 +- **唯一 runtime resolution。** 安装优先、有序 bundle 逐根 breadth-first 遍历生成运行时表。runtime 解析不创建链接;runtime resolution 条目占据 `$DSH_HOME/profiles/node_modules` 上各自的包名位置,其余包名把该目录当作普通祖先。profile 加载时删除 Link 后端发布版写进 profile 的 `.dsh-module-fallback` 投影;pnpm 安装的包保留。package `imports` 选中的外部 bare target 使用相同的选包顺序,映射、conditions 和精确 target 解析仍由 Node 负责。完整后继 runtime resolution 可以原子地增加 package name、移除 profile 范围的映射和 profile 本地包名、更新 linked root 集合;保留映射的规范化目录和作用域不变。安装范围的映射保持版本和锚点不变;profile 映射可以更换声明来源,对原地升级的包还可以更改版本。[插件管理器](../plugin-manager/README.zh.md)在包操作之后发布后继代([决策](../../../.agents/notes/implemented/architecture/2026-09-30-profile-package-refresh-and-manifest-invalidation.zh.md))。 +- **移除链接拦截。** 后继 generation 可以移除 linked root,无需重启。目录不再被任何剩余 root 覆盖时,后续请求使用原生查询,可能找到开发副本,也可能报告缺包。已有模块引用和 Node 缓存保持不变。不属于 profile 映射的链接名称可以指向其他目标,移除之后也可以;其 profile 映射先被移除的链接名称同样可以;Node 缓存的原目标真实路径会被重新定位([generation 规则](../../../.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.zh.md#immutable-generations))。 - **应用自有 profile。** 应用自有 profile 使用相同的 runtime resolution。目标位于当前 profile 目录内的链接(包括 pnpm store 链接)不算外部 root,即使 profile 位于共享 profiles 树外。解析过程不修改其 `node_modules`;已安装包由 pnpm 管理。 - **自有 Worker。** Worker 构建 banner 会在业务 bundle 前导入 `@deepseek-ai/dsh-app-boot/worker/profile-resolution-bootstrap`。每个 Worker 在自己的 isolate 中安装结构化克隆的 runtime resolution。bootstrap bundle 不静态导入任何包。源码 Worker 入口保留自包含依赖,第三方 Worker 不接受注入。 - **更新完成。** App boot 通过 `internal/update` waterfall 观察重启失败。实时 patch 重载在检查激活状态前等待配置树中的 fiber;单独调用 `Fiber.update()` 或 `Entry.update()` 不能确定重启成功。 @@ -151,13 +147,13 @@ Loader 结算后,app-boot 在仅 optional 条目未激活时输出警告。如 ### Helper 行为 -每个导出各负责启动的一个阶段:配置解析与快照回放、分层环境加载、明确报错的保护机制、激活审计、patch 解析、根 include 挂载、配置 dump 渲染、profile 组合,以及 harness 源码段落。各导出的约定在代码中,不在本 README——见 [`src/index.ts`](src/index.ts) 与 [`src/profile.ts`](src/profile.ts)。 +每个导出各负责启动的一个阶段:配置解析与快照回放、分层环境加载、明确报错的保护机制、激活审计、patch 解析、根 include 挂载、配置 dump 渲染与 profile 组合。各导出的约定在代码中,不在本 README——见 [`src/index.ts`](src/index.ts) 与 [`src/profile.ts`](src/profile.ts)。 ### 源码地图 | 文件 | 职责 | |---|---| -| [`src/index.ts`](src/index.ts) | 启动 helper:配置解析、环境加载、会明确报错的保护机制、激活审计、patch 解析、配置 dump、harness 源码段落 | +| [`src/index.ts`](src/index.ts) | 启动 helper:配置解析、环境加载、会明确报错的保护机制、激活审计、patch 解析、配置 dump | | [`src/profile.ts`](src/profile.ts) | profile 发现、初始化、组合包解析、runtime resolution 构造 | | [`src/profile-plugins.ts`](src/profile-plugins.ts) | 已安装依赖、bundle 启用策略与 manifest 更新 | | [`src/profile-sanitize.ts`](src/profile-sanitize.ts) | profile patch 备份与恢复 bundle 启用状态 | @@ -187,11 +183,11 @@ Loader 结算后,app-boot 在仅 optional 条目未激活时输出警告。如 ## 模型体验 -模型通过此包加载的插件树间接受影响——只有该树贡献模型上下文;唯一贡献模型可见文本的导出 `addHarnessSourceSection`,也只有在消费方启动后调用它时才会产生影响。 +模型通过此包加载的插件树间接受影响——只有该树贡献模型上下文;没有导出会添加模型可见文本。 #### KV Cache 影响 -启动本身不改变请求前缀。`addHarnessSourceSection` 将源码路径放在第一方可复用指令之后,因此工具与配置一致时,不同 checkout 不会改变前置字节。不保证提供方复用缓存。 +启动本身不改变请求前缀。 ## 已知限制与延期工作 @@ -200,7 +196,7 @@ Loader 结算后,app-boot 在仅 optional 条目未激活时输出警告。如 这些限制说明此启动库在何时不合适,或何时需要特别注意。它们是当前包约束,不是任务积压。 - **运行时解析依赖 Node 内部机制**——受支持的 Node 版本需要 native builtin access addon 和可执行兼容验证。只有构建后的 Harness 自有 Worker 接收 runtime resolution bootstrap;第三方 Worker 与自定义 `vm` linker 保持原生解析。 -- **重新链接 profile 包需要重启**——Node 缓存真实路径,因此改变 profile 链接或依赖链接的目标需要重启进程。 +- **移动 profile 包需要先卸载**——`replace()` 以 `ResolutionRestartRequired` 拒绝移动到其他目录或链接目标的保留 profile 映射;卸载该包后重新安装,即映射到新目录。linked root 的 profile 映射被移除后,它可以指向其他目录。Node 保留其解析过的每个链接的真实路径;链接指向别处后,或其名称作为目录重新安装后,原生查询为原目标之外的导入方选中原目标之下的文件时,若当前目标之下存在同一文件,会改为选中该文件,包括指向原目标的显式绝对路径。安装映射和本地覆盖仍需要重启。 - **链接作用域以记录的真实目录为准**——提升后的依赖若在所有 linked root 之外,就使用原生 Node。每次读取 peer 都不会使 Node 缓存失效,也不会改变被监视的文件。 - **源码启动只安装 ESM 钩子**——CommonJS 请求仍需要 package exports 选中的 JavaScript 文件,解析器不会补出缺失的构建产物。 - **快照回放替换仅识别特定 basename**——只有以 `cordis.yml` 或 `cordis.yaml` 结尾的配置会映射到同级 `cordis.snapshot.yml`;自定义配置名称需要调用方自行选择。 diff --git a/packages/boot/app-boot/package.json b/packages/boot/app-boot/package.json index a7a8e5646e..5717ca71cc 100644 --- a/packages/boot/app-boot/package.json +++ b/packages/boot/app-boot/package.json @@ -47,7 +47,6 @@ "@deepseek-ai/cordis-plugin-loader": "workspace:~", "@deepseek-ai/dsh-launch-environment": "workspace:*", "@deepseek-ai/dsh-home-paths": "workspace:*", - "@deepseek-ai/dsh-system-prompt": "workspace:*", "@deepseek-ai/cordis": "workspace:~" }, "devDependencies": { diff --git a/packages/boot/app-boot/src/index.ts b/packages/boot/app-boot/src/index.ts index d02c5d554f..fded02c9c8 100644 --- a/packages/boot/app-boot/src/index.ts +++ b/packages/boot/app-boot/src/index.ts @@ -11,13 +11,13 @@ import { readFileSync } from 'node:fs' import { inspect, parseEnv } from 'node:util' import { basename, dirname, isAbsolute, resolve } from 'node:path' import * as yaml from 'js-yaml' -import { Context, type FiberState } from '@deepseek-ai/cordis' -import Loader, { type Entry, type EntryOptions } from '@deepseek-ai/cordis-plugin-loader' +import { Context, type Fiber, type FiberState } from '@deepseek-ai/cordis' +import Loader, { type Entry, type EntryGroup, type EntryOptions } from '@deepseek-ai/cordis-plugin-loader' import Include, { applyEntryPatches, entryListSchema, type PatchOptions } from '@deepseek-ai/cordis-plugin-include' import Group from '@deepseek-ai/cordis-plugin-group' import { dshHomePath, resolveDshHome } from '@deepseek-ai/dsh-home-paths' import { createLaunchEnvironmentSnapshot, type LaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' -export { readProfilePatches, resolveTelemetryPatch, type ProfileContext, type ProfilePnpmInvocation } from './profile-context.ts' +export { readBundlePatches, readProfilePatches, resolveTelemetryPatch, type ProfileContext, type ProfilePnpmInvocation } from './profile-context.ts' export { sanitizeProfile } from './profile-sanitize.ts' export { getDshRuntimeVersion, evaluatePluginCompatibility, pluginCompatibilityWarning, type PluginCompatibility } from './plugin-compatibility.ts' export { @@ -25,13 +25,14 @@ export { setProfileVersionExemption, type ProfileCompatibility, } from './profile-compatibility.ts' import { prepareProfilePatches } from './compatibility-preflight.ts' +import { ProfileRuntimeResolution, type RuntimeResolution } from './profile.ts' +import { ResolutionRestartRequired } from './profile-resolution/resolver.ts' export { prepareProfileEntries, prepareProfilePatches } from './compatibility-preflight.ts' export { readPluginMeta, resolvePluginResource } from './package-meta.ts' export { ON_DEMAND_BUNDLES, OFFICIAL_ON_DEMAND_CATALOG, type OfficialBundleCatalogEntry } from './official-bundles.ts' export { generateConfigSchema, type ConfigSchemaDump, type NativeConfigSchema } from './config-schema/index.ts' export { createConfigProjector, LOADER_EXPRESSION_SCHEMA, type ConfigProjection } from './config-schema/projector.ts' export { isNativeConfigSchema } from './config-schema/native.ts' -import type {} from '@deepseek-ai/dsh-system-prompt' export { readProfilePlugins, reconcileProfilePlugins, writeProfileBundles, @@ -89,6 +90,7 @@ export { type PluginPackage, type PluginPackagesConfig, } from './profile-resolution/service.ts' +export { ResolutionRestartRequired } from './profile-resolution/resolver.ts' /** * Resolve the config to boot. Replay swaps a `cordis.yml` basename for @@ -265,7 +267,52 @@ const bootstrapIncludes = new WeakMap() // reference `process.env`. const userPatchesSchema = entryListSchema -/** Apply one complete patch generation and wait for Loader activation diagnostics. +/** What a caller of {@link recomposeProfile} already did and requires. */ +export interface ProfileRecompositionOptions { + /** Explicit enablement targets whose existing failures also reject the recomposition. */ + readonly requiredIds?: readonly string[] + /** + * Loader entries that ran before the caller stopped some of them. Each that no longer runs starts again, and each that + * failed afterwards counts as a new failure. + */ + readonly running?: readonly Entry[] +} + +/** The outcome of a {@link recomposeProfile} that applied or kept the composition. */ +export interface ProfileRecomposition { + /** Diagnostics for unchanged pre-existing inactive entries. */ + warnings: string[] + /** Present when the runtime resolution could not publish a moved package or a changed installation mapping. */ + restartReason?: 'package-moved' +} + +/** + * Recompose the profile from its complete patch list, together with its runtime resolution. The rows the generation no + * longer lists are removed first and their disposal finishes while their packages are still mapped; the latest + * generation of an installed, recomputable resolution is published; the compatibility preflight reads the published + * packages; the generation is applied to the root Include; entries that ran, and enabled entries whose import failed, + * start afterwards when the update left them without a running fiber; and activation is judged. A failure before the generation applies, + * including a publication the resolution rejects, which reports `package-moved`, puts the removed rows back at their + * places, starting them again, and starts the entries that ran again. An entry waiting for a service is reported as a + * warning unless it is required. Patches are copied with `structuredClone`, so they hold no functions. + * @param ctx Booted root context. + * @param patches Complete ordered patch list. + * @param binName Diagnostic prefix. + * @param options What the caller already did and requires. + * @returns Diagnostics for unchanged pre-existing inactive entries, and the restart reason of a rejected publication. + * @throws When the root Include is missing; the latest resolution cannot be read or published; preparation or the + * Loader update rejects; or for a new or changed failure, an inactive required entry, a failed entry that ran, or a + * healthy fiber whose disposal rejected. + */ +export function recomposeProfile( + ctx: Context, patches: PatchOptions[], binName: string, options: ProfileRecompositionOptions = {}, +): Promise { + return recompose(ctx, patches, binName, options, true) +} + +/** + * Apply a complete patch list to the root Include and judge activation, leaving the runtime resolution and entries + * whose import failed as they are. * @param ctx Booted root context. * @param patches Complete ordered patch list. * @param binName Diagnostic prefix. @@ -275,32 +322,188 @@ const userPatchesSchema = entryListSchema export async function reconcileProfilePatches( ctx: Context, patches: PatchOptions[], binName: string, requiredIds: readonly string[] = [], ): Promise { - const entry = bootstrapIncludes.get(ctx) - if (entry === undefined) throw new Error(`${binName}: profile reload requires the root Include entry`) - const previousFailures = (await inactiveEntries(ctx)).map(failure => ({ - ...failure, diagnostic: inactiveDiagnostic(failure), fiber: failure.entry.fiber, options: JSON.stringify(failure.entry.options), - })) - // Removed entries leave the Loader store before their async disposers finish. - const previousFibers = [...ctx.loader.entries()].flatMap(row => row.fiber === undefined ? [] : [{ - fiber: row.fiber, failed: row.fiber.state === FIBER_FAILED || row.fiber.state === FIBER_DISPOSED, - }]) - const { patches: _previous, ...includeConfig } = entry.options.config as Include.Config - // The recomposition judges the rows the launch judged, resolved from the file this Include read. - const parentURL = new URL('.', new URL(includeConfig.path, entry.parent.tree.ctx.baseUrl)).href - const prepared = prepareProfilePatches(ctx, patches, parentURL, binName) - await entry.update({ config: { ...includeConfig, patches: prepared } }) - const results = await Promise.allSettled(previousFibers.map(({ fiber }) => fiber.await())) - await ctx.loader.await() - const failures = await inactiveEntries(ctx) - const introduced = failures.filter(failure => requiredIds.includes(failure.entry.options.id) || !previousFailures.some(previous => - previous.entry === failure.entry && previous.fiber === failure.entry.fiber - && previous.options === JSON.stringify(failure.entry.options) && previous.diagnostic === inactiveDiagnostic(failure))) - if (introduced.length > 0) throw new Error(activationDiagnostic(binName, introduced).trimEnd()) - for (const [index, result] of results.entries()) { - if (result.status === 'rejected' && !previousFibers[index]?.failed) throw result.reason + return (await recompose(ctx, patches, binName, { requiredIds }, false)).warnings +} + +async function recompose( + ctx: Context, patches: PatchOptions[], binName: string, options: ProfileRecompositionOptions, withResolution: boolean, +): Promise { + const { requiredIds = [], running = [] } = options + let applying = false + // The rows stopped first, which a failure before the generation applies puts back. + let stopped: StoppedRow[] = [] + try { + const entry = bootstrapIncludes.get(ctx) + if (entry === undefined) throw new Error(`${binName}: profile reload requires the root Include entry`) + // Reading the prior state imports nothing, so plugin code does not run before the next resolution publishes. + const previousFailures = (await inactiveEntries(ctx, false)).map((failure): RecordedFailure => ({ + entry: failure.entry, fiber: failure.entry.fiber, options: JSON.stringify(failure.entry.options), + // An entry without a running fiber has no import error yet; the judgment after the update reads it. + diagnostic: failure.entry.fiber?.uid ? reloadDiagnostic(failure) : undefined, + })) + // Removed entries leave the Loader store before their async disposers finish. + const previousFibers = [...ctx.loader.entries()].flatMap(row => row.fiber === undefined ? [] : [{ + fiber: row.fiber, failed: row.fiber.state === FIBER_FAILED || row.fiber.state === FIBER_DISPOSED, + }]) + const { patches: previous = [], ...includeConfig } = entry.options.config as Include.Config + const current = withResolution ? ctx.get('pluginPackages')?.resolution : undefined + // A plain-data resolution has no inputs to recompute from. + const successor = current instanceof ProfileRuntimeResolution ? await current.computeLatestResolution() : undefined + if (successor !== undefined) { + // Rows the generation no longer lists stop, and their disposal finishes, while their packages are still mapped. + const listed = rowIds(structuredClone(patches)) + const composed = rowIds(structuredClone(previous)) + // Only rows of the root Include's tree; a nested Include file has its own ids. + const tree = entry.subgroup?.tree + const dropped = [...ctx.loader.entries()] + .filter(row => row.parent.tree === tree && composed.has(row.options.id) && !listed.has(row.options.id)) + // A dropped group takes its rows with it. + const ids = new Set(dropped.map(row => row.options.id)) + const topmost = dropped.filter(row => !ids.has(row.parent.ctx.fiber.entry?.options.id as string)) + // The Loader marks the options of a row it stops, so the copy taken before is the one restored. + stopped = topmost.map(row => ({ + group: row.parent, options: structuredClone(row.options), + index: row.parent.data.indexOf(row.options), + })) + if (topmost.length > 0) { + const fibers = dropped.flatMap(row => row.fiber === undefined ? [] : [row.fiber]) + for (const row of topmost) { + // A disabled row's disposal is the Loader's own, so it does not rewrite the profile's configuration file. + row.options.disabled = true + row.parent.remove(row.options.id) + } + await Promise.allSettled(fibers.map(fiber => fiber.await())) + await ctx.loader.await() + } + try { + publish(ctx, successor) + } catch (error) { + if (!(error instanceof ResolutionRestartRequired)) throw error + // The generation waits for the mappings the rejected publication would change. + return { warnings: [], restartReason: 'package-moved' } + } + } + // The recomposition judges the rows the launch judged, resolved from the file this Include read. + const parentURL = new URL('.', new URL(includeConfig.path, entry.parent.tree.ctx.baseUrl)).href + const prepared = prepareProfilePatches(ctx, patches, parentURL, binName) + applying = true + const retried = [...new Set([...running, ...withResolution ? [...ctx.loader.entries()].filter(importFailed) : []])] + try { + await entry.update({ config: { ...includeConfig, patches: prepared } }) + } finally { + await resume(ctx, retried) + } + const results = await Promise.allSettled(previousFibers.map(({ fiber }) => fiber.await())) + await ctx.loader.await() + const failures = await inactiveEntries(ctx) + const introduced = failures.filter((failure) => { + if (requiredIds.includes(failure.entry.options.id)) return true + // An entry waiting for a service starts once a provider appears. + if (failure.outcome.kind === 'pending') return false + // An entry that ran before the caller stopped it is a new failure even when it failed the same way before. + if (running.includes(failure.entry)) return true + return !previousFailures.some(previous => sameFailure(previous, failure)) + }) + if (introduced.length > 0) throw new Error(activationDiagnostic(binName, introduced, false).trimEnd()) + for (const [index, result] of results.entries()) { + if (result.status === 'rejected' && !previousFibers[index]?.failed) throw result.reason + } + ctx.emit('app-boot/config-reload') + return { warnings: failures.map(reloadDiagnostic) } + } finally { + // Before the generation applies, the rows removed first and the entries the caller stopped run again. + if (!applying && (stopped.length > 0 || running.length > 0)) { + try { + // Ascending original positions put each row back where it was. + for (const { group, options, index } of stopped.toSorted((left, right) => left.index - right.index)) { + // `create` starts the entry but leaves the group's option list as it is; that list is what the next update + // compares against to remove the row and what the Include writes back. + group.data.splice(index, 0, options) + await group.create(options) + } + await resume(ctx, running) + // A restored row is a new Loader entry with the id of the one that ran. + const ran = new Set(running.map(row => row.options.id)) + for (const failure of await inactiveEntries(ctx)) { + if (ran.has(failure.entry.options.id)) ctx.logger.warn(`${binName}: ${reloadDiagnostic(failure)}`) + } + } catch (error) { + // The recomposition's own failure is the outcome. + ctx.logger.warn(error) + } + } } - ctx.emit('app-boot/config-reload') - return failures.map(inactiveDiagnostic) +} + +/** An inactive entry as a recomposition found it before changing anything. */ +interface RecordedFailure { + entry: Entry + fiber: Fiber | undefined + /** The entry's options as JSON. */ + options: string + /** The reload diagnostic; `undefined` for an import failure, which matches any diagnostic. */ + diagnostic: string | undefined +} + +/** Whether an inactive entry is the failure recorded before: the same entry, fiber and options, and a matching diagnostic. */ +function sameFailure(previous: RecordedFailure, failure: InactiveEntry): boolean { + return previous.entry === failure.entry && previous.fiber === failure.entry.fiber + && previous.options === JSON.stringify(failure.entry.options) + && (previous.diagnostic === undefined || previous.diagnostic === reloadDiagnostic(failure)) +} + +/** A row a recomposition removed before publishing, with its place in its group. */ +interface StoppedRow { + group: EntryGroup + options: EntryOptions + index: number +} + +/** The ids of the rows a patch list inserts, including the rows of groups. */ +function rowIds(patches: readonly PatchOptions[]): Set { + const ids = new Set() + const visit = (rows: readonly EntryOptions[]): void => { + for (const row of rows) { + ids.add(row.id) + // A group keeps its rows in its configuration. + if ((row.group === true || row.name === 'cordis:group') && Array.isArray(row.config)) visit(row.config as EntryOptions[]) + } + } + // Patch warnings are logged when the generation itself is prepared. + visit(applyEntryPatches([], patches as PatchOptions[], () => {})) + return ids +} + +/** Publish a generation unless it maps exactly what is published, which would only drop the package caches. */ +function publish(ctx: Context, next: RuntimeResolution): void { + const table = ({ localPackageNames, entries, linkedRoots }: RuntimeResolution): string => + JSON.stringify([localPackageNames, entries, linkedRoots]) + const packages = ctx.pluginPackages + if (table(packages.resolution as RuntimeResolution) !== table(next)) packages.replace(next) +} + +/** Whether an enabled entry below a running parent has no fiber or keeps the disposed fiber of a failed import. */ +function importFailed(entry: Entry): boolean { + return enabledEntry(entry) && entry.parent.ctx.fiber.uid !== null + && (entry.fiber === undefined || entry.fiber.state === FIBER_DISPOSED) +} + +/** Whether an entry is enabled now; a throwing `disabled` expression counts as disabled. */ +function enabledEntry(entry: Entry): boolean { + try { + return !entry.disabled + } catch (_error) { + // Judgment reports the expression failure. + return false + } +} + +/** Start invalidated entries the Loader still holds without a running fiber, then wait until it settles. */ +async function resume(ctx: Context, invalidated: readonly Entry[]): Promise { + const entries = new Set(ctx.loader.entries()) + // A stopped entry starts again on update even with unchanged options; an unchanged Include does not update its rows. + for (const row of invalidated) if (entries.has(row) && !row.fiber?.uid) await row.update({}, false, true) + await ctx.loader.await() } /** @@ -757,7 +960,7 @@ const requiredStartupEntryIds = new Set([ ]) /** Render plugin stacks, nested causes, and aggregate member failures once per error. */ -function formatActivationError(error: unknown): string { +function formatActivationError(error: unknown, stacks = true): string { const details: string[] = [] const seen = new Set() function visit(value: unknown): void { @@ -767,7 +970,7 @@ function formatActivationError(error: unknown): string { } if (seen.has(value)) return seen.add(value) - details.push(value.stack ?? value.message) + details.push(stacks ? value.stack ?? value.message : String(value)) if (value.cause !== undefined) visit(value.cause) if (value instanceof AggregateError) value.errors.forEach(visit) } @@ -819,12 +1022,24 @@ export class StartupError extends Error { } } +/** The error importing an entry's module raises now, which Node returns from its cache for a module that failed before. */ +async function importFailure(entry: Entry): Promise { + try { + await entry.parent.tree.import(entry.options.name) + return undefined + } catch (error) { + return error + } +} + /** * Collect Loader activation failures and disabled-expression errors. Failed * fibers are awaited to recover their recorded rejection reason and coalesce * duplicate Loader notifications through the next process rejection checkpoint. + * An entry whose import failed reports the error importing it raises now; without + * `importNow` nothing is imported and the entry reports a failed import without the import error. */ -async function inactiveEntries(ctx: Context): Promise { +async function inactiveEntries(ctx: Context, importNow = true): Promise { const failures: InactiveEntry[] = [] const rejectionReasons: unknown[] = [] for (const entry of ctx.loader.entries()) { @@ -835,8 +1050,12 @@ async function inactiveEntries(ctx: Context): Promise { continue } const fiber = entry.fiber - if (fiber === undefined) { - failures.push({ entry, outcome: { kind: 'failed', error: 'failed to import' } }) + // An entry whose import failed has no fiber, or keeps the disposed fiber of its previous activation. + if (fiber === undefined || fiber.state === FIBER_DISPOSED) { + const error = importNow ? await importFailure(entry) : undefined + failures.push({ entry, outcome: error === undefined + ? { kind: 'failed', error: fiber === undefined ? 'failed to import' : `fiber state ${String(fiber.state)}` } + : { kind: 'failed', error, phase: 'failed to import' } }) continue } const state = fiber.state @@ -865,22 +1084,28 @@ async function inactiveEntries(ctx: Context): Promise { } /** Render one failed plugin's original error and activation phase. */ -function failureDetail(outcome: Extract): string { - return `${outcome.phase === undefined ? '' : `${outcome.phase}: `}${formatActivationError(outcome.error)}` +function failureDetail(outcome: Extract, stacks = true): string { + return `${outcome.phase === undefined ? '' : `${outcome.phase}: `}${formatActivationError(outcome.error, stacks)}` } /** Render optional-only warnings without changing startup policy. */ function activationDiagnostic( binName: string, failures: readonly InactiveEntry[], + stacks = true, ): string { const noun = failures.length === 1 ? 'entry' : 'entries' - return `${binName}: warning: ${String(failures.length)} ${noun} did not activate\n${failures.map(inactiveDiagnostic).join('\n')}\n` + return `${binName}: warning: ${String(failures.length)} ${noun} did not activate\n${failures.map(failure => inactiveDiagnostic(failure, stacks)).join('\n')}\n` +} + +/** Per-entry reload text, which people read through the Web UI and agents, so it carries error messages without stack frames. */ +function reloadDiagnostic(failure: InactiveEntry): string { + return inactiveDiagnostic(failure, false) } /** Stable per-entry text for reload comparisons and optional warnings. */ -function inactiveDiagnostic({ entry, outcome }: InactiveEntry): string { - const detail = outcome.kind === 'failed' ? failureDetail(outcome) +function inactiveDiagnostic({ entry, outcome }: InactiveEntry, stacks = true): string { + const detail = outcome.kind === 'failed' ? failureDetail(outcome, stacks) : `pending (waiting for ${outcome.missing.length === 1 ? 'service' : 'services'}: ${outcome.missing.join(', ') || 'unknown'})` return `${entry.options.id} (${entry.options.name}): ${detail}` } @@ -1032,38 +1257,10 @@ export async function boot( deepest = deepest.cause } const stack = deepest instanceof AggregateError - ? `\n${deepest.stack ?? deepest.message}\n${deepest.errors.map(formatActivationError).join('\n')}` + ? `\n${deepest.stack ?? deepest.message}\n${deepest.errors.map(error => formatActivationError(error)).join('\n')}` : deepest instanceof Error && deepest !== cause ? `\n${deepest.stack ?? deepest.message}` : '' throw new Error(`${binName}: ${stage}: ${detail}${stack}`, { cause }) } finally { await diagnostics.fiber.dispose() } } - -/** Prompt-section name for the harness-source location line an app bin adds after boot. */ -export const HARNESS_SOURCE_SECTION = 'harness:source' - -/** - * Add a global prompt section naming the on-disk harness source checkout while - * explicitly distinguishing it from the task workspace and current working - * directory. The self-referential `dsh-tool-cordis` toolset reads and edits this - * checkout. Call once on the settled boot context ({@link boot}); the section - * uses the shared first-party placement after reusable instructions - * and before the Web surface and persona suffix. A booted tree with no - * `systemPrompt` service has no prompt to augment, so this is then a no-op - * that returns `undefined`. The section is - * registered against the `systemPrompt` service's fiber, so a dev HMR reload of - * that plugin drops it until the next boot. - * @param ctx - the settled boot context whose global system prompt to augment. - * @param sourceRoot - the absolute path to the harness checkout root. - * @returns the section disposer, or `undefined` when no `systemPrompt` service is mounted. - */ -export function addHarnessSourceSection(ctx: Context, sourceRoot: string): (() => void) | undefined { - const systemPrompt = ctx.get('systemPrompt') - if (systemPrompt === undefined) return undefined - return systemPrompt.section({ - name: HARNESS_SOURCE_SECTION, - order: systemPrompt.getSectionOrder('HARNESS_SOURCE'), - text: `The DeepSeek Harness implementation checkout is at ${sourceRoot}. The checkout location and current working directory are separate values and may differ; never infer the working directory from this path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself.`, - }) -} diff --git a/packages/boot/app-boot/src/profile-context.ts b/packages/boot/app-boot/src/profile-context.ts index 328b543e00..e2ebfad5ce 100644 --- a/packages/boot/app-boot/src/profile-context.ts +++ b/packages/boot/app-boot/src/profile-context.ts @@ -54,6 +54,17 @@ export function resolveTelemetryPatch(disabledEnv: string | undefined, hasRow: b return { id: TELEMETRY_ROW_ID, disabled: true } } +/** Read the selected bundles' patches, which apply before the profile patch file. + * @param binName Diagnostic prefix for malformed or missing configuration. + * @param context Data supplied by the profile launcher. + * @param initialProfile Already loaded startup profile; omitted reads the current files. + * @returns Bundle patches in application order. + */ +export function readBundlePatches(binName: string, context: ProfileContext, initialProfile?: Profile): PatchOptions[] { + const profile = initialProfile ?? loadProfileDirectory(binName, context.dir, context.installAnchor, { userLayer: false }) + return profile.layers.flatMap(layer => layer.patches) +} + /** Read current bundle and user layers with the launch-time overlays. * @param binName Diagnostic prefix for malformed or missing configuration. * @param context Data supplied by the profile launcher. @@ -61,9 +72,8 @@ export function resolveTelemetryPatch(disabledEnv: string | undefined, hasRow: b * @returns Detached ordered patches; this function does not update the Loader. */ export function readProfilePatches(binName: string, context: ProfileContext, initialProfile?: Profile): PatchOptions[] { - const profile = initialProfile ?? loadProfileDirectory(binName, context.dir, context.installAnchor, { userLayer: false }) const patches = structuredClone([ - ...profile.layers.flatMap(layer => layer.patches), + ...readBundlePatches(binName, context, initialProfile), ...(initialProfile?.patches ?? loadOptionalPatches(binName, context.patchPath) ?? []), ...(loadOptionalPatches(binName, join(context.home, PROFILE_PATCH_FILENAME)) ?? []), ...context.overlays, diff --git a/packages/boot/app-boot/src/profile-resolution/resolver.ts b/packages/boot/app-boot/src/profile-resolution/resolver.ts index 85c530665d..60fce4f032 100644 --- a/packages/boot/app-boot/src/profile-resolution/resolver.ts +++ b/packages/boot/app-boot/src/profile-resolution/resolver.ts @@ -2,7 +2,7 @@ import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs' import { createRequire, isBuiltin } from 'node:module' -import { basename, dirname, join, resolve, sep } from 'node:path' +import { basename, dirname, isAbsolute, join, resolve, sep } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' import { getEnvironmentData, setEnvironmentData } from 'node:worker_threads' import type { ModuleLoaderV1, ModuleLoaderV2, ResolveResult } from '@deepseek-ai/cordis-plugin-loader' @@ -100,8 +100,13 @@ interface ParentRoutes { type ResolutionRoutes = Map +/** A published entry with the real directory its modules load from. */ +interface CompiledEntry extends RuntimeResolutionEntry { + readonly realDir: string +} + interface CompiledResolution { - readonly entries: ReadonlyMap + readonly entries: ReadonlyMap readonly profilesDir: string readonly profileDir: string | undefined readonly profilePaths: readonly string[] @@ -113,6 +118,15 @@ interface CompiledResolution { readonly cjsRoutes: ResolutionRoutes } +/** A successor resolution the running process cannot adopt, such as a retained mapping in another directory; it waits for a restart. */ +export class ResolutionRestartRequired extends Error { + /** @param message Diagnostic naming the rejected changes. */ + constructor(message: string) { + super(message) + this.name = 'ResolutionRestartRequired' + } +} + /** Active interception in one Node isolate. */ export interface RuntimeInterception { /** @@ -125,13 +139,16 @@ export interface RuntimeInterception { packageDir(specifier: string, parentURL: string): string | undefined /** * Atomically publish a complete successor and fresh caches. Profile-scoped mappings, local names, and linked - * roots may be removed; removed roots stop intercepting uncovered directories. Existing modules and Node caches - * remain intact, so callers remove a package only after its plugins stop. - * A retained profile mapping may change its declarer if its normalized directory, version, and scope stay unchanged. + * roots may be removed; removed roots stop intercepting uncovered directories. A retained profile mapping may + * change its version and declarer when its normalized directory and scope stay unchanged, as when a package is + * upgraded in place. Existing modules and Node caches remain intact: callers remove a package only after its + * plugins stop, and replace the loaded modules of a package upgraded in place separately. + * A previously published link name may point to another real directory; callers stop the plugins of its previous + * target first, as removing and installing the package again does. * @param successor - fully constructed generation retaining the installation mappings. - * @throws when the profile scope, a retained directory, version, scope, or installation declarer changes; - * an installation mapping is removed; a new local name overrides an existing mapping; or a previously published - * link name selects a different real directory. + * @throws when the profile scope changes; and `ResolutionRestartRequired` when a retained profile mapping's real + * directory or scope, or an installation version or declarer changes; an installation mapping is removed; or a new + * local name overrides an existing mapping. */ replace(successor: RuntimeResolution): void /** Restore the native resolver methods. Interceptions dispose in reverse order. */ @@ -153,6 +170,18 @@ export function barePackageName(request: string): string | undefined { return second < 0 ? request : request.slice(0, second) } +/** The JavaScript and native real paths of an existing directory; none for a directory that no longer exists. */ +function realSpellings(path: string): string[] { + try { + // A packed worker's file system has no native realpath. + const native: unknown = Reflect.get(realpathSync, 'native') + return [realpathSync(path), ...typeof native === 'function' ? [String(Reflect.apply(native, realpathSync, [path]))] : []] + } catch (_error) { + // A removed previous directory keeps only its recorded spelling. + return [] + } +} + function canonicalPath(path: string): string { try { return realpathSync(path) @@ -170,7 +199,7 @@ function prefixes(path: string): readonly string[] { function compileResolution(resolution: RuntimeResolution): CompiledResolution { return { - entries: new Map(resolution.entries.map(entry => [entry.name, entry])), + entries: new Map(resolution.entries.map(entry => [entry.name, { ...entry, realDir: canonicalPath(entry.packageDir) }])), profilesDir: resolution.profilesDir, profileDir: resolution.profileDir, profilePaths: prefixes(resolution.profilesDir), @@ -363,10 +392,75 @@ function sameResolution(left: string, right: string): boolean { return canonicalPath(left) === canonicalPath(right) } +/** A directory as given and in its JavaScript and native real paths, each with a trailing separator. */ +function spellings(dir: string): string[] { + const result = new Set([dir + sep]) + for (const real of realSpellings(dir)) result.add(real + sep) + return [...result] +} + +/** + * The previous real directories of packages that moved, each mapped to the current one. Node keeps the real path of + * every link it resolved through and never refreshes it, so a lookup through a link that now points elsewhere still + * selects a file below the target Node saw first; {@link MovedDirectories.relocate} selects the same file below the + * current target instead. + */ +class MovedDirectories { + /** Previous directory to current directory, both with a trailing separator. */ + private readonly targets = new Map() + + /** + * Record that a package moved from one directory to another. Every spelling of the previous directory leads to the + * current one. A directory that an earlier move led to the previous one now leads to the current one as well, since + * Node still selects files below the earliest target. + * @param from Previous real directory. + * @param to Current real directory. + */ + record(from: string, to: string): void { + const previous = spellings(from) + const current = to + sep + for (const [key, value] of this.targets) { + if (previous.includes(value)) this.targets.set(key, current) + } + for (const key of previous) this.targets.set(key, current) + } + + /** + * Forget the previous directories that are current again, in any spelling, and records that lead to themselves. + * @param current The real directories the published resolution maps. + */ + forget(current: readonly string[]): void { + const active = new Set() + for (const dir of current) for (const spelling of spellings(dir)) active.add(spelling) + for (const [key, value] of [...this.targets]) { + if (active.has(key) || key === value) this.targets.delete(key) + } + } + + /** + * The same file below the current directory when it exists, for a file below a previous directory that an importer + * outside that directory selected. An importer inside the previous directory keeps its own package's files. The + * innermost previous directory decides. + * @param filename Selected file. + * @param parent Importer path. + * @returns The file to load. + */ + relocate(filename: string, parent: string | undefined): string { + let from = '' + for (const dir of this.targets.keys()) { + if (filename.startsWith(dir) && dir.length > from.length) from = dir + } + if (from === '' || parent?.startsWith(from) === true) return filename + const relocated = (this.targets.get(from) as string) + filename.slice(from.length) + return existsSync(relocated) ? relocated : filename + } +} + /** One mutable pointer to the immutable runtime resolution. */ class ResolutionRouter { private current: CompiledResolution - private readonly linkedTargets: Map + private linkedTargets: Map + private readonly moved = new MovedDirectories() constructor(resolution: RuntimeResolution, private readonly nodeModulePaths: (directory: string) => string[]) { this.current = compileResolution(resolution) @@ -379,37 +473,64 @@ class ResolutionRouter { || successor.profileDir !== this.current.profileDir) { throw new Error('profile resolution: a successor cannot change its profile scope') } + const rejected: string[] = [] + const moves: [string, string][] = [] for (const [name, current] of this.current.entries) { const next = entries.get(name) if (next === undefined && current.scope === 'profile') continue + if (next !== undefined && current.scope === 'profile' && next.scope === 'profile') { + // Real directories are compared: a link keeps its path when its target changes. + if (current.realDir === canonicalPath(next.packageDir)) continue + rejected.push(`profile resolution: replacing ${JSON.stringify(name)} requires a process restart`) + continue + } if (next === undefined || !sameResolution(current.packageDir, next.packageDir) || (current.scope === 'installation' && !sameResolution(current.declarer, next.declarer)) - || current.version !== next.version + || (current.scope === 'installation' && current.version !== next.version) || current.scope !== next.scope) { - throw new Error(`profile resolution: replacing ${JSON.stringify(name)} requires a process restart`) + rejected.push(`profile resolution: replacing ${JSON.stringify(name)} requires a process restart`) } } const localPackageNames = new Set(successor.localPackageNames) for (const name of localPackageNames) { if (!this.current.localPackageNames.has(name) && this.current.entries.has(name)) { - throw new Error(`profile resolution: overriding ${JSON.stringify(name)} locally requires a process restart`) + rejected.push(`profile resolution: overriding ${JSON.stringify(name)} locally requires a process restart`) } } - // Node retains realpath caches after a root leaves the interception scope. - for (const root of successor.linkedRoots) { - const previous = this.linkedTargets.get(root.name) - if (previous !== undefined && !sameResolution(previous, root.realPath)) { - throw new Error(`profile resolution: relinking ${JSON.stringify(root.name)} requires a process restart`) - } + // A link name pointing elsewhere, or installed again as a directory, moves its package. Names whose link and mapping + // are gone stay recorded, so the package that returns under the name is compared with its last target. + const targets = new Map(this.linkedTargets) + for (const [name, previous] of this.linkedTargets) { + const root = successor.linkedRoots.find(candidate => candidate.name === name) + const entry = entries.get(name) + const target = root?.realPath ?? (entry?.scope === 'profile' ? canonicalPath(entry.packageDir) : undefined) + if (target === undefined || sameResolution(previous, target)) continue + moves.push([previous, target]) + targets.set(name, target) } + if (rejected.length > 0) throw new ResolutionRestartRequired(rejected.join('; ')) const next = compileResolution(successor) - for (const { name, realPath } of successor.linkedRoots) { - if (!this.linkedTargets.has(name)) this.linkedTargets.set(name, realPath) - } + for (const { name, realPath } of successor.linkedRoots) targets.set(name, realPath) + this.linkedTargets = targets + for (const [from, to] of moves) this.moved.record(from, to) + const current: string[] = [] + for (const entry of next.entries.values()) current.push(entry.realDir) + for (const root of successor.linkedRoots) current.push(root.realPath) + this.moved.forget(current) this.current = next } + /** + * The file to load instead of one Node selected through a link that has moved since; see {@link MovedDirectories}. + * @param filename Selected file. + * @param parent Importer path. + * @returns The file to load. + */ + relocate(filename: string, parent: string | undefined): string { + return this.moved.relocate(filename, parent) + } + /** Whether a module path has an interception layer: it lies in the profiles tree, the active profile, or a linked root. */ hasInterceptionLayerForPath(path: string): boolean { return findInterceptionLayer(path, this.current) !== undefined @@ -764,7 +885,21 @@ export function installRuntimeInterception( delegatedEsm = previous } } - return adapted + // Node's last real-path step, for routed and native requests alike, may follow a link it resolved earlier. + const relocate = (result: ResolveResult, parent: string | undefined): ResolveResult => { + if (!result.url.startsWith('file:')) return result + const url = new URL(result.url) + const filename = fileURLToPath(url) + const relocated = router.relocate(filename, parent?.startsWith('file:') === true ? fileURLToPath(parent) : undefined) + if (relocated === filename) return result + url.pathname = pathToFileURL(relocated).pathname + return { ...result, url: url.href } + } + return (request, parent, attributes) => { + const result = adapted(request, parent, attributes) + /* v8 ignore next -- Node 24+ resolves synchronously; the Node 22 matrix covers its Promise result */ + return result instanceof Promise ? result.then(resolved => relocate(resolved, parent)) : relocate(result, parent) + } } let restoreEsm: () => void @@ -904,7 +1039,11 @@ export function installRuntimeInterception( delegatedCjs-- } } - cjs._resolveFilename = wrappedFilename + const relocatedFilename: CommonJsModule['_resolveFilename'] = (request, parent, main, options) => { + const filename = wrappedFilename(request, parent, main, options) + return isAbsolute(filename) ? router.relocate(filename, parent?.filename ?? undefined) : filename + } + cjs._resolveFilename = relocatedFilename return { packageDir(specifier, parentURL) { @@ -913,7 +1052,7 @@ export function installRuntimeInterception( replace(next) { router.replace(next) }, dispose() { /* v8 ignore else -- interceptions are disposed in reverse installation order */ - if (cjs._resolveFilename === wrappedFilename) cjs._resolveFilename = originalFilename + if (cjs._resolveFilename === relocatedFilename) cjs._resolveFilename = originalFilename restoreEsm() }, } diff --git a/packages/boot/app-boot/src/profile-resolution/service.ts b/packages/boot/app-boot/src/profile-resolution/service.ts index 7d493698a7..6e73c16c1a 100644 --- a/packages/boot/app-boot/src/profile-resolution/service.ts +++ b/packages/boot/app-boot/src/profile-resolution/service.ts @@ -76,10 +76,15 @@ export class PluginPackages extends Service { }, 'profile package resolution') } + /** The published runtime resolution, or `undefined` when Node resolves packages alone. */ + get resolution(): RuntimeResolution | undefined { + return this.current + } + /** * Publish a complete successor generation for this process and subsequently created Workers. * Profile mappings and local package names may be removed after their plugins stop; linked roots may also be removed. - * Retained profile mappings may change their declarer, but not their normalized directory, version, or scope; + * Retained profile mappings may change their declarer and version, but not their normalized directory or scope; * installation mappings must remain unchanged. Publication does not unload modules or clear Node caches. * @param successor - fully constructed generation accepted by {@link RuntimeInterception.replace}. */ diff --git a/packages/boot/app-boot/tests/app-boot.spec.ts b/packages/boot/app-boot/tests/app-boot.spec.ts index c1601313b6..5e83da0b17 100644 --- a/packages/boot/app-boot/tests/app-boot.spec.ts +++ b/packages/boot/app-boot/tests/app-boot.spec.ts @@ -5,10 +5,10 @@ import { pathToFileURL } from 'node:url' import { inspect } from 'node:util' import { afterAll, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' +import type {} from '@deepseek-ai/dsh-system-prompt' import { - addHarnessSourceSection, auditStartupEntries, boot, StartupError, - FAIL_LOUD_RELEASE_TIMEOUT_MS, HARNESS_SOURCE_SECTION, + auditStartupEntries, boot, StartupError, + FAIL_LOUD_RELEASE_TIMEOUT_MS, installFailLoud, loadEnv, loadLayeredEnv, loadOverlayPatches, resolveConfigPath, type FailLoudEvent, type FailLoudProcess, } from '../src/index.ts' @@ -575,6 +575,7 @@ describe('auditStartupEntries', () => { } disabled?: boolean options: { id: string; name: string } + parent?: { tree: { import(name: string): Promise } } } const ctxWith = (entries: FakeEntry[]): Context => ({ @@ -610,7 +611,7 @@ describe('auditStartupEntries', () => { const warn = vi.fn() const original = new Error('todo apply failure') await auditStartupEntries(ctxWith([ - { options: { id: 'missing-tool', name: './missing.mjs' } }, + { options: { id: 'missing-tool', name: './missing.mjs' }, parent: { tree: { import: () => Promise.resolve() } } }, { fiber: fiber(3, original), options: { id: 'tool-todo', name: '@deepseek-ai/dsh-tool-todo' } }, { fiber: fiber(0, undefined, { ready: {}, missing: {} }, ['ready']), @@ -854,7 +855,7 @@ describe('boot', () => { const error = failure as StartupError expect(error.entries).toEqual([{ id: 'webserver', module: './missing.mjs', required: true, fiberState: undefined, - outcome: { kind: 'failed', error: 'failed to import' }, + outcome: { kind: 'failed', error: expect.objectContaining({ message: expect.stringContaining('missing.mjs') as string }) as Error, phase: 'failed to import' }, }]) expect(error.startup?.configurationPath).toBe(config) expect(error.startup?.messages.some(message => message.args.some(arg => arg instanceof Error && arg.message.includes('missing.mjs')))).toBe(true) @@ -1266,61 +1267,3 @@ describe('boot', () => { }) }) - -describe('addHarnessSourceSection', () => { - const SOURCE_ROOT = `${sep}opt${sep}harness-src` - const EXPECTED = `The DeepSeek Harness implementation checkout is at ${SOURCE_ROOT}. The checkout location and current working directory are separate values and may differ; never infer the working directory from this path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself.` - - it('distinguishes the source path from the current workdir after reusable instructions', async () => { - const ctx = new Context() - try { - await ctx.plugin(SystemPrompt, { personaPrefix: 'You are a coding agent.' }) - ctx.systemPrompt.section({ - name: 'tools:sdk', order: ctx.systemPrompt.getSectionOrder('TOOLS_SDK'), text: 'Reusable tool SDK.', - }) - const dispose = addHarnessSourceSection(ctx, SOURCE_ROOT) - expect(dispose).toBeTypeOf('function') - const systemPrompt = ctx.get('systemPrompt')! - const rendered = renderPrompt(await systemPrompt.assemble()) - expect(rendered).toContain(EXPECTED) - // The >= 0 guards keep a drifted opener/persona string from a false pass - // through `-1 < n`. - const identityAt = rendered.indexOf('You are an AI agent powered by DeepSeek Harness.') - const sourceAt = rendered.indexOf(EXPECTED) - const personaAt = rendered.indexOf('You are a coding agent.') - expect(identityAt).toBeGreaterThanOrEqual(0) - expect(personaAt).toBeGreaterThanOrEqual(0) - const sdkAt = rendered.indexOf('Reusable tool SDK.') - expect(personaAt).toBeGreaterThan(identityAt) - expect(sdkAt).toBeGreaterThan(personaAt) - expect(sdkAt).toBeLessThan(sourceAt) - } finally { - await ctx.fiber.dispose() - } - }) - - it('is a no-op returning undefined when no systemPrompt service is mounted', async () => { - const ctx = new Context() - try { - expect(addHarnessSourceSection(ctx, SOURCE_ROOT)).toBeUndefined() - } finally { - await ctx.fiber.dispose() - } - }) - - it('disposes the section it added, so a systemPrompt reload leaves no residue', async () => { - const ctx = new Context() - try { - await ctx.plugin(SystemPrompt, {}) - const systemPrompt = ctx.get('systemPrompt')! - const dispose = addHarnessSourceSection(ctx, SOURCE_ROOT)! - const present = await systemPrompt.assemble() - expect(present.sections.some(section => section.name === HARNESS_SOURCE_SECTION)).toBe(true) - dispose() - const gone = await systemPrompt.assemble() - expect(gone.sections.some(section => section.name === HARNESS_SOURCE_SECTION)).toBe(false) - } finally { - await ctx.fiber.dispose() - } - }) -}) diff --git a/packages/boot/app-boot/tests/profile-resolution-service.spec.ts b/packages/boot/app-boot/tests/profile-resolution-service.spec.ts index be57535fd4..9ca8a7e0d6 100644 --- a/packages/boot/app-boot/tests/profile-resolution-service.spec.ts +++ b/packages/boot/app-boot/tests/profile-resolution-service.spec.ts @@ -102,7 +102,7 @@ describe('profile package metadata service', () => { expect(createRequire(parentURL).resolve('metadata-lib')).toBe(join(packageDir, 'index.cjs')) }) - it('rejects refreshing a plain-data or absent resolution', async () => { + it('rejects refreshing a plain-data or absent resolution, and exposes the installed one', async () => { const root = mkdtempSync(join(tmpdir(), 'dsh-package-service-plain-')) roots.push(root) const packageDir = join(root, 'lib') @@ -112,10 +112,13 @@ describe('profile package metadata service', () => { resolution: resolution(join(root, 'profiles'), join(root, 'profiles', 'test'), packageDir, pkg(packageDir, '1.0.0'), '1.0.0'), }) await expect(ctx.pluginPackages.refresh()).rejects.toThrow('plugin-packages: the installed runtime resolution cannot be recomputed') + expect(ctx.pluginPackages.resolution?.profileDir).toBe(join(root, 'profiles', 'test')) + // Without an installed resolution Node resolves alone. const bare = new Context() contexts.push(bare) await bare.plugin(PluginPackages) await expect(bare.pluginPackages.refresh()).rejects.toThrow('plugin-packages: the installed runtime resolution cannot be recomputed') + expect(bare.pluginPackages.resolution).toBeUndefined() }) it('reads translated metadata from the selected local package without importing its entry', async () => { @@ -280,11 +283,11 @@ describe('profile package metadata service', () => { expectSelectedPackage(local, '2.0.0') const removedWorkerData = getEnvironmentData(key) expect(removedWorkerData).toEqual({ resolution: removed }) - expect(() => { ctx.pluginPackages.replace(relinked) }).toThrow( - 'profile resolution: relinking "linked-plugin" requires a process restart', - ) + expect(removedWorkerData).toEqual({ resolution: removed }) + // A link name may point to another directory once the plugins of its previous target stopped. + ctx.pluginPackages.replace(relinked) expectSelectedPackage(local, '2.0.0') - expect(getEnvironmentData(key)).toBe(removedWorkerData) + expect(getEnvironmentData(key)).toEqual({ resolution: relinked }) await ctx.fiber.dispose() contexts.pop() diff --git a/packages/boot/app-boot/tests/profile-resolution.spec.ts b/packages/boot/app-boot/tests/profile-resolution.spec.ts index 82cf24b0ea..b56d1a1774 100644 --- a/packages/boot/app-boot/tests/profile-resolution.spec.ts +++ b/packages/boot/app-boot/tests/profile-resolution.spec.ts @@ -18,6 +18,7 @@ import { getEnvironmentData } from 'node:worker_threads' import { afterEach, describe, expect, it } from 'vitest' import { installRuntimeInterception, + ResolutionRestartRequired, registerWorkerResolution, type RuntimeInterception, } from '../src/profile-resolution/resolver.ts' @@ -1642,14 +1643,128 @@ describe('runtime resolution', { concurrent: false }, () => { registration.replace({ ...withLocal, linkedRoots: [linked] }) registration.replace({ ...withLocal, linkedRoots: [] }) registration.replace({ ...withLocal, linkedRoots: [linked] }) - expect(() => { - registration.replace({ ...withLocal, linkedRoots: [{ ...linked, realPath: join(f.root, 'work', 'b') }] }) - }).toThrow(/relinking "linked-plugin" requires a process restart/u) + // A link name may point to another directory once its previous target's plugins stopped. + registration.replace({ ...withLocal, linkedRoots: [{ ...linked, realPath: join(f.root, 'work', 'b') }] }) expect(registration.packageDir( '@deepseek-ai/dsh-core', pathToFileURL(join(f.profile.dir, 'entry.mjs')).href, )).toBe(f.installed) }) + it('selects the current target for a native lookup through a link Node resolved before it moved', async () => { + const f = fixture() + const first = join(f.root, 'work', 'first') + const second = join(f.root, 'work', 'second') + pkg(first, 'linked-lib', 1) + pkg(second, 'linked-lib', 2) + // An importer outside every interception layer resolves natively through the link. + const outside = join(f.root, 'outside') + const link = join(outside, 'node_modules', 'linked-lib') + mkdirSync(dirname(link), { recursive: true }) + symlinkSync(first, link, process.platform === 'win32' ? 'junction' : 'dir') + const parentPath = join(outside, 'entry.mjs') + file(parentPath, '') + const parent = pathToFileURL(parentPath).href + const base = await resolutionOf(f) + const linked = { name: 'linked-lib', realPath: realpathSync.native(first) } + const registration = installRuntimeInterception({ ...base, linkedRoots: [linked] }) + registrations.push(registration) + expect(fileURLToPath(resolveFrom('linked-lib', parent))).toBe(join(realpathSync(first), 'index.js')) + expect(createRequire(parentPath).resolve('linked-lib')).toBe(join(realpathSync(first), 'index.cjs')) + unlinkSync(link) + symlinkSync(second, link, process.platform === 'win32' ? 'junction' : 'dir') + registration.replace({ ...base, linkedRoots: [{ ...linked, realPath: realpathSync.native(second) }] }) + expect(fileURLToPath(resolveFrom('linked-lib', parent))).toBe(join(realpathSync.native(second), 'index.js')) + expect(createRequire(parentPath).resolve('linked-lib')).toBe(join(realpathSync.native(second), 'index.cjs')) + // A file only the previous target has, or a builtin, stays as Node selected it. + file(join(first, 'only-first.cjs'), '') + expect(createRequire(parentPath).resolve(join(first, 'only-first.cjs'))).toBe(join(realpathSync(first), 'only-first.cjs')) + expect(resolveFrom('node:fs', parent)).toBe('node:fs') + // An entry point's lookup has no importer. + expect(fileURLToPath(resolveFrom(pathToFileURL(join(link, 'index.js')).href, undefined))).toBe(join(realpathSync.native(second), 'index.js')) + // A file system without a native realpath, as a packed worker has, records the JavaScript spelling alone. + const native: unknown = Reflect.get(realpathSync, 'native') + Reflect.deleteProperty(realpathSync, 'native') + try { + registration.replace({ ...base, linkedRoots: [{ ...linked, realPath: realpathSync(second) }] }) + } finally { Reflect.set(realpathSync, 'native', native) } + registration.replace({ ...base, linkedRoots: [{ ...linked, realPath: realpathSync.native(second) }] }) + // A second move takes files of the first target straight to the latest one. + const third = join(f.root, 'work', 'third') + pkg(third, 'linked-lib', 3) + unlinkSync(link) + symlinkSync(third, link, process.platform === 'win32' ? 'junction' : 'dir') + registration.replace({ ...base, linkedRoots: [{ ...linked, realPath: realpathSync.native(third) }] }) + expect(createRequire(parentPath).resolve('linked-lib')).toBe(join(realpathSync.native(third), 'index.cjs')) + // Another link's move leaves this link's relocation as it is, and the innermost previous directory decides. + const outer = { name: 'outer-lib', realPath: realpathSync.native(join(f.root, 'work')) } + const atThird = { ...linked, realPath: realpathSync.native(third) } + registration.replace({ ...base, linkedRoots: [atThird, outer] }) + registration.replace({ ...base, linkedRoots: [atThird, { ...outer, realPath: realpathSync.native(f.root) }] }) + expect(createRequire(parentPath).resolve(join(first, 'index.cjs'))).toBe(join(realpathSync.native(third), 'index.cjs')) + unlinkSync(link) + symlinkSync(second, link, process.platform === 'win32' ? 'junction' : 'dir') + registration.replace({ ...base, linkedRoots: [{ ...linked, realPath: realpathSync.native(second) }] }) + // An importer below the previous target keeps resolving there, and pointing the link back ends the relocation. + expect(createRequire(join(first, 'index.cjs')).resolve('./index.js')).toBe(join(realpathSync(first), 'index.js')) + unlinkSync(link) + symlinkSync(first, link, process.platform === 'win32' ? 'junction' : 'dir') + registration.replace({ ...base, linkedRoots: [linked] }) + expect(createRequire(parentPath).resolve('linked-lib')).toBe(join(realpathSync(first), 'index.cjs')) + // A link installed again as a directory at the same path keeps Node's cached link real path, which is relocated too. + expect(createRequire(parentPath).resolve('linked-lib')).toBe(join(realpathSync(first), 'index.cjs')) + unlinkSync(link) + pkg(link, 'linked-lib', 4) + registration.replace({ ...base, linkedRoots: [], entries: [...base.entries, { + name: 'linked-lib', packageDir: link, version: '4.0.0', declarer: join(link, 'package.json'), scope: 'profile', + }] }) + expect(createRequire(parentPath).resolve('linked-lib')).toBe(join(realpathSync.native(link), 'index.cjs')) + }) + + it('rejects a retained profile package moved to another directory, and maps it there once it was removed', async () => { + const f = fixture() + const firstDir = join(f.root, 'bundle-a', 'node_modules', 'private-lib') + const secondDir = join(f.root, 'bundle-b', 'node_modules', 'private-lib') + pkg(firstDir, 'private-lib', 1) + pkg(secondDir, 'private-lib', 2) + const base = await resolutionOf(f) + const entry = { + name: 'private-lib', packageDir: firstDir, version: '1.0.0', + declarer: join(firstDir, 'package.json'), scope: 'profile' as const, + } + const first = { ...base, entries: [...base.entries, entry] } + const registration = installRuntimeInterception(first) + registrations.push(registration) + const parent = pathToFileURL(join(f.profile.dir, 'entry.mjs')).href + const moved = { ...first, entries: [...base.entries, { + ...entry, packageDir: secondDir, version: '2.0.0', declarer: join(secondDir, 'package.json'), + }] } + expect(() => { registration.replace(moved) }).toThrow(ResolutionRestartRequired) + registration.replace(base) + registration.replace(moved) + expect(registration.packageDir('private-lib', parent)).toBe(secondDir) + }) + + it('reports a package whose link keeps its path but points to another directory', async () => { + const f = fixture() + const firstDir = join(f.root, 'source-a') + const secondDir = join(f.root, 'source-b') + pkg(firstDir, 'linked-lib', 1) + pkg(secondDir, 'linked-lib', 2) + const link = join(f.root, 'links', 'linked-lib') + mkdirSync(dirname(link), { recursive: true }) + symlinkSync(firstDir, link, process.platform === 'win32' ? 'junction' : 'dir') + const base = await resolutionOf(f) + const entry = { name: 'linked-lib', packageDir: link, version: '1.0.0', declarer: join(link, 'package.json'), scope: 'profile' as const } + const resolution = { ...base, entries: [...base.entries, entry], linkedRoots: [{ name: 'linked-lib', realPath: realpathSync(firstDir) }] } + const registration = installRuntimeInterception(resolution) + registrations.push(registration) + unlinkSync(link) + symlinkSync(secondDir, link, process.platform === 'win32' ? 'junction' : 'dir') + // The mapping and the linked root report the same package once. + const relinked = { ...resolution, linkedRoots: [{ name: 'linked-lib', realPath: realpathSync(secondDir) }] } + expect(() => { registration.replace(relinked) }).toThrow(/^profile resolution: replacing "linked-lib" requires a process restart$/u) + }) + it('removes profile mappings and local names while retaining changed profile mappings as restarts', async () => { const f = fixture() const firstDir = join(f.root, 'bundle-a', 'node_modules', 'private-lib') @@ -1708,7 +1823,19 @@ describe('runtime resolution', { concurrent: false }, () => { } }) - it.each(['directory', 'version', 'scope'] as const)('rejects a retained profile mapping with a different %s', async (change) => { + it('accepts a retained profile mapping upgraded in place', async () => { + const f = fixture() + const packageDir = join(f.root, 'bundle', 'node_modules', 'private-lib') + pkg(packageDir, 'private-lib', 1) + const base = await resolutionOf(f) + const entry = { name: 'private-lib', packageDir, declarer: join(packageDir, 'package.json'), version: '1.0.0', scope: 'profile' as const } + const registration = installRuntimeInterception({ ...base, entries: [...base.entries, entry] }) + registrations.push(registration) + registration.replace({ ...base, entries: [...base.entries, { ...entry, version: '2.0.0' }] }) + expect(registration.packageDir('private-lib', pathToFileURL(join(f.profile.dir, 'entry.mjs')).href)).toBe(packageDir) + }) + + it.each(['directory', 'scope'] as const)('rejects a retained profile mapping with a different %s', async (change) => { const f = fixture() const packageDir = join(f.root, 'bundle', 'node_modules', 'private-lib') pkg(packageDir, 'private-lib', 1) @@ -1719,7 +1846,6 @@ describe('runtime resolution', { concurrent: false }, () => { const successor = { ...entry, packageDir: change === 'directory' ? join(f.root, 'different') : entry.packageDir, - version: change === 'version' ? '2.0.0' : entry.version, scope: change === 'scope' ? 'installation' as const : entry.scope, } @@ -2205,7 +2331,7 @@ describe('runtime resolution', { concurrent: false }, () => { }) it.each(['initial', 'added'] as const)( - 'rejects a removed %s root retarget without reactivating either directory', async (generation) => { + 'accepts a removed %s root retargeted to another directory', async (generation) => { const { f, linkedRoot } = unsavedFixture() const link = join(f.profile.dir, 'node_modules', 'my-plugin') const linkType = process.platform === 'win32' ? 'junction' : 'dir' @@ -2237,11 +2363,10 @@ describe('runtime resolution', { concurrent: false }, () => { const successor = await resolutionOf(f) expect(successor.localPackageNames).toEqual([]) expect(successor.linkedRoots).toEqual([{ name: 'my-plugin', realPath: target }]) - expect(() => { registration.replace(successor) }) - .toThrow(/^profile resolution: relinking "my-plugin" requires a process restart$/u) + registration.replace(successor) expectPeerLookup(registration, require, parent, join(linkedRoot, 'node_modules', peer)) expectPeerLookup(registration, createRequire(join(target, 'entry.cjs')), - pathToFileURL(join(target, 'entry.mjs')).href, join(target, 'node_modules', peer)) + pathToFileURL(join(target, 'entry.mjs')).href, f.installed) } finally { registration.dispose() registrations.pop() @@ -2249,64 +2374,6 @@ describe('runtime resolution', { concurrent: false }, () => { }, ) - it('does not retain targets from a rejected successor', async () => { - const { f, linkedRoot } = unsavedFixture() - const modules = join(f.profile.dir, 'node_modules') - const originalLink = join(modules, 'my-plugin') - const newLink = join(modules, 'a-new-plugin') - const replacement = join(f.root, 'work', 'replacement') - const rejectedTarget = join(f.root, 'work', 'rejected-target') - const acceptedTarget = join(f.root, 'work', 'accepted-target') - for (const [dir, name, marker] of [ - [replacement, 'my-plugin', 22], - [rejectedTarget, 'a-new-plugin', 31], - [acceptedTarget, 'a-new-plugin', 32], - ] as const) { - pkg(dir, name, 0, {}, { [peer]: '*' }) - pkg(join(dir, 'node_modules', peer), peer, marker) - } - const require = createRequire(join(linkedRoot, 'entry.cjs')) - const parent = pathToFileURL(join(linkedRoot, 'entry.mjs')).href - const first = await resolutionOf(f) - expect(first.localPackageNames).toEqual([]) - const registration = installRuntimeInterception(first) - registrations.push(registration) - try { - expectPeerLookup(registration, require, parent, f.installed) - unlinkSync(originalLink) - const removed = await resolutionOf(f) - expect(removed.linkedRoots).toEqual([]) - registration.replace(removed) - expectPeerLookup(registration, require, parent, join(linkedRoot, 'node_modules', peer)) - - const linkType = process.platform === 'win32' ? 'junction' : 'dir' - symlinkSync(replacement, originalLink, linkType) - symlinkSync(rejectedTarget, newLink, linkType) - const rejected = await resolutionOf(f) - expect(rejected.linkedRoots).toEqual([ - { name: 'a-new-plugin', realPath: rejectedTarget }, - { name: 'my-plugin', realPath: replacement }, - ]) - expect(() => { registration.replace(rejected) }) - .toThrow(/^profile resolution: relinking "my-plugin" requires a process restart$/u) - expectPeerLookup(registration, require, parent, join(linkedRoot, 'node_modules', peer)) - expectPeerLookup(registration, createRequire(join(rejectedTarget, 'entry.cjs')), - pathToFileURL(join(rejectedTarget, 'entry.mjs')).href, join(rejectedTarget, 'node_modules', peer)) - - unlinkSync(originalLink) - unlinkSync(newLink) - symlinkSync(acceptedTarget, newLink, linkType) - const accepted = await resolutionOf(f) - expect(accepted.linkedRoots).toEqual([{ name: 'a-new-plugin', realPath: acceptedTarget }]) - registration.replace(accepted) - expectPeerLookup(registration, createRequire(join(acceptedTarget, 'entry.cjs')), - pathToFileURL(join(acceptedTarget, 'entry.mjs')).href, f.installed) - expectPeerLookup(registration, require, parent, join(linkedRoot, 'node_modules', peer)) - } finally { - registration.dispose() - registrations.pop() - } - }) }) it.each(['src', 'dist'] as const)( diff --git a/packages/boot/app-boot/tests/user-patches.spec.ts b/packages/boot/app-boot/tests/user-patches.spec.ts index 465f9d82c6..09e5653c56 100644 --- a/packages/boot/app-boot/tests/user-patches.spec.ts +++ b/packages/boot/app-boot/tests/user-patches.spec.ts @@ -337,7 +337,10 @@ describe('profile reconciliation settlement', () => { const patches = [{ insert: [{ id: 'missing-plugin', name: './missing.mjs' }] }] const ctx = await boot(NAME, join(dir, 'cordis.yml'), patches) onTestFinished(() => ctx.fiber.dispose()) - expect(await reconcileProfilePatches(ctx, patches, NAME)).toEqual(['missing-plugin (./missing.mjs): failed to import']) + // Reload diagnostics carry the import error's message without stack frames. + const prefix = 'missing-plugin \\(\\./missing\\.mjs\\): failed to import: Error \\[ERR_MODULE_NOT_FOUND\\]' + const diagnostic = new RegExp(`^${prefix}: Cannot find module '[^']*missing\\.mjs' imported from [^\\n]*$`, 'u') + expect(await reconcileProfilePatches(ctx, patches, NAME)).toEqual([expect.stringMatching(diagnostic)]) }) it('rejects a context without the launcher root Include', async () => { diff --git a/packages/boot/app-boot/tsconfig.json b/packages/boot/app-boot/tsconfig.json index e59c38f2ba..9c3e57c809 100644 --- a/packages/boot/app-boot/tsconfig.json +++ b/packages/boot/app-boot/tsconfig.json @@ -20,9 +20,6 @@ { "path": "../../../vendor/group" }, - { - "path": "../../core/system-prompt" - }, { "path": "../../util/launch-environment" }, diff --git a/packages/boot/hmr/README.i18n.yaml b/packages/boot/hmr/README.i18n.yaml index 0c7fcc7a1f..776c0d0fe7 100644 --- a/packages/boot/hmr/README.i18n.yaml +++ b/packages/boot/hmr/README.i18n.yaml @@ -18,11 +18,11 @@ en: 9fd376ac44e7f515 zh: 83bd165982e99d2a /deepseek-ai-dsh-hmr/use-this-package/configuration: - en: 45ee194ad781d603 - zh: b3d7fcd8ed73717c + en: 11ef18c6d08f39f3 + zh: 2911cdd03ff3747d /deepseek-ai-dsh-hmr/understand-the-implementation: - en: 8c19121bdb5176c1 - zh: 8a01a60ecca6175c + en: bd608f418630317f + zh: 053fa77a3fa571a9 /deepseek-ai-dsh-hmr/model-experience: en: 215e7ba838619b7b zh: a311da3843709f90 @@ -39,8 +39,8 @@ en: 89e0624fafd701fb zh: 19d36a33c1d9af9d /deepseek-ai-dsh-hmr/known-limitations-and-deferred-work: - en: 0f46d4df1dab7f3b - zh: d172835159a20cce + en: 709368e0d6a644c2 + zh: 2d0f76c2cece79dc /deepseek-ai-dsh-hmr/known-limitations-and-deferred-work/dev-note: en: 7972e4b2005ec9a4 zh: 754c49ec538c164c diff --git a/packages/boot/hmr/README.md b/packages/boot/hmr/README.md index 97ee67c8e9..8d2270b4a1 100644 --- a/packages/boot/hmr/README.md +++ b/packages/boot/hmr/README.md @@ -46,6 +46,8 @@ Existing configurations replace the module name `@deepseek-ai/cordis-plugin-hmr` Chokidar options, including polling, retain their existing meaning. Exact configuration watches also observe additions, removals and initially missing parent directories. They default to `awaitWriteFinish: true`: edits wait for Chokidar's 2-second write-stability window, avoiding its lossy change-event throttle. Configure `awaitWriteFinish` to adjust that window; disabling it can miss rapid consecutive edits. Direct Plugin Manager operations apply without waiting for file events. +Profile recompositions run by HMR's profile watches and by Plugin Manager also replace plugins whose loaded files below the profile directory changed on disk. This covers installed packages below `node_modules` and the targets of linked packages. A package whose files changed in place, such as a linked package whose build output changed or a dependency another installation upgraded, therefore takes effect without a restart at the next such recomposition; source watching through `root` is not required. A replacement that fails to load stops the previous plugin and is reported as `failed` with its load error instead of as a restart, which would load the same files; every later recomposition imports it again, so it starts once its files or dependencies are corrected. + ----- @@ -58,6 +60,16 @@ Chokidar options, including polling, retain their existing meaning. Exact config App-boot owns profile parsing and patch precedence. HMR reads the launcher’s data-only `profileContext`, registers the profile manifest and both user patch watches during initialization, and waits for application readiness before processing changes. Its disposal closes the watchers and cancels reloads waiting for startup. Configuration watchers start outside the active transaction context so later notifications can enter the queue. +`reconcileProfile()` is shared by HMR’s profile watches and Plugin Manager; callers hold `runExclusive()`. Config Editor recomposes through app-boot without replacing modules. App-boot prepares and preflights the generation before caches or running entries change. + +File comparison covers the profile directory and linked package targets, recorded when the application becomes ready. [ModuleFiles](src/module-files.ts) records inode, size and modification time; adding a hard link alone does not invalidate a module. Existing modules retain the identities read before replacement, preserving later writes. Newly loaded modules are recorded at settlement so a subsequent installation does not restart them merely because they are recent. + +Replacement follows ES module links and every CommonJS parent’s `children`. A module's plugins are read from its ES module namespace and from the CommonJS exports Node already cached, without evaluating anything, so a CommonJS plugin registered outside a Loader entry keeps its cache. Host dependencies and plugins outside the traversable Loader tree stay pending. The remaining changed modules and their importers are evicted without a backup, and the Loader entries running affected plugins stop. HMR imports no replacement code itself: the Loader imports the current files when the generation starts an entry, so disabled, removed and incompatible rows never execute it, and dynamic `disabled` expressions and nested Includes are evaluated as at startup. + +Affected runtimes stop without making their entries disabled, and `reconcileProfile()` passes them to app-boot's single `recomposeProfile()` call, which starts them, including those below an unchanged Include and those outside the profile composition, without importing an entry twice. A failure before any stop leaves every plugin running; stopped plugins start again with the current resolution when no generation applied. After eviction, a replacement that fails to import or start is an activation failure and rejects the operation; its previous code no longer runs. Every recomposition imports entries whose import failed again, after expiring the package configuration below the profile roots and evicting modules whose links failed, so a corrected file or a dependency installed later takes effect without a restart. [ProfileReconciliation](../../../docs/subsystems/boot.md#profile-reconciliation) lists changes requiring a restart. A module first loaded during a recomposition whose file was written after the comparison stays stale, so a write during activation is loaded next time; comparison or cache-inspection failure requires a restart even when no changed URL is known. HMR’s watches log the result. + +After each recomposition, HMR evicts the loaded modules below the directories of the mappings the recomposition dropped and expires their package configuration, so the packages a removed or deselected bundle depends on, including those it imports dynamically, are imported afresh when it returns. A bundle package is not a mapping: its own files are replaced when they changed on disk, and a bundle switched off while its files stay unchanged keeps its modules loaded. A publication the runtime resolution rejects, such as a retained profile mapping that moved to another directory, stops and starts again the rows the generation would drop and leaves the composition as it was; the result has `restartReason: 'package-moved'`. + A changed `package.json` outside `node_modules` expires that package's cached configuration. A configuration-only manifest triggers no module reload; a manifest imported as a JSON module retains dependency-driven reloads, and one in the host dependency graph retains host reloads. Dedicated configuration watches keep their existing ownership. A new package entry takes effect when its Loader entry restarts. Loader entries retain their raw import results. HMR retains every distinct imported namespace for each entry name and configuration-tree base URL; an uninitialized entry does not overwrite another entry's namespace. HMR matches those objects to cached ModuleJob namespaces instead of resolving entry names again. Names without an imported namespace and `cordis:` builtins keep name-based resolution. Source reloads therefore use the loaded modules after `exports` or `main` changes. @@ -94,7 +106,16 @@ Reloading a contributing plugin can change later request prefixes; HMR does not - Module replacement requires Node loader internals. Framework dependency changes call the host-provided `loader.exit()` hook; HMR itself does not restart the process. -- Replacing installed package versions still requires a restart through Plugin Manager; manifests below `node_modules` keep Node's cached configuration. The browser Client module graph retains its separate browser-side loading mechanism. +- A change confined to `package.json` is not detected. Watcher notifications for manifests below `node_modules` are still ignored. The browser Client module graph retains its separate browser-side loading mechanism. +- Replacement follows static imports. A dynamic `import()` of a stale module evaluates the new file on its next call while its importer keeps running; existing Workers and the TSX loader thread keep their module caches. +- Every symbolic link below the profile's `node_modules` widens the comparison scope. Linking a package that the process entry point, located through the real path of a bin link, imports statically, such as `dsh-app-boot`, Cordis or the Loader from a dsh checkout, brings host modules into scope; their changes stay pending, and Plugin Manager reports `restart-required` for later operations until the process restarts. Linked dsh packages that Loader entries import are replaced like other plugins. +- The comparison records belong to one HMR instance. A recomposition that restarts the `hmr` entry itself records the current files again, so changes pending at that point are no longer reported or retried. +- A replacement that fails to import or start stays inactive until its files or dependencies are corrected; the previous version is not restored. +- Compatibility checks run only where DSH composes rows: startup, profile recomposition and preset mounting; the Loader checks nothing when it imports a module. A replaced profile row passes the recomposition's preflight, but a row outside the profile composition, such as a preset row or an entry added through `ctx.loader.create()`, restarts with replaced code without a check. Plugin Manager refuses incompatible versions, so this affects packages changed outside it, such as a manual `pnpm add` in the profile directory or a rebuilt `link:` package. +- The [packed worker loader](../../experimental/webworker-runtime/src/module-system/module-loader.ts) exposes neither Node’s `loadCache` nor `require.cache`; configuration recomposition remains available, but code replacement requires a restart. Native Node retains these caches even with asynchronous module hooks. The [packed worker browser test](../../../apps/web/tests/preview-boot.e2e.ts) exercises this deployment. +- A newly loaded file is first recorded after recomposition settles and counts as changed when its modification time follows the comparison. The comparison time is the modification time of a marker file HMR writes in the temporary directory, so on one machine both come from the clock local file writes are stamped with; an edit within the timestamp granularity of the profile's filesystem after the comparison counts as earlier and can be missed, and network filesystems stamp writes with the server's clock. +- A file replaced by a hard link to an older store file keeps an older modification time; when its module was first loaded after the last recording, the change is not detected. +- Profile recomposition caused by HMR's own watches does not run for dependency-only manifest changes, so an install performed outside Plugin Manager takes effect at the next recomposition or restart. - TSX loaders and ordinary application-created Workers are outside the supported package-cache refresh scope. - CommonJS requests must be resolvable by Node's default resolver. Synchronous hooks may postprocess the resulting filenames, as Desktop's Office resolver does; hooks requiring the original request name or introducing virtual requests are unsupported. Builtins retain Node's original loading behavior. - Future work: retain package invalidation across HMR disposal and replacement. Restoring the native readers can expose their older cached configuration again. diff --git a/packages/boot/hmr/README.zh.md b/packages/boot/hmr/README.zh.md index a7a9f84b1b..21044fe7d1 100644 --- a/packages/boot/hmr/README.zh.md +++ b/packages/boot/hmr/README.zh.md @@ -46,6 +46,8 @@ kind: "package-reference" Chokidar 选项(包括轮询)保持原有含义。精确配置监听同时观察新增、删除及初始不存在的父目录。它们默认使用 `awaitWriteFinish: true`:编辑后等待 Chokidar 的 2 秒写入稳定窗口,避免其变化事件节流丢失通知。可通过 `awaitWriteFinish` 调整窗口;禁用它可能漏掉快速连续编辑。直接通过 Plugin Manager 发起的操作无需等待文件事件即可应用。 +HMR 的 profile 监听和 Plugin Manager 执行的 profile 重新组合,还会替换 profile 目录下已加载文件在磁盘上发生变化的插件。这包括 `node_modules` 之下的已安装包,以及已链接包的目标。因此,文件被原地更改的包,例如构建输出发生变化的已链接包,或被另一次安装升级的依赖,会在下一次此类重新组合时生效,无需重启;不要求通过 `root` 监听源码。替换版本加载失败时,原插件被停止,并以 `failed` 及其加载错误报告,而不是报告为需要重启,因为重启会加载相同的文件;此后每次重新组合都会再次导入它,因此其文件或依赖被修正后它即启动。 + ----- @@ -58,6 +60,16 @@ Chokidar 选项(包括轮询)保持原有含义。精确配置监听同时 App-boot 负责 profile 解析和 patch 优先级规则。HMR 读取启动器提供的纯数据 `profileContext`,在初始化时注册 profile manifest 和两份用户 patch 的监听,并等待应用就绪后处理更改。销毁 HMR 时会关闭监听器并取消等待启动的重载。配置监听器在当前事务上下文之外启动,使后续通知可以进入队列。 +`reconcileProfile()` 由 HMR 的 profile 监听和 Plugin Manager 共用;调用方持有 `runExclusive()`。Config Editor 通过 app-boot 重新组合,不替换模块。app-boot 在缓存或运行中条目发生变化前准备新一代组合并执行预检。 + +文件比较覆盖 profile 目录及已链接包的目标,在应用就绪时记录。[ModuleFiles](src/module-files.ts) 记录 inode、大小和修改时间;仅添加硬链接不会使模块失效。已有模块保留替换前读取的身份,以检测之后的写入。新加载的模块在结算时记录,因此后续安装不会仅因其文件较新而将其重启。 + +替换沿 ES 模块链接以及每个 CommonJS 父模块的 `children` 传播。模块的插件从其 ES 模块命名空间以及 Node 已缓存的 CommonJS exports 中读取,不求值任何代码,因此在 Loader entry 之外注册的 CommonJS 插件保留其缓存。宿主依赖及可遍历 Loader 树之外的插件保持待处理。其余已变化模块及其导入方被逐出且不保留备份,运行受影响插件的 Loader entry 被停止。HMR 本身不导入替换代码:新一代组合启动条目时,Loader 导入当前文件,因此停用、删除和不兼容的行永远不会执行替换代码,动态 `disabled` 表达式和嵌套 Include 按启动时的方式求值。 + +受影响的运行时停止,但其条目不会被标记为停用;`reconcileProfile()` 把它们传给 app-boot 的单次 `recomposeProfile()` 调用,由该调用启动它们,包括未变化 Include 之下的条目和 profile 组合之外的条目,且不会重复导入同一条目。任何插件停止之前发生的失败会让所有插件继续运行;没有新一代组合被应用时,已停止的插件以当前 resolution 重新启动。逐出之后,导入或启动失败的替换版本属于激活失败,使操作失败;其旧代码不再运行。每次重新组合都会先让 profile 根目录下的包配置失效并逐出链接失败的模块,再次导入此前导入失败的条目,因此修正后的文件或之后安装的依赖无需重启即生效。[ProfileReconciliation](../../../docs/subsystems/boot.zh.md#profile-reconciliation) 列出需要重启的变化。在重新组合期间首次加载、且其文件在比较之后被写入的模块保持陈旧,因此激活期间的写入会在下一次加载;比较或缓存检查失败时,即使无法确定已变化的 URL,也要求重启。HMR 的监听记录这些结果。 + +每次重新组合之后,HMR 逐出重新组合删除的映射所在目录之下的已加载模块,并使这些包的配置过期,因此已删除或取消选入的组合包连同其动态导入的依赖,在它再次出现时重新导入。组合包的包本身不是映射:其文件在磁盘上变化时被替换;关闭组合包而其文件未变化时,它的模块保持加载。runtime resolution 拒绝的发布(例如移动到其他目录的保留 profile 映射)会停止并重新启动新一代组合将删除的行,组合保持不变;结果带有 `restartReason: 'package-moved'`。 + `node_modules` 之外的 `package.json` 变化使该包的配置缓存失效。仅作为配置的 manifest 不触发模块重载;作为 JSON 模块导入的 manifest 保留依赖驱动重载,位于宿主依赖图中的 manifest 保留宿主重载。专属配置监听保留原有归属。新包入口在 Loader entry 重启时生效。 Loader entry 保留原始导入结果。HMR 为每个 entry 名称与配置树 base URL 保留所有不同的已导入命名空间;尚未初始化的 entry 不会覆盖其他 entry 的命名空间。HMR 将这些对象与缓存中 ModuleJob 的模块命名空间按对象身份匹配,不再重新解析 entry 名称。没有已导入命名空间的名称和 `cordis:` 内置入口仍按名称解析。因此 `exports` 或 `main` 改变后,源码重载仍使用已加载模块。 @@ -94,7 +106,16 @@ Loader entry 保留原始导入结果。HMR 为每个 entry 名称与配置树 b - 模块替换需要 Node loader 内部接口。框架依赖变化调用宿主提供的 `loader.exit()` 钩子;HMR 本身不重启进程。 -- 通过插件管理器替换已安装包版本仍需要重启;`node_modules` 之下的 manifest 保留 Node 缓存的配置。浏览器 Client 模块图保留独立的浏览器侧加载机制。 +- 仅限于 `package.json` 的变化不会被检测到。`node_modules` 之下 manifest 的监听通知仍被忽略。浏览器 Client 模块图保留独立的浏览器侧加载机制。 +- 替换沿静态导入传播。对陈旧模块的动态 `import()` 在下次调用时求值新文件,而其导入方继续运行;已存在的 Worker 和 TSX loader 线程保留各自的模块缓存。 +- profile 的 `node_modules` 之下的每个符号链接都会扩大比较范围。链接进程入口(通过 bin 链接的真实路径定位)静态导入的包(例如来自 dsh checkout 的 `dsh-app-boot`、Cordis 或 Loader)会把宿主模块纳入范围;这些模块的变化保持待处理,Plugin Manager 对后续操作报告 `restart-required`,直到进程重启。Loader entry 导入的已链接 dsh 包与其他插件一样被替换。 +- 比较记录属于单个 HMR 实例。重启 `hmr` 条目本身的重新组合会重新记录当前文件,因此此时待处理的变化不再被报告或重试。 +- 导入或启动失败的替换版本保持未激活,直到其文件或依赖被修正;旧版本不会被恢复。 +- 兼容性检查只在 DSH 组合行时运行:启动、profile 重新组合和 preset 挂载;Loader 导入模块时不做任何检查。被替换的 profile 行会通过重新组合的预检,但 profile 组合之外的行(例如 preset 行,或通过 `ctx.loader.create()` 添加的条目)以替换后的代码重启,不经检查。Plugin Manager 拒绝不兼容的版本,因此这只影响在它之外变化的包,例如在 profile 目录中手动执行 `pnpm add`,或重新构建的 `link:` 包。 +- [打包 worker loader](../../experimental/webworker-runtime/src/module-system/module-loader.ts) 不暴露 Node 的 `loadCache` 或 `require.cache`;配置重新组合仍可执行,但代码替换需要重启。原生 Node 即使使用异步模块 hook 也保留这些缓存。[打包 worker 浏览器测试](../../../apps/web/tests/preview-boot.e2e.ts) 覆盖此部署。 +- 新加载文件首次在重新组合结算后记录,其修改时间晚于比较时间时计为已变化。比较时间是 HMR 在临时目录中写入的标记文件的修改时间,因此在同一台机器上,两者都来自为本地文件写入记时的时钟;在比较之后、处于 profile 所在文件系统时间戳精度之内的修改被视为更早,可能被漏检;网络文件系统使用服务器的时钟为写入记时。 +- 若文件被替换为指向较旧 store 文件的硬链接,它保留较旧的修改时间;当其模块在上次记录之后才首次加载时,该变化不会被检测到。 +- HMR 自身监听触发的 profile 重新组合不会因仅涉及依赖的 manifest 变化而执行,因此在 Plugin Manager 之外执行的安装在下一次重新组合或重启时生效。 - TSX loader 和普通应用自行创建的 Worker 不在包缓存刷新支持范围内。 - CommonJS 请求必须能由 Node 默认 resolver 解析。同步 hook 可以像 Desktop 的 Office resolver 一样处理解析后的文件名;依赖原始请求名或引入虚拟请求的 hook 不受支持。内置模块保留 Node 原有的加载行为。 - 后续工作:跨 HMR 销毁和替换保留包配置失效状态。恢复原生 reader 后,其旧配置缓存可能重新可见。 diff --git a/packages/boot/hmr/src/index.ts b/packages/boot/hmr/src/index.ts index cad957dbda..47d71d9109 100644 --- a/packages/boot/hmr/src/index.ts +++ b/packages/boot/hmr/src/index.ts @@ -2,16 +2,19 @@ import assert from 'node:assert/strict' import { AsyncLocalStorage } from 'node:async_hooks' import { watchConfig as watchExactConfig } from './watch-config.ts' -import { Context, Inject, Service, type Fiber, type Plugin } from '@deepseek-ai/cordis' -import type { ModuleLoader, ModuleJob, ResolveResult } from '@deepseek-ai/cordis-plugin-loader' +import { Context, FiberState, Inject, Service, type Fiber, type Plugin } from '@deepseek-ai/cordis' +import type { Entry, ModuleLoader, ModuleJob, ResolveResult } from '@deepseek-ai/cordis-plugin-loader' import type { Include } from '@deepseek-ai/cordis-plugin-include' import { FSWatcher, watch, type ChokidarOptions } from 'chokidar' import { basename, dirname, join, relative, resolve, sep } from 'node:path' -import { readFileSync, realpathSync } from 'node:fs' -import { readProfileManifest, readProfilePatches, reconcileProfilePatches, PROFILE_PATCH_FILENAME } from '@deepseek-ai/dsh-app-boot' +import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync } from 'node:fs' +import { + readProfileManifest, readProfilePatches, recomposeProfile, PROFILE_PATCH_FILENAME, type RuntimeResolution, +} from '@deepseek-ai/dsh-app-boot' import type {} from '@deepseek-ai/dsh-cmdline' import { handleError } from './error.ts' import { PackageManifests } from './package-manifest.ts' +import { ModuleFiles } from './module-files.ts' import type {} from '@deepseek-ai/cordis-plugin-timer' import { fileURLToPath, pathToFileURL } from 'node:url' import { createRequire } from 'node:module' @@ -63,16 +66,16 @@ export interface HmrConfig extends ChokidarOptions { /** * Recursively collect all module dependencies from a ModuleJob. - * Skips node: builtins and node_modules to focus on user code. + * Skips node: builtins, and node_modules unless `includeNodeModules` is set, to focus on user code. */ -async function loadDependencies(job: ModuleJob, ignored = new Set()) { +async function loadDependencies(job: ModuleJob, ignored = new Set(), includeNodeModules = false) { const dependencies = new Set() async function traverse(job: ModuleJob) { if (ignored.has(job.url) || dependencies.has(job.url)) return - if (job.url.startsWith('node:') || job.url.includes('/node_modules/')) return + if (job.url.startsWith('node:') || (!includeNodeModules && job.url.includes('/node_modules/'))) return dependencies.add(job.url) - const children = await job.linked - await Promise.all(Array.prototype.map.call(children, traverse)) + const children = await linkedJobs(job) + await Promise.all(children.map(traverse)) } await traverse(job) return dependencies @@ -254,6 +257,9 @@ class Hmr extends Service { private closing = false private readonly configPaths = new Set() private readonly manifests = new PackageManifests() + private readonly files = new ModuleFiles() + /** Every module statically reachable from the process entry point, including those under node_modules; collected on first use. */ + private hostModules!: () => Promise> /** Serialize a caller-owned mutation with all automatic reload paths. * @param operation Work that must not overlap module or configuration replacement. @@ -308,7 +314,48 @@ class Hmr extends Service { this.baseDir = fileURLToPath(new URL(config.base || '.', ctx.baseUrl)) } + /** + * The cached ES module job for a URL. The packed worker loader exposes no Node module cache; native Node + * retains this cache even when asynchronous module hooks run in a separate thread. + */ + private esmJob(url: string): ModuleJob | undefined { + return this.hasEsmCache() ? this.internal.loadCache.get(url) : undefined + } + + /** Whether this thread keeps a Node ES module cache; the packed worker loader keeps none. */ + private hasEsmCache(): boolean { + const cache: unknown = Reflect.get(this.internal, 'loadCache') + return cache !== undefined + } + + /** Whether the ES module cache holds a job for a URL; `false` when this thread keeps no ES module cache. */ + private esmHas(url: string, type?: string): boolean { + return this.hasEsmCache() && (type === undefined ? this.internal.loadCache.has(url) : this.internal.loadCache.has(url, type)) + } + + /** ES module cache keys, empty when this thread keeps no ES module cache. */ + private esmUrls(): string[] { + return this.hasEsmCache() ? [...Map.prototype.keys.call(this.internal.loadCache) as Iterable] : [] + } + + /** ES module cache keys and CommonJS cache filenames as URLs. */ + private* loadedUrls(): Generator { + yield* this.esmUrls() + for (const filename of Object.keys(commonJsCache())) yield pathToFileURL(filename).href + } + + /** The module job of the process entry point, when the loader cached it under the entry's real path. */ + private mainJob(): ModuleJob | undefined { + const entry = process.argv[1] + if (entry === undefined) return undefined + // A package bin starts through a link, and Node caches the module under its target. + return this.esmJob(pathToFileURL(canonicalPath(resolve(entry))).href) + } + async* [Service.init](): AsyncGenerator<() => Promise, void, unknown> { + const mainJob = this.mainJob() + let host: Promise> | undefined + this.hostModules = () => host ??= mainJob === undefined ? Promise.resolve(new Set()) : loadDependencies(mainJob, new Set(), true) yield async () => { this.closing = true await this.watcher?.close() @@ -323,7 +370,16 @@ class Hmr extends Service { if (ready === undefined) throw new Error('Profile HMR requires application readiness') const started = Promise.withResolvers() this.applicationReady = started.promise - const unsubscribe = ready.onReady(() => { started.resolve(true) }) + const unsubscribe = ready.onReady(() => { + // Plugins started with the application are recorded; modules loaded later are compared by modification time. + try { + this.files.observe(this.profileModules(profileRoots(profile.dir))) + } catch (error) { + // Without a first record, modules are compared by modification time against this HMR instance’s creation time. + this.ctx.logger.warn(error) + } + started.resolve(true) + }) yield () => { unsubscribe(); started.resolve(false); return Promise.resolve() } const manifestPath = join(profile.dir, 'package.json') const patchFiles = [profile.patchPath, join(profile.home, PROFILE_PATCH_FILENAME)] @@ -340,8 +396,9 @@ class Hmr extends Service { } })]) if (inputs === lastInputs) return - const patches = readProfilePatches('dsh', profile) - const warnings = await reconcileProfilePatches(this.ownerContext.root, patches, 'dsh') + const { warnings, pending, restartReason } = await this.reconcileProfile() + if (restartReason !== undefined) this.ctx.logger.warn('restart required: %s', restartReason) + if (pending.length > 0) this.ctx.logger.warn('changed modules keep running their previous code until a restart: %o', pending) lastInputs = inputs lastBundles = bundles for (const diagnostic of warnings) this.ctx.logger.warn(diagnostic) @@ -363,8 +420,6 @@ class Hmr extends Service { // Collect externals before opening the watcher so every post-ready change // is observed by listeners that already have their classification state. - const mainJob = process.argv[1] === undefined ? undefined - : this.internal.loadCache.get(pathToFileURL(resolve(process.argv[1])).href) if (mainJob) { this.externals = await loadDependencies(mainJob) } else { @@ -399,7 +454,7 @@ class Hmr extends Service { fullReload = true continue } - if (this.internal.loadCache.has(url) || this.internal.loadCache.has(url, 'json')) { + if (this.esmHas(url) || this.esmHas(url, 'json')) { this.stashed.add(url) continue } @@ -454,7 +509,7 @@ class Hmr extends Service { * @returns Linked module URLs, or an empty list for an uncached module. */ async getLinked(url: string): Promise { - const job = this.internal.loadCache.get(url) + const job = this.esmJob(url) if (!job) return [] const linked = await job.linked return Array.prototype.map.call(linked, (job: ModuleJob) => job.url) as string[] @@ -546,7 +601,7 @@ class Hmr extends Service { try { const { url } = await names.resolve(name, baseUrl, moduleNamespace) if (this.declined.has(url)) continue - const job = this.internal.loadCache.get(url) + const job = this.esmJob(url) const plugin = this.ctx.loader.unwrapExports(job?.module?.getNamespace()) as Plugin | undefined if (!job || !plugin) continue pending.set(job, plugin) @@ -584,49 +639,7 @@ class Hmr extends Service { if (ReloadModules.include(reloads, plugin, job, this.ctx.registry.get(plugin))) this.accepted.add(job.url) } - /** - * Clear module caches for all accepted files before re-importing. - * - * We need to clear both: - * 1. ESM loadCache — managed by Node's internal ModuleLoader - * 2. CJS Module._cache — for CJS modules that were imported via import() - * - * In Node 24, CJS modules loaded via import() appear in both caches. - * If we only clear loadCache, the CJS cache may serve stale modules. - * - * We use Map.prototype methods directly on loadCache because: - * - In Node 22/23, loadCache is a plain Map - * - In Node 24, loadCache is a LoadCache extends Map - * where .delete() only sets the type slot to undefined (doesn't remove the entry) - * Using Map.prototype.delete ensures complete removal in both versions. - */ - const esmBackup = new Map() - const cjsBackup = new Map() - const require = createRequire(import.meta.url) - for (const filename of this.accepted) { - // Backup and clear ESM loadCache - const job: unknown = Map.prototype.get.call(this.internal.loadCache, filename) - esmBackup.set(filename, job) - Map.prototype.delete.call(this.internal.loadCache, filename) - - // Backup and clear CJS Module._cache - try { - const filepath = fileURLToPath(filename) - if (require.cache[filepath]) { - cjsBackup.set(filepath, require.cache[filepath]) - Reflect.deleteProperty(require.cache, filepath) - } - } catch { - // filename might not be a file: URL (e.g. node: protocol), ignore - } - } - - const rollback = () => { - for (const [filename, job] of esmBackup) { - Map.prototype.set.call(this.internal.loadCache, filename, job) - } - for (const [filepath, module] of cjsBackup) require.cache[filepath] = module - } + const rollback = this.evict(this.accepted) // Attempt to re-import all plugin entry files const generations = [...reloads].map(([previous, info]) => ({ @@ -726,8 +739,386 @@ class Hmr extends Service { replacements.updateEntries(this.ctx.loader) } this.ctx.emit('hmr/reload', reloads) + this.files.refresh(this.accepted) this.stashed = new Set() } + + /** + * Remove modules from the ES module and CommonJS caches so the next import evaluates their files again. + * + * CommonJS modules loaded through `import()` appear in both caches. Node's `LoadCache` maps each URL to per-type + * jobs, and its own `delete()` only clears one type slot, so whole entries are removed through `Map.prototype`. + * @returns Restores the removed cache entries. + */ + private evict(urls: Iterable): () => void { + const esmBackup = new Map() + const cjsBackup = new Map() + const cache = commonJsCache() + const list = [...urls] + // CommonJS-only files have no loadCache entry to restore. + const cached = new Set(this.esmUrls()) + for (const url of list) { + if (cached.has(url)) { + esmBackup.set(url, Map.prototype.get.call(this.internal.loadCache, url)) + Map.prototype.delete.call(this.internal.loadCache, url) + } + } + for (const filepath of list.filter(url => url.startsWith('file:')).map(url => fileURLToPath(url))) { + const module = cache[filepath] + if (module !== undefined) { + cjsBackup.set(filepath, module) + Reflect.deleteProperty(cache, filepath) + } + } + return () => { + for (const [url, job] of esmBackup) Map.prototype.set.call(this.internal.loadCache, url, job) + for (const [filepath, module] of cjsBackup) cache[filepath] = module + } + } + + /** Loaded `file:` modules below the given profile roots. */ + private profileModules(roots: readonly string[]): string[] { + return [...this.loadedUrls()].filter((url) => { + if (!url.startsWith('file:')) return false + const filename = fileURLToPath(url) + return roots.some(root => filename.startsWith(root)) + }) + } + + /** Static import edges between loaded modules, across ES module links and CommonJS `require` children. */ + private async moduleGraph(): Promise>> { + const importers = new Map>() + const add = (parent: string, child: string) => { + importers.set(child, (importers.get(child) ?? new Set()).add(parent)) + } + await Promise.all([...moduleJobs(this.internal)].map(async (job) => { + for (const child of await linkedJobs(job)) add(job.url, child.url) + })) + for (const [filename, module] of Object.entries(commonJsCache())) { + // Code that writes `require.cache` directly can store objects that are not Node modules. + /* v8 ignore next -- Node stores no undefined values in require.cache; the index type admits them */ + const children: unknown = module === undefined ? undefined : Reflect.get(module, 'children') + if (!Array.isArray(children)) continue + for (const child of children as NodeJS.Module[]) add(pathToFileURL(filename).href, pathToFileURL(child.filename).href) + } + return importers + } + + /** + * Plan the replacement of changed modules without changing anything. A changed module whose importers reach the + * process entry point's static graph, or a plugin that no affected Loader entry starts, stays loaded with its + * importers and is pending. The other changed modules and every module importing them are evicted by + * {@link stopPlanned}, which also expires the manifests from each changed file up to its root and stops the Loader + * entries running affected plugins. + * @param stale Changed module URLs. + * @param roots Directories, with a trailing separator, that bound the expired manifests. + * @returns The pending modules and the replacement of the others. + */ + private async planReplacement(stale: ReadonlySet, roots: readonly string[]): Promise { + const importers = await this.moduleGraph() + const host = await this.hostModules() + const managed = new Set(this.ctx.loader.entries()) + const plan: ReplacementPlan = { + pending: [], evicted: new Set(), manifests: new Set(), plugins: new Set(), fibers: new Set(), + } + for (const url of stale) { + const affected = new Set([url]) + for (const module of affected) for (const importer of importers.get(module) ?? []) affected.add(importer) + const running = [...new Set([...affected].flatMap(module => this.pluginsOf(module)))] + .flatMap(plugin => [...this.ctx.registry.get(plugin)?.fibers ?? []].map(fiber => ({ plugin, fiber }))) + const affectedFibers = new Set(running.map(({ fiber }) => fiber)) + /** The Loader entry whose own fiber runs an affected plugin and starts this fiber directly or through its ancestors. */ + const owner = (fiber: Fiber): Entry | undefined => { + for (let current = fiber; current.uid !== 0; current = current.parent.fiber) { + const entry = current.entry + if (entry?.fiber === current && managed.has(entry) && affectedFibers.has(current)) return entry + } + return undefined + } + const entries = running.flatMap(({ fiber }) => owner(fiber) ?? []) + // A plugin registered outside a Loader entry has no owner that can start its replacement. + const reason = [...affected].some(module => host.has(module)) ? 'host-module' + : entries.length < running.length ? 'unmanaged-plugin' : undefined + if (reason !== undefined) { + plan.pending.push({ url, reason }) + continue + } + for (const manifest of packageManifests(url, roots)) plan.manifests.add(manifest) + for (const module of affected) plan.evicted.add(module) + for (const { plugin, fiber } of running) { + plan.plugins.add(plugin) + plan.fibers.add(fiber) + } + } + return plan + } + + /** + * The plugins a loaded module exports, read from the ES module namespace and the CommonJS exports Node cached for it + * without evaluating anything. + * @param url Loaded module URL. + * @returns The exported plugins. + */ + private pluginsOf(url: string): Plugin[] { + const namespace = namespaceOf(this.esmJob(url)) + const required = url.startsWith('file:') ? commonJsCache()[fileURLToPath(url)] : undefined + const exports: unknown = required?.loaded === true ? required.exports : undefined + return [namespace, exports].filter(value => value !== undefined).map(value => this.ctx.loader.unwrapExports(value) as Plugin) + } + + /** + * Carry out a planned replacement: expire its manifests, evict its modules without a backup, and stop its Loader + * entries, so applying the generation imports the current files when it starts them. + * @param plan The planned replacement. + */ + private async stopPlanned(plan: ReplacementPlan): Promise { + for (const manifest of plan.manifests) this.manifests.invalidate(manifest) + this.evict(plan.evicted) + // Deleting the runtime stops its fibers without the Loader recording the entries as disabled. + for (const plugin of plan.plugins) this.ctx.registry.delete(plugin) + // Cordis contains disposal failures in the fiber, which logs them. + await Promise.allSettled([...plan.fibers].map(fiber => fiber.await())) + } + + /** + * Evict the loaded modules below the directories of mappings a recomposition dropped and expire their package + * configuration, so a disabled or removed package that is installed or enabled again is imported afresh. Modules of + * DSH's own static graph, and those below a directory that is still mapped, stay loaded. + * @param dropped Real directories, with a trailing separator, of the dropped mappings. + * @param kept Real directories, with a trailing separator, of the mappings that stay. + */ + private async evictDropped(dropped: readonly string[], kept: ReadonlySet): Promise { + if (dropped.length === 0) return + const host = new Set(await this.hostModules()) + const urls = this.profileModules(dropped).filter((url) => { + const filename = fileURLToPath(url) + return !host.has(url) && ![...kept].some(dir => filename.startsWith(dir)) + }) + for (const url of urls) for (const manifest of packageManifests(url, dropped)) this.manifests.invalidate(manifest) + this.evict(urls) + } + + /** Report a failed replacement step and retain the caller’s fallback outcome. */ + private async contained(step: string, operation: () => T | Promise, fallback: T): Promise { + try { + return await operation() + } catch (error) { + this.ctx.logger.warn('%s failed; module replacement was not completed', step) + this.ctx.logger.warn(error) + return fallback + } + } + + /** + * Recompose the profile from its manifest and patch files, replacing running plugins whose loaded files below the + * profile directory or its linked package targets changed on disk, and publishing its runtime resolution. Changed + * modules are evicted and the Loader entries running them stop; app-boot's {@link recomposeProfile} then publishes the + * resolution, applies the generation, starts the stopped entries with the current files and configuration, + * imports entries whose import failed again, and judges activation. Afterwards the modules below the mappings the + * recomposition dropped are evicted, so a removed or deselected package is imported afresh when it returns. Callers run + * inside {@link runExclusive}. + * @param requiredIds Entry ids whose inactive state is reported as a failure even when it predates this call. + * @returns Inactive-entry diagnostics, the changed modules that load after a restart, and a profile-wide restart reason. + * @throws The patch read, publication, preparation, apply, or activation failure of the recomposition, including a + * replacement plugin that fails to load or start, whose previous code no longer runs. + */ + async reconcileProfile(requiredIds: readonly string[] = []): Promise { + if (this.executing.getStore() !== true) throw new Error('HMR profile reconciliation must run inside runExclusive') + const profile = this.ownerContext.get('profileContext') + if (profile === undefined) throw new Error('HMR profile reconciliation requires profileContext') + // Entries this recomposition stops start again, and fail it when they cannot. + const running = [...this.ctx.loader.entries()].filter(entry => entry.fiber?.state === FiberState.ACTIVE) + // Read while the packages are still on disk, so a removed link's target is known in its real spellings. + const mapped = mappedDirectories(this.ownerContext.get('pluginPackages')?.resolution) + let comparison: ProfileComparison | undefined + let comparedAt = 0 + let pending: PendingModule[] = [] + try { + const patches = readProfilePatches('dsh', profile) + comparedAt = this.files.now() + // A failed comparison keeps every record, so the next recomposition compares the same modules again. + comparison = await this.contained('comparing loaded modules with disk', () => { + const roots = profileRoots(profile.dir) + return Promise.resolve({ roots, ...this.files.compare(this.profileModules(roots)) }) + }, undefined) + // Changed modules stay stale until replaced, including when the generation cannot be applied. + pending = [...comparison?.stale ?? []].map(url => ({ url, reason: 'module-comparison' })) + const compared = comparison + const unavailable = !this.hasEsmCache() + if (!unavailable && compared !== undefined && pending.length > 0) { + pending = await this.contained('planning module replacement', async () => { + const plan = await this.planReplacement(compared.stale, compared.roots) + await this.stopPlanned(plan) + return plan.pending + }, pending) + } + // Modules whose links or evaluation failed are evicted, so the recomposition retries failed imports against a + // corrected file or a dependency installed since. + await this.contained('expiring failed imports', () => { + // Without an ES module cache no failed module is known. + if (unavailable) return + const roots = compared?.roots ?? [realpathSync(profile.dir) + sep] + // A dependency installed since the failed import can sit in a directory Node recorded as having no manifest. + for (const root of roots) this.manifests.invalidate(join(root, 'package.json')) + // Node keeps a module whose links or evaluation failed. + this.evict(this.profileModules(roots).filter((url) => { + const job = this.esmJob(url) + return job !== undefined && namespaceOf(job) === undefined + })) + }, undefined) + const result = await recomposeProfile(this.ownerContext.root, patches, 'dsh', { requiredIds, running }) + const restartReason = compared === undefined || unavailable ? 'module-comparison' : result.restartReason + return { warnings: result.warnings, pending, ...restartReason === undefined ? {} : { restartReason } } + } finally { + const recorded = comparison + await this.contained('evicting dropped packages', () => { + const kept = mappedDirectories(this.ownerContext.get('pluginPackages')?.resolution) + return this.evictDropped([...mapped].filter(dir => !kept.has(dir)), kept) + }, undefined) + await this.contained('recording loaded module files', () => { + if (recorded === undefined) return + // Identities read before replacement keep writes made since then detectable. + const urls = this.profileModules(recorded.roots) + this.files.observe(urls, { seen: recorded.seen, retained: new Set(pending.map(({ url }) => url)), at: comparedAt }) + }, undefined) + } + } +} + +/** Loaded profile modules compared with disk, with the roots that bound them. */ +interface ProfileComparison { + roots: string[] + stale: Set + seen: Map +} + +/** The modules a replacement evicts, the manifests it expires, and the plugins it stops; pending modules stay loaded. */ +interface ReplacementPlan { + pending: PendingModule[] + evicted: Set + manifests: Set + plugins: Set + fibers: Set +} + +/** Outcome of {@link Hmr.reconcileProfile}. */ +export interface ProfileReconciliation { + /** Diagnostics for Loader entries left inactive. */ + warnings: string[] + /** Loaded modules whose files changed on disk and whose previous code keeps running until a restart. */ + pending: PendingModule[] + /** + * Why every change waits for a restart: `module-comparison` when module caches cannot be inspected or their files + * compared, even when no URLs are known; `package-moved` when the runtime resolution cannot publish a moved package. + */ + restartReason?: 'module-comparison' | 'package-moved' +} + +/** A changed module that loads only after a restart. */ +export interface PendingModule { + url: string + /** + * `host-module`: the process entry point's static graph imports it. `unmanaged-plugin`: it is imported by a plugin + * that runs outside a traversable Loader entry. `module-comparison`: loaded modules could not be compared with disk. + */ + reason: 'host-module' | 'unmanaged-plugin' | 'module-comparison' +} + +/** Node's `kErrored` module status, the same in every supported Node version. */ +const MODULE_ERRORED = 5 + +/** The namespace of a cached module job, or `undefined` when it has none or its evaluation threw. */ +function namespaceOf(job: ModuleJob | undefined): unknown { + if (job?.module === undefined) return undefined + // A module whose evaluation threw keeps a namespace whose bindings were never initialized. `getStatus()` exists on + // ModuleWrap in every supported Node version, like the other loader internals HMR reads. + const status: unknown = Reflect.apply(Reflect.get(job.module, 'getStatus') as () => number, job.module, []) + if (status === MODULE_ERRORED) return undefined + try { + return job.module.getNamespace() + /* v8 ignore start -- a cached module still linking in a concurrent import has no namespace yet; tests cannot hold an import there */ + } catch (_error) { + // A module that has not been instantiated exports no running plugin. + return undefined + } + /* v8 ignore stop */ +} + +/** + * The jobs a module job links to. Node 24+ may expose them as an array; Node 22 keeps the job of a failed import + * whose links reject, and such a job contributes no links. + */ +async function linkedJobs(job: ModuleJob): Promise { + /* v8 ignore next -- CI coverage runs Node 24+, which drops failed jobs from the cache */ + const linked = await Promise.resolve(job.linked).catch((_error: unknown) => []) + // Node 22 may return a null-prototype array without methods or an iterator. + return Array.from(linked) +} + +/** The Node-compatible CommonJS cache; the packed worker implements require without exposing this cache. */ +function commonJsCache(): NodeJS.Dict { + // Node's type declares the cache present for every `require`. + const cache: unknown = Reflect.get(createRequire(import.meta.url), 'cache') + return (cache ?? {}) as NodeJS.Dict +} + +/** Every module job in the loader cache; Node's `LoadCache` maps each URL to per-type jobs. */ +function* moduleJobs(internal: ModuleLoader): Generator { + for (const value of Map.prototype.values.call(internal.loadCache) as Iterable>>) { + yield* Object.values(value).filter(job => job !== undefined) + } +} + +/** + * The directories of a runtime resolution's profile mappings and linked roots, in every real spelling of those that + * exist, each with a trailing separator. + * @param resolution The published resolution, if any. + * @returns The directories; none without a resolution. + */ +function mappedDirectories(resolution: RuntimeResolution | undefined): Set { + const directories = new Set() + if (resolution === undefined) return directories + const dirs = [...resolution.entries.filter(entry => entry.scope === 'profile').map(entry => entry.packageDir), + ...resolution.linkedRoots.map(root => root.realPath)] + for (const dir of dirs) for (const spelling of existsSync(dir) ? realSpellings(dir) : [dir]) directories.add(spelling + sep) + return directories +} + +/** + * The JavaScript and native real paths of an existing path. A packed worker's file system has no native realpath. + * @param path Existing path. + * @returns The distinct spellings. + */ +function realSpellings(path: string): string[] { + const native: unknown = Reflect.get(realpathSync, 'native') + return [realpathSync(path), ...typeof native === 'function' ? [String(Reflect.apply(native, realpathSync, [path]))] : []] +} + +/** + * Real directories, with a trailing separator, of the profile and of the targets its linked packages point to. Each + * appears in both real-path spellings: Node's module caches use the JavaScript one, the runtime resolution the native + * one, and on Windows they differ for 8.3 short names. + */ +function profileRoots(dir: string): string[] { + const real = realpathSync(dir) + const modules = join(real, 'node_modules') + const names = existsSync(modules) ? readdirSync(modules) : [] + const packages = names.flatMap(name => name.startsWith('@') + ? readdirSync(join(modules, name)).map(child => join(modules, name, child)) + : [join(modules, name)]) + const linked = packages.filter(path => lstatSync(path).isSymbolicLink() && existsSync(path)) + return [...new Set([dir, ...linked].flatMap(path => realSpellings(path)).map(root => root + sep))] +} + +/** Every `package.json` from a module file's directory up to the profile root or linked package target containing it. */ +function packageManifests(url: string, roots: readonly string[]): string[] { + const manifests: string[] = [] + const contains = (directory: string) => roots.some(root => (directory + sep).startsWith(root)) + for (let directory = dirname(fileURLToPath(url)); contains(directory); directory = dirname(directory)) { + const candidate = join(directory, 'package.json') + if (existsSync(candidate)) manifests.push(candidate) + } + return manifests } namespace Hmr { diff --git a/packages/boot/hmr/src/module-files.ts b/packages/boot/hmr/src/module-files.ts new file mode 100644 index 0000000000..b734401ea4 --- /dev/null +++ b/packages/boot/hmr/src/module-files.ts @@ -0,0 +1,124 @@ +/** Disk identities of loaded module files, used to find modules whose files changed after loading. */ +import { mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' + +/** + * Coarsest file timestamp resolution among supported filesystems (FAT stores modification times in 2-second units). + * An unrecorded file modified within this interval before the last observation is treated as possibly changed. + */ +const TIMESTAMP_RESOLUTION_MS = 2000 + +interface FileIdentity { + /** Inode, size and modification time. */ + key: string + /** Modification time in epoch milliseconds. */ + modifiedAt: number +} + +function identityOf(url: string): FileIdentity | undefined { + let stats + try { + stats = statSync(fileURLToPath(url)) + } catch (_error) { + // A removed or unreadable file no longer provides the loaded module. + return undefined + } + return { key: `${String(stats.ino)}:${String(stats.size)}:${String(stats.mtimeMs)}`, modifiedAt: stats.mtimeMs } +} + +/** + * Records the inode, size and modification time of loaded `file:` modules. A module is stale when its file + * disappeared or its identity differs from the recorded one. Change time is not recorded: creating a hard link to a + * file, as pnpm does when it imports from its store, changes it without changing content. A module first loaded after + * the last observation has no record; it is stale when its file was modified at or after that observation, less the + * timestamp resolution, so a reinstall between loading and the next comparison is detected at the cost of reloading a + * module that was first loaded after such a write. + */ +export class ModuleFiles { + /** Identity per recorded module; `undefined` marks a stale module, such as a pending one first loaded after the last observation. */ + private records = new Map() + private observedAt = Date.now() + + /** + * The current time of the clock the operating system stamps local file writes with, read from a new marker file that is + * removed again. The system clock and file timestamps can differ by a clock tick in either direction, so module files + * are compared with this time; when no marker can be created, the system clock is used. + * @returns Epoch milliseconds. + */ + now(): number { + let directory: string + try { + directory = mkdtempSync(join(tmpdir(), 'dsh-hmr-clock-')) + } catch (_error) { + // An unwritable temporary directory leaves the less precise system clock. + return Date.now() + } + try { + const marker = join(directory, 'now') + writeFileSync(marker, '') + return statSync(marker).mtimeMs + } finally { + rmSync(directory, { recursive: true, force: true }) + } + } + + /** + * Compare loaded file modules with disk. + * @param urls `file:` URLs of the loaded modules to compare. + * @returns URLs of stale modules, and the identity read for each compared URL (`undefined` for a missing file). + */ + compare(urls: Iterable): { stale: Set; seen: Map } { + const stale = new Set() + const seen = new Map() + for (const url of urls) { + const identity = identityOf(url) + seen.set(url, identity?.key) + if (identity === undefined) { + stale.add(url) + continue + } + if (this.records.has(url) ? this.records.get(url) !== identity.key + : identity.modifiedAt >= this.observedAt - TIMESTAMP_RESOLUTION_MS) stale.add(url) + } + return { stale, seen } + } + + /** + * Replace every record with the identities of the given loaded modules. + * @param urls `file:` URLs of the loaded modules to record. + * @param options `seen` supplies identities read earlier for modules whose cache entry has not changed since, so a + * later write stays detectable; other modules are read now, and stay stale when their file was modified after `at`. + * `retained` modules keep their record, or are marked stale when they have none. `at` is the observation time from + * {@link now} used for unrecorded modules. + */ + observe(urls: Iterable, options: { + seen?: ReadonlyMap + retained?: ReadonlySet + at?: number + } = {}): void { + const { seen = new Map(), retained = new Set(), at = this.now() } = options + const records = new Map() + for (const url of urls) { + if (retained.has(url)) records.set(url, this.records.get(url)) + else if (seen.has(url)) records.set(url, seen.get(url)) + else { + // A module first loaded since `at` may have been evaluated before a write that followed it. A write in the same + // file clock tick as `at` counts as earlier, so files installed just before the comparison are not reloaded. + const identity = identityOf(url) + records.set(url, identity === undefined || identity.modifiedAt > at ? undefined : identity.key) + } + } + this.records = records + this.observedAt = at + } + + /** + * Record the current identity of modules just evaluated again, leaving every other record unchanged. + * @param urls `file:` URLs of the re-evaluated modules. + */ + refresh(urls: Iterable): void { + for (const url of urls) this.records.set(url, identityOf(url)?.key) + } +} diff --git a/packages/boot/hmr/tests/module-files.spec.ts b/packages/boot/hmr/tests/module-files.spec.ts new file mode 100644 index 0000000000..1a0c32de83 --- /dev/null +++ b/packages/boot/hmr/tests/module-files.spec.ts @@ -0,0 +1,24 @@ +/** The file clock ModuleFiles compares module files with. */ +import * as fs from 'node:fs' +import { expect, it, vi } from 'vitest' +import { ModuleFiles } from '../src/module-files.ts' + +vi.mock('node:fs', async original => ({ ...await original() })) + +it('reads the time from a new marker file and removes it', () => { + const created = vi.spyOn(fs, 'mkdtempSync') + try { + const time = new ModuleFiles().now() + expect(time).toBeGreaterThan(0) + const directory = created.mock.results[0]?.value as string + expect(fs.existsSync(directory)).toBe(false) + } finally { created.mockRestore() } +}) + +it('falls back to the system clock when no marker can be created', () => { + const created = vi.spyOn(fs, 'mkdtempSync').mockImplementationOnce(() => { throw new Error('read-only') }) + try { + const before = Date.now() + expect(new ModuleFiles().now()).toBeGreaterThanOrEqual(before) + } finally { created.mockRestore() } +}) diff --git a/packages/boot/hmr/tests/package-reload.spec.ts b/packages/boot/hmr/tests/package-reload.spec.ts new file mode 100644 index 0000000000..5b3dc27571 --- /dev/null +++ b/packages/boot/hmr/tests/package-reload.spec.ts @@ -0,0 +1,1280 @@ +/** Profile reconciliation replaces plugins whose installed package files changed, through a real Loader and Node cache. */ +import { existsSync, linkSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, symlinkSync, unlinkSync, utimesSync, writeFileSync } from 'node:fs' +import NodeModule, { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { dirname, isAbsolute, join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { Context, FiberState, type Plugin } from '@deepseek-ai/cordis' +import Loader, { ModuleLoader, type Entry } from '@deepseek-ai/cordis-plugin-loader' +import { + boot, initProfile, ProfileRuntimeResolution, readProfileManifest, readProfilePatches, reconcileProfilePatches, ResolutionRestartRequired, + type PluginPackages, type ProfileContext, type RuntimeResolution, +} from '@deepseek-ai/dsh-app-boot' +import Timer from '@deepseek-ai/cordis-plugin-timer' +import { expect, it, onTestFinished, vi } from 'vitest' +import Hmr from '../src/index.ts' + +const watchers = vi.hoisted(() => [] as import('chokidar').FSWatcher[]) +vi.mock('chokidar', async (original) => { + const native = await original() + return { ...native, watch: () => { + const watcher = new native.FSWatcher() + watchers.push(watcher) + queueMicrotask(() => watcher.emit('ready')) + return watcher + } } +}) + +/** Silence the Loader's report of an entry whose import fails. */ +function quiet(ctx: Context): void { + const error = vi.spyOn(ctx.logger, 'error').mockImplementation(() => {}) + onTestFinished(() => { error.mockRestore() }) +} + +function file(path: string, source: string): void { + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, source) +} + +function plugin(version: string, imports = 'shared-lib'): string { + return `import { value } from '${imports}' +export const name = 'addon-${version}' +export function apply(ctx, config = {}) { + ctx.get('reloadTrace').push('start:${version}:' + value + ':' + (config.mode ?? '-')) + ctx.effect(() => () => { ctx.get('reloadTrace').push('stop:${version}') }) +} +` +} + +function addon(dir: string, version: string, source = plugin(version), mode?: string): void { + const root = join(dir, 'node_modules', 'addon') + file(join(root, 'package.json'), JSON.stringify({ + name: 'addon', version, type: 'module', exports: './index.mjs', dsh: { bundle: { patch: './cordis.patch.yml' } }, + })) + file(join(root, 'index.mjs'), source) + file(join(root, 'cordis.patch.yml'), `- insert:\n - id: addon\n name: addon\n${mode === undefined ? '' : ` config: { mode: ${mode} }\n`}`) +} + +function shared(dir: string, value: number): void { + const root = join(dir, 'node_modules', 'shared-lib') + file(join(root, 'package.json'), JSON.stringify({ name: 'shared-lib', version: `${String(value)}.0.0`, type: 'module', exports: './index.mjs' })) + file(join(root, 'index.mjs'), `export const value = ${String(value)}\n`) +} + +function other(dir: string): void { + const root = join(dir, 'node_modules', 'other') + file(join(root, 'package.json'), JSON.stringify({ + name: 'other', version: '1.0.0', type: 'module', exports: './index.mjs', dsh: { bundle: { patch: './cordis.patch.yml' } }, + })) + file(join(root, 'index.mjs'), `export function apply(ctx) { + ctx.get('reloadTrace').push('start:other') + ctx.effect(() => () => { ctx.get('reloadTrace').push('stop:other') }) +} +`) + file(join(root, 'cordis.patch.yml'), '- insert:\n - id: other\n name: other\n') +} + +function commonjs(dir: string, value: number): void { + const root = join(dir, 'node_modules', 'cjs-addon') + file(join(root, 'package.json'), JSON.stringify({ + name: 'cjs-addon', version: '1.0.0', main: './index.cjs', dsh: { bundle: { patch: './cordis.patch.yml' } }, + })) + file(join(root, 'index.cjs'), `const { value } = require('./helper.cjs') +exports.apply = (ctx) => { + ctx.get('reloadTrace').push('start:cjs:' + value) + ctx.effect(() => () => { ctx.get('reloadTrace').push('stop:cjs') }) +} +`) + file(join(root, 'helper.cjs'), `exports.value = ${String(value)}\n`) + file(join(root, 'cordis.patch.yml'), '- insert:\n - id: cjs-addon\n name: cjs-addon\n') +} + +/** Date every file below `dir` an hour back, as for packages installed before the application started. */ +function age(dir: string): void { + const past = new Date(Date.now() - 3_600_000) + for (const entry of readdirSync(dir, { recursive: true, withFileTypes: true })) { + if (entry.isFile()) utimesSync(join(entry.parentPath, entry.name), past, past) + } +} + +function linked(home: string, dir: string, value: number): string { + const target = join(home, 'work', 'linked') + file(join(target, 'package.json'), JSON.stringify({ + name: '@local/linked', version: '1.0.0', type: 'module', exports: './index.mjs', dsh: { bundle: { patch: './cordis.patch.yml' } }, + })) + file(join(target, 'index.mjs'), `export function apply(ctx) { + ctx.get('reloadTrace').push('start:linked:${String(value)}') + ctx.effect(() => () => { ctx.get('reloadTrace').push('stop:linked') }) +} +`) + file(join(target, 'cordis.patch.yml'), '- insert:\n - id: linked\n name: "@local/linked"\n') + const link = join(dir, 'node_modules', '@local', 'linked') + mkdirSync(dirname(link), { recursive: true }) + if (lstatSync(link, { throwIfNoEntry: false }) !== undefined) unlinkSync(link) + symlinkSync(target, link, process.platform === 'win32' ? 'junction' : 'dir') + return target +} + +async function fixture(bundles = ['addon', 'other'], before?: (home: string, dir: string) => Promise, ready = true) { + const home = realpathSync(mkdtempSync(join(tmpdir(), 'hmr-package-reload-'))) + const dir = join(home, 'profiles', 'test') + initProfile(dir, bundles) + writeFileSync(join(dir, 'cordis.yml'), '[]\n') + addon(dir, '1') + shared(dir, 1) + other(dir) + commonjs(dir, 1) + linked(home, dir, 1) + age(home) + await before?.(home, dir) + const profile: ProfileContext = { + name: 'test', dir, patchPath: join(dir, 'cordis.patch.yml'), home, cwd: home, + installAnchor: join(home, 'package.json'), startedBundles: bundles, overlays: [], telemetryDisabledEnv: undefined, + } + writeFileSync(profile.patchPath, JSON.stringify([{ insert: [ + { id: 'timer', name: 'cordis:timer' }, + { id: 'hmr', name: 'cordis:hmr', config: { root: [] } }, + ] }])) + const trace: string[] = [] + let commit: (() => void) | undefined + const firstWatcher = watchers.length + const ctx = await boot('test', join(dir, 'cordis.yml'), readProfilePatches('test', profile), (host) => { + host.provide('profileContext', profile) + host.provide('reloadTrace', trace) + host.provide('appReady', { onReady(listener) { commit = listener; return () => { commit = undefined } } }) + host.loader.builtins.timer = Timer + host.loader.builtins.hmr = Hmr + }) + onTestFinished(async () => { await ctx.fiber.dispose(); rmSync(home, { recursive: true, force: true }) }) + await ctx.loader.await() + if (ready) commit?.() + const reconcile = () => ctx.hmr.runExclusive(() => ctx.hmr.reconcileProfile()) + const select = (bundles: string[]) => { + const manifest = readProfileManifest('test', dir) + manifest.dsh = { profile: { bundles } } + writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest)) + } + /** Deliver a change of the profile patch file to HMR's own watch and wait for its recomposition. */ + const editPatch = async () => { + // Each configuration watch ignores paths other than its own file. + for (const watcher of watchers.slice(firstWatcher)) watcher.emit('change', profile.patchPath) + await vi.waitFor(async () => { await ctx.hmr.runExclusive(async () => {}) }) + await ctx.hmr.runExclusive(async () => {}) + } + return { ready: () => commit?.(), ctx, home, dir, trace, reconcile, select, editPatch, profile } +} + +it('leaves plugins running when no loaded file changed', async () => { + const f = await fixture() + expect(f.trace.toSorted()).toEqual(['start:1:1:-', 'start:other']) + f.trace.length = 0 + await expect(f.reconcile()).resolves.toEqual({ warnings: [], pending: [] }) + expect(f.trace).toEqual([]) +}) + +it('keeps a module whose file gains a hard link', async () => { + const f = await fixture() + f.trace.length = 0 + linkSync(join(f.dir, 'node_modules', 'addon', 'index.mjs'), join(f.home, 'store-copy.mjs')) + await expect(f.reconcile()).resolves.toEqual({ warnings: [], pending: [] }) + expect(f.trace).toEqual([]) +}) + +it('replaces a plugin upgraded in place with the configuration of its new bundle patch', async () => { + const f = await fixture() + f.trace.length = 0 + addon(f.dir, '2', plugin('2'), 'fast') + await expect(f.reconcile()).resolves.toEqual({ warnings: [], pending: [] }) + expect(f.trace).toEqual(['stop:1', 'start:2:1:fast']) +}) + +it('starts an upgraded plugin with a configuration its previous version rejects', async () => { + const f = await fixture() + addon(f.dir, '1', plugin('1').replace('export function apply(ctx, config = {}) {', "export function apply(ctx, config = {}) {\n if (config.mode) throw new Error('mode is unsupported')")) + age(f.dir) + await f.reconcile() + f.trace.length = 0 + addon(f.dir, '2', plugin('2'), 'fast') + await expect(f.reconcile()).resolves.toEqual({ warnings: [], pending: [] }) + expect(f.trace).toEqual(['stop:1', 'start:2:1:fast']) +}) + +it('links an entry inserted by the upgraded bundle to the current files of a changed dependency', async () => { + const f = await fixture() + const extra = join(f.dir, 'node_modules', 'addon', 'extra.mjs') + f.trace.length = 0 + shared(f.dir, 2) + addon(f.dir, '1') + file(extra, `import { value } from 'shared-lib' +export function apply(ctx) { ctx.get('reloadTrace').push('start:extra:' + value) } +`) + file(join(f.dir, 'node_modules', 'addon', 'cordis.patch.yml'), '- insert:\n - id: addon\n name: addon\n - id: extra\n name: ./extra.mjs\n') + await expect(f.reconcile()).resolves.toEqual({ warnings: [], pending: [] }) + expect(f.trace.toSorted()).toEqual(['start:1:2:-', 'start:extra:2', 'stop:1']) +}) + +it('leaves a changed plugin that no Loader entry owns for a restart', async () => { + const f = await fixture([]) + const warn = vi.spyOn(f.ctx.logger, 'warn').mockImplementation(() => {}) + onTestFinished(() => { warn.mockRestore() }) + const url = pathToFileURL(join(f.dir, 'node_modules', 'addon', 'index.mjs')).href + await f.ctx.plugin(f.ctx.loader.unwrapExports(await f.ctx.loader.import(url)) as Plugin) + expect(f.trace).toEqual(['start:1:1:-']) + addon(f.dir, '2') + await expect(f.reconcile()).resolves.toEqual({ warnings: [], pending: [{ url, reason: 'unmanaged-plugin' }] }) + expect(f.trace).toEqual(['start:1:1:-']) +}) + +/** A runtime resolution table mapping `addon` to a directory, with further dropped profile packages. */ +function table(addonDir: string, ...dropped: string[]): RuntimeResolution { + const entry = (name: string, packageDir: string) => ({ + name, packageDir, version: '1.0.0', declarer: join(packageDir, 'package.json'), scope: 'profile' as const, + }) + return { + profilesDir: '/profiles', profileDir: '/profiles/test', localPackageNames: ['addon', ...dropped], + entries: [entry('addon', addonDir), ...dropped.map(name => entry(name, isAbsolute(name) ? name : `/packages/${name}`))], + linkedRoots: [], + } +} + +/** + * Install a recomputable runtime resolution whose latest generation is `latest`; `replace` decides each publication. + * @returns Each publication's mapped packages. + */ +function resolution( + ctx: Context, latest: RuntimeResolution, replace: () => void = () => {}, + published = table('/packages/addon', 'gone'), +) { + const source = { installAnchor: '', home: '', profileDir: undefined } + let current: RuntimeResolution = new ProfileRuntimeResolution(source, published) + const compute = vi.spyOn(ProfileRuntimeResolution.prototype, 'computeLatestResolution') + .mockResolvedValue(new ProfileRuntimeResolution(source, latest)) + onTestFinished(() => { compute.mockRestore() }) + const calls: string[] = [] + ctx.provide('pluginPackages', { + // The compatibility preflight finds no package metadata and admits every row. + packageOf: () => undefined, + get resolution() { return current }, + replace(next: RuntimeResolution) { + calls.push(`publish:${next.entries.map(entry => entry.name).join(',')}`) + replace() + current = next + }, + } as Pick as PluginPackages) + return calls +} + +it('stops the rows a generation drops before publishing its resolution, then applies it', async () => { + const f = await fixture() + f.trace.length = 0 + const calls = resolution(f.ctx, table('/packages/addon-2'), () => { f.trace.push('published') }) + f.select(['addon']) + await expect(f.reconcile()).resolves.toEqual({ warnings: [], pending: [] }) + expect(calls).toEqual(['publish:addon']) + expect(f.trace).toEqual(['stop:other', 'published']) +}) + +it.each(['unchanged', 'changed'] as const)('rejects a publication failure, starting %s plugins it stopped again', async (kind) => { + const f = await fixture() + f.trace.length = 0 + resolution(f.ctx, table('/packages/addon-2'), () => { throw new Error('resolution unreadable') }) + if (kind === 'changed') addon(f.dir, '2') + await expect(f.reconcile()).rejects.toThrow('resolution unreadable') + expect(f.trace).toEqual(kind === 'changed' ? ['stop:1', 'start:2:1:-'] : []) +}) + +it('reports an entry left waiting for a service the replacement no longer provides as a warning', async () => { + const f = await fixture(['addon', 'other'], async (_home, dir) => { + addon(dir, '1', `export function apply(ctx) { + ctx.get('reloadTrace').push('start:1') + ctx.provide('addonService', {}) +} +`) + file(join(dir, 'node_modules', 'other', 'index.mjs'), `export const inject = ['addonService'] +export function apply(ctx) { ctx.get('reloadTrace').push('start:other') } +`) + age(join(dir, 'node_modules')) + }) + quiet(f.ctx) + f.trace.length = 0 + addon(f.dir, '2', "export function apply(ctx) { ctx.get('reloadTrace').push('start:2') }\n") + // The entry starts once a provider appears. + await expect(f.reconcile()).resolves.toMatchObject({ warnings: [expect.stringContaining('addonService')] }) + expect(f.trace).toEqual(['start:2']) +}) + +it('imports a row the update inserts once when its import fails', async () => { + const f = await fixture(['addon']) + const error = vi.spyOn(f.ctx.logger, 'error').mockImplementation(() => {}) + onTestFinished(() => { error.mockRestore() }) + file(join(f.dir, 'node_modules', 'other', 'index.mjs'), "import 'missing-lib'\n") + f.select(['addon', 'other']) + await expect(f.reconcile()).rejects.toThrow(/missing-lib/u) + expect(error.mock.calls.filter(([value]) => String(value).includes('missing-lib'))).toHaveLength(1) +}) + +it('keeps the publication failure when starting the stopped plugins again throws', async () => { + const f = await fixture() + const warn = vi.spyOn(f.ctx.logger, 'warn').mockImplementation(() => {}) + onTestFinished(() => { warn.mockRestore() }) + resolution(f.ctx, table('/packages/addon-2'), () => { throw new Error('resolution unreadable') }) + addon(f.dir, '2') + const settle = vi.spyOn(f.ctx.loader, 'await').mockRejectedValueOnce(new Error('loader unavailable')) + onTestFinished(() => { settle.mockRestore() }) + await expect(f.reconcile()).rejects.toThrow('resolution unreadable') + expect(warn).toHaveBeenCalledWith(expect.objectContaining({ message: 'loader unavailable' })) +}) + +it('logs a stopped plugin that does not start again after the publication fails', async () => { + const f = await fixture() + const warn = vi.spyOn(f.ctx.logger, 'warn').mockImplementation(() => {}) + onTestFinished(() => { warn.mockRestore() }) + resolution(f.ctx, table('/packages/addon-2'), () => { throw new Error('resolution unreadable') }) + addon(f.dir, '2', "export function apply() { throw new Error('addon cannot start') }\n") + quiet(f.ctx) + // An unrelated failed entry is left to the next judgment. + await f.ctx.loader.create({ name: 'late-plugin' }) + await f.ctx.loader.await() + await expect(f.reconcile()).rejects.toThrow('resolution unreadable') + expect(warn).toHaveBeenCalledWith(expect.stringMatching(/^dsh: addon \(addon\): .*addon cannot start/su)) + expect(warn).not.toHaveBeenCalledWith(expect.stringContaining('late-plugin')) +}) + +it('reports a stopped entry that no recomposition started again by its fiber state', async () => { + const f = await fixture() + const url = pathToFileURL(join(f.dir, 'node_modules', 'addon', 'index.mjs')).href + const plugin = f.ctx.loader.unwrapExports(await f.ctx.loader.import(url)) as Plugin + const fibers = [...f.ctx.registry.get(plugin)?.fibers ?? []] + f.ctx.registry.delete(plugin) + await Promise.all(fibers.map(fiber => fiber.await())) + // Applying the same patches leaves the unchanged Include's rows alone. + const warnings = await f.ctx.hmr.runExclusive(() => reconcileProfilePatches(f.ctx.root, readProfilePatches('test', f.profile), 'test')) + expect(warnings).toEqual([expect.stringMatching(/^addon \(addon\): fiber state 4/u)]) +}) + +it('logs a failure to restore the stopped rows and still reports the rejected publication', async () => { + const f = await fixture() + const warn = vi.spyOn(f.ctx.logger, 'warn').mockImplementation(() => {}) + onTestFinished(() => { warn.mockRestore() }) + resolution(f.ctx, table('/packages/addon-2'), () => { throw new ResolutionRestartRequired('installation mapping changed') }) + const include = [...f.ctx.loader.entries()].find(entry => entry.options.id === 'include') as Entry + const restoring = vi.spyOn(include.subgroup!, 'create').mockRejectedValueOnce(new Error('restore failed')) + onTestFinished(() => { restoring.mockRestore() }) + f.select(['addon']) + await expect(f.reconcile()).resolves.toEqual({ warnings: [], pending: [], restartReason: 'package-moved' }) + expect(warn).toHaveBeenCalledWith(expect.objectContaining({ message: 'restore failed' })) +}) + +it('restores the composition while the resolution waits for a restart', async () => { + const f = await fixture() + f.trace.length = 0 + resolution(f.ctx, table('/packages/addon-2'), () => { throw new ResolutionRestartRequired('installation mapping changed') }) + f.select(['addon']) + const config = readFileSync(join(f.dir, 'cordis.yml'), 'utf8') + await expect(f.reconcile()).resolves.toEqual({ warnings: [], pending: [], restartReason: 'package-moved' }) + // The row the generation drops stops before the publication and starts again once it is rejected. + expect(f.trace).toEqual(['stop:other', 'start:other']) + // Stopping and restoring rows leaves the profile's configuration file as it was. + await new Promise(resolve => setTimeout(resolve, 50)) + expect(readFileSync(join(f.dir, 'cordis.yml'), 'utf8')).toBe(config) +}) + +it('waits for a dropped row to finish disposing, and keeps a stopped kept row stopped, until the resolution publishes', async () => { + const f = await fixture(['addon', 'other'], async (_home, dir) => { + file(join(dir, 'node_modules', 'other', 'index.mjs'), `export function apply(ctx) { + ctx.get('reloadTrace').push('start:other') + ctx.effect(() => async () => { await new Promise(resolve => setTimeout(resolve, 20)); ctx.get('reloadTrace').push('stop:other') }) +} +`) + }) + f.trace.length = 0 + // The addon's own file changed, so HMR stops its entry before the recomposition. + addon(f.dir, '2') + resolution(f.ctx, table('/packages/addon-2'), () => { f.trace.push('published') }) + f.select(['addon']) + await f.reconcile() + expect(f.trace).toEqual(['stop:1', 'stop:other', 'published', 'start:2:1:-']) +}) + +it('puts several dropped rows back at their places when the resolution waits for a restart', async () => { + const f = await fixture() + const rows = [{ id: 'timer', name: 'cordis:timer' }, { id: 'hmr', name: 'cordis:hmr', config: { root: [] } }] + const order = ['first', 'middle', 'second'] + writeFileSync(f.profile.patchPath, JSON.stringify([{ insert: [...rows, { id: 'first', name: 'other' }, { id: 'middle', name: 'other' }, { id: 'second', name: 'other' }] }])) + await f.reconcile() + resolution(f.ctx, table('/packages/addon-2'), () => { throw new ResolutionRestartRequired('installation mapping changed') }) + writeFileSync(f.profile.patchPath, JSON.stringify([{ insert: [...rows, { id: 'middle', name: 'other' }] }])) + await expect(f.reconcile()).resolves.toMatchObject({ restartReason: 'package-moved' }) + const include = [...f.ctx.loader.entries()].find(entry => entry.options.id === 'include') as Entry + expect(include.subgroup!.data.map(row => row.id).filter(id => order.includes(id))).toEqual(order) +}) + +it('puts a dropped row back into its group when the resolution waits for a restart', async () => { + const f = await fixture() + const rows = [{ id: 'timer', name: 'cordis:timer' }, { id: 'hmr', name: 'cordis:hmr', config: { root: [] } }] + writeFileSync(f.profile.patchPath, JSON.stringify([{ insert: [...rows, { id: 'group', name: 'cordis:group', config: [{ id: 'child', name: 'other' }] }] }])) + await f.reconcile() + f.trace.length = 0 + resolution(f.ctx, table('/packages/addon-2'), () => { throw new ResolutionRestartRequired('installation mapping changed') }) + writeFileSync(f.profile.patchPath, JSON.stringify([{ insert: [...rows, { id: 'group', name: 'cordis:group', config: [] }] }])) + await expect(f.reconcile()).resolves.toMatchObject({ restartReason: 'package-moved' }) + expect(f.trace).toEqual(['stop:other', 'start:other']) + const group = [...f.ctx.loader.entries()].find(entry => entry.options.id === 'group') as Entry + expect(group.subgroup!.data.map(row => row.id)).toEqual(['child']) +}) + +it('removes a disabled row a generation drops without waiting for a fiber', async () => { + const f = await fixture() + const rows = [{ id: 'timer', name: 'cordis:timer' }, { id: 'hmr', name: 'cordis:hmr', config: { root: [] } }] + writeFileSync(f.profile.patchPath, JSON.stringify([{ insert: [...rows, { id: 'idle', name: 'other', disabled: true }] }])) + await f.reconcile() + f.trace.length = 0 + resolution(f.ctx, table('/packages/addon-2'), () => { f.trace.push('published') }) + writeFileSync(f.profile.patchPath, JSON.stringify([{ insert: rows }])) + await f.reconcile() + expect(f.trace).toEqual(['published']) + expect([...f.ctx.loader.entries()].some(entry => entry.options.id === 'idle')).toBe(false) +}) + +it('stops a row a generation drops from a group before publishing its resolution', async () => { + const f = await fixture() + const rows = [{ id: 'timer', name: 'cordis:timer' }, { id: 'hmr', name: 'cordis:hmr', config: { root: [] } }] + writeFileSync(f.profile.patchPath, JSON.stringify([{ insert: [...rows, { id: 'group', name: 'cordis:group', config: [{ id: 'child', name: 'other' }] }] }])) + await f.reconcile() + f.trace.length = 0 + resolution(f.ctx, table('/packages/addon-2'), () => { f.trace.push('published') }) + writeFileSync(f.profile.patchPath, JSON.stringify([{ insert: [...rows, { id: 'group', name: 'cordis:group', config: [] }] }])) + await f.reconcile() + expect(f.trace).toEqual(['stop:other', 'published']) +}) +it.each(['added', 'nested', 'renamed'] as const)('leaves a generation with an %s row unapplied while the resolution waits for a restart', async (kind) => { + const f = await fixture(['addon']) + f.trace.length = 0 + resolution(f.ctx, table('/packages/addon-2'), () => { throw new ResolutionRestartRequired('installation mapping changed') }) + const rows = [{ id: 'timer', name: 'cordis:timer' }, { id: 'hmr', name: 'cordis:hmr', config: { root: [] } }] + if (kind === 'added') f.select(['addon', 'other']) + else { + writeFileSync(f.profile.patchPath, JSON.stringify(kind === 'nested' + ? [{ insert: [...rows, { id: 'group', name: 'cordis:group', config: [{ id: 'child', name: 'other' }] }] }] + : [{ insert: rows }, { id: 'addon', name: 'other' }])) + } + await expect(f.reconcile()).resolves.toEqual({ warnings: [], pending: [], restartReason: 'package-moved' }) + expect(f.trace).toEqual([]) +}) + +it('judges the activation without importing an entry whose import failed before the resolution publishes', async () => { + const f = await fixture([]) + quiet(f.ctx) + const id = await f.ctx.loader.create({ name: 'late-plugin' }) + await f.ctx.loader.await() + await f.reconcile() + const order: string[] = [] + resolution(f.ctx, table('/packages/addon-2'), () => { order.push('publish') }) + const tree = f.ctx.loader.resolve(id).parent.tree + const original = tree.import.bind(tree) + const load = vi.spyOn(tree, 'import').mockImplementation((...args): Promise => { order.push('import'); return original(...args) as Promise }) + onTestFinished(() => { load.mockRestore() }) + await f.reconcile() + // Importing before the resolution publishes could evaluate plugin code against the previous mappings. + expect(order[0]).toBe('publish') + expect(order).toContain('import') +}) + +it('keeps an entry that failed to import before as a warning when it fails with another error', async () => { + const f = await fixture([]) + quiet(f.ctx) + const id = await f.ctx.loader.create({ name: 'late-plugin' }) + await f.ctx.loader.await() + await f.reconcile() + const tree = f.ctx.loader.resolve(id).parent.tree + const load = vi.spyOn(tree, 'import').mockRejectedValue(new Error('another missing dependency')) + onTestFinished(() => { load.mockRestore() }) + const result = await f.reconcile() + expect(result.warnings).toEqual([expect.stringContaining('another missing dependency')]) +}) + +it('records plugins started before application readiness without restarting a recently installed one', async () => { + const f = await fixture(['addon'], undefined, false) + // A plugin installed just now starts after HMR but before the application is ready; writing its files stamps them + // with the file clock, as an installation does. + other(f.dir) + await f.ctx.loader.create({ name: 'other' }) + await f.ctx.loader.await() + f.ready() + f.trace.length = 0 + await expect(f.reconcile()).resolves.toEqual({ warnings: [], pending: [] }) + expect(f.trace).toEqual([]) +}) + +// The recomposition that disables the failed entry still retries it, which expires the profile's package +// configuration and evicts its failed modules, so enabling it later reads the installed dependency. +it('loads a failed entry enabled again after its dependency was installed', async () => { + const f = await fixture(undefined, async (_home, dir) => { + addon(dir, '1', plugin('1', 'late-lib')) + }) + quiet(f.ctx) + const rows = [{ id: 'timer', name: 'cordis:timer' }, { id: 'hmr', name: 'cordis:hmr', config: { root: [] } }] + writeFileSync(f.profile.patchPath, JSON.stringify([{ insert: rows }, { id: 'addon', disabled: true }])) + await f.reconcile() + file(join(f.dir, 'node_modules', 'late-lib', 'package.json'), JSON.stringify({ name: 'late-lib', type: 'module', exports: './index.mjs' })) + file(join(f.dir, 'node_modules', 'late-lib', 'index.mjs'), 'export const value = 7\n') + writeFileSync(f.profile.patchPath, JSON.stringify([{ insert: rows }])) + await expect(f.reconcile()).resolves.toEqual({ warnings: [], pending: [] }) + expect(f.trace).toContain('start:1:7:-') +}) + +it('starts a service plugin again once the dependency whose evaluation failed is corrected', async () => { + const f = await fixture(['addon'], async (_home, dir) => { + file(join(dir, 'node_modules', 'addon', 'dep.mjs'), "throw new Error('dep fails')\n") + file(join(dir, 'node_modules', 'addon', 'index.mjs'), `import './dep.mjs' +export default class Addon { + constructor(ctx) { ctx.get('reloadTrace').push('start:service') } +} +`) + }) + quiet(f.ctx) + file(join(f.dir, 'node_modules', 'addon', 'dep.mjs'), 'export {}\n') + await expect(f.reconcile()).resolves.toEqual({ warnings: [], pending: [] }) + expect(f.trace).toEqual(['start:service']) +}) + +it.each(['link', 'missing'] as const)('finds the host module graph through a process entry given as a %s path', async (kind) => { + const argv = process.argv + onTestFinished(() => { process.argv = argv }) + const f = await fixture(undefined, async (home, dir) => { + const main = join(dir, 'main.mjs') + file(main, "import './node_modules/shared-lib/index.mjs'\n") + await ModuleLoader.fromInternal()!.import(pathToFileURL(main).href, import.meta.url, {}) + const entry = join(home, 'bin-dsh') + if (kind === 'link') symlinkSync(main, entry) + process.argv = [argv[0]!, entry] + }) + shared(f.dir, 2) + const url = pathToFileURL(join(f.dir, 'node_modules', 'shared-lib', 'index.mjs')).href + await expect(f.reconcile()).resolves.toEqual({ warnings: [], + pending: kind === 'link' ? [{ url, reason: 'host-module' }] : [] }) +}) + +it('leaves a CommonJS plugin registered outside a Loader entry, and its cache, for a restart', async () => { + const f = await fixture([]) + const require = createRequire(import.meta.url) + const root = join(f.dir, 'node_modules', 'cjs-addon') + await f.ctx.plugin(f.ctx.loader.unwrapExports(require(join(root, 'index.cjs'))) as Plugin) + expect(f.trace).toEqual(['start:cjs:1']) + const cached = require.cache[join(root, 'helper.cjs')] + commonjs(f.dir, 2) + const result = await f.reconcile() + expect(result.pending).toContainEqual({ url: pathToFileURL(join(root, 'helper.cjs')).href, reason: 'unmanaged-plugin' }) + expect(require.cache[join(root, 'helper.cjs')]).toBe(cached) + expect(f.trace).toEqual(['start:cjs:1']) +}) + +it('replaces a changed module that a data URL module imports', async () => { + const f = await fixture() + const url = pathToFileURL(join(f.dir, 'node_modules', 'shared-lib', 'index.mjs')).href + await import(`data:text/javascript,import ${JSON.stringify(url)}`) + f.trace.length = 0 + shared(f.dir, 2) + await expect(f.reconcile()).resolves.toEqual({ warnings: [], pending: [] }) + expect(f.trace).toEqual(['stop:1', 'start:1:2:-']) +}) + +it('reads no plugin from a CommonJS module that is still loading', async () => { + const f = await fixture() + const require = createRequire(import.meta.url) + const partial = join(f.dir, 'node_modules', 'addon', 'partial.cjs') + file(partial, 'module.exports = {}\n') + // A module whose evaluation has not finished is cached with `loaded: false`. + require.cache[partial] = { exports: { apply() {} }, loaded: false } as NodeJS.Module + onTestFinished(() => { Reflect.deleteProperty(require.cache, partial) }) + f.trace.length = 0 + await expect(f.reconcile()).resolves.toEqual({ warnings: [], pending: [] }) + expect(f.trace).toEqual([]) +}) + +it('replaces a module first loaded during a recomposition whose file changed before it settled', async () => { + const f = await fixture(['addon'], async (_home, dir) => { + const root = join(dir, 'node_modules', 'other') + file(join(root, 'dep.mjs'), 'export const value = 1\n') + // The write lands well after the comparison; file clocks can date a write within one tick of it earlier. + file(join(root, 'index.mjs'), `import { writeFileSync } from 'node:fs' +import { setTimeout } from 'node:timers/promises' +import { value } from './dep.mjs' +export async function apply(ctx) { + ctx.get('reloadTrace').push('start:other:' + value) + await setTimeout(50) + writeFileSync(${JSON.stringify(join(root, 'dep.mjs'))}, 'export const value = 2\\n') +} +`) + }) + f.trace.length = 0 + f.select(['addon', 'other']) + await f.reconcile() + expect(f.trace).toEqual(['start:other:1']) + f.trace.length = 0 + await f.reconcile() + expect(f.trace).toEqual(['start:other:2']) +}) + +it('replaces other changed plugins when one changed plugin cannot be replaced', async () => { + const f = await fixture(['other']) + const warn = vi.spyOn(f.ctx.logger, 'warn').mockImplementation(() => {}) + onTestFinished(() => { warn.mockRestore() }) + const url = pathToFileURL(join(f.dir, 'node_modules', 'addon', 'index.mjs')).href + await f.ctx.plugin(f.ctx.loader.unwrapExports(await f.ctx.loader.import(url)) as Plugin) + f.trace.length = 0 + addon(f.dir, '2') + other(f.dir) + file(join(f.dir, 'node_modules', 'other', 'index.mjs'), `export function apply(ctx) { + ctx.get('reloadTrace').push('start:other:2') +} +`) + await expect(f.reconcile()).resolves.toEqual({ warnings: [], pending: [{ url, reason: 'unmanaged-plugin' }] }) + expect(f.trace).toEqual(['stop:other', 'start:other:2']) +}) + +it('starts a replaced entry outside the profile composition once and judges its activation', async () => { + const f = await fixture([]) + const warn = vi.spyOn(f.ctx.logger, 'warn').mockImplementation(() => {}) + onTestFinished(() => { warn.mockRestore() }) + const id = await f.ctx.loader.create({ name: 'addon' }) + await f.ctx.loader.await() + f.trace.length = 0 + addon(f.dir, '2') + await expect(f.reconcile()).resolves.toEqual({ warnings: [], pending: [] }) + expect(f.trace).toEqual(['stop:1', 'start:2:1:-']) + addon(f.dir, '3', "export function apply() { throw new Error('version 3 cannot start') }\n") + await expect(f.reconcile()).rejects.toThrow('version 3 cannot start') + expect(f.ctx.loader.resolve(id).fiber?.state).not.toBe(FiberState.ACTIVE) +}) + +it('reports a replaced entry whose package entry point now fails to import', async () => { + const f = await fixture([]) + const error = vi.spyOn(f.ctx.logger, 'error').mockImplementation(() => {}) + onTestFinished(() => { error.mockRestore() }) + await f.ctx.loader.create({ name: 'addon' }) + await f.ctx.loader.await() + const root = join(f.dir, 'node_modules', 'addon') + addon(f.dir, '2') + file(join(root, 'moved.mjs'), "import 'missing-lib'\n") + file(join(root, 'package.json'), JSON.stringify({ name: 'addon', version: '2', type: 'module', exports: './moved.mjs' })) + await expect(f.reconcile()).rejects.toThrow('did not activate') +}) + +it('does not start the new code of a plugin its new bundle patch disables', async () => { + const f = await fixture() + f.trace.length = 0 + addon(f.dir, '2', plugin('2')) + file(join(f.dir, 'node_modules', 'addon', 'cordis.patch.yml'), '- insert:\n - id: addon\n name: addon\n disabled: true\n') + await expect(f.reconcile()).resolves.toEqual({ warnings: [], pending: [] }) + expect(f.trace).toEqual(['stop:1']) +}) + +it('replaces the importers of a changed shared dependency only', async () => { + const f = await fixture() + f.trace.length = 0 + shared(f.dir, 2) + await f.reconcile() + expect(f.trace).toEqual(['stop:1', 'start:1:2:-']) +}) + +it('replaces a plugin of a linked package whose target changed', async () => { + const f = await fixture(['@local/linked']) + expect(f.trace).toEqual(['start:linked:1']) + linked(f.home, f.dir, 2) + await expect(f.reconcile()).resolves.toEqual({ warnings: [], pending: [] }) + expect(f.trace).toEqual(['start:linked:1', 'stop:linked', 'start:linked:2']) +}) + +it('stops a plugin whose replacement fails to load and starts its corrected files', async () => { + const f = await fixture() + quiet(f.ctx) + f.trace.length = 0 + addon(f.dir, '2', plugin('2', 'missing-lib')) + await expect(f.reconcile()).rejects.toThrow(/failed to import: .*'missing-lib'/u) + expect(f.trace).toEqual(['stop:1']) + // An unchanged failure is reported without rejecting later recompositions. + await expect(f.reconcile()).resolves.toEqual({ warnings: [expect.stringContaining("'missing-lib'")], pending: [] }) + addon(f.dir, '3') + await expect(f.reconcile()).resolves.toEqual({ warnings: [], pending: [] }) + expect(f.trace).toEqual(['stop:1', 'start:3:1:-']) +}) + +it('loads a changed dependency together with the corrected plugin that failed to load', async () => { + const f = await fixture() + quiet(f.ctx) + f.trace.length = 0 + shared(f.dir, 2) + addon(f.dir, '2', plugin('2', 'missing-lib')) + await expect(f.reconcile()).rejects.toThrow('missing-lib') + addon(f.dir, '3') + await expect(f.reconcile()).resolves.toEqual({ warnings: [], pending: [] }) + expect(f.trace).toEqual(['stop:1', 'start:3:2:-']) +}) + +it('loads a plugin whose missing dependency was installed after its replacement failed', async () => { + const f = await fixture() + quiet(f.ctx) + f.trace.length = 0 + addon(f.dir, '2', plugin('2', 'late-lib')) + await expect(f.reconcile()).rejects.toThrow('late-lib') + file(join(f.dir, 'node_modules', 'late-lib', 'package.json'), JSON.stringify({ name: 'late-lib', type: 'module', exports: './index.mjs' })) + file(join(f.dir, 'node_modules', 'late-lib', 'index.mjs'), 'export const value = 7\n') + await expect(f.reconcile()).resolves.toEqual({ warnings: [], pending: [] }) + expect(f.trace).toEqual(['stop:1', 'start:2:7:-']) +}) + +it('loads a package reinstalled at the same path after removal', async () => { + const f = await fixture() + f.select(['other']) + await f.reconcile() + rmSync(join(f.dir, 'node_modules', 'addon'), { recursive: true }) + await expect(f.reconcile()).resolves.toEqual({ warnings: [], pending: [] }) + addon(f.dir, '2') + f.select(['addon', 'other']) + f.trace.length = 0 + await f.reconcile() + expect(f.trace).toEqual(['start:2:1:-']) +}) + +it('replaces a CommonJS plugin whose required file changed', async () => { + const f = await fixture(['cjs-addon']) + expect(f.trace).toEqual(['start:cjs:1']) + commonjs(f.dir, 2) + await expect(f.reconcile()).resolves.toEqual({ warnings: [], pending: [] }) + expect(f.trace).toEqual(['start:cjs:1', 'stop:cjs', 'start:cjs:2']) +}) + +it('stops a CommonJS plugin whose required file fails and applies its correction', async () => { + const f = await fixture(['cjs-addon']) + quiet(f.ctx) + const helper = join(f.dir, 'node_modules', 'cjs-addon', 'helper.cjs') + file(helper, "throw new Error('broken helper')\n") + await expect(f.reconcile()).rejects.toThrow('broken helper') + expect(f.trace).toEqual(['start:cjs:1', 'stop:cjs']) + commonjs(f.dir, 3) + await expect(f.reconcile()).resolves.toEqual({ warnings: [], pending: [] }) + expect(f.trace).toEqual(['start:cjs:1', 'stop:cjs', 'start:cjs:3']) +}) + +it('keeps a module first loaded after the last observation when its file predates it', async () => { + const f = await fixture() + const late = join(f.dir, 'node_modules', 'late', 'index.mjs') + file(late, 'export const loaded = Symbol("late")\nexport function apply() {}\n') + age(join(f.dir, 'node_modules', 'late')) + const url = pathToFileURL(late).href + const first = await f.ctx.loader.import(url) as { loaded: symbol } + // A running plugin keeps its package loaded. + await f.ctx.plugin(f.ctx.loader.unwrapExports(first) as Plugin) + await f.reconcile() + expect((await f.ctx.loader.import(url) as { loaded: symbol }).loaded).toBe(first.loaded) +}) + +it('compares module files on a file system without a native realpath, as a packed worker has', async () => { + const f = await fixture() + const native: unknown = Reflect.get(realpathSync, 'native') + Reflect.deleteProperty(realpathSync, 'native') + onTestFinished(() => { Reflect.set(realpathSync, 'native', native) }) + f.trace.length = 0 + addon(f.dir, '2') + try { + await expect(f.reconcile()).resolves.toEqual({ warnings: [], pending: [] }) + } finally { Reflect.set(realpathSync, 'native', native) } + expect(f.trace).toEqual(['stop:1', 'start:2:1:-']) +}) + +it('evicts a package a stopped plugin imported dynamically once the resolution drops its mapping', async () => { + const f = await fixture() + const lazy = join(f.dir, 'node_modules', 'lazy-dep') + file(join(lazy, 'package.json'), JSON.stringify({ name: 'lazy-dep', type: 'module' })) + file(join(lazy, 'index.mjs'), 'export const loaded = Symbol("lazy")\n') + age(lazy) + const url = pathToFileURL(join(realpathSync(lazy), 'index.mjs')).href + const first = await f.ctx.loader.import(url) as { loaded: symbol } + resolution(f.ctx, table('/packages/addon'), () => {}, table('/packages/addon', realpathSync(lazy))) + await f.reconcile() + expect((await f.ctx.loader.import(url) as { loaded: symbol }).loaded).not.toBe(first.loaded) +}) + +it('keeps the modules below a directory that stays mapped inside a dropped one', async () => { + const f = await fixture() + const outer = join(f.dir, 'node_modules', 'outer-lib') + const inner = join(outer, 'node_modules', 'inner-lib') + file(join(outer, 'package.json'), JSON.stringify({ name: 'outer-lib', type: 'module' })) + file(join(outer, 'index.mjs'), 'export const loaded = Symbol("outer")\n') + file(join(inner, 'package.json'), JSON.stringify({ name: 'inner-lib', type: 'module' })) + file(join(inner, 'index.mjs'), 'export const loaded = Symbol("inner")\n') + age(outer) + const outerUrl = pathToFileURL(join(realpathSync(outer), 'index.mjs')).href + const innerUrl = pathToFileURL(join(realpathSync(inner), 'index.mjs')).href + const outerFirst = await f.ctx.loader.import(outerUrl) as { loaded: symbol } + const innerFirst = await f.ctx.loader.import(innerUrl) as { loaded: symbol } + resolution(f.ctx, table('/packages/addon', realpathSync(inner)), () => {}, table('/packages/addon', realpathSync(outer), realpathSync(inner))) + await f.reconcile() + expect((await f.ctx.loader.import(outerUrl) as { loaded: symbol }).loaded).not.toBe(outerFirst.loaded) + expect((await f.ctx.loader.import(innerUrl) as { loaded: symbol }).loaded).toBe(innerFirst.loaded) +}) + +it('keeps the modules of a stopped plugin whose package stays mapped', async () => { + const f = await fixture() + const url = pathToFileURL(join(f.dir, 'node_modules', 'addon', 'index.mjs')).href + const first: unknown = await f.ctx.loader.import(url) + f.select(['other']) + await f.reconcile() + // Only the packages of dropped mappings are evicted; the deselected bundle's unchanged files stay loaded. + expect(await f.ctx.loader.import(url)).toBe(first) +}) + +it('evaluates again a module first loaded after the last observation when its file was written since', async () => { + const f = await fixture() + const late = join(f.dir, 'node_modules', 'late', 'index.mjs') + file(late, 'export const loaded = Symbol("late")\n') + const url = pathToFileURL(late).href + const first = await f.ctx.loader.import(url) as { loaded: symbol } + await f.reconcile() + expect((await f.ctx.loader.import(url) as { loaded: symbol }).loaded).not.toBe(first.loaded) +}) + +it('ignores modules whose loading failed while collecting importers', async () => { + const f = await fixture() + const broken = join(f.dir, 'node_modules', 'broken', 'index.mjs') + file(broken, "import 'missing-lib'\n") + await expect(f.ctx.loader.import(pathToFileURL(broken).href)).rejects.toThrow() + f.trace.length = 0 + shared(f.dir, 2) + await f.reconcile() + expect(f.trace).toEqual(['stop:1', 'start:1:2:-']) +}) + +it('leaves changed modules of the host module graph for a restart', async () => { + const argv = process.argv + onTestFinished(() => { process.argv = argv }) + const f = await fixture(undefined, async (_home, dir) => { + const main = join(dir, 'main.mjs') + file(main, "import './node_modules/shared-lib/index.mjs'\n") + process.argv = [argv[0]!, main] + await ModuleLoader.fromInternal()!.import(pathToFileURL(main).href, import.meta.url, {}) + }) + const warn = vi.spyOn(f.ctx.logger, 'warn').mockImplementation(() => {}) + onTestFinished(() => { warn.mockRestore() }) + const url = pathToFileURL(join(f.dir, 'node_modules', 'shared-lib', 'index.mjs')).href + f.trace.length = 0 + shared(f.dir, 2) + await expect(f.reconcile()).resolves.toEqual({ warnings: [], pending: [{ url, reason: 'host-module' }] }) + expect(f.trace).toEqual([]) +}) + +it('still recomposes when planning the replacement fails', async () => { + const f = await fixture() + const warn = vi.spyOn(f.ctx.logger, 'warn').mockImplementation(() => {}) + onTestFinished(() => { warn.mockRestore() }) + Reflect.set(f.ctx.hmr, 'hostModules', () => Promise.reject(new Error('graph unavailable'))) + f.trace.length = 0 + shared(f.dir, 2) + f.select(['addon']) + const url = pathToFileURL(join(f.dir, 'node_modules', 'shared-lib', 'index.mjs')).href + await expect(f.reconcile()).resolves.toEqual({ warnings: [], pending: [{ url, reason: 'module-comparison' }] }) + expect(f.trace).toEqual(['stop:other']) + expect(warn).toHaveBeenCalledWith(expect.objectContaining({ message: 'graph unavailable' })) +}) + +it('replaces running plugins before reporting a recomposition failure', async () => { + const f = await fixture() + f.trace.length = 0 + shared(f.dir, 2) + writeFileSync(join(f.dir, 'cordis.patch.yml'), JSON.stringify([{ insert: [ + { id: 'timer', name: 'cordis:timer' }, + { id: 'hmr', name: 'cordis:hmr', config: { root: [] } }, + { id: 'broken', name: './missing.mjs' }, + ] }])) + const error = vi.spyOn(f.ctx.logger, 'error').mockImplementation(() => {}) + onTestFinished(() => { error.mockRestore() }) + await expect(f.ctx.hmr.runExclusive(() => f.ctx.hmr.reconcileProfile(['broken']))).rejects.toThrow('broken') + expect(f.trace).toEqual(['stop:1', 'start:1:2:-']) +}) + +it('reconciles only inside an HMR transaction of a profile application', async () => { + const f = await fixture() + await expect(f.ctx.hmr.reconcileProfile()).rejects.toThrow('must run inside runExclusive') + const ctx = new Context() + ctx.baseUrl = pathToFileURL(f.dir + '/').href + onTestFinished(() => ctx.fiber.dispose()) + await ctx.plugin(Loader) + await ctx.plugin(Timer) + await ctx.plugin(Hmr, { root: [], ignored: [], debounce: 0 }) + await expect(ctx.hmr.runExclusive(() => ctx.hmr.reconcileProfile())).rejects.toThrow('requires profileContext') +}) + +it('replaces changed modules when the process has no entry module', async () => { + const argv = process.argv + process.argv = [] + onTestFinished(() => { process.argv = argv }) + const f = await fixture() + f.trace.length = 0 + shared(f.dir, 2) + await f.reconcile() + expect(f.trace).toEqual(['stop:1', 'start:1:2:-']) +}) + +it('retries a failed replacement of a plugin loaded after the last observation', async () => { + const f = await fixture([]) + quiet(f.ctx) + await f.ctx.loader.create({ name: 'addon' }) + await f.ctx.loader.await() + addon(f.dir, '2', plugin('2', 'missing-lib')) + await expect(f.reconcile()).rejects.toThrow('missing-lib') + addon(f.dir, '3') + await expect(f.reconcile()).resolves.toEqual({ warnings: [], pending: [] }) + expect(f.trace).toEqual(['start:1:1:-', 'stop:1', 'start:3:1:-']) +}) + +it('logs the changed modules its own profile watch leaves', async () => { + const argv = process.argv + onTestFinished(() => { process.argv = argv }) + const f = await fixture(undefined, async (_home, dir) => { + const main = join(dir, 'main.mjs') + file(main, "import './node_modules/shared-lib/index.mjs'\n") + process.argv = [argv[0]!, main] + await ModuleLoader.fromInternal()!.import(pathToFileURL(main).href, import.meta.url, {}) + }) + const warn = vi.spyOn(f.ctx.logger, 'warn').mockImplementation(() => {}) + onTestFinished(() => { warn.mockRestore() }) + shared(f.dir, 2) + await f.editPatch() + const url = pathToFileURL(join(f.dir, 'node_modules', 'shared-lib', 'index.mjs')).href + expect(warn).toHaveBeenCalledWith(expect.stringContaining('keep running their previous code'), [{ url, reason: 'host-module' }]) +}) + +it('reports a required restart when the profile packages cannot be listed', async () => { + const f = await fixture(undefined, async (_home, dir) => { + // A scope entry that is a file makes listing its packages fail. + file(join(dir, 'node_modules', '@broken'), '') + }) + const warn = vi.spyOn(f.ctx.logger, 'warn').mockImplementation(() => {}) + onTestFinished(() => { warn.mockRestore() }) + f.trace.length = 0 + f.select(['addon']) + await expect(f.reconcile()).resolves.toEqual({ warnings: [], pending: [], restartReason: 'module-comparison' }) + expect(f.trace).toEqual(['stop:other']) + expect(warn).toHaveBeenCalledWith(expect.objectContaining({ code: 'ENOTDIR' })) +}) + +it('compares only CommonJS modules when module hooks keep the ES module cache on another thread', async () => { + const f = await fixture(['cjs-addon']) + const internal = ModuleLoader.fromInternal()! + const cache: unknown = Reflect.get(internal, 'loadCache') + Reflect.set(internal, 'loadCache', undefined) + onTestFinished(() => { Reflect.set(internal, 'loadCache', cache) }) + const warn = vi.spyOn(f.ctx.logger, 'warn') + onTestFinished(() => { warn.mockRestore() }) + commonjs(f.dir, 2) + await expect(f.reconcile()).resolves.toMatchObject({ warnings: [] }) + expect(warn).not.toHaveBeenCalled() +}) + +it('reads no module links and ignores source changes with a worker-style loader', async () => { + const f = await fixture() + const internal = ModuleLoader.fromInternal()! + const cache: unknown = Reflect.get(internal, 'loadCache') + Reflect.set(internal, 'loadCache', undefined) + onTestFinished(() => { Reflect.set(internal, 'loadCache', cache) }) + const warn = vi.spyOn(f.ctx.logger, 'warn') + onTestFinished(() => { warn.mockRestore() }) + const changes: string[] = [] + f.ctx.on('hmr/change', (url) => { changes.push(url) }) + const source = join(f.dir, 'node_modules', 'addon', 'index.mjs') + await expect(f.ctx.hmr.getLinked(pathToFileURL(source).href)).resolves.toEqual([]) + for (const watcher of watchers.slice(-1)) watcher.emit('change', source) + await vi.waitFor(() => { expect(changes).toEqual([pathToFileURL(source).href]) }) + expect(warn).not.toHaveBeenCalled() +}) + +it('requests a restart for module replacement without Node-compatible caches', async () => { + const f = await fixture() + const internal = ModuleLoader.fromInternal()! + const esm: unknown = Reflect.get(internal, 'loadCache') + const commonjs: unknown = Reflect.get(NodeModule, '_cache') + Reflect.set(internal, 'loadCache', undefined) + Reflect.set(NodeModule, '_cache', undefined) + onTestFinished(() => { Reflect.set(internal, 'loadCache', esm); Reflect.set(NodeModule, '_cache', commonjs) }) + const warn = vi.spyOn(f.ctx.logger, 'warn') + onTestFinished(() => { warn.mockRestore() }) + f.trace.length = 0 + // Patches remain identical: native imports cannot run while these cache fields are hidden. + shared(f.dir, 2) + const result = await f.reconcile() + Reflect.set(internal, 'loadCache', esm) + Reflect.set(NodeModule, '_cache', commonjs) + expect(result).toEqual({ warnings: [], pending: [], restartReason: 'module-comparison' }) + expect(f.trace).toEqual([]) + expect(warn).not.toHaveBeenCalled() +}) + +it('removes a plugin whose replacement fails to load without reporting the failure', async () => { + const f = await fixture() + addon(f.dir, '2', plugin('2', 'missing-lib')) + f.select(['other']) + f.trace.length = 0 + await expect(f.reconcile()).resolves.toEqual({ warnings: [], pending: [] }) + expect(f.trace).toEqual(['stop:1']) +}) + +it('replaces changed plugins beside CommonJS cache entries that are not Node modules', async () => { + const f = await fixture() + const require = createRequire(import.meta.url) + const planted = join(f.dir, 'planted.cjs') + // Some libraries store plain objects in require.cache to provide a module without loading it. + require.cache[planted] = { exports: {} } as NodeJS.Module + onTestFinished(() => { Reflect.deleteProperty(require.cache, planted) }) + f.trace.length = 0 + addon(f.dir, '2') + await expect(f.reconcile()).resolves.toEqual({ warnings: [], pending: [] }) + expect(f.trace).toEqual(['stop:1', 'start:2:1:-']) +}) + +it('imports the corrected files of an entry whose import failed', async () => { + const f = await fixture() + addon(f.dir, '2', plugin('2', 'missing-lib')) + f.select(['other']) + await f.reconcile() + // Selected again, the entry imports the broken files and has no fiber. + // The Loader logs the entry's import failure. + const error = vi.spyOn(f.ctx.logger, 'error').mockImplementation(() => {}) + onTestFinished(() => { error.mockRestore() }) + f.select(['addon', 'other']) + await expect(f.reconcile()).rejects.toThrow(/failed to import: .*missing-lib/u) + f.trace.length = 0 + addon(f.dir, '3') + await expect(f.reconcile()).resolves.toEqual({ warnings: [], pending: [] }) + expect(f.trace).toEqual(['start:3:1:-']) +}) + +it('leaves a disabled entry whose import failed until it is enabled', async () => { + const f = await fixture() + addon(f.dir, '2', plugin('2', 'missing-lib')) + f.select(['other']) + await f.reconcile() + // The Loader logs the entry's import failure. + const error = vi.spyOn(f.ctx.logger, 'error').mockImplementation(() => {}) + onTestFinished(() => { error.mockRestore() }) + f.select(['addon', 'other']) + await expect(f.reconcile()).rejects.toThrow('failed to import') + const rows = [{ id: 'timer', name: 'cordis:timer' }, { id: 'hmr', name: 'cordis:hmr', config: { root: [] } }] + writeFileSync(f.profile.patchPath, JSON.stringify([{ insert: rows }, { id: 'addon', disabled: true }])) + await f.reconcile() + f.trace.length = 0 + addon(f.dir, '3') + await expect(f.reconcile()).resolves.toEqual({ warnings: [], pending: [] }) + expect(f.trace).toEqual([]) + writeFileSync(f.profile.patchPath, JSON.stringify([{ insert: rows }])) + await expect(f.reconcile()).resolves.toEqual({ warnings: [], pending: [] }) + expect(f.trace).toEqual(['start:3:1:-']) +}) + + +/** Install a plugin package that records its start. */ +function late(dir: string): void { + const root = join(dir, 'node_modules', 'late-plugin') + file(join(root, 'package.json'), JSON.stringify({ name: 'late-plugin', type: 'module', exports: './index.mjs' })) + file(join(root, 'index.mjs'), "export function apply(ctx) { ctx.get('reloadTrace').push('start:late') }\n") +} + +it('retries a failed import while the profile packages cannot be listed', async () => { + const f = await fixture([]) + quiet(f.ctx) + await f.ctx.loader.create({ name: 'late-plugin' }) + await f.ctx.loader.await() + // A scope entry that is a file makes listing its packages fail. + file(join(f.dir, 'node_modules', '@broken'), '') + const warn = vi.spyOn(f.ctx.logger, 'warn').mockImplementation(() => {}) + onTestFinished(() => { warn.mockRestore() }) + late(f.dir) + await expect(f.reconcile()).resolves.toEqual({ warnings: [], pending: [], restartReason: 'module-comparison' }) + expect(f.trace).toEqual(['start:late']) +}) + +it('starts an entry whose missing dependency was installed after startup', async () => { + const f = await fixture([], async (_home, dir) => { + file(join(dir, 'node_modules', 'needs-late', 'package.json'), JSON.stringify({ name: 'needs-late', type: 'module', exports: './index.mjs' })) + file(join(dir, 'node_modules', 'needs-late', 'index.mjs'), "export { apply } from 'late-plugin'\n") + }) + quiet(f.ctx) + await f.ctx.loader.create({ name: 'needs-late' }) + await f.ctx.loader.await() + await f.reconcile() + late(f.dir) + await expect(f.reconcile()).resolves.toEqual({ warnings: [], pending: [] }) + expect(f.trace).toEqual(['start:late']) +}) + +it('imports a failing replacement with a new configuration once', async () => { + const f = await fixture() + const error = vi.spyOn(f.ctx.logger, 'error').mockImplementation(() => {}) + onTestFinished(() => { error.mockRestore() }) + addon(f.dir, '2', plugin('2', 'missing-lib'), 'fast') + await expect(f.reconcile()).rejects.toThrow('missing-lib') + expect(error.mock.calls.filter(([value]) => String(value).includes('missing-lib'))).toHaveLength(1) +}) + +it('reports an entry whose import failed and whose disabling expression throws', async () => { + const f = await fixture([]) + quiet(f.ctx) + const id = await f.ctx.loader.create({ name: 'late-plugin' }) + await f.ctx.loader.await() + Reflect.set(f.ctx.loader.resolve(id).options, 'disabled', { __jsExpr: 'undefined.x' }) + // Recomposition still applies and judges; the unchanged expression failure is reported. + await expect(f.reconcile()).resolves.toEqual({ warnings: [expect.stringContaining('disabled expression failed')], pending: [] }) +}) + +it('leaves a changed plugin that an unchanged plugin registered dynamically for a restart', async () => { + const f = await fixture(['other'], async (_home, dir) => { + const root = join(dir, 'node_modules', 'other') + file(join(root, 'child.mjs'), "export function apply(ctx) { ctx.get('reloadTrace').push('start:child:1') }\n") + file(join(root, 'index.mjs'), "const child = await import('./child.mjs')\nexport function apply(ctx) { ctx.plugin(child) }\n") + }) + f.trace.length = 0 + const child = join(f.dir, 'node_modules', 'other', 'child.mjs') + file(child, "export function apply(ctx) { ctx.get('reloadTrace').push('start:child:2') }\n") + await expect(f.reconcile()).resolves.toEqual({ warnings: [], pending: [{ url: pathToFileURL(child).href, reason: 'unmanaged-plugin' }] }) + expect(f.trace).toEqual([]) +}) + +it('recovers from an initial listing failure without replacing unchanged old files', async () => { + const f = await fixture(undefined, async (_home, dir) => { file(join(dir, 'node_modules', '@broken'), '') }) + unlinkSync(join(f.dir, 'node_modules', '@broken')) + f.trace.length = 0 + await expect(f.reconcile()).resolves.toEqual({ warnings: [], pending: [] }) + expect(f.trace).toEqual([]) +}) + +it('records freshly mounted plugin files so installing the next bundle does not restart them', async () => { + const f = await fixture([]) + addon(f.dir, '2') + f.select(['addon']) + await f.reconcile() + f.trace.length = 0 + other(f.dir) + f.select(['addon', 'other']) + await f.reconcile() + expect(f.trace).toEqual(['start:other']) +}) + +it('walks Node 22 null-prototype linked arrays', async () => { + const f = await fixture() + const internal = ModuleLoader.fromInternal()! + const url = pathToFileURL(join(f.dir, 'node_modules', 'addon', 'index.mjs')).href + const job = internal.loadCache.get(url)! + const linked = job.linked + const children = Array.from(await linked) + Object.setPrototypeOf(children, null) + Reflect.set(job, 'linked', Promise.resolve(children)) + onTestFinished(() => { Reflect.set(job, 'linked', linked) }) + shared(f.dir, 2) + f.trace.length = 0 + await f.reconcile() + expect(f.trace).toEqual(['stop:1', 'start:1:2:-']) +}) + +it('keeps CommonJS plugins and their caches pending when no ES module jobs are available', async () => { + const f = await fixture(['cjs-addon']) + const internal = ModuleLoader.fromInternal()! + const esm: unknown = Reflect.get(internal, 'loadCache') + const require = createRequire(import.meta.url) + const helper = join(f.dir, 'node_modules', 'cjs-addon', 'helper.cjs') + const cached = require.cache[helper] + file(helper, 'exports.value = 2\n') + Reflect.set(internal, 'loadCache', undefined) + onTestFinished(() => { Reflect.set(internal, 'loadCache', esm) }) + try { + const expected = { warnings: [], restartReason: 'module-comparison', + pending: [{ url: pathToFileURL(helper).href, reason: 'module-comparison' }] } + await expect(f.reconcile()).resolves.toEqual(expected) + await expect(f.reconcile()).resolves.toEqual(expected) + expect(require.cache[helper]).toBe(cached) + expect(f.trace).toEqual(['start:cjs:1']) + } finally { Reflect.set(internal, 'loadCache', esm) } +}) + +it.each(['disabled', 'removed', 'incompatible', 'dynamic'] as const)('never evaluates the replacement of a %s row', async (kind) => { + const f = await fixture() + const marker = join(f.home, 'evaluated') + addon(f.dir, '2', `import { writeFileSync } from 'node:fs'; writeFileSync(${JSON.stringify(marker)}, 'yes');\n` + plugin('2')) + if (kind === 'incompatible') { + const path = join(f.dir, 'node_modules', 'addon', 'package.json') + file(path, JSON.stringify({ name: 'addon', version: '2.0.0', type: 'module', exports: './index.mjs', + peerDependencies: { '@deepseek-ai/dsh': '<0.0.0' }, dsh: { bundle: { patch: './cordis.patch.yml' } } })) + } else if (kind === 'removed') { + f.select(['other']) + } else { + file(join(f.dir, 'node_modules', 'addon', 'cordis.patch.yml'), + `- insert:\n - id: addon\n name: addon\n disabled: ${kind === 'dynamic' ? '!!js "true"' : 'true'}\n`) + } + f.trace.length = 0 + await f.reconcile() + expect(existsSync(marker)).toBe(false) + expect(f.trace).toEqual(['stop:1']) + addon(f.dir, '3') + f.select(['addon', 'other']) + await f.reconcile() + expect(f.trace).toContain('start:3:1:-') +}) + +it('restarts both CommonJS requirers of a changed shared child', async () => { + const f = await fixture(['cjs-addon'], async (_home, dir) => { + const root = join(dir, 'node_modules', 'other') + file(join(root, 'package.json'), JSON.stringify({ name: 'other', version: '1.0.0', main: './index.cjs', + dsh: { bundle: { patch: './cordis.patch.yml' } } })) + file(join(root, 'index.cjs'), `const helper = require('../cjs-addon/helper.cjs') +exports.apply = ctx => { + ctx.get('reloadTrace').push('start:second:' + helper.value) + ctx.effect(() => () => ctx.get('reloadTrace').push('stop:second')) +} +`) + }) + f.select(['cjs-addon', 'other']) + await f.reconcile() + f.trace.length = 0 + file(join(f.dir, 'node_modules', 'cjs-addon', 'helper.cjs'), 'exports.value = 2\n') + await f.reconcile() + expect(f.trace.toSorted()).toEqual(['start:cjs:2', 'start:second:2', 'stop:cjs', 'stop:second']) +}) + + +it('replaces a row whose disabling is an expression', async () => { + const f = await fixture(undefined, async (_home, dir) => { + file(join(dir, 'node_modules', 'addon', 'cordis.patch.yml'), + '- insert:\n - id: addon\n name: addon\n disabled: !!js "false"\n') + }) + f.trace.length = 0 + shared(f.dir, 2) + await expect(f.reconcile()).resolves.toEqual({ warnings: [], pending: [] }) + expect(f.trace).toEqual(['stop:1', 'start:1:2:-']) +}) + +it('logs a profile-wide restart requirement from its watch when caches are unavailable', async () => { + const f = await fixture() + const internal = ModuleLoader.fromInternal()! + const esm: unknown = Reflect.get(internal, 'loadCache') + const warn = vi.spyOn(f.ctx.logger, 'warn').mockImplementation(() => {}) + onTestFinished(() => { warn.mockRestore(); Reflect.set(internal, 'loadCache', esm) }) + Reflect.set(internal, 'loadCache', undefined) + try { + await f.editPatch() + expect(warn).toHaveBeenCalledWith('restart required: %s', 'module-comparison') + } finally { Reflect.set(internal, 'loadCache', esm) } +}) + + +it.each(['group', 'include'] as const)('replaces the children of a nested %s', async (kind) => { + const f = await fixture(['addon'], async (_home, dir) => { + const config = join(dir, 'nested.yml') + file(config, '- id: child\n name: addon\n') + file(join(dir, 'node_modules', 'addon', 'cordis.patch.yml'), JSON.stringify([{ insert: [kind === 'group' + ? { id: 'nested', name: 'cordis:group', config: [{ id: 'child', name: 'addon' }] } + : { id: 'nested', name: 'cordis:include', config: { path: pathToFileURL(config).href } }] }])) + }) + f.trace.length = 0 + addon(f.dir, '2') + await expect(f.reconcile()).resolves.toEqual({ warnings: [], pending: [] }) + expect(f.trace).toEqual(['stop:1', 'start:2:1:-']) +}) diff --git a/packages/boot/plugin-manager/README.i18n.yaml b/packages/boot/plugin-manager/README.i18n.yaml index 033a14fc65..9cb4ec1a9f 100644 --- a/packages/boot/plugin-manager/README.i18n.yaml +++ b/packages/boot/plugin-manager/README.i18n.yaml @@ -15,8 +15,8 @@ en: 87679c51aa3693f5 zh: 11684a48a5806afb /deepseek-ai-dsh-plugin-manager/use-this-package: - en: 4eef24eeb95ebb30 - zh: 99fb6a7e4e3f759f + en: 95c1b18f8432090a + zh: 213e54a1547a1da1 /deepseek-ai-dsh-plugin-manager/use-this-package/version-compatibility-and-exemptions: en: 920c0acb3b9feaf3 zh: ba6e832a256880a7 @@ -24,8 +24,8 @@ en: d5643a06f300c30a zh: 9348b6a7676a2849 /deepseek-ai-dsh-plugin-manager/understand-the-implementation: - en: 7b54d30e231bb325 - zh: 08df9441fed9927f + en: 495dadf79c48c43c + zh: 9884dd04a5bb49dc /deepseek-ai-dsh-plugin-manager/further-exploration: en: c6538243d8702a0d zh: 04a82d923353c75f @@ -36,8 +36,8 @@ en: 1815ec6ce56c80d4 zh: 2d7ac75d3b25eb6b /deepseek-ai-dsh-plugin-manager/model-experience/management-tool/what-the-model-sees: - en: 72d6c70869b12f22 - zh: 45218d6f1e765fb1 + en: b1dae91f98350290 + zh: 802ae86d923ac6ce /deepseek-ai-dsh-plugin-manager/model-experience/management-tool/token-effect: en: bc70208bd361dbcd zh: bb651d6066066c6c @@ -45,11 +45,11 @@ en: c29f60be7f3edc1a zh: d9b9388b96ef95cc /deepseek-ai-dsh-plugin-manager/known-limitations-and-deferred-work: - en: fde2d9491fe5dd4f - zh: ef6e6db15c4f19d2 + en: 6b93fd9851b79a0e + zh: d2cf1982e8cfc718 /deepseek-ai-dsh-plugin-manager/known-limitations-and-deferred-work/failure-behavior: - en: 4447e4df8d62b274 - zh: c76443e2edb97b1d + en: c670eb2330b441e8 + zh: 2c8223e03e1e3ef0 /deepseek-ai-dsh-plugin-manager/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: 3bb0c20c93f16f98 diff --git a/packages/boot/plugin-manager/README.md b/packages/boot/plugin-manager/README.md index bc1550d9aa..0c36040de2 100644 --- a/packages/boot/plugin-manager/README.md +++ b/packages/boot/plugin-manager/README.md @@ -37,7 +37,7 @@ For a deployment without agent presets, enable the tool in the profile patch. Pr disabled: false ``` -A plugin toggle updates only `disabled` in the last matching override in the profile's `cordis.patch.yml`, or appends an override when none matches. Matching uses the entry id and any module-name assertion. A bundle toggle changes `package.json`'s ordered `dsh.profile.bundles` list. Disabling retains the dependency; enabling appends the bundle at the end, which can change configuration precedence. Installation enables a new bundle by default. Home and invocation patches retain their higher priority. +A plugin toggle updates only `disabled` in the last matching override in the profile's `cordis.patch.yml`, or appends an override when none matches. Matching uses the entry id and any module-name assertion. An override holding only `id` and `disabled` is removed, and none is appended, when the bundle layers and the remaining profile patches already give the entry the requested state, so the profile patch records only departures from the bundles. Home and invocation patches are left out of that comparison; they apply after the profile patch and do not decide what it records. A bundle toggle changes `package.json`'s ordered `dsh.profile.bundles` list. Disabling retains the dependency; enabling appends the bundle at the end, which can change configuration precedence. Installation enables a new bundle by default. Home and invocation patches retain their higher priority. A selected bundle that cannot load remains in `listBundles` with an `error`; `enabled` records the saved selection, not successful loading. The plugin page shows the error and allows deselection. A broken bundle cannot be enabled. Management bundles remain protected if their files become unreadable. @@ -49,7 +49,7 @@ A selected bundle that cannot load remains in `listBundles` with an `error`; `en Published installations offer `package@`. Source Web and development Desktop offer `link:` from the running CLI installation, including its development symlink. Package names resolve through workspace manifests; current working directory and prerelease suffixes do not select a checkout. Missing packages, bundle patches, or built entry points produce a diagnostic without registry fallback. Prepare the checkout with `pnpm install` and `pnpm run build`. Linked packages use that checkout’s dependencies and peers; rebuilding and restarting remain necessary after code changes. A registry copy or another checkout’s link can need replacement even when its version matches. -Official on-demand installs and explicit updates call the same `installBundle` operation as other bundles. Registry targets use `saveExact: true`; development targets retain the ordinary `link:` dependency. Updates pass the current `enabled` selection; replacing an existing dependency reports `restart-required`. Turning Off only deselects the layer, and Remove uses the existing removal checks. Installation failure restores the profile manifest and lockfile; activation failure after installation can retain the saved dependency and selection. The existing peer-compatibility check still applies, without an additional Official-only version admission rule. +Official on-demand installs call the same `installBundle` operation as other bundles. Registry targets use `saveExact: true`; development targets retain the ordinary `link:` dependency. An explicit update removes the installed package and installs the target with the current `enabled` selection; without HMR, a bundle that runs since startup cannot be removed, so the installation goes over it and reports `restart-required`. A failed or cancelled installation after the removal leaves the package uninstalled, and the reinstalled bundle joins the end of the selection. Turning Off only deselects the layer, and Remove uses the existing removal checks. Installation failure restores the profile manifest and lockfile; activation failure after installation can retain the saved dependency and selection. The existing peer-compatibility check still applies, without an additional Official-only version admission rule. `installBundle` checks GitHub repositories with `git ls-remote` before starting pnpm, using the profile directory and the installer's Git and proxy configuration. `githubConnectionTimeoutMs` defaults to 5000 ms and limits only this check, not package download or builds. The check disables credential helpers and prompts; only network failures and timeouts stop installation, reporting `failedAt: 'spec-host'` with the existing failure kind and diagnostic log. Authentication, repository lookup and other failures are left to pnpm, including its HTTPS-to-SSH fallback. Cancellation and manager disposal stop the check and its descendants. Registry packages, paths, tarballs and other Git hosts skip this check. A reachable repository can still fail during download or bundle validation. @@ -97,9 +97,9 @@ The CLI exposes `dsh plugin --profile version-exemptions`, `allow-vers 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 configuration 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. -Installing a new package, enabling a bundle, disabling it with HMR after its plugins stop, and a successful removal publish the profile's runtime resolution. Shared dependencies may pass to another bundle's declaration when their normalized directory, version, and scope stay unchanged. Without HMR, a deselected startup bundle still runs: while any such bundle is absent from the saved selection, subsequent package operations retain the entire current table and report `restart-required`. Other new installations and removals of non-running bundles can still publish without HMR; activation waits for restart. Overwriting an installed package publishes nothing and reports `restart-required` ([decision](../../../.agents/notes/implemented/architecture/2026-09-30-profile-package-refresh-and-manifest-invalidation.md)). +Without HMR, installing a new package, enabling a bundle and a successful removal publish the profile's runtime resolution; with HMR, every operation leaves publication to [HMR](../hmr/README.md)'s recomposition, which removes dropped mappings after their plugins stop. Shared dependencies may pass to another bundle's declaration when their normalized directory and scope stay unchanged. Without HMR, a deselected startup bundle still runs: while any such bundle is absent from the saved selection, subsequent package operations retain the entire current table and report `restart-required`. Other new installations and removals of non-running bundles can still publish without HMR; activation waits for restart. With HMR, `installBundle()` refuses a package that is already installed with `already-installed`, before pnpm runs, when the spec names it: a registry name, a local directory, or the git or tarball spec its dependency was saved with. A package that cannot be removed, such as a bundle carrying plugin management, therefore cannot be installed over with HMR. Installing another version is a removal and an installation, so the previous version's plugins stop, its modules are evicted and its mappings removed before the new one is installed; the Web page's Update for an Official plugin does both. A git or tarball spec whose package is known only after pnpm ran can still install over an installed package: without HMR it reports `restart-required`, and with HMR the recomposition replaces the running plugins whose loaded files changed. A replacement that fails to import or start reports `failed` with the activation diagnostic, which names the import error; the previous version no longer runs, and every later operation imports it again, so it starts once its files or dependencies are corrected. An unchanged import failure is a warning of later operations; a changed one fails them. A publication the runtime resolution rejects, because an installed package now resolves to another directory or a mapping of the DSH installation changed, leaves the composition as it was, after stopping and starting again the rows it would drop, and reports `restart-required` with `package-moved`. Disabling or removing a bundle evicts the modules of the packages whose mappings it drops. Every `restart-required` result carries `restartReason`: `no-hmr`, `package-moved`, or the [HMR pending reason](../hmr/README.md) of a changed module that loads only after a restart, unless the operation reports `overridden` ([decision](../../../.agents/notes/implemented/architecture/2026-09-30-profile-package-refresh-and-manifest-invalidation.md)). -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). Each operation records the pnpm run it starts in `.plugin-manager/run.json` and removes the record when the run ends. A lock whose holder process exited is taken over by the next writer, but that process's pnpm tree can still be running, so an operation that finds a record waits up to five seconds for the recorded run to stop and otherwise fails with a diagnostic that names the process and the record, without running pnpm. Dependency-only changes do not trigger configuration reloads. +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. The diagnostic file is closed before the call settles, including preparation or spawn failures. 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). Each operation records the pnpm run it starts in `.plugin-manager/run.json` and removes the record when the run ends. A lock whose holder process exited is taken over by the next writer, but that process's pnpm tree can still be running, so an operation that finds a record waits up to five seconds for the recorded run to stop and otherwise fails with a diagnostic that names the process and the record, without running pnpm. 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 package run, or failed bundle validation, restores the manifest and lockfile snapshotted before pnpm ran ([rationale](../../../.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.md)); later failures retain saved changes as described in [Failure behavior](#failure-behavior). 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. @@ -122,7 +122,7 @@ Results contain the last attempted stage, target, saved-state change, applicatio #### What the model sees -The [`plugin_manager` tool](../../../docs/tool-catalog.md#deepseek-aidsh-plugin-manager) lists plugin entries and bundles and performs profile-wide changes. Its results include saved-state changes, application status and package diagnostics. Management operations do not inject messages into Agents. +The [`plugin_manager` tool](../../../docs/tool-catalog.md#deepseek-aidsh-plugin-manager) lists plugin entries and bundles and performs profile-wide changes. Its results include saved-state changes, application status and package diagnostics. A change result that is not plainly applied adds `nextStep`, a sentence derived from its application status, refusal code, package-manager failure kind or restart reason that says what the model does next or tells the user. Management operations do not inject messages into Agents. #### Token effect @@ -137,7 +137,8 @@ Tool results append to the transcript. Enabling or disabling other tools can cha - Web approves the entire displayed pending group; it has no per-package selection. -- Package replacements require restarting the process to load a fresh JavaScript module generation. +- pnpm verifies every lockfile tarball URL against its default registry when a command changes the lockfile. After a package was installed from another registry, installing a different package with the default registry, or removing that package after a restart, can fail with `ERR_PNPM_TARBALL_URL_MISMATCH`. +- Package replacements require restarting the process without HMR. A package relinked outside Plugin Manager while it is still installed waits for a restart. - Startup-only profiles cannot remove packages used to start the current process; stop it and use `dsh plugin`. - The manager cannot disable its own management components, change another profile, or edit an agent preset's composition. - A failed removal may leave dependencies partially changed, and a failed or cancelled installation can leave downloaded files under `node_modules` or the pnpm store. Inactive dependencies with missing files remain removable. Diagnostic logs remain under the profile's `.plugin-manager/logs` directory. @@ -156,7 +157,7 @@ Failures preserve completed steps and report the actual remaining state. Profile | Resolution publication after a successful package operation fails | Keep completed disk changes and the previous runtime table. Report `application: 'failed'` with the publication diagnostic; pnpm's zero exit code does not mean publication succeeded. | | Remove: any step fails | Stop at the failed step. Preserve completed changes, retain remaining dependencies for retry, and report removal failure. Do not re-enable the bundle. | -Installation finishes after pnpm and bundle validation succeed; subsequent publication or enablement failure does not undo installation. Removal proceeds in order: remove the bundle from `dsh.profile.bundles`, unload its runtime contributions, run `pnpm remove`, then publish runtime resolution unless a deselected startup bundle still runs without HMR. A failed step prevents subsequent steps; publication failure after removal does not restore deleted packages. A selected name that neither the profile's dependencies nor the installation holds, such as a retired bundle, is also removable; its removal stops after deselection and runs no pnpm. +Installation finishes after pnpm and bundle validation succeed; subsequent publication or enablement failure does not undo installation. Removal proceeds in order: remove the bundle from `dsh.profile.bundles`, unload its runtime contributions, run `pnpm remove`, then publish runtime resolution unless a deselected startup bundle still runs without HMR. `pnpm remove` runs with the registry this manager last installed the package from in the running process, because pnpm verifies lockfile tarball URLs against its default registry; a package installed before the process started is removed with the registry an installation asks first. A failed step prevents subsequent steps; publication failure after removal does not restore deleted packages. A selected name that neither the profile's dependencies nor the installation holds, such as a retired bundle, is also removable; its removal stops after deselection and runs no pnpm. Restoration rewrites only the two snapshotted files; user-authored patch configuration, application data, diagnostic logs and files pnpm downloaded remain untouched, and the next package operation prunes packages no manifest references. diff --git a/packages/boot/plugin-manager/README.zh.md b/packages/boot/plugin-manager/README.zh.md index 8d5d3aabe7..d5211beaa5 100644 --- a/packages/boot/plugin-manager/README.zh.md +++ b/packages/boot/plugin-manager/README.zh.md @@ -28,7 +28,7 @@ kind: "package-reference" ## 使用本包 -基于 base 的 profile 提供管理服务。在 Web 中,侧边栏的**插件**页([ui-plugin-manager](../../client/ui-plugin-manager/README.zh.md))管理 profile 的组合包及其能唯一定位的行;设置页的插件列表保持只读。Agent 预设条目保持只读。`plugin_manager` 工具提供相同操作,在 Creator 模式中启用。其他预设仍默认禁用。 每个工具操作都要求 `danger-full-access` 或本次调用的批准。在较低沙箱模式下,`ask` 会请求审批;`never`、拒绝、取消或审批渠道不可用时均不执行。批准不改变会话的权限模式。profile 变更跨会话持久化,已安装的 Host 代码在宿主进程内运行,不受工作区沙箱限制。依赖构建脚本仍需单独批准。 +基于 base 的 profile 提供管理服务。在 Web 中,侧边栏的**插件**页([ui-plugin-manager](../../client/ui-plugin-manager/README.zh.md))管理 profile 的组合包及其能唯一定位的行;设置页的插件列表保持只读。Agent 预设条目保持只读。`plugin_manager` 工具提供相同操作,在 Creator 模式中启用。其他预设仍默认禁用。每个工具操作都要求 `danger-full-access` 或本次调用的批准。在较低沙箱模式下,`ask` 会请求审批;`never`、拒绝、取消或审批渠道不可用时均不执行。批准不改变会话的权限模式。profile 变更跨会话持久化,已安装的 Host 代码在宿主进程内运行,不受工作区沙箱限制。依赖构建脚本仍需单独批准。 未使用 Agent 预设的部署在 profile patch 中启用工具;使用预设的会话由其预设中的 `tool-plugin-manager` 条目控制。 @@ -37,7 +37,7 @@ kind: "package-reference" disabled: false ``` -插件开关只更新 profile 的 `cordis.patch.yml` 中最后一条匹配覆盖项的 `disabled`;没有匹配项时追加。匹配依据是条目 id,以及覆盖项声明的模块名称。组合包开关修改 `package.json` 的有序 `dsh.profile.bundles` 列表。关闭保留依赖;开启追加到列表末尾,可能改变配置优先级。安装新组合包默认启用。home 和单次启动 patch 保留更高优先级。 +插件开关只更新 profile 的 `cordis.patch.yml` 中最后一条匹配覆盖项的 `disabled`;没有匹配项时追加。匹配依据是条目 id,以及覆盖项声明的模块名称。如果组合包层和其余 profile patch 已使条目处于所请求的状态,仅含 `id` 与 `disabled` 的覆盖项会被移除,也不会追加覆盖项,因此 profile patch 只记录与组合包不同的设置。该比较不计入 home 和单次启动 patch;它们在 profile patch 之后应用,不决定 profile patch 记录什么。组合包开关修改 `package.json` 的有序 `dsh.profile.bundles` 列表。关闭保留依赖;开启追加到列表末尾,可能改变配置优先级。安装新组合包默认启用。home 和单次启动 patch 保留更高优先级。 已选择但无法加载的组合包仍会出现在 `listBundles` 中,并携带 `error`;`enabled` 表示保存的选择,不代表加载成功。插件页面显示错误并允许取消选择。损坏的组合包无法启用。管理组合包的文件变得不可读后仍受保护。 @@ -49,7 +49,7 @@ kind: "package-reference" 已发布的安装提供 `package@<当前 DSH 版本>`。源码 Web 与开发 Desktop 提供 `link:<工作区包的绝对目录>`,目录来自当前 CLI 的安装位置,包括其开发符号链接。包名通过工作区清单解析;当前工作目录和预发布版本后缀不用于选择 checkout。包、bundle patch 或已构建入口缺失时报告诊断,不回退到注册表。使用 `pnpm install` 和 `pnpm run build` 准备 checkout。链接包使用该 checkout 的依赖与 peer;代码改变后仍需重新构建并重启。即使版本相同,注册表副本或其他 checkout 的链接也可能需要替换。 -官方按需安装与显式更新调用和其他组合包相同的 `installBundle` 操作。注册表目标使用 `saveExact: true`;开发目标保留普通 `link:` 依赖。更新传入当前 `enabled` 选择状态;替换已有依赖会报告 `restart-required`。关闭仅取消选择配置层,移除使用原有移除检查。安装失败恢复 profile 清单和锁文件;安装成功后激活失败可能保留已保存的依赖和选择。原有 peer 兼容性检查继续生效,不增加仅针对官方条目的版本准入规则。 +官方按需安装调用和其他组合包相同的 `installBundle` 操作。注册表目标使用 `saveExact: true`;开发目标保留普通 `link:` 依赖。显式更新先删除已安装的包,再以当前 `enabled` 选择状态安装目标;没有 HMR 时,自启动起运行的组合包无法删除,因此安装直接覆盖它并报告 `restart-required`。删除之后安装失败或被取消时,该包保持未安装,重新安装的组合包排在选择列表末尾。关闭仅取消选择配置层,移除使用原有移除检查。安装失败恢复 profile 清单和锁文件;安装成功后激活失败可能保留已保存的依赖和选择。原有 peer 兼容性检查继续生效,不增加仅针对官方条目的版本准入规则。 `installBundle` 在启动 pnpm 前通过 `git ls-remote` 检查 GitHub 仓库,使用 profile 目录及安装器的 Git 与代理配置。`githubConnectionTimeoutMs` 默认为 5000 毫秒,只限制这次检查,不限制包下载或构建。检查禁用凭据助手和认证提示;只有网络失败与超时会停止安装,通过现有失败类型与诊断日志返回,并标记 `failedAt: 'spec-host'`。认证、仓库查找及其他失败继续交给 pnpm,包括其 HTTPS 到 SSH 的回退。取消安装或销毁管理器会停止检查及其子进程。注册表包、本地路径、压缩包和其他 Git 主机跳过此检查。仓库可达后,下载或组合包验证仍可能失败。 @@ -97,9 +97,9 @@ CLI 提供 `dsh plugin --profile version-exemptions`、`allow-version 服务与 `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 前取消选入并完成卸载。 -安装新包、启用组合包、有 HMR 时停用组合包并等插件退出后、以及删除成功后,都会发布 profile 的 runtime resolution。共享依赖的规范化目录、版本和作用域不变时,可以改由另一个组合包声明。没有 HMR 时,已取消选入的启动组合包仍在运行:只要保存的选择列表中缺少任何这样的组合包,后续包操作就保留整张当前表,并报告 `restart-required`。其他新安装和未运行组合包的删除仍可在没有 HMR 时发布;激活等待重启。覆盖已安装的包不发布,并报告 `restart-required`([决策](../../../.agents/notes/implemented/architecture/2026-09-30-profile-package-refresh-and-manifest-invalidation.zh.md))。 +没有 HMR 时,安装新包、启用组合包以及删除成功后,都会发布 profile 的 runtime resolution;有 HMR 时,每个操作都把发布交给 [HMR](../hmr/README.zh.md) 的重新组合,该重新组合在相关插件停止后移除被丢弃的映射。共享依赖的规范化目录和作用域不变时,可以改由另一个组合包声明。没有 HMR 时,已取消选入的启动组合包仍在运行:只要保存的选择列表中缺少任何这样的组合包,后续包操作就保留整张当前表,并报告 `restart-required`。其他新安装和未运行组合包的删除仍可在没有 HMR 时发布;激活等待重启。有 HMR 时,spec 指明已安装的包,`installBundle()` 在运行 pnpm 之前以 `already-installed` 拒绝安装;spec 可以是 registry 包名、本地目录,或保存依赖时使用的 git 或 tarball spec。因此,有 HMR 时无法在不能删除的包(例如带有插件管理的组合包)上安装。安装另一个版本需要先卸载再安装,因此在安装新版本之前,旧版本的插件停止,其模块被逐出,其映射被移除;Web 页面中官方插件的更新会依次执行这两步。运行 pnpm 之后才能确定包名的 git 或 tarball spec 仍可能安装到已安装的包上:没有 HMR 时报告 `restart-required`;有 HMR 时,重新组合替换已加载文件发生变化的运行中插件。替换版本导入或启动失败时报告 `failed` 及激活诊断,该诊断给出导入错误;旧版本不再运行,此后每次操作都会再次导入它,因此其文件或依赖被修正后它即启动。未变化的导入失败在后续操作中作为警告报告;已变化的导入失败使后续操作失败。已安装的包现在解析到其他目录,或 DSH 安装的映射发生变化,使 runtime resolution 拒绝发布时,它停止并重新启动该发布将丢弃的行,组合保持不变,并报告带 `package-moved` 的 `restart-required`。停用或删除组合包时,会逐出因此被删除映射的那些包的模块。每个 `restart-required` 结果都带有 `restartReason`:`no-hmr`、`package-moved`,或只有重启后才能加载的已变化模块的 [HMR 待处理原因](../hmr/README.zh.md),除非操作报告 `overridden`([决策](../../../.agents/notes/implemented/architecture/2026-09-30-profile-package-refresh-and-manifest-invalidation.zh.md))。 -service 运行若在 `idleTimeoutMs` 内没有任何捕获输出即被终止,与退出状态一并报告 `timedOut`,不论信号留下什么退出状态都归类为 `timeout`,且不再转问下一个注册表,因此单次操作占用 profile 锁的时长有上界;CLI 继承终端、不捕获输出,因此不受此上界约束,由操作者中断。运行以进程退出为完成点,随后只在一个有界的宽限窗口内排空管道,因此继承管道的孙进程无法让操作挂起。被终止的运行会停止整棵进程树并等待其消失,因为生命周期脚本的存活时间超过启动它的 pnpm 进程(issue #4981)。每个操作把它启动的 pnpm 运行记录在 `.plugin-manager/run.json` 中,并在运行结束时删除该记录。持有者进程已退出的锁会被下一个写入方接管,但该进程的 pnpm 进程树可能仍在运行,因此发现记录的操作最多等待五秒让记录中的运行停止,否则不运行 pnpm,并以指明该进程与记录文件的诊断失败。仅依赖字段变化不会触发配置重载。 +service 运行若在 `idleTimeoutMs` 内没有任何捕获输出即被终止,与退出状态一并报告 `timedOut`,不论信号留下什么退出状态都归类为 `timeout`,且不再转问下一个注册表,因此单次操作占用 profile 锁的时长有上界;CLI 继承终端、不捕获输出,因此不受此上界约束,由操作者中断。运行以进程退出为完成点,随后只在一个有界的宽限窗口内排空管道,因此继承管道的孙进程无法让操作挂起。诊断文件会在调用结束前关闭,包括准备或创建进程失败的情况。被终止的运行会停止整棵进程树并等待其消失,因为生命周期脚本的存活时间超过启动它的 pnpm 进程(issue #4981)。每个操作把它启动的 pnpm 运行记录在 `.plugin-manager/run.json` 中,并在运行结束时删除该记录。持有者进程已退出的锁会被下一个写入方接管,但该进程的 pnpm 进程树可能仍在运行,因此发现记录的操作最多等待五秒让记录中的运行停止,否则不运行 pnpm,并以指明该进程与记录文件的诊断失败。仅依赖字段变化不会触发配置重载。 结果包含最后尝试的阶段、目标、磁盘变化、应用状态和错误码。Web 词典呈现管理文案;pnpm 与 Loader 的诊断保持原样。无关的已有故障作为警告返回;新出现、配置变化后的故障,以及显式启用目标未激活,都会使操作失败。包管理运行失败或被取消,或组合包校验失败时,会恢复 pnpm 运行前快照的 manifest 与 lockfile([理由](../../../.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.zh.md));后续失败按[失败行为](#failure-behavior)所述保留已保存的改动。安装按 request id 跟踪到调用结束,因此取消只针对一次运行,并且不取 profile 锁就能等待它结束。CLI 继承认证环境和终端描述符;service 使用清理后的环境并捕获输出。管理器直接读取文件和 Loader 状态,不维护第二份目标状态注册表,因此不发布单独的运行时不变式伴生入口。 @@ -122,7 +122,7 @@ service 运行若在 `idleTimeoutMs` 内没有任何捕获输出即被终止, #### 模型看到什么 -[`plugin_manager` 工具](../../../docs/tool-catalog.zh.md#deepseek-aidsh-plugin-manager) 列出插件条目和组合包,并执行影响整个 profile 的改动。结果包含保存状态变化、应用状态和包管理诊断。管理操作不会向 Agent 注入消息。 +[`plugin_manager` 工具](../../../docs/tool-catalog.zh.md#deepseek-aidsh-plugin-manager) 列出插件条目和组合包,并执行影响整个 profile 的改动。结果包含保存状态变化、应用状态和包管理诊断。未直接应用的变更结果会增加 `nextStep`,这是一句根据应用状态、拒绝代码、包管理器失败类型或重启原因生成的说明,指出模型下一步做什么或告诉用户什么。管理操作不会向 Agent 注入消息。 #### Token 影响 @@ -137,7 +137,8 @@ service 运行若在 `idleTimeoutMs` 内没有任何捕获输出即被终止, - Web 一次批准显示出来的整组待决定包,没有逐包选择。 -- 替换已有包后需要重启进程,以加载新的 JavaScript 模块版本。 +- 命令改动锁文件时,pnpm 按默认注册表校验锁文件中的每个 tarball URL。某个包从其他注册表安装后,使用默认注册表安装另一个包,或在重启后删除该包,可能以 `ERR_PNPM_TARBALL_URL_MISMATCH` 失败。 +- 没有 HMR 时,替换包需要重启进程。在 Plugin Manager 之外重新链接仍处于安装状态的包,需要等待重启。 - 仅启动时加载的 profile 不能删除当前进程启动时使用的包;停止进程后使用 `dsh plugin`。 - 管理器不能关闭自身所需的管理组件、修改其他 profile 或编辑 agent 预设组合。 - 失败的删除可能留下部分依赖改动,失败或被取消的安装可能在 `node_modules` 或 pnpm 缓存中留下已下载文件。文件缺失的未启用依赖仍可删除。诊断日志保留在 profile 的 `.plugin-manager/logs` 目录中。 @@ -156,7 +157,7 @@ service 运行若在 `idleTimeoutMs` 内没有任何捕获输出即被终止, | 包操作成功后解析表发布失败 | 保留已完成的磁盘改动和上一张运行时表。报告 `application: 'failed'` 及发布诊断;pnpm 的零退出码不代表发布成功。 | | 卸载:任一步失败 | 停在失败步骤,保留已完成的改动和待重试删除的依赖,报告卸载失败。不重新启用组合包。 | -pnpm 执行和组合包校验成功即完成安装,后续发布或启用失败不撤销安装。卸载依次执行:从 `dsh.profile.bundles` 移除组合包、卸载运行时贡献、执行 `pnpm remove`,然后发布 runtime resolution,除非没有 HMR 且已取消选入的启动组合包仍在运行。任一步失败都不继续执行后续步骤;删除后的发布失败不会恢复已删除的包。 profile 依赖和安装都不持有的已选名称(例如已下线的组合包)同样可卸载;其卸载在取消选择后结束,不运行 pnpm。 +pnpm 执行和组合包校验成功即完成安装,后续发布或启用失败不撤销安装。卸载依次执行:从 `dsh.profile.bundles` 移除组合包、卸载运行时贡献、执行 `pnpm remove`,然后发布 runtime resolution,除非没有 HMR 且已取消选入的启动组合包仍在运行。`pnpm remove` 使用本管理器在当前进程中最近一次安装该包时所用的注册表,因为 pnpm 按默认注册表校验锁文件中的 tarball URL;进程启动前安装的包使用安装时首先询问的注册表删除。任一步失败都不继续执行后续步骤;删除后的发布失败不会恢复已删除的包。 profile 依赖和安装都不持有的已选名称(例如已下线的组合包)同样可卸载;其卸载在取消选择后结束,不运行 pnpm。 恢复只重写这两份快照文件;用户编写的 patch 配置、应用数据、诊断日志以及 pnpm 已下载的文件保持原样,没有 manifest 引用的包由下一次包操作清理。 diff --git a/packages/boot/plugin-manager/src/index.ts b/packages/boot/plugin-manager/src/index.ts index cc4db943eb..6734d2872b 100644 --- a/packages/boot/plugin-manager/src/index.ts +++ b/packages/boot/plugin-manager/src/index.ts @@ -13,10 +13,10 @@ import { TypertRemoteService, Remote } from '@deepseek-ai/dsh-typert-protocol' import { pluginEntryId, readPluginInventory } from '@deepseek-ai/dsh-host-plugin-inventory' import { readPluginMeta, readProfileManifest, resolveBundleDir, loadOverlayPatches, composeEntries, - reconcileProfilePatches, readProfilePatches, OPTIONAL_BUNDLES, bundlePatchPaths, + readProfilePatches, OPTIONAL_BUNDLES, bundlePatchPaths, evaluatePluginCompatibility, readProfileCompatibility, readProfileVersionExemptions, setProfileVersionExemption, PROFILE_COMPATIBILITY_FILENAME, - OFFICIAL_ON_DEMAND_CATALOG, getDshRuntimeVersion, + OFFICIAL_ON_DEMAND_CATALOG, getDshRuntimeVersion, ResolutionRestartRequired, } from '@deepseek-ai/dsh-app-boot' import type {} from '@deepseek-ai/dsh-hmr' import type { ProfileContext, ProfileManifest } from '@deepseek-ai/dsh-app-boot' @@ -25,7 +25,7 @@ import { classifyInstallFailure } from './install-failure.ts' import { officialBundleInstallTarget } from './official-install-target.ts' import { dependencySpec, InvalidInstallSpecError, parseInstallSpec, type ParsedInstallSpec } from './install-spec.ts' import { attributeFailure, normalizeRegistry, NPMMIRROR_REGISTRY, registryPlan } from './registry.ts' -import { writePluginEnabled } from './patch.ts' +import { flatten, writePluginEnabled } from './patch.ts' import { incompatiblePlugin, ManagementFailure } from './failure.ts' import { approveBuilds, recordPendingBuilds } from './build-approval.ts' import { checkGithubConnection } from './github-connection.ts' @@ -71,7 +71,7 @@ const protectedModules = new Set([ '@deepseek-ai/dsh-host-webserver', '@deepseek-ai/dsh-client-modules', '@deepseek-ai/dsh-client-ui-settings-plugin-inventory', '@deepseek-ai/dsh-client-ui-plugin-manager', '@deepseek-ai/dsh-host-plugin-inventory', '@deepseek-ai/dsh-typert-registry', - '@deepseek-ai/dsh-api-remotes', + '@deepseek-ai/dsh-api-remotes', '@deepseek-ai/dsh-api-remote-access', '@deepseek-ai/cordis-plugin-timer', '@deepseek-ai/dsh-client-connection', '@deepseek-ai/dsh-host-frontend-static', '@deepseek-ai/dsh-tools', '@deepseek-ai/dsh-hmr', @@ -83,11 +83,6 @@ const RESTORED_FILES = ['package.json', 'pnpm-lock.yaml'] as const /** pnpm's colour escapes, which a JSON answer may be wrapped in. */ const ANSI_SEQUENCE = /\x1b\[[0-9;]*m/g -/** Flatten only the groups addressable by the profile's patch composer. */ -function flatten(rows: EntryOptions[]): EntryOptions[] { - return rows.flatMap(row => [row, ...(row.group && Array.isArray(row.config) ? flatten(row.config as EntryOptions[]) : [])]) -} - /** Preserve the exact observed diagnostic, including non-Error failures. */ function messageOf(error: unknown): string { return error instanceof Error ? error.message : String(error) } @@ -156,6 +151,40 @@ function printedError(printed: string): string { return [code, message].filter((part): part is string => typeof part === 'string').join(' ') } +/** What applying the saved profile reports into a change result. */ +type Reload = Pick + +/** The bundles a profile manifest selects. */ +function bundleSelection(manifest: ProfileManifest): readonly string[] { + return manifest.dsh?.profile?.bundles ?? [] +} + +/** + * The package an installation spec names before pnpm runs: a registry name, a local directory's manifest name, or the + * dependency saved with this git or tarball spec, compared as the plugin list shows saved specs. + * @param spec The installation spec. + * @param dependencies The profile's saved dependencies. + * @param profileDir The profile directory saved local paths are relative to. + * @returns The package name, or `undefined` when only the installation tells it. + */ +function specPackageName(spec: string, dependencies: Readonly>, profileDir: string): string | undefined { + const parsed = parsedForRegistry(spec) + if (parsed.kind === 'registry') return parsed.name + if (parsed.kind === 'path') return packageName(parsed.path) + const specs = parsed.kind === 'tarball' && parsed.path !== undefined ? [spec, `file:${parsed.path}`] : [spec] + return Object.entries(dependencies).find(([dependency, saved]) => specs.includes(dependencySpec(dependency, saved, profileDir)))?.[0] +} + +/** The package name a local directory declares, or `undefined` without a readable manifest. */ +function packageName(dir: string): string | undefined { + try { + return readProfileManifest('dsh', dir).name + } catch (_error) { + // pnpm reports the unreadable directory itself. + return undefined + } +} + /** The spec's form, for deciding whether a failed attempt was the registry's; a form the parser refuses has no host of its own. */ function parsedForRegistry(spec: string): ParsedInstallSpec { try { @@ -189,6 +218,11 @@ export class PluginManager extends TypertRemoteService { }) /** Management bundles remain protected if their files become unreadable. */ private readonly managementBundles = new Set() + /** + * The registry each package was last installed from by this manager in this process. pnpm verifies lockfile tarball + * URLs against its default registry unless they belong to a named registry, so a later removal names it again. + */ + private readonly installedFrom = new Map() private readonly ownerEntryId: string | undefined private readonly packageOperations = new Set>() private readonly profile: ProfileContext @@ -247,7 +281,7 @@ export class PluginManager extends TypertRemoteService { setVersionExemption(packageVersion: string, runtimeVersion: string, enabled: boolean, acceptRisk?: boolean): Promise { return this.change(result => this.configure(async () => { await setProfileVersionExemption(this.profile.dir, packageVersion, runtimeVersion, enabled, acceptRisk === true) - result.warnings = await this.reload() + Object.assign(result, await this.reload()) }), { stage: 'enable', target: packageVersion, enabled }, 'bundle') } @@ -282,7 +316,7 @@ export class PluginManager extends TypertRemoteService { listBundles(): Promise { const manifest = readProfileManifest('dsh', this.profile.dir) const exemptions = readProfileVersionExemptions(this.profile.dir) - const selected = manifest.dsh?.profile?.bundles ?? [] + const selected = bundleSelection(manifest) const recorded = manifest.dependencies ?? {} const dependencies = Object.keys(recorded) const installation = JSON.parse(readFileSync(this.profile.installAnchor, 'utf8')) as InstallationManifest @@ -383,7 +417,7 @@ export class PluginManager extends TypertRemoteService { const manifest = readProfileManifest('dsh', this.profile.dir) const installation = JSON.parse(readFileSync(this.profile.installAnchor, 'utf8')) as InstallationManifest const known = new Set([ - ...manifest.dsh?.profile?.bundles ?? [], ...Object.keys(manifest.dependencies ?? {}), ...Object.keys(installation.dependencies ?? {}), + ...bundleSelection(manifest), ...Object.keys(manifest.dependencies ?? {}), ...Object.keys(installation.dependencies ?? {}), ]) const plan = registryPlan(options?.registry, await this.registries()) const registry = plan[0] as Registry @@ -460,8 +494,8 @@ export class PluginManager extends TypertRemoteService { const row = (await this.listPlugins()).find(item => item.entryId === id) if (row === undefined) throw new ManagementFailure('unknown-plugin') if (row.readOnlyReason !== undefined) throw new ManagementFailure(row.readOnlyReason) - await writePluginEnabled(this.profile.patchPath, row.patchId, row.moduleName, enabled) - result.warnings = await this.reload(enabled ? [row.patchId] : []) + await writePluginEnabled(this.profile, row.patchId, row.moduleName, enabled) + Object.assign(result, await this.reload(enabled ? [row.patchId] : [])) const current = (await this.listPlugins()).find(item => item.entryId === id) return current?.enabled !== enabled && this.ownerContext.get('hmr') !== undefined ? 'overridden' : undefined }), { stage: 'enable', target: id, enabled }, 'plugin') @@ -476,9 +510,7 @@ export class PluginManager extends TypertRemoteService { setBundleEnabled(name: string, enabled: boolean): Promise { return this.change(result => this.configure(async () => { await this.selectBundle(name, enabled) - if (enabled) await this.refreshPackages() - result.warnings = await this.reload(enabled ? this.bundleRows(name).map(row => row.id) : []) - if (!enabled && this.ownerContext.get('hmr') !== undefined) await this.refreshPackages() + Object.assign(result, enabled ? await this.publishAndReload(this.bundleRows(name).map(row => row.id)) : await this.reload()) }), { stage: 'enable', target: name, enabled }, 'bundle') } @@ -505,12 +537,17 @@ export class PluginManager extends TypertRemoteService { const result = this.change(async (result) => { if (spec.trim() === '' || spec.startsWith('-')) throw new ManagementFailure('invalid-spec') if (stopped()) throw new InstallCancelledError() + const before = readProfileManifest('dsh', this.profile.dir).dependencies ?? {} + // With HMR, an installed package is removed before another version is installed, so its plugins stop first. + const known = specPackageName(spec, before, this.profile.dir) + if (this.ownerContext.get('hmr') !== undefined && known !== undefined && Object.hasOwn(before, known)) { + throw new ManagementFailure('already-installed') + } if (options?.approvedBuilds !== undefined) { await approveBuilds(this.profile.dir, options.approvedBuilds) result.approvedBuilds = options.approvedBuilds } const files = await this.readRestoredFiles() - const before = readProfileManifest('dsh', this.profile.dir).dependencies ?? {} let name: string let version: string | undefined try { @@ -533,11 +570,13 @@ export class PluginManager extends TypertRemoteService { // The last run is the result's; the registries asked stay listed whatever the outcome. const plan = registryPlan(options?.registry, await this.registries()) let run: PackageResult | undefined + let used: Registry = null for (const [index, registry] of plan.entries()) { if (index > 0) await this.restoreFiles(files) // A stop that landed while the files went back, or before the first run, starts no run with a dead signal. if (stopped()) throw new InstallCancelledError() result.registries.push(registry) + used = registry announce('installing', { registry, index: index + 1, total: plan.length }) run = await this.runPnpm(['add', spec, ...options?.saveExact === true ? ['--save-exact'] : [], ...registryArguments(registry)], control.abort.signal, requestId) result.packageResult = run @@ -571,11 +610,10 @@ export class PluginManager extends TypertRemoteService { } const after = readProfileManifest('dsh', this.profile.dir).dependencies ?? {} const installed = Object.keys(after).filter(name => before[name] !== after[name]) - // Registry retries can retain the saved range after a partial installation. + // Without HMR a repeated installation can leave the saved dependency as it was. if (installed.length === 0) { - const parsed = parsedForRegistry(spec) - const localName = parsed.kind === 'path' ? readProfileManifest('dsh', parsed.path).name : undefined - installed.push(...Object.keys(after).filter(name => name === localName || spec === name || spec.startsWith(`${name}@`))) + const local = specPackageName(spec, after, this.profile.dir) + if (local !== undefined && Object.hasOwn(after, local)) installed.push(local) } const target = installed[0] if (installed.length !== 1 || target === undefined) throw new ManagementFailure('ambiguous-install') @@ -587,6 +625,7 @@ export class PluginManager extends TypertRemoteService { if (compatibility !== undefined && !compatibility.exempted) throw new ManagementFailure('incompatible-version', [incompatiblePlugin(compatibility)]) for (const file of bundlePatchPaths(dir, manifest.dsh.bundle)) loadOverlayPatches('dsh', file) version = manifest.version + this.installedFrom.set(name, used) } catch (error) { // pnpm has exited by now, so the files it rewrote go back as they were. await this.restoreFiles(files) @@ -600,9 +639,11 @@ export class PluginManager extends TypertRemoteService { result.stage = 'enable' return this.configure(async () => { if (options?.enabled !== false) await this.selectBundle(name, true) - if (Object.hasOwn(before, name)) return 'restart-required' - await this.refreshPackages() - if (options?.enabled !== false) result.warnings = await this.reload() + // Without HMR the replaced package's plugins keep running until the restart the result already reports. + if (Object.hasOwn(before, name) && this.ownerContext.get('hmr') === undefined) return + // A selected bundle's own rows must start. + const selected = bundleSelection(readProfileManifest('dsh', this.profile.dir)).includes(name) + Object.assign(result, await this.publishAndReload(selected ? this.bundleRows(name).map(row => row.id) : [])) }) }, { stage: 'install', target: spec, enabled: options?.enabled !== false }, 'install') control.result = result @@ -655,7 +696,7 @@ export class PluginManager extends TypertRemoteService { const contributions = bundle.error === undefined ? this.bundleRows(name) : [] if (bundle.enabled) { await this.selectBundle(name, false) - result.warnings = await this.reload() + Object.assign(result, await this.reload()) } if ([...this.ctx.loader.entries()].some(entry => entry.fiber?.uid != null && contributions.some(row => row.id === entry.options.id && row.name === entry.options.name))) { @@ -664,11 +705,15 @@ export class PluginManager extends TypertRemoteService { return bundle.installed }) if (!installed) return - result.packageResult = await this.runPnpm(['remove', name]) + // `pnpm remove` takes no `--registry` option; the generic configuration flag sets the same setting. A package + // installed before this process started is removed with the registry an installation asks first. + const registry = this.installedFrom.has(name) ? this.installedFrom.get(name) : this.configuredRegistries.registry + result.packageResult = await this.runPnpm(['remove', name, ...typeof registry === 'string' ? [`--config.registry=${registry}`] : []]) if (result.packageResult.exitCode !== 0 || result.packageResult.timedOut === true) { throw new Error(result.packageResult.output) } - await this.configure(() => this.refreshPackages()) + this.installedFrom.delete(name) + Object.assign(result, await this.configure(() => this.publishAndReload())) }, { stage: 'remove', target: name }, 'remove') } @@ -762,7 +807,7 @@ export class PluginManager extends TypertRemoteService { private async selectBundle(name: string, enabled: boolean): Promise { const manifest = readProfileManifest('dsh', this.profile.dir) - const previous = manifest.dsh?.profile?.bundles ?? [] + const previous = bundleSelection(manifest) if (enabled || !previous.includes(name)) { const metadata = bundleManifest(name, this.profile.dir, this.profile.installAnchor) if (metadata === undefined) throw new ManagementFailure('not-bundle') @@ -807,18 +852,47 @@ export class PluginManager extends TypertRemoteService { return hmr === undefined ? apply() : hmr.runExclusive(apply) } + /** + * Publish the profile's runtime resolution without HMR, unless a deselected startup bundle still runs and needs the + * current table. A publication that needs a restart waits for the restart the result already reports. + */ private async refreshPackages(): Promise { - if (this.ownerContext.get('hmr') === undefined) { - const selected = readProfileManifest('dsh', this.profile.dir).dsh?.profile?.bundles ?? [] - // Deselected startup bundles still run without HMR and need the existing package table. - if (this.profile.startedBundles.some(name => !selected.includes(name))) return + const selected = bundleSelection(readProfileManifest('dsh', this.profile.dir)) + if (this.profile.startedBundles.some(name => !selected.includes(name))) return + try { + const packages = this.ownerContext.get('pluginPackages') + // A dropped mapping's bundle is not running: the guard above keeps the table while a deselected startup bundle runs. + if (packages?.resolution !== undefined) await packages.refresh() + } catch (error) { + if (!(error instanceof ResolutionRestartRequired)) throw error } - await this.ownerContext.get('pluginPackages')?.refresh() } - private async reload(requiredIds: readonly string[] = []): Promise { - if (this.ownerContext.get('hmr') === undefined) return [] - return reconcileProfilePatches(this.ownerContext.root, readProfilePatches('dsh', this.profile), 'dsh', requiredIds) + /** + * Apply a package change: without HMR, publish the runtime resolution when that is safe, then {@link reload}. + * @param requiredIds Entry ids that must start. + * @returns The reload's outcome. + */ + private async publishAndReload(requiredIds: readonly string[] = []): Promise { + if (this.ownerContext.get('hmr') === undefined) await this.refreshPackages() + return this.reload(requiredIds) + } + + /** + * Apply the saved profile; with HMR it recomposes live, including the runtime resolution. + * @param requiredIds Entry ids that must start. + * @returns Warnings about entries that were already inactive, and why the change waits for a restart, if it does. + */ + private async reload(requiredIds: readonly string[] = []): Promise { + const hmr = this.ownerContext.get('hmr') + if (hmr === undefined) { + // Startup-only profiles recompose at restart; the saved patches still have to compose. + composeEntries([readProfilePatches('dsh', this.profile)]) + return { warnings: [] } + } + const { warnings, pending, restartReason } = await hmr.reconcileProfile(requiredIds) + const restart = restartReason ?? pending[0]?.reason + return restart === undefined ? { warnings } : { warnings, restartReason: restart } } private async change( @@ -830,9 +904,12 @@ export class PluginManager extends TypertRemoteService { this.abort.signal.throwIfAborted() const before = this.diskState() const result: ChangeResult = { ...request, changed: false, - application: this.ownerContext.get('hmr') !== undefined ? 'applied' : 'restart-required' } + ...this.ownerContext.get('hmr') !== undefined ? { application: 'applied' } : { application: 'restart-required', restartReason: 'no-hmr' } } try { - result.application = await operation(result) ?? result.application + const outcome = await operation(result) + if (outcome !== undefined) result.application = outcome + // A reload that waits for a restart leaves the change saved but not applied. + else if (result.application === 'applied' && result.restartReason !== undefined) result.application = 'restart-required' } catch (error) { if (error instanceof InstallCancelledError) { result.application = 'cancelled' @@ -841,6 +918,7 @@ export class PluginManager extends TypertRemoteService { result.error = managementError(error) } } + if (result.application !== 'restart-required') delete result.restartReason result.changed = before !== this.diskState() this.ownerContext.emit('plugin-manager/changed', { reason }) return result diff --git a/packages/boot/plugin-manager/src/operations.ts b/packages/boot/plugin-manager/src/operations.ts index 2df35b7efc..a6fe676544 100644 --- a/packages/boot/plugin-manager/src/operations.ts +++ b/packages/boot/plugin-manager/src/operations.ts @@ -280,6 +280,7 @@ function bundleComponentManifests(manifest: ProfileManifest, dir: string, anchor * @param options Output, activation and cancellation policy. * @returns Exit status, whether the silence bound stopped the run, and the diagnostic path. * A compatibility denial returns exit code 1. Service output is bounded; CLI output uses inherited descriptors. + * The diagnostic file is closed on return or rejection, including preparation and spawn failures. */ export async function runProfilePnpm( context: PackageOperationContext, args: readonly string[], options: PackageOperationOptions, @@ -291,7 +292,7 @@ export async function runProfilePnpm( await mkdir(logRoot, { recursive: true, mode: 0o700 }) const logDir = await mkdtemp(join(logRoot, 'operation-')) const logPath = join(logDir, 'pnpm.log') - const log = await open(logPath, 'wx', 0o600) + await using log = await open(logPath, 'wx', 0o600) if (active !== undefined) { await log.write(active) await log.close() @@ -348,7 +349,7 @@ export async function runProfilePnpm( } } catch (error) { void error; continue } } - if (preflight.length > 0) return rejected(preflight, 'nothing was installed') + if (preflight.length > 0) return await rejected(preflight, 'nothing was installed') 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. diff --git a/packages/boot/plugin-manager/src/patch.ts b/packages/boot/plugin-manager/src/patch.ts index 5776b586fa..0dfacb2de6 100644 --- a/packages/boot/plugin-manager/src/patch.ts +++ b/packages/boot/plugin-manager/src/patch.ts @@ -1,17 +1,29 @@ -/** Comment-preserving profile plugin enablement edits. */ +/** Comment-preserving profile plugin enablement edits and the entry flattening they compare against. */ import { readFile } from 'node:fs/promises' -import { isMap, isSeq, parseDocument } from 'yaml' -import { loadOptionalPatches } from '@deepseek-ai/dsh-app-boot' +import { isMap, isNode, isScalar, isSeq, parseDocument } from 'yaml' +import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader' +import { composeEntries, loadOptionalPatches, readBundlePatches, type ProfileContext } from '@deepseek-ai/dsh-app-boot' import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' -/** Replace the last matching override or append one after existing insertions. - * @param filename Current profile patch file. +/** Flatten only the groups addressable by the profile's patch composer. + * @param rows Composed top-level entries. + * @returns Entries in depth-first order. + */ +export function flatten(rows: EntryOptions[]): EntryOptions[] { + return rows.flatMap(row => [row, ...(row.group && Array.isArray(row.config) ? flatten(row.config as EntryOptions[]) : [])]) +} + +/** Replace the last matching override or append one after existing insertions. An override that holds only `id` and + * `disabled`, without anchors, is removed, or not appended, when the bundle layers and the remaining profile patches + * already give the entry the requested literal `disabled` value; home patches and launch overlays are not consulted. + * @param profile Profile whose patch file is edited and whose selected bundles supply the compared composition. * @param id Unique composition entry id. * @param name Module name used to match name-qualified overrides. * @param enabled Desired entry enablement. * @returns Whether the file changed. */ -export async function writePluginEnabled(filename: string, id: string, name: string, enabled: boolean): Promise { +export async function writePluginEnabled(profile: ProfileContext, id: string, name: string, enabled: boolean): Promise { + const filename = profile.patchPath let text: string try { text = await readFile(filename, 'utf8') @@ -25,17 +37,40 @@ export async function writePluginEnabled(filename: string, id: string, name: str const error = document.errors[0] if (error !== undefined) throw error if (!isSeq(document.contents)) throw new Error('Profile patch must be a YAML sequence') - loadOptionalPatches('dsh', filename) + const patches = loadOptionalPatches('dsh', filename) ?? [] + const bundlePatches = readBundlePatches('dsh', profile) const items = document.contents.items const target = items.findLast((item, index) => { if (!isMap(item) || document.getIn([index, 'id']) !== id || item.has('insert')) return false const expectedName = document.getIn([index, 'name']) return !expectedName || expectedName === name }) + // Compares the entry's own literal `disabled`; a `!!js` value never matches, so its override is kept. + const implied = (index: number): boolean => { + const row = flatten(composeEntries([[...bundlePatches, ...patches.toSpliced(index, 1)]])).findLast(entry => entry.id === id) + return row !== undefined && (row.disabled ?? false) === !enabled + } if (isMap(target)) { - if (document.getIn([items.indexOf(target), 'disabled']) === !enabled) return false - document.setIn([items.indexOf(target), 'disabled'], !enabled) + const index = items.indexOf(target) + // A removed anchor would leave later aliases unresolved. + const onlySwitch = target.anchor === undefined && target.items.every(pair => isScalar(pair.key) && isScalar(pair.value) + && pair.key.anchor === undefined && pair.value.anchor === undefined && (pair.key.value === 'id' || pair.key.value === 'disabled')) + if (onlySwitch && implied(index)) { + document.deleteIn([index]) + // The comment above the removed override can be the file header, so it moves to the following item, above an + // emptied sequence, or after the remaining items. + const comment = target.commentBefore + const next = items[index] + if (comment && isNode(next)) next.commentBefore = next.commentBefore ? `${comment}\n${next.commentBefore}` : comment + else if (comment && items.length === 0) document.contents.commentBefore = comment + else if (comment) document.comment = document.comment ? `${comment}\n${document.comment}` : comment + } else if (document.getIn([index, 'disabled']) === !enabled) { + return false + } else { + document.setIn([index, 'disabled'], !enabled) + } } else { + if (implied(items.length)) return false document.add({ id, disabled: !enabled }) } await writeFileAtomic(filename, String(document), { mode: 0o600 }) diff --git a/packages/boot/plugin-manager/src/tools.ts b/packages/boot/plugin-manager/src/tools.ts index 29c58e898d..553f73572c 100644 --- a/packages/boot/plugin-manager/src/tools.ts +++ b/packages/boot/plugin-manager/src/tools.ts @@ -5,10 +5,76 @@ import type {} from './index.ts' import type {} from '@deepseek-ai/dsh-sandbox-policy' import type {} from '@deepseek-ai/dsh-user-approval' import { approveEscalation } from '@deepseek-ai/dsh-sandbox' -import type { PluginEntryId } from './types.ts' +import type { ChangeResult, ManagementError, PluginEntryId, PluginInstallFailureKind, RestartReason } from './types.ts' import { defineTool } from '@deepseek-ai/dsh-tools' import { getDshRuntimeVersion } from '@deepseek-ai/dsh-app-boot' +/** What the model does next for each refusal the manager reports. */ +const ERROR_STEPS: Record = { + 'management-required': 'This entry belongs to plugin management itself and cannot be changed.', + 'unaddressable': 'This entry has no unique profile patch target; tell the user to edit the profile patch file directly.', + 'unknown-plugin': 'Call list_plugins and retry with an exact entryId.', + 'invalid-spec': 'Retry with an npm package name, a git URL, a tarball URL, or an absolute local directory.', + 'already-installed': 'The package is already installed; to install another version, call remove_bundle first, then install_bundle again.', + 'ambiguous-install': 'The dependency change named no single package; call list_bundles to see what was installed.', + 'not-bundle': 'The package declares no dsh.bundle and cannot be managed as a plugin; install a DSH plugin bundle instead.', + 'not-removable': 'Call list_bundles; only bundles marked removable can be removed.', + 'stop-profile': 'This profile runs without HMR; tell the user to stop DSH and run `dsh plugin --profile remove `.', + 'bundle-in-use': 'Call list_plugins, disable the entries from other layers that still use this bundle, then retry.', + 'stale-approval': 'Call install_bundle again without approvedBuilds to read the current pendingBuilds.', + 'incompatible-version': 'Install a version compatible with the running DSH. A version exemption needs the user\'s explicit acceptance of crash and data-loss risk.', + 'operation-error': 'Report the diagnostic to the user; retry only after its cause is addressed.', +} + +/** What the model does next for each package-manager failure. */ +const FAILURE_STEPS: Partial> = { + 'pnpm-missing': 'pnpm is not available to DSH; tell the user to install pnpm.', + 'timeout': 'Retry, or retry with another registry.', + 'not-found': 'Check the package name and version with the user, then retry.', + 'no-matching-version': 'Retry with a published version.', + 'network': 'Retry later or pass another registry.', + 'disk-full': 'Tell the user to free disk space, then retry.', + 'permission': 'Tell the user DSH cannot write the profile directory; retry after they fix its permissions.', + 'build-blocked': 'Ask the user whether to allow the install scripts of the packages in pendingBuilds; if they approve, retry with approvedBuilds.', +} + +/** Why a change waits for a restart, as the model tells the user. */ +const RESTART_STEPS: Record = { + 'no-hmr': 'this profile applies changes only at startup', + 'package-moved': 'an installed package now resolves to another directory, or the change replaces a package the DSH installation provides; remove a package before installing it from another location', + 'host-module': 'the change reaches modules DSH itself loaded', + 'unmanaged-plugin': 'the change reaches a plugin running outside the plugin list', + 'module-comparison': 'DSH could not compare loaded plugins with their files', +} + +/** + * The model-facing result of a change: the manager's result plus the next step its outcome calls for. + * @param result The manager's result. + * @returns The result with `nextStep` when it is not plainly applied. + */ +function withNextStep(result: ChangeResult): ChangeResult & { nextStep?: string } { + switch (result.application) { + case 'applied': + return result + case 'cancelled': + return { ...result, nextStep: 'The installation was cancelled and its files restored; nothing changed.' } + case 'overridden': + return { ...result, nextStep: 'Saved, but a higher-priority layer such as the agent preset overrides it; tell the user it is not in effect.' } + case 'restart-required': { + const reason = result.restartReason === undefined ? '' : `: ${RESTART_STEPS[result.restartReason]}` + return { ...result, nextStep: `Tell the user to restart DSH to apply this change${reason}.` } + } + case 'failed': { + const code = result.error?.code ?? 'operation-error' + // A refusal code is more specific than the package manager's failure kind. + const kind = code === 'operation-error' ? result.packageResult?.kind : undefined + return { ...result, nextStep: (kind === undefined ? undefined : FAILURE_STEPS[kind]) ?? ERROR_STEPS[code] } + } + /* v8 ignore next -- ChangeResult.application is a closed union */ + default: return assertNever(result.application) + } +} + /** Required services for the management tool. */ export const inject = ['tools', 'pluginManager', 'sandboxPolicy'] @@ -18,7 +84,7 @@ export const inject = ['tools', 'pluginManager', 'sandboxPolicy'] export function apply(ctx: Context): void { ctx.tools.register(defineTool({ name: 'plugin_manager', - description: 'List plugins or bundles in the current profile, enable or disable them, install a bundle, or remove an installed bundle. Every action requires danger-full-access permission or approval for this call. Approval does not change the session permission mode. Changes affect every session in this profile. List first to obtain exact identifiers. Package installation can execute allowed build scripts. Live profiles apply changes immediately; startup profiles require restart. Incompatible DSH peer dependencies block installation and activation. Version exemptions risk crashes and data loss: warn the user and obtain explicit permission for the exact plugin and runtime versions before granting one.', + description: 'List plugins or bundles in the current profile, enable or disable them, install a bundle, or remove an installed bundle. Every action requires danger-full-access permission or approval for this call. Approval does not change the session permission mode. Changes affect every session in this profile. List first to obtain exact identifiers. Package installation can execute allowed build scripts. Live profiles apply changes immediately, including upgrades of installed packages; a change result that is not applied carries nextStep, which says what to do or tell the user. Incompatible DSH peer dependencies block installation and activation. Version exemptions risk crashes and data loss: warn the user and obtain explicit permission for the exact plugin and runtime versions before granting one.', parameters: { action: { type: 'string', required: true, enum: ['list_plugins', 'list_bundles', 'set_plugin', 'set_bundle', 'install_bundle', 'remove_bundle', 'list_version_exemptions', 'set_version_exemption'], description: 'Management operation.' }, target: { type: 'string', description: 'Plugin entry id, bundle package name, or installation spec, according to action. For an Official catalog entry, pass list_bundles installTarget.spec to install_bundle.' }, @@ -50,7 +116,8 @@ export function apply(ctx: Context): void { if (args.target === undefined || args.runtimeVersion === undefined || args.enabled === undefined) { throw new Error('target package-name@version, runtimeVersion, and enabled are required') } - return JSON.stringify(await manager.setVersionExemption(args.target, args.runtimeVersion, args.enabled, args.acceptRisk)) + return JSON.stringify(withNextStep( + await manager.setVersionExemption(args.target, args.runtimeVersion, args.enabled, args.acceptRisk))) case 'list_plugins': case 'list_bundles': { const offset = args.offset ?? 0 @@ -69,20 +136,20 @@ export function apply(ctx: Context): void { case 'set_plugin': case 'set_bundle': { if (args.target === undefined || args.enabled === undefined) throw new Error('target and enabled are required') - return JSON.stringify(await (args.action === 'set_plugin' + return JSON.stringify(withNextStep(await (args.action === 'set_plugin' ? manager.setPluginEnabled(args.target as PluginEntryId, args.enabled) - : manager.setBundleEnabled(args.target, args.enabled))) + : manager.setBundleEnabled(args.target, args.enabled)))) } case 'install_bundle': if (args.target === undefined) throw new Error('target package spec is required') - return JSON.stringify(await manager.installBundle(args.target, { + return JSON.stringify(withNextStep(await manager.installBundle(args.target, { ...args.enabled === undefined ? {} : { enabled: args.enabled }, ...args.approvedBuilds === undefined ? {} : { approvedBuilds: args.approvedBuilds }, ...args.registry === undefined ? {} : { registry: args.registry }, - })) + }))) case 'remove_bundle': if (args.target === undefined) throw new Error('target bundle name is required') - return JSON.stringify(await manager.removeBundle(args.target)) + return JSON.stringify(withNextStep(await manager.removeBundle(args.target))) /* v8 ignore next -- tool JSON validation rejects actions outside the declared enum */ default: return assertNever(args.action) } diff --git a/packages/boot/plugin-manager/src/types.ts b/packages/boot/plugin-manager/src/types.ts index 5c780a2575..15e808e198 100644 --- a/packages/boot/plugin-manager/src/types.ts +++ b/packages/boot/plugin-manager/src/types.ts @@ -19,12 +19,21 @@ export interface IncompatiblePlugin { /** Localizable management failure and optional external diagnostic. */ export interface ManagementError { - code: ReadOnlyReason | 'unknown-plugin' | 'invalid-spec' | 'ambiguous-install' | 'not-bundle' | 'not-removable' | 'stop-profile' | 'bundle-in-use' | 'stale-approval' | 'incompatible-version' | 'operation-error' + code: ReadOnlyReason | 'unknown-plugin' | 'invalid-spec' | 'already-installed' | 'ambiguous-install' | 'not-bundle' | 'not-removable' | 'stop-profile' | 'bundle-in-use' | 'stale-approval' | 'incompatible-version' | 'operation-error' diagnostic?: string /** Present with `incompatible-version`: the packages the running DSH version rejects. */ incompatible?: IncompatiblePlugin[] } +/** + * Why a change waits for the next start: `no-hmr` profiles apply changes only at startup; `package-moved` changes map an + * installed package to another directory without removing it first, or change or locally override a package the DSH + * installation provides; `host-module` changes reach + * modules the running process itself imports; `unmanaged-plugin` changes reach a plugin running outside a Loader entry; + * `module-comparison` means loaded modules could not be compared with disk. + */ +export type RestartReason = 'no-hmr' | 'package-moved' | 'host-module' | 'unmanaged-plugin' | 'module-comparison' + /** One running-profile entry and its persistent control availability. */ export type PluginInfo = PluginInventoryEntry & ( | { patchId: string; readOnlyReason?: never } @@ -123,6 +132,8 @@ export interface ChangeResult { changed: boolean /** `cancelled` is an installation the caller stopped, its files restored. */ application: 'applied' | 'restart-required' | 'overridden' | 'failed' | 'cancelled' + /** Present with `restart-required`: why the change takes effect only at the next start. */ + restartReason?: RestartReason /** Last attempted step; successful installation can proceed to enablement. */ stage: 'install' | 'enable' | 'remove' target: string diff --git a/packages/boot/plugin-manager/tests/manager.spec.ts b/packages/boot/plugin-manager/tests/manager.spec.ts index be914515fd..844d376b26 100644 --- a/packages/boot/plugin-manager/tests/manager.spec.ts +++ b/packages/boot/plugin-manager/tests/manager.spec.ts @@ -458,14 +458,16 @@ it.each([ }) }) -it('turns a plugin off and on without duplicating patch overrides', async () => { +it('turns a plugin off and on without leaving a patch override the bundle already implies', async () => { const { manager, dir } = await fixture() const id = (await manager.listPlugins()).find(row => row.patchId === 'managed')!.entryId expect(await manager.setPluginEnabled(id, false)).toMatchObject({ changed: true, application: 'applied' }) expect((await manager.listPlugins()).find(row => row.entryId === id)?.enabled).toBe(false) expect(await manager.setPluginEnabled(id, false)).toMatchObject({ changed: false, application: 'applied' }) - expect(await manager.setPluginEnabled(id, true)).toMatchObject({ changed: true, application: 'applied' }) expect(readFileSync(join(dir, 'cordis.patch.yml'), 'utf8').match(/id: managed/g)).toHaveLength(1) + expect(await manager.setPluginEnabled(id, true)).toMatchObject({ changed: true, application: 'applied' }) + expect((await manager.listPlugins()).find(row => row.entryId === id)?.enabled).toBe(true) + expect(readFileSync(join(dir, 'cordis.patch.yml'), 'utf8')).not.toContain('id: managed') }) it('retains installed dependencies when toggling a bundle and appends it when re-enabled', async () => { @@ -481,9 +483,11 @@ it('retains installed dependencies when toggling a bundle and appends it when re }) it('reports an overlay overriding a saved plugin toggle', async () => { - const { manager } = await fixture('live', true) + const { manager, dir } = await fixture('live', true) + writeFileSync(join(dir, 'cordis.patch.yml'), '- id: managed\n disabled: true\n') const id = (await manager.listPlugins()).find(row => row.patchId === 'managed')!.entryId expect(await manager.setPluginEnabled(id, true)).toMatchObject({ changed: true, application: 'overridden' }) + expect(await manager.setPluginEnabled(id, true)).toMatchObject({ changed: false, application: 'overridden' }) }) it('saves startup-only toggles and refuses removal of currently used packages', async () => { @@ -731,6 +735,7 @@ it.each([ '@deepseek-ai/dsh-host-plugin-inventory', '@deepseek-ai/dsh-typert-registry', '@deepseek-ai/dsh-api-remotes', + '@deepseek-ai/dsh-api-remote-access', ])('protects the management dependency %s and its containing bundle', async (name) => { const { ctx, manager, bundle, profile, dir } = await fixture('startup') bundle('extra', [{ id: 'dependency', name, disabled: true }]) @@ -793,14 +798,23 @@ it('restores the manifest when the package pnpm added declares no bundle', async expect((await manager.listBundles()).some(row => row.name === 'plain')).toBe(false) }) -it('never removes an existing dependency after installation validation fails', async () => { +it('refuses to install over an installed package, by name, version or local directory, without running pnpm', async () => { const { manager, dir } = await fixture() - writeFileSync(join(dir, 'node_modules', 'extra', 'package.json'), '{"name":"extra"}') const install = vi.spyOn(operations, 'runProfilePnpm').mockResolvedValue({ exitCode: 0, output: '', truncated: false, logPath: '/operation.log' }) onTestFinished(() => { install.mockRestore() }) - const result = await manager.installBundle('extra') - expect(result).toMatchObject({ application: 'failed', stage: 'install', error: { code: 'not-bundle' } }) - expect(install).toHaveBeenCalledOnce() + for (const spec of ['extra', 'extra@2', join(dir, 'node_modules', 'extra'), `link:${join(dir, 'node_modules', 'extra')}`]) { + expect(await manager.installBundle(spec)).toMatchObject({ application: 'failed', stage: 'install', changed: false, error: { code: 'already-installed' } }) + } + // A tarball saved as a path relative to the profile, and a refused install's script approvals, write nothing. + const manifest = readProfileManifest('test', dir) + writeFileSync(join(dir, 'package.json'), JSON.stringify({ ...manifest, dependencies: { extra: 'file:../extra-1.0.0.tgz' } })) + const workspace = join(dir, 'pnpm-workspace.yaml') + const settings = existsSync(workspace) ? readFileSync(workspace, 'utf8') : undefined + expect(await manager.installBundle(join(dir, '..', 'extra-1.0.0.tgz'), { approvedBuilds: ['native-provider'] })) + .toMatchObject({ application: 'failed', changed: false, error: { code: 'already-installed' } }) + expect(existsSync(workspace) ? readFileSync(workspace, 'utf8') : undefined).toBe(settings) + writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest)) + expect(install).not.toHaveBeenCalled() expect(readProfileManifest('test', dir).dependencies).toEqual({ extra: '1.0.0' }) }) @@ -851,12 +865,18 @@ it('returns unchanged failures as warnings while toggling and removing another b expect(remove).toHaveBeenCalledOnce() }) -it('reports repeated installs as requiring restart and ambiguous package changes as failures', async () => { +it('recognizes a repeated installation without HMR that leaves the saved dependency as it was', async () => { + const { manager, dir } = await fixture('startup') + const install = vi.spyOn(operations, 'runProfilePnpm').mockResolvedValue({ exitCode: 0, output: '', truncated: false, logPath: join(dir, 'pnpm.log') }) + onTestFinished(() => { install.mockRestore() }) + expect(await manager.installBundle(`link:${join(dir, 'node_modules', 'extra')}`, { enabled: false })) + .toMatchObject({ changed: false, application: 'restart-required', bundle: 'extra', enabled: false }) +}) + +it('reports ambiguous package changes as failures', async () => { const { manager, dir } = await fixture() const install = vi.spyOn(operations, 'runProfilePnpm').mockResolvedValue({ exitCode: 0, output: '', truncated: false, logPath: join(dir, 'pnpm.log') }) onTestFinished(() => { install.mockRestore() }) - expect(await manager.installBundle('extra')).toMatchObject({ changed: false, application: 'restart-required' }) - expect(await manager.installBundle('extra@1')).toMatchObject({ changed: false, application: 'restart-required' }) expect(await manager.installBundle('extra-long@1')).toMatchObject({ changed: false, application: 'failed', error: { code: 'ambiguous-install' } }) install.mockImplementationOnce(async () => { writeFileSync(join(dir, 'package.json'), '{}') @@ -866,6 +886,41 @@ it('reports repeated installs as requiring restart and ambiguous package changes expect(readProfileManifest('test', dir).dependencies).toEqual({ extra: '1.0.0' }) }) +it('removes a package with the registry it was installed from, so pnpm verifies its lockfile entries there', async () => { + const { manager, dir, bundle } = await fixture() + const corp = 'https://npm.corp.example/' + const pnpm = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async (_context, args) => { + const name = String(args[1]) + const manifest = readProfileManifest('test', dir) + if (args[0] === 'add') { + bundle(name, [{ id: name, name: './plugin.mjs', config: { service: name } }]) + manifest.dependencies = { ...manifest.dependencies, [name]: '1.0.0' } + } else { + delete manifest.dependencies?.[name] + } + writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest)) + return { exitCode: 0, output: '', truncated: false, logPath: join(dir, 'pnpm.log') } + }) + onTestFinished(() => { pnpm.mockRestore() }) + expect(await manager.installBundle('mirrored', { enabled: false, registry: corp })).toMatchObject({ application: 'applied' }) + expect(await manager.removeBundle('mirrored')).toMatchObject({ application: 'applied' }) + expect(pnpm.mock.calls.at(-1)?.[1]).toEqual(['remove', 'mirrored', `--config.registry=${corp}`]) + // A failed installation records no registry, and a failed removal keeps the recorded one for the retry. + pnpm.mockResolvedValueOnce({ exitCode: 1, output: 'failed', truncated: false, logPath: join(dir, 'pnpm.log') }) + expect(await manager.installBundle('failing', { enabled: false, registry: corp })).toMatchObject({ application: 'failed' }) + expect(await manager.installBundle('mirrored', { enabled: false, registry: corp })).toMatchObject({ application: 'applied' }) + pnpm.mockResolvedValueOnce({ exitCode: 1, output: 'remove failed', truncated: false, logPath: join(dir, 'pnpm.log') }) + expect(await manager.removeBundle('mirrored')).toMatchObject({ application: 'failed' }) + expect(await manager.removeBundle('mirrored')).toMatchObject({ application: 'applied' }) + expect(pnpm.mock.calls.at(-1)?.[1]).toEqual(['remove', 'mirrored', `--config.registry=${corp}`]) + // A package this manager did not install is removed with the registry an installation asks first. + bundle('mirrored', []) + const manifest = readProfileManifest('test', dir) + writeFileSync(join(dir, 'package.json'), JSON.stringify({ ...manifest, dependencies: { ...manifest.dependencies, mirrored: '1.0.0' } })) + await manager.removeBundle('mirrored') + expect(pnpm.mock.calls.at(-1)?.[1]).toEqual(['remove', 'mirrored']) +}) + it('streams pnpm output, reports the installation phases, and names the installed bundle', async () => { const { ctx, manager, dir, bundle } = await fixture() const chunks: PluginInstallLogChunk[] = [] @@ -1523,30 +1578,6 @@ it('uses the generic exact installer and keeps an Official entry through on, off expect((await manager.listBundles()).find(item => item.name === name)).toMatchObject({ official: true, availability: 'missing', enabled: false, installed: false }) }) -it.each([true, false])('updates an Official dependency while preserving selection=%s and requiring restart', async (enabled) => { - const { manager, dir, bundle, profile } = await fixture() - const name = OFFICIAL_ON_DEMAND_CATALOG[0]!.packageName - bundle(name, [], profile.home) - const manifest = readProfileManifest('test', dir) - manifest.dependencies = { ...manifest.dependencies, [name]: '1.0.0' } - if (enabled) manifest.dsh!.profile!.bundles!.push(name) - writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest)) - const listed = (await manager.listBundles()).find(item => item.name === name) - expect(listed).toMatchObject({ version: '1.0.0', official: true, availability: 'profile', - installTarget: { spec: `${name}@${getDshRuntimeVersion()}`, version: getDshRuntimeVersion() } }) - expect(listed?.error).toBeUndefined() - const pnpm = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async () => { - const current = readProfileManifest('test', dir) - current.dependencies = { ...current.dependencies, [name]: getDshRuntimeVersion() } - writeFileSync(join(dir, 'package.json'), JSON.stringify(current)) - return { exitCode: 0, output: '', truncated: false, logPath: '/install.log' } - }) - onTestFinished(() => { pnpm.mockRestore() }) - expect(await manager.installBundle(`${name}@${getDshRuntimeVersion()}`, { enabled, saveExact: true })) - .toMatchObject({ application: 'restart-required', enabled }) - expect(readProfileManifest('test', dir).dsh?.profile?.bundles?.includes(name)).toBe(enabled) -}) - it('restores dependency and selection after an exact version is unavailable', async () => { const { manager, dir, bundle, profile } = await fixture() const name = OFFICIAL_ON_DEMAND_CATALOG[0]!.packageName @@ -1654,11 +1685,3 @@ it.each(['absent', 'bundle', 'not-bundle', 'unreadable'])('keeps a missing sourc expect((await manager.listBundles()).find(item => item.name === name)?.error?.diagnostic).toContain('restore the checkout') expect((await manager.listBundles()).find(item => item.name === name)?.installTarget).toBeUndefined() }) - -it('recognizes a repeated local link install without a dependency-spec change', async () => { - const { manager, dir } = await fixture() - const install = vi.spyOn(operations, 'runProfilePnpm').mockResolvedValue({ exitCode: 0, output: '', truncated: false, logPath: join(dir, 'pnpm.log') }) - onTestFinished(() => { install.mockRestore() }) - expect(await manager.installBundle(`link:${join(dir, 'node_modules', 'extra')}`, { enabled: false })) - .toMatchObject({ changed: false, application: 'restart-required', bundle: 'extra', enabled: false }) -}) diff --git a/packages/boot/plugin-manager/tests/operation-logs.spec.ts b/packages/boot/plugin-manager/tests/operation-logs.spec.ts new file mode 100644 index 0000000000..a7a19367ab --- /dev/null +++ b/packages/boot/plugin-manager/tests/operation-logs.spec.ts @@ -0,0 +1,48 @@ +/** Pnpm diagnostics release their real file descriptor when preparation rejects. */ +import { fstatSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import type { FileHandle } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, it, onTestFinished, vi } from 'vitest' +import { initProfile } from '@deepseek-ai/dsh-app-boot' +import { runProfilePnpm } from '../src/operations.ts' + +const observed = vi.hoisted(() => { + const logs: Array<{ handle: FileHandle; fd: number }> = [] + return { logs } +}) + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + open: async (...args: Parameters) => { + const handle = await actual.open(...args) + if (typeof args[0] === 'string' && args[0].endsWith('pnpm.log')) { + observed.logs.push({ handle, fd: handle.fd }) + } + return handle + }, + } +}) + +it('closes the pnpm log when the profile manifest cannot be parsed', async () => { + const home = mkdtempSync(join(tmpdir(), 'manager-log-lifetime-')) + onTestFinished(async () => { + for (const { handle } of observed.logs.splice(0)) await handle.close() + rmSync(home, { recursive: true, force: true }) + }) + const dir = join(home, 'profiles', 'test') + const installAnchor = join(home, 'package.json') + writeFileSync(installAnchor, '{}\n') + initProfile(dir, []) + writeFileSync(join(dir, 'package.json'), '{') + + await expect(runProfilePnpm({ home, profile: 'test', installAnchor, cwd: home }, ['install'], { + execution: 'service', outputBytes: 100, activateNewBundles: false, + })).rejects.toThrow(SyntaxError) + + expect(observed.logs).toHaveLength(1) + const { fd } = observed.logs[0]! + expect(() => fstatSync(fd)).toThrow(expect.objectContaining({ code: 'EBADF' })) +}) diff --git a/packages/boot/plugin-manager/tests/package-reload.spec.ts b/packages/boot/plugin-manager/tests/package-reload.spec.ts index d83aab3be3..b5569a6229 100644 --- a/packages/boot/plugin-manager/tests/package-reload.spec.ts +++ b/packages/boot/plugin-manager/tests/package-reload.spec.ts @@ -1,14 +1,15 @@ /** GUI package operations publish profile package resolution around activation and disposal through the real Loader. */ -import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync, symlinkSync, utimesSync, writeFileSync } from 'node:fs' import { createRequire } from 'node:module' import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' -import { FiberState, type Context } from '@deepseek-ai/cordis' +import { FiberState, type Context, type Plugin } from '@deepseek-ai/cordis' import Timer from '@deepseek-ai/cordis-plugin-timer' import { - boot, createRuntimeResolution, initProfile, loadProfileDirectory, PluginPackages, + boot, createRuntimeResolution, initProfile, loadProfileDirectory, PluginPackages, ProfileRuntimeResolution, readProfileManifest, readProfilePatches, type ProfileContext, + ResolutionRestartRequired, } from '@deepseek-ai/dsh-app-boot' import Hmr from '@deepseek-ai/dsh-hmr' import type { ChokidarOptions } from 'chokidar' @@ -160,6 +161,12 @@ export function apply(ctx) { } /** Answer each pnpm run by recording it in the trace and applying `run` to the profile files. */ +/** Remove the installed addon bundle, as an upgrade does before it installs another version. */ +async function removeAddon(manager: PluginManager, dir: string, trace: string[]): Promise { + mockPnpm(dir, trace, () => { removeFiles(dir) }) + expect(await manager.removeBundle('addon')).toMatchObject({ application: 'applied' }) +} + function mockPnpm(dir: string, trace: string[], run: (args: readonly string[]) => void, exitCode = 0) { const pnpm = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async (_context, args) => { trace.push(`pnpm:${String(args[0])}`) @@ -187,6 +194,11 @@ it('publishes a newly installed enabled bundle before its private bare plugin st expect(ctx.get('addonVersion')).toBe(1) expect(ctx.pluginPackages.packageOf('addon-plugin', parentURL)?.version).toBe('1.0.0') expect(readFileSync(evaluationPath, 'utf8')).toBe('addon:1\n') + // A failure without HMR carries no restart reason. + mockPnpm(dir, trace, () => {}, 1) + const failed = await manager.installBundle('addon@3', { enabled: true }) + expect(failed).toMatchObject({ application: 'failed' }) + expect(failed).not.toHaveProperty('restartReason') }) it('publishes a newly installed disabled bundle without evaluating its plugin', async () => { @@ -296,8 +308,9 @@ it.each(['disable', 'remove'] as const)('retains a shared dependency after %s of const result = operation === 'disable' ? await manager.setBundleEnabled('addon', false) : await manager.removeBundle('addon') expect(result).toMatchObject({ application: 'applied', changed: true }) - expect(published).toHaveBeenCalledOnce() - expect(published.mock.calls[0]?.[0].entries.find(item => item.name === 'addon-plugin')).toMatchObject({ + // A removal publishes when its plugins stopped, and again once pnpm removed the package. + expect(published).toHaveBeenCalledTimes(operation === 'disable' ? 1 : 2) + expect(published.mock.calls.at(-1)?.[0].entries.find(item => item.name === 'addon-plugin')).toMatchObject({ packageDir: sharedDir, version: '1.0.0', scope: 'profile', declarer: join(dir, 'node_modules', 'other', 'package.json'), }) expect(entry()).toBeUndefined() @@ -321,8 +334,9 @@ it('removes a running bundle after its plugin stops and then publishes', async ( application: 'applied', changed: true, packageResult: { exitCode: 0 }, }) expect(pnpm).toHaveBeenCalledOnce() - expect(published).toHaveBeenCalledOnce() - expect(trace).toEqual(['start:sibling', 'start:addon:1', 'stop:addon:1', 'pnpm:remove', 'publish']) + // The bundle's private mappings go once its plugin stopped; the package's own name goes after pnpm removed it. + expect(published).toHaveBeenCalledTimes(2) + expect(trace).toEqual(['start:sibling', 'start:addon:1', 'stop:addon:1', 'publish', 'pnpm:remove', 'publish']) expect(previous?.uid).toBeNull() expect(existsSync(bundleDir)).toBe(false) expect(ctx.pluginPackages.packageOf('addon', parentURL)).toBeUndefined() @@ -356,23 +370,183 @@ it('removes and publishes a bundle that is not running without HMR', async () => expect(ctx.get('packageReloadSibling')).toBe(sibling) }) -it.each([{ live: true }, { live: false }])('keeps the running version and publishes nothing when an installed bundle is overwritten with HMR=$live', async ({ live }) => { - const { ctx, dir, trace, published, manager, evaluationPath, entry } = await fixture({ live, installed: true }) - const previous = entry()?.fiber - expect(previous?.state).toBe(FiberState.ACTIVE) - expect(ctx.get('addonVersion')).toBe(1) - mockPnpm(dir, trace, (args) => { - expect(args.slice(0, 2)).toEqual(['add', 'addon@2']) - installFiles(dir, 2) - }) +it('refuses to overwrite an installed bundle, and installs another version once it is removed', async () => { + const { ctx, dir, trace, manager, evaluationPath, entry } = await fixture({ installed: true }) + const pnpm = mockPnpm(dir, trace, () => { installFiles(dir, 2) }) + expect(await manager.installBundle('addon@2', { enabled: true })) + .toMatchObject({ application: 'failed', changed: false, error: { code: 'already-installed' } }) + expect(pnpm).not.toHaveBeenCalled() + await removeAddon(manager, dir, trace) + mockPnpm(dir, trace, () => { installFiles(dir, 2) }) + expect(await manager.installBundle('addon@2', { enabled: true })).toMatchObject({ application: 'applied', changed: true }) + expect(trace).toEqual(['start:sibling', 'start:addon:1', 'stop:addon:1', 'publish', 'pnpm:remove', 'publish', 'pnpm:add', 'publish', 'start:addon:2']) + expect(entry()?.fiber?.state).toBe(FiberState.ACTIVE) + expect(ctx.get('addonVersion')).toBe(2) + expect(readFileSync(evaluationPath, 'utf8')).toBe('addon:1\naddon:2\n') +}) - expect(await manager.installBundle('addon@2', { enabled: true })).toMatchObject({ application: 'restart-required', changed: true }) +it('reports the load error when a reinstalled version fails to load', async () => { + const { ctx, dir, trace, manager, entry } = await fixture({ installed: true }) + await removeAddon(manager, dir, trace) + mockPnpm(dir, trace, () => { + installFiles(dir, 2) + writeFileSync(join(dir, 'node_modules', 'addon', 'node_modules', 'addon-plugin', 'plugin.mjs'), "import 'missing-lib'\n") + }) + const result = await manager.installBundle('addon@2', { enabled: true }) + expect(result).toMatchObject({ application: 'failed', changed: true, error: { code: 'operation-error', + diagnostic: expect.stringMatching(/failed to import: .*'missing-lib'/u) as string } }) + expect(entry()?.fiber?.uid).toBeFalsy() + expect(ctx.get('addonVersion')).toBeUndefined() +}) + +/** Write a linkable bundle source and return the package directory an installation spec names. */ +function linkSource(source: string, version: number): string { + writeBundle(source, version) + return join(source, 'node_modules', 'addon') +} + +/** Link the profile's addon dependency to a source directory, as a local directory install does. */ +function linkTo(dir: string, source: string, version: number): void { + writeBundle(source, version) + rmSync(join(dir, 'node_modules', 'addon'), { recursive: true, force: true }) + symlinkSync(join(source, 'node_modules', 'addon'), join(dir, 'node_modules', 'addon'), process.platform === 'win32' ? 'junction' : 'dir') + const manifest = readProfileManifest('test', dir) + manifest.dependencies = { ...manifest.dependencies, addon: `link:${source}` } + writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest)) +} + +it('starts a plugin relinked to another directory without a restart', async () => { + const { ctx, dir, trace, manager } = await fixture({ installed: true }) + // Sources outside the profiles tree are linked roots, whose targets the resolution records. + const work = mkdtempSync(join(tmpdir(), 'plugin-manager-relink-')) + onTestFinished(() => { rmSync(work, { recursive: true, force: true }) }) + await removeAddon(manager, dir, trace) + mockPnpm(dir, trace, () => { linkTo(dir, join(work, 'source-2'), 2) }) + expect(await manager.installBundle(linkSource(join(work, 'source-2'), 2), { enabled: true })).toMatchObject({ application: 'applied' }) + expect(ctx.get('addonVersion')).toBe(2) + // Removing the bundle stops its plugins and evicts the previous directory, so the next link target is published. + trace.length = 0 + await removeAddon(manager, dir, trace) + mockPnpm(dir, trace, () => { linkTo(dir, join(work, 'source-3'), 3) }) + expect(await manager.installBundle(linkSource(join(work, 'source-3'), 3), { enabled: true })).toMatchObject({ application: 'applied', changed: true }) + expect(trace).toEqual(['stop:addon:2', 'publish', 'pnpm:remove', 'publish', 'pnpm:add', 'publish', 'start:addon:3']) + expect(ctx.get('addonVersion')).toBe(3) +}) + +it('starts a registry installation of a package that was linked before', async () => { + const { ctx, dir, trace, manager } = await fixture({ installed: true }) + await removeAddon(manager, dir, trace) + mockPnpm(dir, trace, () => { linkTo(dir, join(dir, '..', 'addon-source-2'), 2) }) + expect(await manager.installBundle(linkSource(join(dir, '..', 'addon-source-2'), 2), { enabled: true })).toMatchObject({ application: 'applied' }) + trace.length = 0 + await removeAddon(manager, dir, trace) + mockPnpm(dir, trace, () => { installFiles(dir, 3) }) + expect(await manager.installBundle('addon@3', { enabled: true })).toMatchObject({ application: 'applied', changed: true }) + expect(trace).toEqual(['stop:addon:2', 'publish', 'pnpm:remove', 'publish', 'pnpm:add', 'publish', 'start:addon:3']) + expect(ctx.get('addonVersion')).toBe(3) +}) + +it('stops nothing before installing a directory without a readable manifest', async () => { + const { dir, trace, manager } = await fixture({ installed: true }) + mockPnpm(dir, trace, () => {}, 1) + trace.length = 0 + expect(await manager.installBundle(join(dir, '..', 'missing-source'))).toMatchObject({ application: 'failed' }) + expect(trace).not.toContain('stop:addon:1') +}) + +it('starts a bundle reinstalled from another link target while a plugin outside a Loader entry keeps the previous one', async () => { + const { ctx, dir, trace, manager } = await fixture({ installed: true }) + const source = join(dir, '..', 'addon-source-2') + await removeAddon(manager, dir, trace) + mockPnpm(dir, trace, () => { linkTo(dir, source, 2) }) + expect(await manager.installBundle(linkSource(source, 2), { enabled: true })).toMatchObject({ application: 'applied' }) + const extra = join(realpathSync(source), 'node_modules', 'addon', 'extra.mjs') + writeFileSync(extra, "export function apply(ctx) { ctx.get('packageReloadTrace')?.push('start:extra') }\n") + // A file written before HMR's last observation is not suspected of changing after it loaded. + utimesSync(extra, new Date(Date.now() - 3_600_000), new Date(Date.now() - 3_600_000)) + await ctx.plugin(ctx.loader.unwrapExports(await ctx.loader.import(pathToFileURL(extra).href)) as Plugin) + await removeAddon(manager, dir, trace) + trace.length = 0 + mockPnpm(dir, trace, () => { linkTo(dir, join(dir, '..', 'addon-source-3'), 3) }) + // Plugins outside a Loader entry are the caller's; the reinstalled bundle starts from the new link target. + expect(await manager.installBundle(linkSource(join(dir, '..', 'addon-source-3'), 3), { enabled: true })) + .toMatchObject({ application: 'applied', changed: true }) + expect(trace).toEqual(['pnpm:add', 'publish', 'start:addon:3']) +}) + +it('restores a disable whose publication waits for a restart and reports other publication failures', async () => { + const { ctx, trace, manager } = await fixture({ installed: true }) + // Another package's link points elsewhere since the last publication, so the next one is rejected. + const current = ctx.pluginPackages.resolution as ProfileRuntimeResolution + const next = await current.computeLatestResolution() + const relinked = vi.spyOn(ProfileRuntimeResolution.prototype, 'computeLatestResolution').mockResolvedValueOnce(new ProfileRuntimeResolution( + { installAnchor: '', home: '', profileDir: undefined }, + { profilesDir: next.profilesDir, profileDir: next.profileDir, localPackageNames: next.localPackageNames, entries: next.entries, + linkedRoots: [...next.linkedRoots, { name: 'other', realPath: '/elsewhere' }] }, + )) + onTestFinished(() => { relinked.mockRestore() }) + const packages = ctx.pluginPackages + const replace = packages.replace.bind(packages) + const blocked = vi.spyOn(packages, 'replace').mockImplementation((successor) => { + if (successor.linkedRoots.some(root => root.name === 'other')) throw new ResolutionRestartRequired('installation mapping changed') + replace(successor) + }) + onTestFinished(() => { blocked.mockRestore() }) + // The bundle's plugins stop before the publication and start again once it is rejected. + trace.length = 0 + const disabled = await manager.setBundleEnabled('addon', false) + expect(disabled).toMatchObject({ application: 'restart-required', restartReason: 'package-moved' }) + expect(trace).toEqual(['stop:addon:1', 'start:addon:1']) + blocked.mockRestore() + const latest = vi.spyOn(ProfileRuntimeResolution.prototype, 'computeLatestResolution').mockRejectedValueOnce(new Error('resolution unreadable')) + onTestFinished(() => { latest.mockRestore() }) + expect(await manager.setBundleEnabled('addon', false)) + .toMatchObject({ application: 'failed', error: { code: 'operation-error', diagnostic: 'resolution unreadable' } }) +}) + +it('requires a restart when a reinstalled plugin also runs outside a Loader entry', async () => { + const { ctx, dir, trace, manager } = await fixture({ installed: true, enabled: false }) + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) + onTestFinished(() => { warn.mockRestore() }) + const url = pathToFileURL(join(dir, 'node_modules', 'addon', 'node_modules', 'addon-plugin', 'plugin.mjs')).href + await ctx.plugin(ctx.loader.unwrapExports(await ctx.loader.import(url)) as Plugin) + // Removing the files of a plugin that keeps running outside a Loader entry waits for a restart too. + mockPnpm(dir, trace, () => { removeFiles(dir) }) + expect(await manager.removeBundle('addon')).toMatchObject({ application: 'restart-required', restartReason: 'unmanaged-plugin' }) + mockPnpm(dir, trace, () => { installFiles(dir, 2) }) + expect(await manager.installBundle('addon@2', { enabled: false })) + .toMatchObject({ application: 'restart-required', restartReason: 'unmanaged-plugin', changed: true }) + expect(ctx.get('addonVersion')).toBe(1) +}) + +it('keeps the no-HMR restart when publishing the resolution needs a restart, and fails on other publication errors', async () => { + const { ctx, dir, trace, manager } = await fixture({ live: false }) + const refresh = vi.spyOn(ctx.pluginPackages, 'refresh') + .mockRejectedValueOnce(new ResolutionRestartRequired('moved')) + .mockRejectedValueOnce(new Error('resolution unreadable')) + onTestFinished(() => { refresh.mockRestore() }) + mockPnpm(dir, trace, () => { installFiles(dir, 1) }) + expect(await manager.installBundle('addon')).toMatchObject({ application: 'restart-required', restartReason: 'no-hmr' }) + mockPnpm(dir, trace, () => { removeFiles(dir) }) + expect(await manager.removeBundle('addon')).toMatchObject({ application: 'failed', error: { diagnostic: 'resolution unreadable' } }) +}) + +it('requires a restart when a tarball whose package is known only after installation replaces an installed bundle without HMR', async () => { + const { dir, trace, manager } = await fixture({ live: false, installed: true }) + const tarball = join(dir, '..', 'addon-2.tgz') + writeFileSync(tarball, '') + mockPnpm(dir, trace, () => { installFiles(dir, 2) }) + expect(await manager.installBundle(tarball, { enabled: true })) + .toMatchObject({ application: 'restart-required', restartReason: 'no-hmr', bundle: 'addon', changed: true }) +}) + +it('installs over an installed bundle without HMR and keeps the running version until the restart', async () => { + const { ctx, dir, trace, published, manager } = await fixture({ live: false, installed: true }) + mockPnpm(dir, trace, () => { installFiles(dir, 2) }) + expect(await manager.installBundle('addon@2', { enabled: true })) + .toMatchObject({ application: 'restart-required', restartReason: 'no-hmr', changed: true }) expect(published).not.toHaveBeenCalled() - expect(trace).toEqual(['start:sibling', 'start:addon:1', 'pnpm:add']) - expect(entry()?.fiber).toBe(previous) - expect(previous?.state).toBe(FiberState.ACTIVE) expect(ctx.get('addonVersion')).toBe(1) - expect(readFileSync(evaluationPath, 'utf8')).toBe('addon:1\n') }) it('publishes nothing after installation fails', async () => { @@ -392,21 +566,26 @@ it('publishes nothing after installation fails', async () => { it.each(['install', 'remove'] as const)('keeps successful %s disk changes when resolution publication fails', async (operation) => { const { ctx, dir, trace, published, manager, parentURL, entry } = await fixture({ installed: operation === 'remove' }) - const previous = ctx.pluginPackages.packageOf('addon-plugin', parentURL) mockPnpm(dir, trace, () => { if (operation === 'install') installFiles(dir, 1); else removeFiles(dir) }) - published.mockImplementation(() => { throw new Error('publication rejected') }) + const publish = published.getMockImplementation() + // Removal publishes once its plugins stopped and again after pnpm removed the package; the second publication fails. + published.mockImplementation((successor) => { + if (operation === 'install' || trace.includes('pnpm:remove')) throw new Error('publication rejected') + publish?.(successor) + }) const result = operation === 'install' ? await manager.installBundle('addon') : await manager.removeBundle('addon') expect(result).toMatchObject({ application: 'failed', changed: true, error: { diagnostic: 'publication rejected' }, packageResult: { exitCode: 0 }, }) - expect(published).toHaveBeenCalledOnce() + expect(published).toHaveBeenCalledTimes(operation === 'install' ? 1 : 2) const manifest = readProfileManifest('test', dir) expect(manifest.dependencies).toEqual(operation === 'install' ? { addon: '1.0.0' } : {}) expect(manifest.dsh?.profile?.bundles).toEqual(operation === 'install' ? ['core', 'addon'] : ['core']) expect(existsSync(join(dir, 'node_modules', 'addon'))).toBe(operation === 'install') - expect(ctx.pluginPackages.packageOf('addon-plugin', parentURL)).toBe(previous) + // A removal publishes the bundle's private mappings away once its plugins stopped. + expect(ctx.pluginPackages.packageOf('addon-plugin', parentURL)).toBeUndefined() expect(entry()).toBeUndefined() }) @@ -455,3 +634,19 @@ it('publishes a newly installed bundle without HMR and leaves its activation to expect(ctx.get('addonVersion')).toBeUndefined() expect(existsSync(evaluationPath)).toBe(false) }) + + +it('reports restart-required when a reinstallation cannot compare the loaded module files', async () => { + const { ctx, dir, trace, manager } = await fixture({ installed: true }) + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) + onTestFinished(() => { warn.mockRestore() }) + await removeAddon(manager, dir, trace) + mockPnpm(dir, trace, () => { + installFiles(dir, 2) + writeFileSync(join(dir, 'node_modules', '@broken'), '') + }) + const result = await manager.installBundle('addon@2', { enabled: true }) + expect(result).toMatchObject({ application: 'restart-required', restartReason: 'module-comparison' }) + expect(ctx.get('addonVersion')).toBe(2) + expect(warn).toHaveBeenCalledWith(expect.objectContaining({ code: 'ENOTDIR' })) +}) diff --git a/packages/boot/plugin-manager/tests/patch.spec.ts b/packages/boot/plugin-manager/tests/patch.spec.ts index 30bb947c84..9580da1e52 100644 --- a/packages/boot/plugin-manager/tests/patch.spec.ts +++ b/packages/boot/plugin-manager/tests/patch.spec.ts @@ -4,72 +4,83 @@ import { join } from 'node:path' import { tmpdir } from 'node:os' import { expect, it, onTestFinished } from 'vitest' import { applyEntryPatches } from '@deepseek-ai/cordis-plugin-include' -import { loadOptionalPatches } from '@deepseek-ai/dsh-app-boot' +import { initProfile, loadOptionalPatches, type ProfileContext } from '@deepseek-ai/dsh-app-boot' import { writePluginEnabled } from '../src/patch.ts' -async function fixture(text?: string): Promise { - const dir = await mkdtemp(join(tmpdir(), 'manager-patch-')) - onTestFinished(() => rm(dir, { recursive: true, force: true })) - const file = join(dir, 'cordis.patch.yml') - if (text !== undefined) await writeFile(file, text) - return file +const TOOL = { id: 'tool', name: 'package' } + +/** Create a profile whose patch file holds `text` (none when omitted) and whose one selected bundle inserts `rows`. */ +async function fixture(text?: string, rows: object[] = []): Promise { + const home = await mkdtemp(join(tmpdir(), 'manager-patch-')) + onTestFinished(() => rm(home, { recursive: true, force: true })) + const dir = join(home, 'profile') + const installAnchor = join(home, 'package.json') + await writeFile(installAnchor, '{"name":"installation","dependencies":{}}') + initProfile(dir, ['bundle']) + const bundle = join(dir, 'node_modules', 'bundle') + await mkdir(bundle, { recursive: true }) + await writeFile(join(bundle, 'package.json'), JSON.stringify({ name: 'bundle', version: '1.0.0', dsh: { bundle: { patch: './cordis.patch.yml' } } })) + await writeFile(join(bundle, 'cordis.patch.yml'), JSON.stringify([{ insert: rows }])) + const patchPath = join(dir, 'cordis.patch.yml') + await (text === undefined ? rm(patchPath) : writeFile(patchPath, text)) + return { name: 'test', dir, patchPath, installAnchor, cwd: home, home, startedBundles: ['bundle'], overlays: [], telemetryDisabledEnv: undefined } } it('preserves comments, expressions, unrelated configuration and the last override', async () => { - const file = await fixture('# personal configuration\n- id: tool\n config:\n value: !!js process.platform\n- id: tool\n disabled: false # availability\n') - expect(await writePluginEnabled(file, 'tool', 'package', false)).toBe(true) - const text = await readFile(file, 'utf8') + const profile = await fixture('# personal configuration\n- id: tool\n config:\n value: !!js process.platform\n- id: tool\n disabled: false # availability\n') + expect(await writePluginEnabled(profile, 'tool', 'package', false)).toBe(true) + const text = await readFile(profile.patchPath, 'utf8') expect(text).toContain('# personal configuration') expect(text).toContain('!!js process.platform') expect(text).toContain('# availability') - expect(loadOptionalPatches('test', file)).toEqual([ + expect(loadOptionalPatches('test', profile.patchPath)).toEqual([ { id: 'tool', config: { value: { __jsExpr: 'process.platform' } } }, { id: 'tool', disabled: true }, ]) - expect(await writePluginEnabled(file, 'tool', 'package', false)).toBe(false) - expect(await readFile(file, 'utf8')).toBe(text) - expect(await writePluginEnabled(file, 'tool', 'package', true)).toBe(true) + expect(await writePluginEnabled(profile, 'tool', 'package', false)).toBe(false) + expect(await readFile(profile.patchPath, 'utf8')).toBe(text) + expect(await writePluginEnabled(profile, 'tool', 'package', true)).toBe(true) }) it('creates a missing patch file and appends after insertions', async () => { - const file = await fixture() - await writePluginEnabled(file, 'tool', 'package', false) - expect(loadOptionalPatches('test', file)).toEqual([{ id: 'tool', disabled: true }]) - await writeFile(file, '- insert:\n - id: tool\n name: package\n') - await writePluginEnabled(file, 'tool', 'package', true) - expect(loadOptionalPatches('test', file)).toEqual([ - { insert: [{ id: 'tool', name: 'package' }] }, { id: 'tool', disabled: false }, + const profile = await fixture() + await writePluginEnabled(profile, 'tool', 'package', false) + expect(loadOptionalPatches('test', profile.patchPath)).toEqual([{ id: 'tool', disabled: true }]) + await writeFile(profile.patchPath, '- insert:\n - id: tool\n name: package\n') + await writePluginEnabled(profile, 'tool', 'package', false) + expect(loadOptionalPatches('test', profile.patchPath)).toEqual([ + { insert: [{ id: 'tool', name: 'package' }] }, { id: 'tool', disabled: true }, ]) }) it.each(['- id: [broken', 'mapping: true\n'])('refuses malformed documents without overwriting %s', async (text) => { - const file = await fixture(text) - await expect(writePluginEnabled(file, 'tool', 'package', true)).rejects.toThrow() - expect(await readFile(file, 'utf8')).toBe(text) + const profile = await fixture(text) + await expect(writePluginEnabled(profile, 'tool', 'package', true)).rejects.toThrow() + expect(await readFile(profile.patchPath, 'utf8')).toBe(text) }) it('retains name-asserting overrides and appends an unambiguous switch', async () => { - const file = await fixture('- id: tool\n name: another-package\n disabled: false\n') - await writePluginEnabled(file, 'tool', 'package', false) - expect(loadOptionalPatches('test', file)).toEqual([ + const profile = await fixture('- id: tool\n name: another-package\n disabled: false\n') + await writePluginEnabled(profile, 'tool', 'package', false) + expect(loadOptionalPatches('test', profile.patchPath)).toEqual([ { id: 'tool', name: 'another-package', disabled: false }, { id: 'tool', disabled: true }, ]) - expect(await writePluginEnabled(file, 'tool', 'package', false)).toBe(false) + expect(await writePluginEnabled(profile, 'tool', 'package', false)).toBe(false) }) it('reports read failures without replacing a directory with configuration', async () => { - const file = await fixture() - await mkdir(file) - await expect(writePluginEnabled(file, 'tool', 'package', true)).rejects.toThrow() + const profile = await fixture() + await mkdir(profile.patchPath) + await expect(writePluginEnabled(profile, 'tool', 'package', true)).rejects.toThrow() }) it('updates the last matching named override and leaves mismatched names untouched', async () => { - const file = await fixture('- id: tool\n disabled: true\n- id: tool\n name: package\n config:\n value: !!js process.platform\n disabled: true # availability\n- id: tool\n name: another-package\n disabled: true\n') - expect(await writePluginEnabled(file, 'tool', 'package', true)).toBe(true) - const text = await readFile(file, 'utf8') + const profile = await fixture('- id: tool\n disabled: true\n- id: tool\n name: package\n config:\n value: !!js process.platform\n disabled: true # availability\n- id: tool\n name: another-package\n disabled: true\n') + expect(await writePluginEnabled(profile, 'tool', 'package', true)).toBe(true) + const text = await readFile(profile.patchPath, 'utf8') expect(text).toContain('!!js process.platform') expect(text).toContain('# availability') - const patches = loadOptionalPatches('test', file) + const patches = loadOptionalPatches('test', profile.patchPath) expect(patches).toEqual([ { id: 'tool', disabled: true }, { id: 'tool', name: 'package', config: { value: { __jsExpr: 'process.platform' } }, disabled: false }, @@ -77,8 +88,75 @@ it('updates the last matching named override and leaves mismatched names untouch ]) expect(applyEntryPatches([{ id: 'tool', name: 'package' }], patches, () => {})) .toMatchObject([{ id: 'tool', name: 'package', disabled: false }]) - expect(await writePluginEnabled(file, 'tool', 'package', true)).toBe(false) - expect(await readFile(file, 'utf8')).toBe(text) - expect(await writePluginEnabled(file, 'tool', 'package', false)).toBe(true) - expect(loadOptionalPatches('test', file)).toHaveLength(3) + expect(await writePluginEnabled(profile, 'tool', 'package', true)).toBe(false) + expect(await readFile(profile.patchPath, 'utf8')).toBe(text) + expect(await writePluginEnabled(profile, 'tool', 'package', false)).toBe(true) + expect(loadOptionalPatches('test', profile.patchPath)).toHaveLength(3) +}) + +it('removes or omits a plain switch that restates the bundle layers', async () => { + const profile = await fixture('# personal configuration\n- id: other\n disabled: true\n- id: tool\n disabled: true\n', [TOOL]) + expect(await writePluginEnabled(profile, 'tool', 'package', true)).toBe(true) + expect(await readFile(profile.patchPath, 'utf8')).toBe('# personal configuration\n- id: other\n disabled: true\n') + expect(await writePluginEnabled(profile, 'tool', 'package', true)).toBe(false) + expect(await readFile(profile.patchPath, 'utf8')).toBe('# personal configuration\n- id: other\n disabled: true\n') + expect(await writePluginEnabled(profile, 'tool', 'package', false)).toBe(true) + expect(loadOptionalPatches('test', profile.patchPath)).toEqual([{ id: 'other', disabled: true }, { id: 'tool', disabled: true }]) +}) + +it('keeps a switch that carries other settings or differs from the bundle layers', async () => { + const profile = await fixture('- id: tool\n config: { value: 1 }\n disabled: true\n', [TOOL]) + expect(await writePluginEnabled(profile, 'tool', 'package', true)).toBe(true) + expect(loadOptionalPatches('test', profile.patchPath)).toEqual([{ id: 'tool', config: { value: 1 }, disabled: false }]) + const plain = await fixture('- id: tool\n disabled: true\n', [{ ...TOOL, disabled: true }]) + expect(await writePluginEnabled(plain, 'tool', 'package', true)).toBe(true) + expect(loadOptionalPatches('test', plain.patchPath)).toEqual([{ id: 'tool', disabled: false }]) +}) + +it('keeps the comment above a removed switch and prunes a switch the composition already implies', async () => { + const profile = await fixture('# header\n- id: tool\n disabled: false\n# other\n- id: other\n disabled: true\n', [TOOL]) + expect(await writePluginEnabled(profile, 'tool', 'package', true)).toBe(true) + expect(await readFile(profile.patchPath, 'utf8')).toBe('# header\n# other\n- id: other\n disabled: true\n') + const first = await fixture('# header\n- id: tool\n disabled: true\n- id: other\n disabled: true\n', [TOOL]) + expect(await writePluginEnabled(first, 'tool', 'package', true)).toBe(true) + expect(await readFile(first.patchPath, 'utf8')).toBe('# header\n- id: other\n disabled: true\n') + const only = await fixture('# header\n- id: tool\n disabled: true\n', [TOOL]) + expect(await writePluginEnabled(only, 'tool', 'package', true)).toBe(true) + expect(loadOptionalPatches('test', only.patchPath)).toEqual([]) + expect(await readFile(only.patchPath, 'utf8')).toBe('# header\n[]\n') + const last = await fixture('- id: other\n disabled: true\n# about tool\n- id: tool\n disabled: true\n', [TOOL]) + expect(await writePluginEnabled(last, 'tool', 'package', true)).toBe(true) + expect(await readFile(last.patchPath, 'utf8')).toBe('- id: other\n disabled: true\n\n# about tool\n') + const lastWithTail = await fixture('- id: other\n disabled: true\n# about tool\n- id: tool\n disabled: true\n# tail\n', [TOOL]) + expect(await writePluginEnabled(lastWithTail, 'tool', 'package', true)).toBe(true) + expect(await readFile(lastWithTail.patchPath, 'utf8')).toBe('- id: other\n disabled: true\n\n# about tool\n# tail\n') + const trailing = await fixture('- id: tool\n disabled: true # removed\n# tail\n', [TOOL]) + expect(await writePluginEnabled(trailing, 'tool', 'package', true)).toBe(true) + expect(loadOptionalPatches('test', trailing.patchPath)).toEqual([]) + expect(await readFile(trailing.patchPath, 'utf8')).toContain('# tail') + const both = await fixture('# header\n- id: tool\n disabled: true\n# tail\n', [TOOL]) + expect(await writePluginEnabled(both, 'tool', 'package', true)).toBe(true) + expect(await readFile(both.patchPath, 'utf8')).toBe('# header\n[]\n\n# tail\n') +}) + +it('treats insertions in the profile patch as part of the inherited composition', async () => { + const profile = await fixture('- insert:\n - id: tool\n name: package\n') + expect(await writePluginEnabled(profile, 'tool', 'package', true)).toBe(false) + expect(loadOptionalPatches('test', profile.patchPath)).toEqual([{ insert: [{ id: 'tool', name: 'package' }] }]) +}) + +it.each([ + ['a value', '- id: &plugin tool\n disabled: true\n- id: other\n config: { target: *plugin }\n', { target: 'tool' }], + ['the entry', '- &plugin { id: tool, disabled: true }\n- id: other\n config: { target: *plugin }\n', { target: { id: 'tool', disabled: false } }], + ['a key', '- id: tool\n &key disabled: true\n- id: other\n config: { *key : 1 }\n', { disabled: 1 }], +])('keeps a switch whose anchor on %s other entries reference', async (_, text, config) => { + const profile = await fixture(text, [TOOL]) + expect(await writePluginEnabled(profile, 'tool', 'package', true)).toBe(true) + expect(loadOptionalPatches('test', profile.patchPath)).toEqual([{ id: 'tool', disabled: false }, { id: 'other', config }]) +}) + +it('keeps a switch whose value is an alias', async () => { + const profile = await fixture('- id: other\n config: { flag: &flag true }\n- id: tool\n disabled: *flag\n', [TOOL]) + expect(await writePluginEnabled(profile, 'tool', 'package', true)).toBe(true) + expect(loadOptionalPatches('test', profile.patchPath)).toEqual([{ id: 'other', config: { flag: true } }, { id: 'tool', disabled: false }]) }) diff --git a/packages/boot/plugin-manager/tests/tools.spec.ts b/packages/boot/plugin-manager/tests/tools.spec.ts index dab620bff1..c7981e2296 100644 --- a/packages/boot/plugin-manager/tests/tools.spec.ts +++ b/packages/boot/plugin-manager/tests/tools.spec.ts @@ -282,3 +282,25 @@ it('presents reads and changes distinctly and disposes its registration', async await fiber.dispose() expect(ctx.tools.get('plugin_manager')).toBeUndefined() }) + +it('tells the agent the next step for each change outcome it can act on', async () => { + const { manager, call } = await fixture() + const outcome = async (result: object): Promise => { + manager.setBundleEnabled.mockResolvedValueOnce(result as never) + return (JSON.parse(resultText(await call({ action: 'set_bundle', target: 'bundle', enabled: true }))) as { nextStep?: string }).nextStep + } + expect(await outcome({ changed: true, application: 'applied' })).toBeUndefined() + expect(await outcome({ changed: false, application: 'cancelled' })).toContain('nothing changed') + expect(await outcome({ changed: true, application: 'overridden' })).toContain('higher-priority layer') + expect(await outcome({ changed: true, application: 'restart-required', restartReason: 'host-module' })) + .toBe('Tell the user to restart DSH to apply this change: the change reaches modules DSH itself loaded.') + expect(await outcome({ changed: true, application: 'restart-required' })).toBe('Tell the user to restart DSH to apply this change.') + expect(await outcome({ changed: false, application: 'failed', packageResult: { kind: 'build-blocked' } })).toContain('approvedBuilds') + // A refusal code is more specific than the package manager's failure kind. + expect(await outcome({ changed: false, application: 'failed', packageResult: { kind: 'network' }, error: { code: 'stale-approval' } })) + .toContain('without approvedBuilds') + // A failure kind without its own step falls back to the refusal code. + expect(await outcome({ changed: false, application: 'failed', packageResult: { kind: 'unknown' }, error: { code: 'operation-error' } })) + .toContain('Report the diagnostic') + expect(await outcome({ changed: false, application: 'failed' })).toContain('Report the diagnostic') +}) diff --git a/packages/bundle/base/cordis.patch.yml b/packages/bundle/base/cordis.patch.yml index 9e3f03eefc..ee2f8f280b 100644 --- a/packages/bundle/base/cordis.patch.yml +++ b/packages/bundle/base/cordis.patch.yml @@ -52,6 +52,11 @@ - id: typert-gateway name: '@deepseek-ai/dsh-api-gateway' + # Method-level access control for non-owner Peers; the Gateway requires it, + # and its Client half publishes the standard access Hooks. + - id: remote-access + name: '@deepseek-ai/dsh-api-remote-access' + - id: session-title name: '@deepseek-ai/dsh-session-title' config: @@ -105,8 +110,8 @@ name: '@deepseek-ai/dsh-settings' disabled: !!js "!ctx.get('profileContext')" - # Credential sources: inherited environment over the managed - # `$DSH_HOME/.credentials.yaml`, with project and user `.env` fallbacks. + # Credential sources: managed `$DSH_HOME/.credentials.yaml`, then inherited + # environment, project `.env`, and user `.env` fallbacks. # Adapters resolve references per request; the Models page writes only the # managed document, which is never materialized into the process environment. - id: authorization diff --git a/packages/bundle/base/package.json b/packages/bundle/base/package.json index fa80fcfb55..8ec635fde1 100644 --- a/packages/bundle/base/package.json +++ b/packages/bundle/base/package.json @@ -58,6 +58,7 @@ "@deepseek-ai/dsh-goal": "workspace:*", "@deepseek-ai/dsh-goal-round-driver": "workspace:*", "@deepseek-ai/dsh-api-gateway": "workspace:*", + "@deepseek-ai/dsh-api-remote-access": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-llm-deepseek-api-key": "workspace:*", "@deepseek-ai/dsh-llm-deepseek-account": "workspace:*", diff --git a/packages/bundle/headless/README.i18n.yaml b/packages/bundle/headless/README.i18n.yaml index c3715b490f..6597e4b433 100644 --- a/packages/bundle/headless/README.i18n.yaml +++ b/packages/bundle/headless/README.i18n.yaml @@ -18,8 +18,11 @@ en: 4447012f4c84100d zh: faf579ae9f04c096 /deepseek-ai-dsh-headless/use-this-package/running-a-one-shot-task: - en: 76a22e98d2b3cc2f - zh: c9eb624609b8cc32 + en: 89a48af61a38afc3 + zh: fe202a1bb2c11781 +/deepseek-ai-dsh-headless/use-this-package/credentials: + en: ca8217837ee9c698 + zh: fa20daf27d4e09ef /deepseek-ai-dsh-headless/use-this-package/choosing-the-session-identity: en: dbaa2fac63910a4c zh: 4fd5c17815ca444b @@ -57,8 +60,8 @@ en: 0b0f74b412c7c6d8 zh: 6e8f918d0c4bf342 /deepseek-ai-dsh-headless/known-limitations-and-deferred-work: - en: b2df65836d53ba03 - zh: 82a56dc1255a0c3b + en: 3a6dc9666d852219 + zh: b764f203815e179e /deepseek-ai-dsh-headless/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: 3bb0c20c93f16f98 diff --git a/packages/bundle/headless/README.md b/packages/bundle/headless/README.md index e7776f0591..bc2a7ff532 100644 --- a/packages/bundle/headless/README.md +++ b/packages/bundle/headless/README.md @@ -49,6 +49,26 @@ The task and run options are supplied through three settings: The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-headless) is the exhaustive source for every accepted field and its JSDoc. + +### Credentials + +The shipped headless profile disables the managed credential store and its authorization and DeepSeek account consumers. API-key routes use the launch environment, then `/.env`, then `$DSH_HOME/.env`. Keys and sign-ins saved by the GUI are ignored without changing their file. Providers that require a saved sign-in fail unless stored authentication is restored; external provider credential chains, such as AWS profiles, remain available. + +Unset an empty launch variable to allow its `.env` fallback; an exported empty value shadows that fallback. + +To restore stored authentication, add these rows to `$DSH_HOME/profiles/headless/cordis.patch.yml` or a `--patch` overlay: + +```yaml +- id: credentials + disabled: false +- id: authorization + disabled: false +- id: deepseek-account + disabled: false +``` + +The restored store uses [saved credentials before environment fallbacks](../../credentials/credentials-local/README.md). Web, SDK and ACP profiles keep that default ordering. Headless sessions, settings and tool behavior are unchanged; this profile choice is not a filesystem access boundary. + ### Choosing the session identity Every invocation defaults to a fresh `session-` identity, which `--json` reports in its opening `session` event. Pass `--session-id ` to continue that conversation: the runner adopts the persisted Session with that id, and an id with no stored Session fails before the task runs rather than quietly opening an empty history. Adoption requires the composed `sessionPersistence` and `sessionQuery` services, so a profile that omits either fails loudly instead of returning an id whose history dies with the process. An Agent already live under the requested id in this process is refused: another owner may still drive it, so the runner cannot claim an exclusive run interval over it. The identity is opaque, so the exact string is used, whitespace included. The working directory is resolved through the mounted filesystem provider (`fs.resolve('.')` and `fs.processPath()`), or the process cwd when no filesystem service is mounted; new Sessions record that directory. Adoption compares the recorded cwd with the same provider-resolved directory and refuses a Session that is a subagent or forked session, that recorded no working directory, that runs under an agent preset this profile does not compose, or whose preset record is malformed — the check reads the preset the Session log currently records, so a Session that switched preset while blank is rejected too. A supervisor therefore cannot silently drive someone else's conversation under a different composition; any mismatch fails before the task runs. @@ -133,6 +153,7 @@ The runner adds nothing to the request prefix; it only drives one user message t These limits tell you when headless does not fit and what it needs from the `dsh` launcher. They are current package constraints, not a general CLI comparison or a task backlog. - **One task per run** — after the task is answered the process exits; there is no interactive follow-up, so split multi-step work into separate runs. +- **Final background-job settlement may be absent from persisted history** — process teardown can end Session routing or close its writer before an owned job settles, including normal completion and signal shutdown; the [job lifecycle contract](../../../docs/subsystems/jobs.md#session-lifecycle-records) defines how consumers retain incomplete intervals. - **Runs through the `dsh` launcher** — starting the headless profile another way fails at startup, because only the launcher can request the process exit. - **No pre-token heartbeat** — in default mode stderr stays silent until the provider emits a non-empty reasoning delta; a delayed first token exposes no earlier progress signal. - **Reasoning enters stderr logs** — in default mode, redirection and supervisors may retain substantially more and potentially sensitive model output; route stderr to a controlled sink when needed. diff --git a/packages/bundle/headless/README.zh.md b/packages/bundle/headless/README.zh.md index a7d35fdbe9..fb79a898e2 100644 --- a/packages/bundle/headless/README.zh.md +++ b/packages/bundle/headless/README.zh.md @@ -49,6 +49,26 @@ agent 会完成该任务,把提供方的每个非空推理(reasoning)增 生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-headless)是所有受支持字段及其 JSDoc 的完整真源。 + +### 凭据 + +随附的 headless profile 禁用托管凭据存储,以及依赖它的 authorization 和 DeepSeek account 消费方。API 密钥路由依次使用启动环境、`<运行命令的目录>/.env` 和 `$DSH_HOME/.env`。GUI 保存的密钥和登录凭据不会被使用,存储文件保持原样。需要已保存登录凭据的提供方会失败,除非恢复存储认证;AWS profile 等提供方外部凭据链仍然可用。 + +启动变量为空时,请取消导出该变量,让 `.env` 后备生效;已导出的空值会遮蔽后备值。 + +要恢复存储认证,请把以下行加入 `$DSH_HOME/profiles/headless/cordis.patch.yml` 或 `--patch` 覆盖文件: + +```yaml +- id: credentials + disabled: false +- id: authorization + disabled: false +- id: deepseek-account + disabled: false +``` + +恢复后,存储仍按[已保存凭据优先于环境后备](../../credentials/credentials-local/README.zh.md)解析。Web、SDK 和 ACP profile 保留这一默认顺序。headless 的会话、settings 和工具行为不变;这种 profile 配置选择不构成文件系统访问边界。 + ### 选择 Session 标识 每次调用默认使用全新的 `session-` 标识,`--json` 会在开头的 `session` 事件里报告它。传入 `--session-id ` 延续这段对话:runner 沿用该 id 对应的持久化 Session,而该 id 没有持久化 Session 时会在任务运行前失败,而不是悄悄开出一段空历史。沿用要求已组合 `sessionPersistence` 与 `sessionQuery` 服务,因此缺少任一服务的 profile 会显式失败,而不会返回一个历史随进程消失的 id。本进程中已存在持有该 id 的存活 Agent 时会被拒绝:它的原 owner 可能仍在驱动它,runner 无法取得独占的运行区间。标识是不透明的,因此会原样使用调用方给出的字符串,包括空白字符。工作目录通过已挂载的文件系统提供方解析(`fs.resolve('.')` 与 `fs.processPath()`);未挂载文件系统服务时使用进程 cwd,新 Session 会记录该目录。沿用会将已记录 cwd 与同一提供方解析出的目录比较,并拒绝子 agent 或 fork 会话、未记录工作目录的会话、运行在本 profile 不组合的 agent preset 下的会话,以及 preset 记录畸形的会话——该检查读取 Session 日志当前记录的 preset,因此在空白期切换过 preset 的会话同样会被拒绝。因此监督进程无法在另一套组合下悄悄驱动他人的会话;任一不匹配都会在任务运行前失败。 @@ -133,6 +153,7 @@ runner 不向请求前缀添加任何内容;它只是驱动组合出的配置 这些限制告诉你 headless 何时不适用、它需要 `dsh` 启动器提供什么。它们是当前包约束,不是通用的 CLI(命令行界面)对比或任务积压。 - **每次运行一个任务**——任务得到回答后进程即退出;没有交互式后续,因此多步工作请拆成多次运行。 +- **持久化历史可能缺少后台 job 的最后一条结算**——进程销毁可能在有主 job 结算前停止 Session 路由或关闭写句柄,正常结束和信号关停也可能如此;[job 生命周期约定](../../../docs/subsystems/jobs.zh.md#session-lifecycle-records)说明消费方如何保留不完整区间。 - **通过 `dsh` 启动器运行**——以其他方式启动 headless profile 会在启动时失败,因为只有启动器能请求进程退出。 - **首个 token 前没有心跳**——默认模式下,提供方发出第一个非空推理增量前,stderr 保持静默;延迟首个 token 的提供方不会更早给出进度信号。 - **推理进入 stderr 日志**——默认模式下,重定向与监督进程可能保留显著更多且可能敏感的模型输出;需要时应把 stderr 路由到受控位置。 diff --git a/packages/bundle/headless/cordis.patch.yml b/packages/bundle/headless/cordis.patch.yml index 20f13b54e6..cf224539eb 100644 --- a/packages/bundle/headless/cordis.patch.yml +++ b/packages/bundle/headless/cordis.patch.yml @@ -31,3 +31,13 @@ - id: hmr disabled: true + +# One-shot jobs use launch-time credentials instead of the interactive store. +- id: credentials + disabled: true + +- id: authorization + disabled: true + +- id: deepseek-account + disabled: true diff --git a/packages/bundle/headless/tests/headless.spec.ts b/packages/bundle/headless/tests/headless.spec.ts index cc73507d5f..468c1e67a9 100644 --- a/packages/bundle/headless/tests/headless.spec.ts +++ b/packages/bundle/headless/tests/headless.spec.ts @@ -260,7 +260,7 @@ describe('headless runner', () => { await test.ctx.plugin(SubagentRuntime) test.ctx.subagents.registerProvider({ name: 'external', inheritsParentContext: false, - capabilities: { agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + capabilities: { agentOptions: false, agentPreset: false, outputSchema: false, depthLimit: false }, start: async () => ({ id: brandString('settlement-child'), result: Promise.resolve({ output: [{ type: 'text' as const, text: 'child answer' }], stopReason: 'completed' as const }), diff --git a/packages/bundle/sdk-minimal/README.i18n.yaml b/packages/bundle/sdk-minimal/README.i18n.yaml index 1a22a57b32..b93af7197d 100644 --- a/packages/bundle/sdk-minimal/README.i18n.yaml +++ b/packages/bundle/sdk-minimal/README.i18n.yaml @@ -3,23 +3,23 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/bundle/sdk-minimal/README.md /: - en: 7cbc0e332890851f - zh: 07c71026d8e1bb15 + en: b2fce08b0fa865da + zh: 97e214d87ded4b54 /deepseek-ai-dsh-sdk-minimal: en: 807c43eedba7d53e zh: 9f1efe5d6ac393f7 /deepseek-ai-dsh-sdk-minimal/summary: - en: c0a98b4bcbb3e719 - zh: 92895dd5ce381cd1 + en: 31b83af058b212f9 + zh: 289678ac3738ee5e /deepseek-ai-dsh-sdk-minimal/table-of-contents: en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-sdk-minimal/use-this-package: - en: f920307c0cf12118 - zh: edf9051846fecd5c + en: 39483eef4a61f212 + zh: 62d82f46a04e07fe /deepseek-ai-dsh-sdk-minimal/understand-the-implementation: - en: 91f4a78ab7a688d3 - zh: 7bb8bf33c0d8b429 + en: 5db5d8f041867958 + zh: bff5ef3a2e140712 /deepseek-ai-dsh-sdk-minimal/understand-the-implementation/source-map: en: 3784c554e96ab004 zh: 0a05d4fe1e26b3b1 @@ -33,17 +33,17 @@ en: bd87e16b0c6e0506 zh: c8ee5e3b4578754e /deepseek-ai-dsh-sdk-minimal/model-experience/minimal-coding-agent-composition/what-the-model-sees: - en: 724e1f4075df9293 - zh: 35955569ded10b56 + en: df40d9529b566cfa + zh: 4678769b4f85120b /deepseek-ai-dsh-sdk-minimal/model-experience/minimal-coding-agent-composition/token-effect: - en: 5df298b2c9f95d27 - zh: def93cd59b5ca843 + en: 9b7a23c7a447eb22 + zh: e01bfd06d19b7d18 /deepseek-ai-dsh-sdk-minimal/model-experience/minimal-coding-agent-composition/kv-cache-effect: - en: 904ae09b5fdb87c1 - zh: de6fcaaa12bd9733 + en: dd887b6519f40ab3 + zh: a98e56411fdc79df /deepseek-ai-dsh-sdk-minimal/known-limitations-and-deferred-work: - en: 8f74b1b4f39eda83 - zh: af96ec4cb42810c5 + en: 00c4192d6af9f621 + zh: 460451ab6dc07c3a /deepseek-ai-dsh-sdk-minimal/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: e88aeb617ffb74ea diff --git a/packages/bundle/sdk-minimal/README.md b/packages/bundle/sdk-minimal/README.md index 7a4a0ff127..9accd1fec2 100644 --- a/packages/bundle/sdk-minimal/README.md +++ b/packages/bundle/sdk-minimal/README.md @@ -1,5 +1,5 @@ --- -description: "Standalone SDK profile with a persistent shell and Session working-directory tool for a minimal cross-platform coding agent without the shared base bundle." +description: "Standalone SDK profile with a persistent shell, image reading, automatic context compaction, and a Session working-directory tool for a minimal cross-platform coding agent without the shared base bundle." kind: "package-bundle" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Use `dsh --profile sdk-minimal` when an SDK client needs a small, explicit coding-agent runtime. By default, the profile advertises a platform-selected persistent shell and `working_directory`, persists sessions as uncompressed JSONL, and selects the model from the SDK initialization request. It supplies a complete Cordis tree and excludes `dsh-base`, Web, settings, managed credentials, telemetry, compaction, filesystem tools, workspace instructions, skills, jobs, and subagents. Its danger-full-access policy lets the shell modify any path available to the process, so use it only with an isolated workspace. +Use `dsh --profile sdk-minimal` when an SDK client needs a small, explicit coding-agent runtime. By default, the profile advertises a platform-selected persistent shell, `read_image`, and `working_directory`, persists sessions as uncompressed JSONL, and selects the model from the SDK initialization request. It supplies a complete Cordis tree and excludes `dsh-base`, Web, settings, managed credentials, telemetry, text file tools, workspace instructions, skills, jobs, and subagents. Its danger-full-access policy lets the shell modify any path available to the process, so use it only with an isolated workspace. ## Table of Contents @@ -38,7 +38,7 @@ Use `dsh plugin --profile sdk-minimal` to manage persistent external dependencie The profile mounts exactly one persistent shell stack: Bash on Linux and macOS, or PowerShell on Windows. Both stacks use a 300-second timeout and one owner-scoped terminal; the other platform's rows remain disabled. -Like every shipped profile, it mounts [MCP resources](../../mcp/mcp-resources/README.md) once. Configure only [MCP client entries](../../mcp/mcp-client/README.md) to add servers. Clients mounted by another provider also count as configured. With no server configured in the caller's scope, MCP contributes no prompt text or tools and the default remains the shell and `working_directory` tools. +Like every shipped profile, it mounts [MCP resources](../../mcp/mcp-resources/README.md) once. Configure only [MCP client entries](../../mcp/mcp-client/README.md) to add servers. Clients mounted by another provider also count as configured. With no server configured in the caller's scope, MCP contributes no prompt text or tools and the default remains the shell, `read_image`, and `working_directory` tools. ----- @@ -48,7 +48,7 @@ Like every shipped profile, it mounts [MCP resources](../../mcp/mcp-resources/RE
Implementation internals — click to expand -The bundle's single insert is the complete application tree: SDK stdio startup and JSON-RPC serving, one environment-configured DeepSeek adapter, the explicit agent core, conditional MCP resource tools, local filesystem and subprocess services, Session working-directory state and its tool, a platform-selected persistent shell PTY, and uncompressed JSONL persistence under `$DSH_HOME/sessions`. It does not inherit another bundle, so every extra row is an explicit profile change. +The bundle's single insert is the complete application tree: SDK stdio startup and JSON-RPC serving, one environment-configured DeepSeek adapter, the explicit agent core, conditional MCP resource tools, local filesystem, image attachments, automatic context compaction, and subprocess services, Session working-directory state and its tool, a platform-selected persistent shell PTY, and uncompressed JSONL persistence under `$DSH_HOME/sessions`. It does not inherit another bundle, so every extra row is an explicit profile change. ### Source map @@ -78,21 +78,23 @@ The bundle's single insert is the complete application tree: SDK stdio startup a #### What the model sees -The system prompt is `DSH_SYSTEM_PROMPT` or `You are a helpful software engineer assistant.`. With no MCP server configured, the advertised tools are `working_directory` and owner-scoped persistent `bash` on Linux/macOS or `pwsh` on Windows. Required user context supplies the current directory. Optional runtime context, filesystem tools, workspace instructions, skills, jobs controls, compaction, and Harness identity are absent. +The system prompt is `DSH_SYSTEM_PROMPT` or `You are a helpful software engineer assistant.`. With no MCP server configured, the advertised tools are `read_image`, `working_directory`, and owner-scoped persistent `bash` on Linux/macOS or `pwsh` on Windows. Required user context supplies the current directory. Optional runtime context, text file tools, workspace instructions, skills, jobs controls, and Harness identity are absent. #### Token effect -By default, one stable persona, two tool schemas, and user context for the initial directory and subsequent changes. Configured MCP servers add their tools, shared resource tools, and available server instructions. Tool results and ordinary conversation history grow with the session. +By default, one stable persona, three tool schemas, and user context for the initial directory and subsequent changes. Configured MCP servers add their tools, shared resource tools, and available server instructions. Images consume model input tokens. When images exceed the route budget, [image offload](../../compaction/compaction-image-offload/README.md) permanently replaces older image occurrences with attachment/path placeholders and retries the request or compaction summary. Automatic compaction sends an additional summarization request when context pressure reaches the configured threshold and replaces older model-visible history with its summary; the durable JSONL log retains those events. #### KV Cache effect -Stable for a fixed persona, platform, provider, model, and bundle patch stack. Profile changes take effect on the next process. +Stable for a fixed persona, platform, provider, model, and bundle patch stack. Compaction replaces part of the request history and can reduce cache reuse. Image offload also ends cache reuse at the first changed image. Profile changes take effect on the next process. ## Known Limitations and Deferred Work - **The composition intentionally omits shared product services** — select `dsh --profile sdk` when settings, managed credentials, policy presets, telemetry, Web tools, or the full default tool roster are required. +- **Image reading requires an image-capable model** — select a route whose adapter declares image input; text-only routes return a tool error. Images are stored under `$DSH_HOME/attachments/v1`. +- **Compaction is automatic** — configure [compaction-basic](../../compaction/compaction-basic/README.md) through profile patches. SDK prompt text does not execute `/compact` or other slash commands. - **User patches can expand the tree and corrupt stdout** — profile customization is trusted application composition; a plugin that writes ordinary text to stdout can break JSON-RPC framing. diff --git a/packages/bundle/sdk-minimal/README.zh.md b/packages/bundle/sdk-minimal/README.zh.md index 96de535e19..d35655df20 100644 --- a/packages/bundle/sdk-minimal/README.zh.md +++ b/packages/bundle/sdk-minimal/README.zh.md @@ -1,5 +1,5 @@ --- -description: "独立 SDK profile,提供持久 shell 和 Session 工作目录工具,用于不含共享 base bundle 的极简跨平台 coding agent(编程智能体)。" +description: "独立 SDK profile,提供持久 shell、图片读取、自动上下文压缩和 Session 工作目录工具,用于不含共享 base bundle 的极简跨平台 coding agent(编程智能体)。" kind: "package-bundle" --- @@ -9,7 +9,7 @@ kind: "package-bundle" ## 概述 -当 SDK 客户端需要小型、显式的 coding agent 运行时时,请使用 `dsh --profile sdk-minimal`。该 profile 默认公布按平台选择的持久 shell 和 `working_directory`,把会话持久化为未压缩 JSONL,并从 SDK 初始化请求选择模型。它提供完整 Cordis 配置树,并排除 `dsh-base`、Web、settings、托管凭据、遥测、压缩(compaction)、文件系统工具、workspace 指令、skill(技能)、jobs 与 subagent。其 danger-full-access 策略允许 shell 修改进程可访问的任何路径,因此只能配合隔离 workspace 使用。 +当 SDK 客户端需要小型、显式的 coding agent 运行时时,请使用 `dsh --profile sdk-minimal`。该 profile 默认公布按平台选择的持久 shell、`read_image` 和 `working_directory`,把会话持久化为未压缩 JSONL,并从 SDK 初始化请求选择模型。它提供完整 Cordis 配置树,并排除 `dsh-base`、Web、settings、托管凭据、遥测、文本文件工具、workspace 指令、skill(技能)、jobs 与 subagent。其 danger-full-access 策略允许 shell 修改进程可访问的任何路径,因此只能配合隔离 workspace 使用。 ## 目录 @@ -38,7 +38,7 @@ dsh --profile sdk-minimal 该 profile 只挂载一套持久 shell:Linux 和 macOS 使用 Bash,Windows 使用 PowerShell。两套配置都使用 300 秒超时与一个 agent 自有终端;另一平台的配置项保持禁用。 -与其他随附 profile 一样,它统一挂载 [MCP 资源](../../mcp/mcp-resources/README.zh.md)一次。只需配置 [MCP 客户端条目](../../mcp/mcp-client/README.zh.md)即可添加服务器。其他提供方挂载的客户端也属于已配置状态。调用方作用域中未配置服务器时,MCP 不贡献提示词文本或工具,默认仍为 shell 与 `working_directory` 两个工具。 +与其他随附 profile 一样,它统一挂载 [MCP 资源](../../mcp/mcp-resources/README.zh.md)一次。只需配置 [MCP 客户端条目](../../mcp/mcp-client/README.zh.md)即可添加服务器。其他提供方挂载的客户端也属于已配置状态。调用方作用域中未配置服务器时,MCP 不贡献提示词文本或工具,默认仍为 shell、`read_image` 与 `working_directory` 三个工具。 ----- @@ -48,7 +48,7 @@ dsh --profile sdk-minimal
实现细节——点击展开 -该 bundle 的单个 insert 就是完整应用配置树:SDK stdio 启动与 JSON-RPC 服务、一个由环境配置的 DeepSeek 适配器、显式 agent 核心、按配置启用的 MCP 资源工具、本地文件系统与子进程服务、Session 工作目录状态及其工具、按平台选择的持久 shell PTY,以及位于 `$DSH_HOME/sessions` 的未压缩 JSONL 持久化。它不继承其他 bundle,因此每个额外配置项都是显式 profile 变更。 +该 bundle 的单个 insert 就是完整应用配置树:SDK stdio 启动与 JSON-RPC 服务、一个由环境配置的 DeepSeek 适配器、显式 agent 核心、按配置启用的 MCP 资源工具、本地文件系统、图片附件、自动上下文压缩与子进程服务、Session 工作目录状态及其工具、按平台选择的持久 shell PTY,以及位于 `$DSH_HOME/sessions` 的未压缩 JSONL 持久化。它不继承其他 bundle,因此每个额外配置项都是显式 profile 变更。 ### 源码地图 @@ -78,21 +78,23 @@ dsh --profile sdk-minimal #### 模型看到的内容 -系统提示词取 `DSH_SYSTEM_PROMPT`,未设置时使用 `You are a helpful software engineer assistant.`。未配置 MCP 服务器时,对外公布的工具为 `working_directory`,以及 Linux/macOS 上 agent 所有的持久 `bash` 或 Windows 上的 `pwsh`。必需的用户上下文提供当前目录。可选运行时上下文、文件系统工具、workspace 指令、skill、jobs 控制、压缩与 Harness 身份均不存在。 +系统提示词取 `DSH_SYSTEM_PROMPT`,未设置时使用 `You are a helpful software engineer assistant.`。未配置 MCP 服务器时,对外公布的工具为 `read_image`、`working_directory`,以及 Linux/macOS 上 agent 所有的持久 `bash` 或 Windows 上的 `pwsh`。必需的用户上下文提供当前目录。可选运行时上下文、文本文件工具、workspace 指令、skill、jobs 控制与 Harness 身份均不存在。 #### Token 影响 -默认是一个稳定 persona、两个工具 schema,以及初始目录与后续变更的用户上下文。已配置 MCP 服务器会添加自己的工具、共享资源工具及可用的服务器指令。工具结果与普通对话历史随会话增长。 +默认是一个稳定 persona、三个工具 schema,以及初始目录与后续变更的用户上下文。已配置 MCP 服务器会添加自己的工具、共享资源工具及可用的服务器指令。图片消耗模型输入 token。图片超过路由预算时,[图片省略](../../compaction/compaction-image-offload/README.zh.md)会永久将较早的图片位置替换为包含附件与路径的占位文本,并重试请求或压缩摘要。上下文压力达到配置阈值时,自动压缩会额外发起摘要请求,并用摘要替换较早的模型可见历史;持久 JSONL 日志保留这些事件。 #### KV Cache 影响 -当 persona、平台、提供方、模型与 bundle patch 栈固定时保持稳定。Profile 变更在下一个进程生效。 +当 persona、平台、提供方、模型与 bundle patch 栈固定时保持稳定。压缩会替换部分请求历史,可能降低缓存复用率。图片省略还会使缓存复用止于第一张被修改的图片。Profile 变更在下一个进程生效。 ## 已知限制与延期工作 - **该组合刻意省略共享产品服务** — 需要 settings、托管凭据、策略预设、遥测、Web 工具或完整默认工具清单时,请选择 `dsh --profile sdk`。 +- **图片读取需要支持图片输入的模型** — 选择适配器声明支持图片输入的路由;纯文本路由会返回工具错误。图片存储在 `$DSH_HOME/attachments/v1` 下。 +- **压缩由系统自动执行** — 通过 profile patch 配置 [compaction-basic](../../compaction/compaction-basic/README.zh.md)。SDK 提示词文本不会执行 `/compact` 或其他斜杠命令。 - **用户 patch 可以扩展配置树并破坏 stdout** — profile 自定义属于受信任的应用组合;向 stdout 写入普通文本的插件会破坏 JSON-RPC 分帧。 diff --git a/packages/bundle/sdk-minimal/cordis.patch.yml b/packages/bundle/sdk-minimal/cordis.patch.yml index 6c8dbce78a..79922e7113 100644 --- a/packages/bundle/sdk-minimal/cordis.patch.yml +++ b/packages/bundle/sdk-minimal/cordis.patch.yml @@ -38,6 +38,17 @@ - id: filesystem name: '@deepseek-ai/dsh-fs-local' + - id: attachment-local + name: '@deepseek-ai/dsh-attachment-local' + + - id: tool-fs + name: '@deepseek-ai/dsh-tool-fs' + config: + read: false + readImage: true + write: false + edit: false + - id: working-directory name: '@deepseek-ai/dsh-working-directory' @@ -47,6 +58,15 @@ - id: session-projection name: '@deepseek-ai/dsh-session-projection' + - id: token-meter + name: '@deepseek-ai/dsh-token-meter' + + - id: compaction-basic + name: '@deepseek-ai/dsh-compaction-basic' + + - id: image-offload + name: '@deepseek-ai/dsh-compaction-image-offload' + - id: sandbox-policy name: '@deepseek-ai/dsh-sandbox-policy' config: diff --git a/packages/bundle/sdk-minimal/package.json b/packages/bundle/sdk-minimal/package.json index e952695405..04095beabb 100644 --- a/packages/bundle/sdk-minimal/package.json +++ b/packages/bundle/sdk-minimal/package.json @@ -37,6 +37,9 @@ "@deepseek-ai/cordis-plugin-timer": "workspace:~", "@deepseek-ai/dsh-agent": "workspace:*", "@deepseek-ai/dsh-agent-loop": "workspace:*", + "@deepseek-ai/dsh-attachment-local": "workspace:*", + "@deepseek-ai/dsh-compaction-basic": "workspace:*", + "@deepseek-ai/dsh-compaction-image-offload": "workspace:*", "@deepseek-ai/dsh-deepseek-llm-api-extensions": "workspace:*", "@deepseek-ai/dsh-jobs-local": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*", @@ -57,7 +60,9 @@ "@deepseek-ai/dsh-system-prompt": "workspace:*", "@deepseek-ai/dsh-terminal": "workspace:*", "@deepseek-ai/dsh-terminal-bash": "workspace:*", + "@deepseek-ai/dsh-token-meter": "workspace:*", "@deepseek-ai/dsh-tool-bash-persistent": "workspace:*", + "@deepseek-ai/dsh-tool-fs": "workspace:*", "@deepseek-ai/dsh-tool-pwsh-persistent": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-working-directory": "workspace:*", diff --git a/packages/bundle/sdk-minimal/tests/sdk-minimal.spec.ts b/packages/bundle/sdk-minimal/tests/sdk-minimal.spec.ts index 4194bdf4b0..f4e24e5bc5 100644 --- a/packages/bundle/sdk-minimal/tests/sdk-minimal.spec.ts +++ b/packages/bundle/sdk-minimal/tests/sdk-minimal.spec.ts @@ -34,9 +34,14 @@ describe('dsh-sdk-minimal bundle', () => { ['llm-deepseek', '@deepseek-ai/dsh-llm-deepseek-api-key'], ['sandbox', '@deepseek-ai/dsh-sandbox-local'], ['filesystem', '@deepseek-ai/dsh-fs-local'], + ['attachment-local', '@deepseek-ai/dsh-attachment-local'], + ['tool-fs', '@deepseek-ai/dsh-tool-fs'], ['working-directory', '@deepseek-ai/dsh-working-directory'], ['tool-working-directory', '@deepseek-ai/dsh-tool-working-directory'], ['session-projection', '@deepseek-ai/dsh-session-projection'], + ['token-meter', '@deepseek-ai/dsh-token-meter'], + ['compaction-basic', '@deepseek-ai/dsh-compaction-basic'], + ['image-offload', '@deepseek-ai/dsh-compaction-image-offload'], ['sandbox-policy', '@deepseek-ai/dsh-sandbox-policy'], ['subprocess', '@deepseek-ai/dsh-subprocess-local'], ['pty', '@deepseek-ai/dsh-terminal'], @@ -73,6 +78,8 @@ describe('dsh-sdk-minimal bundle', () => { personaPrefix: { __jsExpr: "process.env.DSH_SYSTEM_PROMPT ?? 'You are a helpful software engineer assistant.'" }, }) expect(rows.find(row => row.id === 'agent-loop')?.config).toEqual({ agents: [] }) + expect(rows.find(row => row.id === 'tool-fs')?.config).toEqual({ read: false, readImage: true, write: false, edit: false }) + expect(rows.find(row => row.id === 'compaction-basic')?.config).toBeUndefined() expect(rows.find(row => row.id === 'terminal-bash')).toMatchObject({ disabled: { __jsExpr: "process.platform === 'win32'" }, }) diff --git a/packages/bundle/web-app/README.i18n.yaml b/packages/bundle/web-app/README.i18n.yaml index 1c6e763915..1d8e07a01e 100644 --- a/packages/bundle/web-app/README.i18n.yaml +++ b/packages/bundle/web-app/README.i18n.yaml @@ -21,11 +21,11 @@ en: e3cac911f080d8ef zh: 60749e9f5c39c1bc /deepseek-ai-dsh-web-app/use-this-package/configuration: - en: 2b09528c8fc8d370 - zh: 7b374ebda8841172 + en: b1787804544f2587 + zh: ce09d2c248ffd999 /deepseek-ai-dsh-web-app/use-this-package/listening-trust-and-public-deployments: - en: 177400de33509826 - zh: dd88bc096accffc8 + en: e6ddea1d2fc40501 + zh: ff08b6fa3fc24136 /deepseek-ai-dsh-web-app/use-this-package/running-over-ssh: en: df205a26b6d49168 zh: e46ed7cf9e06248a @@ -33,38 +33,38 @@ en: ccd6fc264b51e44b zh: 3265c68477a89ec9 /deepseek-ai-dsh-web-app/understand-the-implementation: - en: 2ea88398028ebff8 - zh: 0e589c297aeb9d43 + en: 323742a00cfe67b2 + zh: eff5245a82fc16e7 /deepseek-ai-dsh-web-app/understand-the-implementation/patch-semantics: en: 8a2554b3d17daa1f zh: 66f18946791cabb6 /deepseek-ai-dsh-web-app/understand-the-implementation/advertised-application-url: - en: ca977d2b2bcf72c5 - zh: 2b81b377c133c1a8 + en: 0b22e03e28d41420 + zh: fd45a97dd4336c25 /deepseek-ai-dsh-web-app/understand-the-implementation/readiness: en: 973128fd346869df zh: 54e6792f9f82a192 /deepseek-ai-dsh-web-app/understand-the-implementation/source-map: - en: d4c7f39a9fd90683 - zh: ac2dbf75b2ceb255 + en: 58800b6f2142c34f + zh: 10bf512b16db1cb8 /deepseek-ai-dsh-web-app/further-exploration: en: f81694f7424e2988 zh: 6643f9227039e228 /deepseek-ai-dsh-web-app/model-experience: en: 215e7ba838619b7b zh: a311da3843709f90 -/deepseek-ai-dsh-web-app/model-experience/harness-source-and-web-surface-context: - en: e6febc53efe0f51d - zh: 9177816a8678d65d -/deepseek-ai-dsh-web-app/model-experience/harness-source-and-web-surface-context/what-the-model-sees: - en: 583592075e04687d - zh: 9defddfd77bab0f7 -/deepseek-ai-dsh-web-app/model-experience/harness-source-and-web-surface-context/token-effect: - en: 908fc84d6849a830 - zh: 56fe5e418bab60ba -/deepseek-ai-dsh-web-app/model-experience/harness-source-and-web-surface-context/kv-cache-effect: - en: 1d7d87f54b20b563 - zh: d150a51615d278e8 +/deepseek-ai-dsh-web-app/model-experience/web-runtime-variable: + en: 623bf8a0bf05599d + zh: 398ded2e7b20c1ea +/deepseek-ai-dsh-web-app/model-experience/web-runtime-variable/what-the-model-sees: + en: ebaddc153ad7a835 + zh: 21aa60f73d14f26c +/deepseek-ai-dsh-web-app/model-experience/web-runtime-variable/token-effect: + en: 449fbfa3986f98f0 + zh: a7d66333594ab2dd +/deepseek-ai-dsh-web-app/model-experience/web-runtime-variable/kv-cache-effect: + en: 37542a8b81a744f5 + zh: c0048aaadecae296 /deepseek-ai-dsh-web-app/known-limitations-and-deferred-work: en: 635c1336e2f4e821 zh: caabdecb02e2df14 diff --git a/packages/bundle/web-app/README.md b/packages/bundle/web-app/README.md index b1bce71984..2305d402cd 100644 --- a/packages/bundle/web-app/README.md +++ b/packages/bundle/web-app/README.md @@ -46,13 +46,15 @@ Saved model selections override the composition default. The settings card accep ### Configuration +The `minimal` agent preset uses a persistent shell, the `read_image` tool, and a fixed persona, with automatic history compaction and the idle-session `/compact` command. It shares the Host token meter and owns an isolated compaction backend. [Compaction configuration and retention](../../compaction/compaction-basic/README.md#use-this-package) follow the existing backend. Image reads reuse the Host filesystem and attachment store and require an image-capable model; text read, write, and edit tools remain excluded. + `--host` and `--port` configure the listener; `--tls-cert` and `--tls-key` make it serve HTTPS; `--public-url` names the advertised public HTTP(S) root the GUI is reached at behind a prefix-stripping proxy, and `--trusted-host` adds further accepted authorities. All are described under [Listening, trust, and public deployments](#public-deployments), and the `tls` field those flags set is documented with the [carrier's config](../../../docs/subsystems/web-server.md#config): | Field | Default | Meaning | |---|---|---| | `openBrowser` | `true` | Open the default browser after startup; SSH launches suppress it | | `printUrl` | `true` | Print the `dsh web:` URL line at startup | -| `surfaceContext` | `true` | Give the agent GUI-orientation context and expose `DSH_WEB_URL` to its shell commands | +| `surfaceContext` | `true` | Expose `DSH_WEB_URL` to the agent's shell commands | | `publicUrl` | Unset | Advertised HTTP(S) application root; otherwise announce the bind-address URL | The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-web-app) lists this runtime plugin's accepted fields and their JSDoc. The shipped composition inserts the `schedule` service row and the `ui-schedule` task page row, while the clock reading and the four reminder tools belong to the `standard`, `cordis`, and `ptc` presets. The `tool-subagent` and `tool-subagent-fork` rows in those presets deny the four tools, so a delegated child's scope never lists them. @@ -74,7 +76,7 @@ dsh --profile web --no-open --host "$(hostname -i | awk '{print $1}')" --public- Without `--public-url`, mapped IPv6 loopback binds such as `::ffff:127.0.0.1` advertise their IPv4 form, `127.0.0.1`, so the browser recognizes a local, trustworthy origin. The listener keeps the configured bind address. A dotted-quad IPv6 tail names the address's own low 32 bits, exactly as `listen` reads it, so `::0.0.0.1` is IPv6 loopback and advertises `[::1]`, while `::127.0.0.1` is a non-loopback address, not IPv4 loopback. -`--public-url` advertises the HTTP(S) root browsers use — the printed and opened startup URL, `DSH_WEB_URL`, and the web-surface orientation. Advertisement grants no trust: the browser-visible authority must also be named with `--trusted-host`. The flag configures no listener, routing, or cookie scope, because the proxy owns the external leg: [Publish the Web UI behind a reverse proxy](../../../docs/user/guide/public-deployments.md) lists what such a deployment must provide. +`--public-url` advertises the HTTP(S) root browsers use — the printed and opened startup URL, `DSH_WEB_URL`, and the Web GUI URL in the agent's runtime context. Advertisement grants no trust: the browser-visible authority must also be named with `--trusted-host`. The flag configures no listener, routing, or cookie scope, because the proxy owns the external leg: [Publish the Web UI behind a reverse proxy](../../../docs/user/guide/public-deployments.md) lists what such a deployment must provide. Neither the advertised URL nor the browser-trust fence protects the port itself, so restrict the port to the trusted proxy or network. @@ -96,7 +98,7 @@ Each browser session selects a shipped preset (`standard` by default). The Agent
Implementation internals — click to expand -The bundle is one patch layer of five files plus one runtime glue plugin: `cordis.patch.yml` carries the host rows and the preset registry, and each `presets/.patch.yml` inserts one shipped preset declaration, applied in the order `dsh.bundle.patch` lists them. The storage stack and projection cache come from `dsh-base`; the web overlay's workspace and message-feedback rows consume that shared `storageDomain` service. The patch restates the surface-specific values the base deliberately omits, inserts the web-only host rows and browser roster, then moves the agent plane behind presets. The glue plugin owns dist serving, the advertised application URL, the plain-HTTP exposure warning, prompt sections, the bash variable, and the readiness announcements. The `office-to-pdf` row mounts one lazy [Office conversion provider](../../document/office-to-pdf/README.md) for Host consumers, including Desktop compositions using this bundle. The conversion service's Remote methods authorize preview reads, while Document Preview owns the Office viewer and Client cache. +The bundle is one patch layer of five files plus one runtime glue plugin: `cordis.patch.yml` carries the host rows and the preset registry, and each `presets/.patch.yml` inserts one shipped preset declaration, applied in the order `dsh.bundle.patch` lists them. The storage stack and projection cache come from `dsh-base`; the web overlay's workspace and message-feedback rows consume that shared `storageDomain` service. The patch restates the surface-specific values the base deliberately omits, inserts the web-only host rows and browser roster, then moves the agent plane behind presets. The glue plugin owns dist serving, the advertised application URL, the plain-HTTP exposure warning, the bash variable, and the readiness announcements. The `office-to-pdf` row mounts one lazy [Office conversion provider](../../document/office-to-pdf/README.md) for Host consumers, including Desktop compositions using this bundle. The conversion service's Remote methods authorize preview reads, while Document Preview owns the Office viewer and Client cache. ### Patch semantics @@ -104,7 +106,7 @@ A patch replaces the targeted row's whole `config`, so each web row restates eve ### Advertised application URL -Startup display and browser handoff receive the advertised root with its launch token; the web-surface prompt and `DSH_WEB_URL` receive it clean. The root itself is defined under [Listening, trust, and public deployments](#public-deployments). +Startup display and browser handoff receive the advertised root with its launch token; the agent's runtime context and `DSH_WEB_URL` receive it clean. The root itself is defined under [Listening, trust, and public deployments](#public-deployments). ### Readiness @@ -114,12 +116,12 @@ The URL line and browser handoff are readiness signals: supervisors RPC as soon | File | Role | |---|---| -| [`src/index.ts`](src/index.ts) | The `web-app` glue plugin: dist resolution, advertised application URL, prompt sections, bash variable, URL line, browser handoff | +| [`src/index.ts`](src/index.ts) | The `web-app` glue plugin: dist resolution, advertised application URL, bash variable, URL line, browser handoff | | [`src/public-url.ts`](src/public-url.ts) | Advertised-root validation and trailing-slash normalization; a leaf module for local imports, not package API | | [`src/startup.ts`](src/startup.ts) | The `web-startup` provider: `--host`, `--port`, `--tls-cert`, `--tls-key`, `--public-url`, `--trusted-host`, `--no-open`, `--help` | | [`cordis.patch.yml`](cordis.patch.yml) | The web patch: restated base values, web host rows, browser roster, preset registry | | [`presets/`](presets) | One `@deepseek-ai/dsh-agent-preset` declaration per shipped preset (`standard`, `ptc`, `minimal`, `cordis`), each its own patch file | -| [`tests/web-app.spec.ts`](tests/web-app.spec.ts) | Dist index anchoring, advertised-URL publication and warnings, prompt sections, readiness publication | +| [`tests/web-app.spec.ts`](tests/web-app.spec.ts) | Dist index anchoring, advertised-URL publication and warnings, bash variable, readiness publication | | [`tests/startup.spec.ts`](tests/startup.spec.ts) | Command-line parsing over a real Loader tree | | [`tests/public-url.spec.ts`](tests/public-url.spec.ts) | Advertised-root parsing and normalization | | [`tests/browser-open.spec.ts`](tests/browser-open.spec.ts) | Default-browser handoff after the page is reachable | @@ -144,19 +146,19 @@ Read these pages when you want to go deeper into the shared core, the browser re ## Model Experience -### Harness-source and Web-surface context +### Web runtime variable #### What the model sees -When `surfaceContext` is true, the `harness:source` section identifies the on-disk Harness implementation without claiming it is the working directory, and the `app:web-surface` global section (first-party order 10100, after reusable instructions) orients the model to the GUI: the advertised application URL (defined under Listening, trust, and public deployments above), the "this page" referent, the update contract (the reload receiver is always on; no-refresh reloads additionally need the `pnpm run dev:web` watcher), and the instruction not to start replacement servers. `DSH_WEB_URL` additionally appears in the managed bash environment with its description, resolved per invocation from the live server. When it is false, neither section nor the variable is registered. +When `surfaceContext` is true, `DSH_WEB_URL` appears in the managed bash environment with its description, resolved per invocation as the advertised application URL defined under Listening, trust, and public deployments above. When it is false, the variable is not registered. The GUI orientation, the implementation checkout, and the current GUI URL come from the [`dsh-harness-dev-context`](../../context/harness-dev-context/README.md) row this bundle mounts. #### Token effect -One source line and one prompt paragraph per session plus two managed-environment variable lines; constant per process. +Two managed-environment variable lines; constant per process. #### KV Cache effect -Source and Web sections follow first-party reusable instructions. Different checkout paths or application URLs leave that preceding prefix unchanged when tools and configuration match; provider cache reuse is not guaranteed. +None; the plugin contributes no system-prompt text. ## Known Limitations and Deferred Work diff --git a/packages/bundle/web-app/README.zh.md b/packages/bundle/web-app/README.zh.md index c74fd1ccc0..8d08ede8af 100644 --- a/packages/bundle/web-app/README.zh.md +++ b/packages/bundle/web-app/README.zh.md @@ -46,13 +46,15 @@ dsh --profile web --no-open --port 8080 ### 配置 +`minimal` agent preset 使用持久 Shell、`read_image` 工具和固定 persona,支持自动历史压缩以及会话空闲时的 `/compact` 命令。它复用 Host token meter,并拥有隔离的压缩后端。[压缩配置与保留策略](../../compaction/compaction-basic/README.zh.md#use-this-package) 沿用现有后端。图片读取复用 Host 文件系统和附件存储,要求模型支持图片输入;不加载文本读取、写入和编辑工具。 + `--host` 与 `--port` 配置监听器;`--tls-cert` 与 `--tls-key` 让它直接提供 HTTPS;`--public-url` 指定 GUI 在剥离前缀的代理之后对外公告的公开 HTTP(S) 根,`--trusted-host` 则添加更多被接受的 authority。这些都在[监听、信任与公开部署](#public-deployments)中说明,而这些 flag 设置的 `tls` 字段记录在[载体的配置](../../../docs/subsystems/web-server.zh.md#config)中: | 字段 | 默认值 | 含义 | |---|---|---| | `openBrowser` | `true` | 启动后用默认浏览器打开;SSH 启动会抑制它 | | `printUrl` | `true` | 启动时打印 `dsh web:` URL 行 | -| `surfaceContext` | `true` | 给 agent 提供 GUI 定位上下文,并把 `DSH_WEB_URL` 暴露给其 shell 命令 | +| `surfaceContext` | `true` | 把 `DSH_WEB_URL` 暴露给 agent 的 shell 命令 | | `publicUrl` | 未设置 | 对外公告的 HTTP(S) 应用根;否则公告绑定地址 URL | 生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-web-app)列出了本运行时插件接受的字段及其 JSDoc。随发行版交付的组合插入 `schedule` 服务行与 `ui-schedule` 任务页面行,时钟读数与四个提醒工具则属于 `standard`、`cordis` 与 `ptc` 三个 preset。这三个 preset 的 `tool-subagent` 与 `tool-subagent-fork` 两行都 deny 这四个工具,因此被委派子 agent 的作用域不会列出它们。 @@ -74,7 +76,7 @@ dsh --profile web --no-open --host "$(hostname -i | awk '{print $1}')" --public- 未设置 `--public-url` 时,`::ffff:127.0.0.1` 这样的 mapped IPv6 回环绑定会公告其 IPv4 形式 `127.0.0.1`,让浏览器将其识别为本地可信来源。监听器仍使用配置的绑定地址。点分四段式的 IPv6 尾部表示该地址自身的低 32 位,与 `listen` 的读法完全一致:`::0.0.0.1` 属于 IPv6 回环并公告 `[::1]`,而 `::127.0.0.1` 是非回环地址,并非 IPv4 回环。 -`--public-url` 公告浏览器使用的 HTTP(S) 根——打印与打开的启动 URL、`DSH_WEB_URL` 与 web 表层定位。公告不授予任何信任:浏览器可见的 authority 还必须用 `--trusted-host` 点名。该 flag 不配置监听器、路由或 cookie 作用域,因为外部链路归代理所有:[在反向代理之后发布 Web UI](../../../docs/user/guide/public-deployments.zh.md)列出了这样的部署必须提供什么。 +`--public-url` 公告浏览器使用的 HTTP(S) 根——打印与打开的启动 URL、`DSH_WEB_URL`,以及 agent 运行时上下文中的 Web GUI URL。公告不授予任何信任:浏览器可见的 authority 还必须用 `--trusted-host` 点名。该 flag 不配置监听器、路由或 cookie 作用域,因为外部链路归代理所有:[在反向代理之后发布 Web UI](../../../docs/user/guide/public-deployments.zh.md)列出了这样的部署必须提供什么。 无论是公告 URL 还是浏览器信任栅栏都不保护端口本身,因此请把端口限制在可信代理或网络内。 @@ -96,7 +98,7 @@ dsh --profile web --no-open --host "$(hostname -i | awk '{print $1}')" --public-
实现细节——点击展开 -此 bundle 由一层五个文件的补丁和一个运行时胶水插件组成:`cordis.patch.yml` 承载宿主行和 preset 注册表,每个 `presets/.patch.yml` 插入一条随发行版交付的 preset 声明,按 `dsh.bundle.patch` 列出的顺序应用。存储栈与投影缓存来自 `dsh-base`;Web 叠加层的工作区和消息反馈条目消费共享的 `storageDomain` 服务。补丁重述 base 有意省略的界面专用值,插入 Web 专用宿主条目和浏览器插件列表,再将 Agent 层移到预设后面。胶水插件负责 dist 服务、公告应用 URL、明文 HTTP 暴露警告、提示词段落、bash 变量和就绪通知。`office-to-pdf` 条目为宿主消费者挂载一个延迟创建引擎的 [Office 转换提供方](../../document/office-to-pdf/README.zh.md),使用此 bundle 的 Desktop 组合也共享该提供方。 转换服务的 Remote 方法负责预览读取授权,Document Preview 负责 Office 查看器和客户端缓存。 +此 bundle 由一层五个文件的补丁和一个运行时胶水插件组成:`cordis.patch.yml` 承载宿主行和 preset 注册表,每个 `presets/.patch.yml` 插入一条随发行版交付的 preset 声明,按 `dsh.bundle.patch` 列出的顺序应用。存储栈与投影缓存来自 `dsh-base`;Web 叠加层的工作区和消息反馈条目消费共享的 `storageDomain` 服务。补丁重述 base 有意省略的界面专用值,插入 Web 专用宿主条目和浏览器插件列表,再将 Agent 层移到预设后面。胶水插件负责 dist 服务、公告应用 URL、明文 HTTP 暴露警告、bash 变量和就绪通知。`office-to-pdf` 条目为宿主消费者挂载一个延迟创建引擎的 [Office 转换提供方](../../document/office-to-pdf/README.zh.md),使用此 bundle 的 Desktop 组合也共享该提供方。 转换服务的 Remote 方法负责预览读取授权,Document Preview 负责 Office 查看器和客户端缓存。 ### patch 语义 @@ -104,7 +106,7 @@ patch 会替换目标行的整个 `config`,因此每个 Web 行都重述自己 ### 公告应用 URL -启动显示与浏览器交接接收附带启动 token 的公告根;web 表层提示词与 `DSH_WEB_URL` 接收不含凭据的形式。根本身由[监听、信任与公开部署](#public-deployments)定义。 +启动显示与浏览器交接接收附带启动 token 的公告根;agent 的运行时上下文与 `DSH_WEB_URL` 接收不含凭据的形式。根本身由[监听、信任与公开部署](#public-deployments)定义。 ### 就绪宣告 @@ -114,12 +116,12 @@ URL 行与浏览器交接都是就绪信号:监督方一观察到该行就发 | 文件 | 职责 | |---|---| -| [`src/index.ts`](src/index.ts) | `web-app` 粘合插件:dist 解析、公告应用 URL、提示词段落、bash 变量、URL 行、浏览器交接 | +| [`src/index.ts`](src/index.ts) | `web-app` 粘合插件:dist 解析、公告应用 URL、bash 变量、URL 行、浏览器交接 | | [`src/public-url.ts`](src/public-url.ts) | 公告根的校验与尾斜杠归一化;供本地导入的叶子模块,不属于包 API | | [`src/startup.ts`](src/startup.ts) | `web-startup` 提供方:`--host`、`--port`、`--tls-cert`、`--tls-key`、`--public-url`、`--trusted-host`、`--no-open`、`--help` | | [`cordis.patch.yml`](cordis.patch.yml) | Web patch:重述的基础值、Web 宿主行、浏览器名录、preset 注册表 | | [`presets/`](presets) | 每个随发行版交付的 preset(`standard`、`ptc`、`minimal`、`cordis`)各一条 `@deepseek-ai/dsh-agent-preset` 声明,各自一个补丁文件 | -| [`tests/web-app.spec.ts`](tests/web-app.spec.ts) | dist index 锚定、公告 URL 发布与警告、提示词段落、就绪宣告发布 | +| [`tests/web-app.spec.ts`](tests/web-app.spec.ts) | dist index 锚定、公告 URL 发布与警告、bash 变量、就绪宣告发布 | | [`tests/startup.spec.ts`](tests/startup.spec.ts) | 在真实 Loader 树上的命令行解析 | | [`tests/public-url.spec.ts`](tests/public-url.spec.ts) | 公告根的解析与归一化 | | [`tests/browser-open.spec.ts`](tests/browser-open.spec.ts) | 页面可达后的默认浏览器交接 | @@ -144,19 +146,19 @@ URL 行与浏览器交接都是就绪信号:监督方一观察到该行就发 ## 模型体验 -### Harness 源码与 Web 表层上下文 +### Web 运行时变量 #### 模型看到什么 -当 `surfaceContext` 为 true 时,`harness:source` 段落标明磁盘上的 Harness 实现,但不会声称它就是工作目录;全局段落 `app:web-surface`(first-party 顺序 10100,位于可复用指令之后)则向模型说明 GUI:公告应用 URL(定义见上文「监听、信任与公开部署」)、「this page」指代什么、更新约定(重载接收端始终开启;无刷新重载还需要 `pnpm run dev:web` watcher),以及不要启动替代服务器的指令。`DSH_WEB_URL` 还会连同描述出现在受管 bash 环境中,每次调用时从运行中的服务器解析。当它为 false 时,这两个段落和该变量都不会注册。 +当 `surfaceContext` 为 true 时,`DSH_WEB_URL` 连同描述出现在受管 bash 环境中,每次调用时解析为上文「监听、信任与公开部署」定义的公告应用 URL。为 false 时不注册该变量。GUI 定位、实现 checkout 与当前 GUI URL 由本 bundle 挂载的 [`dsh-harness-dev-context`](../../context/harness-dev-context/README.zh.md) 行提供。 #### Token 影响 -每个会话一行源码说明和一段提示词,外加两行受管环境变量;每个进程内保持恒定。 +两行受管环境变量;每个进程内保持恒定。 #### KV Cache 影响 -源码与 Web 段落位于第一方可复用指令之后。工具与配置一致时,不同 checkout 路径或应用 URL 不会改变前置前缀;不保证提供方复用缓存。 +无;该插件不贡献系统提示词文本。 ## 已知限制与延期工作 diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 987df7c3f7..a1ca0b5483 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -189,14 +189,11 @@ # Web glue owned by this bundle: resolves the built frontend dist (an # assembly fact of dsh-web-app, never user config), mounts the - # frontend-static fallback owner, registers the web-surface prompt - # section and the bash runtime variable, prints the URL line, and opens the - # canonical URL after the full Loader tree settles and the required- - # entry audit passes. Invocation-only values come from the webStartup - # provider; the advertised application URL is this row's `publicUrl`, - # otherwise the bind-address URL. A complete agent-preset persona - # suppresses the prompt section for that agent while retaining the - # host-owned shell variable. + # frontend-static fallback owner, registers the bash runtime variable, + # prints the URL line, and opens the canonical URL after the full Loader + # tree settles and the required-entry audit passes. Invocation-only values + # come from the webStartup provider; the advertised application URL is + # this row's `publicUrl`, otherwise the bind-address URL. - id: web-runtime name: '@deepseek-ai/dsh-web-app' inject: [webStartup] @@ -206,6 +203,14 @@ publicUrl: !!js ctx.webStartup.publicUrl surfaceContext: true + # Model context for an agent that inspects or extends the harness serving + # this GUI: value-free prompt sections plus the current checkout and GUI + # URL as optional runtime context. A complete agent-preset persona replaces + # the sections, and a persona that suppresses optional runtime context + # also drops the two value lines. + - id: harness-dev-context + name: '@deepseek-ai/dsh-harness-dev-context' + # The client-plugin reload chain, always mounted: it is idle until a # rebuild watcher (pnpm run dev:web) actually rewrites client bundles. It # is a row rather than a child of web-runtime because its node half is a @@ -378,6 +383,10 @@ - id: ui-reference name: '@deepseek-ai/dsh-client-ui-reference' + # Display masking for recognized credentials in sent user text; remove + # this entry to draw credentials as written. + - id: ui-secret-mask + name: '@deepseek-ai/dsh-client-ui-secret-mask' # Background jobs: the session-header roster with on-demand streaming # record panels over the session job-output service. diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index dd002b596b..f831a62741 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-web-app", - "description": "The dsh browser-surface bundle: the web patch layer over dsh-base plus the runtime glue plugin (frontend dist serving, web-surface prompt, bash runtime variables, URL line)", + "description": "The dsh browser-surface bundle: the web patch layer over dsh-base plus the runtime glue plugin (frontend dist serving, bash runtime variables, URL line)", "version": "0.2.1-alpha.2", "publishConfig": { "access": "public" @@ -96,6 +96,7 @@ "@deepseek-ai/dsh-client-ui-plugin-manager": "workspace:*", "@deepseek-ai/dsh-client-ui-reference": "workspace:*", "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", + "@deepseek-ai/dsh-client-ui-secret-mask": "workspace:*", "@deepseek-ai/dsh-client-ui-session": "workspace:*", "@deepseek-ai/dsh-client-ui-schedule": "workspace:*", "@deepseek-ai/dsh-client-ui-settings": "workspace:*", @@ -184,13 +185,13 @@ "@deepseek-ai/dsh-client-ui-shortcuts": "workspace:*", "@deepseek-ai/dsh-client-product-analytics": "workspace:*", "@deepseek-ai/dsh-host-product-telemetry-otel": "workspace:*", - "@deepseek-ai/dsh-client-ui-settings-session-log": "workspace:*" + "@deepseek-ai/dsh-client-ui-settings-session-log": "workspace:*", + "@deepseek-ai/dsh-harness-dev-context": "workspace:*" }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/cordis-plugin-loader": "workspace:~", - "@deepseek-ai/dsh-shell-env": "workspace:*", - "@deepseek-ai/dsh-system-prompt": "workspace:*" + "@deepseek-ai/dsh-shell-env": "workspace:*" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:~", @@ -202,7 +203,6 @@ "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-session-projection": "workspace:*", "@deepseek-ai/dsh-shell-env": "workspace:*", - "@deepseek-ai/dsh-system-prompt": "workspace:*", "@deepseek-ai/dsh-typert-registry": "workspace:*", "@deepseek-ai/libreoffice-kit": "^0.1.5", "@deepseek-ai/dsh-otel": "workspace:*" diff --git a/packages/bundle/web-app/presets/cordis.patch.yml b/packages/bundle/web-app/presets/cordis.patch.yml index 8c8d567ae3..75b94016d2 100644 --- a/packages/bundle/web-app/presets/cordis.patch.yml +++ b/packages/bundle/web-app/presets/cordis.patch.yml @@ -95,23 +95,11 @@ provider: spawn toolName: subagent modelSelectionSettings: true - toolFilter: - deny: - - schedule_create - - schedule_delete - - schedule_list - - schedule_update - id: tool-subagent-fork name: '@deepseek-ai/dsh-tool-subagent' config: provider: fork toolName: subagent_fork - toolFilter: - deny: - - schedule_create - - schedule_delete - - schedule_list - - schedule_update - id: workflow-ptc name: '@deepseek-ai/dsh-workflow-ptc' config: diff --git a/packages/bundle/web-app/presets/minimal.patch.yml b/packages/bundle/web-app/presets/minimal.patch.yml index ee36823e27..043e34cb01 100644 --- a/packages/bundle/web-app/presets/minimal.patch.yml +++ b/packages/bundle/web-app/presets/minimal.patch.yml @@ -14,6 +14,23 @@ prefix: You are a helpful software engineer assistant. complete: true includeRuntimeContext: false + - id: tool-fs + name: '@deepseek-ai/dsh-tool-fs' + config: + read: false + readImage: true + write: false + edit: false + - id: compaction + name: cordis:group + group: true + isolate: + compaction: true + config: + - id: compaction-basic + name: '@deepseek-ai/dsh-compaction-basic' + - id: command-compact + name: '@deepseek-ai/dsh-command-compact' - id: persistent-shell name: cordis:group group: true diff --git a/packages/bundle/web-app/presets/ptc.patch.yml b/packages/bundle/web-app/presets/ptc.patch.yml index 8e1ec1fb70..19771435ba 100644 --- a/packages/bundle/web-app/presets/ptc.patch.yml +++ b/packages/bundle/web-app/presets/ptc.patch.yml @@ -96,23 +96,11 @@ provider: spawn toolName: subagent modelSelectionSettings: true - toolFilter: - deny: - - schedule_create - - schedule_delete - - schedule_list - - schedule_update - id: tool-subagent-fork name: '@deepseek-ai/dsh-tool-subagent' config: provider: fork toolName: subagent_fork - toolFilter: - deny: - - schedule_create - - schedule_delete - - schedule_list - - schedule_update - id: workflow-ptc name: '@deepseek-ai/dsh-workflow-ptc' disabled: true diff --git a/packages/bundle/web-app/presets/standard.patch.yml b/packages/bundle/web-app/presets/standard.patch.yml index 6a42d0a351..77c048feb8 100644 --- a/packages/bundle/web-app/presets/standard.patch.yml +++ b/packages/bundle/web-app/presets/standard.patch.yml @@ -96,23 +96,11 @@ provider: spawn toolName: subagent modelSelectionSettings: true - toolFilter: - deny: - - schedule_create - - schedule_delete - - schedule_list - - schedule_update - id: tool-subagent-fork name: '@deepseek-ai/dsh-tool-subagent' config: provider: fork toolName: subagent_fork - toolFilter: - deny: - - schedule_create - - schedule_delete - - schedule_list - - schedule_update - id: workflow-ptc name: '@deepseek-ai/dsh-workflow-ptc' config: diff --git a/packages/bundle/web-app/src/index.ts b/packages/bundle/web-app/src/index.ts index 28e078bd31..b93b559620 100644 --- a/packages/bundle/web-app/src/index.ts +++ b/packages/bundle/web-app/src/index.ts @@ -4,9 +4,9 @@ * manifest field). The plugin owns the browser-surface glue: it resolves * the built frontend dist (workspace knowledge of this bundle, never user * config), mounts the `frontend-static` fallback owner over it, registers the - * harness-source and web-surface prompt sections, the bash-visible web runtime - * variable, the process-token URL line, and the default-browser handoff. An - * advertised `publicUrl` replaces the published bind-address URL. Non-loopback + * bash-visible web runtime variable, the process-token URL line, and the + * default-browser handoff, and provides the `webApp` service. An advertised + * `publicUrl` replaces the published bind-address URL. Non-loopback * HTTP binds trigger an exposure warning. App command-line values arrive * through the `webStartup` service expressions in the bundle patch. * @module @deepseek-ai/dsh-web-app @@ -14,26 +14,38 @@ import { spawn, type ChildProcess } from 'node:child_process' import { createRequire } from 'node:module' -import { isIP } from 'node:net' import { dirname, join } from 'node:path' -import { fileURLToPath } from 'node:url' import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' -import { addHarnessSourceSection, auditStartupEntries } from '@deepseek-ai/dsh-app-boot' +import { auditStartupEntries } from '@deepseek-ai/dsh-app-boot' import type {} from '@deepseek-ai/dsh-client-connection' import * as FrontendStatic from '@deepseek-ai/dsh-host-frontend-static' -import { isLoopbackHost, normalizeBindAddress } from '@deepseek-ai/dsh-host-webserver' +import { bindUrlHost, isLoopbackHost } from '@deepseek-ai/dsh-host-webserver' import { launchedThroughSsh, launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment' import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess' import type {} from '@deepseek-ai/cordis-plugin-loader' import type {} from '@deepseek-ai/dsh-shell-env' import { parsePublicUrl } from './public-url.ts' +/** The Web GUI this process serves, provided by the `web-runtime` row. */ +export interface WebApp { + /** + * The URL this GUI advertises, also used for `DSH_WEB_URL` and the startup URL line. + * @returns the configured `publicUrl`, otherwise the listener origin. + */ + getAdvertisedUrl(): string +} + +declare module '@deepseek-ai/cordis' { + interface Context { + /** The Web GUI this process serves; absent when no Web runtime is mounted. */ + webApp: WebApp + } +} + /** Stable Cordis plugin name. */ export const name = 'web-app' -/** This dsh installation's root, from either this package's source or built entry. */ -const SOURCE_ROOT = fileURLToPath(new URL('../../../..', import.meta.url)) const ANNOUNCED_ROOTS = new WeakSet() /** Services required before the web runtime can mount. */ @@ -46,16 +58,14 @@ export interface Config { /** Print the URL line on activation; a non-interactive layer can turn it off. */ printUrl: boolean /** - * Register the model-visible surface context (the `app:web-surface` prompt - * section and the `DSH_WEB_URL` bash variable). A one-shot non-interactive - * layer can turn it off when its user is not in the GUI, so the - * orientation text would be false. + * Register the `DSH_WEB_URL` bash variable naming this GUI. A one-shot + * non-interactive layer can turn it off when its user is not in the GUI. */ surfaceContext: boolean /** * Canonical HTTP(S) root to advertise in the printed and opened URL, - * `DSH_WEB_URL`, and the web-surface orientation, e.g. - * `https://app.example/ui/`, normalized to end in `/`. Configures no routing + * `DSH_WEB_URL`, and `webApp.getAdvertisedUrl()`, which supplies the Web GUI + * URL in agent runtime context, e.g. `https://app.example/ui/`, normalized to end in `/`. Configures no routing * or authentication; see [public deployments](../README.md#public-deployments). * Absent or YAML `null` advertises the bind-address URL. */ @@ -103,65 +113,6 @@ try { } ` -/** - * Local root URL of the active Web server, using the browser-compatible form - * of its bind address and brackets for IPv6 literals. Non-loopback binds such - * as a container's Pod address advertise that address. - * @throws when the runtime resolves without a bound webServer, or when the bind - * address has no URL form (see {@link advertisedBindHost}). - */ -function localWebUrl(ctx: Context): string { - const webServer = ctx.get('webServer') - const port = webServer?.port - if (webServer === undefined || port === undefined) { - throw new Error('web-app: webServer service missing while resolving Web runtime') - } - const host = advertisedBindHost(webServer.host) - return `${webServer.protocol}//${isIP(host) === 6 ? `[${host}]` : host}:${String(port)}` -} - -/** - * Bind address in a URL. Loopback addresses use their canonical address text, - * so browsers treat them as local, trustworthy origins: a genuinely mapped - * literal such as `::ffff:127.0.0.1` reads as `127.0.0.1`, and a dotted-quad - * tail reads as the address it names, so `::0.0.0.1` reads as `::1` rather - * than as `0.0.0.1`. Interface zone IDs cannot appear in a URL; only a - * redundant loopback zone can be dropped. - * @param host - webserver bind address. - * @returns the zone-free URL host. - * @throws when a non-loopback zone requires an explicit public URL. - */ -function advertisedBindHost(host: string): string { - const zoneAt = host.indexOf('%') - const bare = zoneAt === -1 ? host : host.slice(0, zoneAt) - if (isLoopbackHost(bare)) return normalizeBindAddress(bare) - if (zoneAt === -1) return host - throw new Error( - `web-app: bind address ${JSON.stringify(host)} carries an interface zone id, which no URL can express;` - + ' pass --public-url with the root browsers actually reach it through', - ) -} - -/** Resolve the advertised root: this plugin's canonical `publicUrl`, or the bind-address URL (loopback when bound to loopback). */ -function appRootUrl(ctx: Context, publicUrl: string | undefined): string { - if (publicUrl !== undefined) return publicUrl - return localWebUrl(ctx) -} - -/** Model-visible orientation and acceptance boundary for sessions created through `dsh web`. */ -function webSurfacePrompt(webUrl: string): string { - const updateContract = 'The client-plugin HMR receiver is active, but client-plugin changes reload without a refresh only while ' - + '`pnpm run dev:web` is also running from this same checkout to rebuild their bundles; verify that watcher before promising automatic updates. ' - + 'Every other change — the apps/web shell and plain packages — requires rebuilding the affected Web artifacts and verifying this existing URL after a page refresh. ' - return `You are interacting with the user through the DeepSeek Harness Web GUI at ${webUrl}. ` - + 'When the user refers to "this page", "this GUI", or "this app" without naming another target, they mean this GUI. ' - + 'The browser provides no implicit DOM, route, or screenshot context. ' - + updateContract - + 'Starting another server does not update this GUI. ' - + 'The apps/web Vite entry builds the shell but is not a standalone application because only dsh web injects window.__DSH_BOOT__. ' - + 'Do not start a replacement server unless the user asks; if one is needed, use a managed background job and verify its exact URL.' -} - /** * Dist location is workspace knowledge of this bundle: anchored on the * frontend package manifest, not configured. Existence is a request-time @@ -227,8 +178,8 @@ export const internals: { } = { resolveDistIndex, openBrowser } /** - * Mount the Web runtime: dist serving, surface prompt, the bash runtime - * variable, the URL line, and the default-browser handoff. + * Mount the Web runtime: dist serving, the bash runtime variable, the URL + * line, and the default-browser handoff. * @param ctx - plugin context carrying the webServer service. * @param config - validated {@link Config}. */ @@ -237,30 +188,30 @@ export function apply(ctx: Context, config: Config): void { // the string transform and reaches here, meaning unset. const publicUrl = config.publicUrl ?? undefined const boundHost = ctx.webServer.host - if (publicUrl === undefined) advertisedBindHost(boundHost) + if (publicUrl === undefined && bindUrlHost(boundHost) === undefined) { + throw new Error( + `web-app: bind address ${JSON.stringify(boundHost)} carries an interface zone id, which no URL can express;` + + ' pass --public-url with the root browsers actually reach it through', + ) + } if (ctx.webServer.protocol === 'http:' && !isLoopbackHost(boundHost)) { console.warn(`dsh web: listening on ${boundHost} over plain HTTP; restrict this port to a trusted proxy or network`) } // The bind URL belongs to this host. Under SSH, the operator reaches it // through a local forwarding address that this process cannot derive. const handoffBrowser = config.openBrowser && !launchedThroughSsh(launchEnvironmentOf(ctx)) + /** The advertised root: the canonical `publicUrl`, or the listener's own origin. */ + const appRootUrl = (): string => publicUrl ?? ctx.webServer.getOrigin() + ctx.provide('webApp', { getAdvertisedUrl: appRootUrl } satisfies WebApp) ctx.plugin(FrontendStatic, { distIndex: internals.resolveDistIndex() }) if (config.surfaceContext) { - ctx.inject(['systemPrompt'], (promptCtx) => { - addHarnessSourceSection(promptCtx, SOURCE_ROOT) - promptCtx.systemPrompt.section({ - name: 'app:web-surface', - order: promptCtx.systemPrompt.getSectionOrder('WEB_SURFACE'), - text: () => webSurfacePrompt(appRootUrl(promptCtx, publicUrl)), - }) - }) ctx.inject(['shellEnv'], (runtimeCtx) => { runtimeCtx.shellEnv.register({ name: 'web-runtime', variables: { [DSH_WEB_URL]: { description: 'Advertised URL of the DeepSeek Harness Web GUI serving this session.' }, }, - resolve: () => ({ [DSH_WEB_URL]: appRootUrl(runtimeCtx, publicUrl) }), + resolve: () => ({ [DSH_WEB_URL]: appRootUrl() }), }) }) } @@ -273,7 +224,7 @@ export function apply(ctx: Context, config: Config): void { // hand-built tree without a Loader is already the complete tree. const announceReady = (): void => { if (ANNOUNCED_ROOTS.has(connectionCtx.root)) return - const webUrl = appRootUrl(connectionCtx, publicUrl) + const webUrl = appRootUrl() const authenticatedUrl = connectionCtx.connection.authenticatedUrl(webUrl) ANNOUNCED_ROOTS.add(connectionCtx.root) if (config.printUrl) { diff --git a/packages/bundle/web-app/src/startup.ts b/packages/bundle/web-app/src/startup.ts index f24e4ea386..3152e085fb 100644 --- a/packages/bundle/web-app/src/startup.ts +++ b/packages/bundle/web-app/src/startup.ts @@ -64,7 +64,7 @@ function webCommand(): Command { .option('--host ', 'bind address: one concrete IPv4 or IPv6 literal of a local interface; wildcard addresses are rejected') .option('--no-open', 'do not open the Web UI in the default browser') .option('--port ', 'listen port; pass 0 to let the OS pick a free one') - .option('--public-url ', 'advertise this HTTP(S) root in the printed, opened, web-surface, and DSH_WEB_URL forms; grants no trust') + .option('--public-url ', 'advertise this HTTP(S) root in the printed, opened, runtime-context, and DSH_WEB_URL forms; grants no trust') .option('--trusted-host ', 'extra authority the /api browser-trust fence accepts (host or host:port; repeatable)') .option('--tls-cert ', 'PEM server certificate chain; requires --tls-key') .option('--tls-key ', 'unencrypted PEM private key; requires --tls-cert') diff --git a/packages/bundle/web-app/tests/web-app.spec.ts b/packages/bundle/web-app/tests/web-app.spec.ts index e1c21573ac..67a350355d 100644 --- a/packages/bundle/web-app/tests/web-app.spec.ts +++ b/packages/bundle/web-app/tests/web-app.spec.ts @@ -11,8 +11,7 @@ import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' import * as AppBoot from '@deepseek-ai/dsh-app-boot' import { createLaunchEnvironmentSnapshot, DSH_LAUNCH_ENVIRONMENT_KEY } from '@deepseek-ai/dsh-launch-environment' -import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import type { WebServer } from '@deepseek-ai/dsh-host-webserver' +import { bindUrlHost, type WebServer } from '@deepseek-ai/dsh-host-webserver' import { apply, Config, internals } from '../src/index.ts' vi.mock('node:child_process', async importOriginal => ({ @@ -59,10 +58,11 @@ function stageDist(): string { /** Web carrier without a network listener. */ function fakeHttpServer(host = '127.0.0.1'): { server: WebServer } { - const server: Pick = { + const server: Pick = { host, port: 4567, protocol: 'http:', + getOrigin() { return `http://${bindUrlHost(host) ?? ''}:${String(this.port)}` }, registerFallback: () => () => {}, renderIndex: (html: string) => html, } @@ -126,7 +126,6 @@ describe('web-app runtime glue', () => { const openBrowser = vi.fn(async (url: string) => { lifecycle.push(`open:${url}`) }) internals.openBrowser = openBrowser apply(ctx, new Config({ openBrowser: true, printUrl: true, surfaceContext: true })) - await ctx.plugin(SystemPrompt, { personaPrefix: '' }) await vi.waitFor(() => { expect(openBrowser).toHaveBeenCalled() }) expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567/?token=test-token') @@ -137,15 +136,9 @@ describe('web-app runtime glue', () => { 'dsh web: opening the default browser; pass --no-open to disable', 'open:http://127.0.0.1:4567/?token=test-token', ]) - const assembly = await ctx.systemPrompt.assemble() - expect(assembly.sections.find(entry => entry.name === 'harness:source')?.text).toContain('DeepSeek Harness implementation checkout') - const section = assembly.sections.find(entry => entry.name === 'app:web-surface') - expect(section?.text).toContain('http://127.0.0.1:4567') - // The single update contract: the receiver is always on; no-refresh - // reloads additionally need the rebuild watcher. - expect(section?.text).toContain('pnpm run dev:web') const webRuntime = contributions.find(contribution => contribution.name === 'web-runtime') expect(webRuntime?.resolve()).toEqual({ DSH_WEB_URL: 'http://127.0.0.1:4567' }) + expect(ctx.webApp.getAdvertisedUrl()).toBe('http://127.0.0.1:4567') }) it('advertises and opens the configured public root behind a prefix-stripping proxy', async () => { @@ -173,7 +166,6 @@ describe('web-app runtime glue', () => { surfaceContext: true, publicUrl: 'https://proxy.example:8443/web', })) - await ctx.plugin(SystemPrompt, { personaPrefix: '' }) await vi.waitFor(() => { expect(openBrowser).toHaveBeenCalled() }) expect(lifecycle).toEqual([ @@ -181,12 +173,10 @@ describe('web-app runtime glue', () => { 'dsh web: opening the default browser; pass --no-open to disable', 'open:https://proxy.example:8443/web/?token=test-token', ]) - const assembly = await ctx.systemPrompt.assemble() - const section = assembly.sections.find(entry => entry.name === 'app:web-surface') - expect(section?.text).toContain('https://proxy.example:8443/web/') - expect(section?.text).not.toContain('127.0.0.1') const urlContribution = contributions.find(contribution => contribution.name === 'web-runtime') expect(urlContribution?.resolve()).toEqual({ DSH_WEB_URL: 'https://proxy.example:8443/web/' }) + // The service advertises the row's effective publicUrl, whether it came from --public-url or a profile override. + expect(ctx.webApp.getAdvertisedUrl()).toBe('https://proxy.example:8443/web/') }) it('publishes no readiness side effect when printing and browser opening are disabled', async () => { @@ -198,17 +188,13 @@ describe('web-app runtime glue', () => { const openBrowser = vi.fn(async () => {}) internals.openBrowser = openBrowser apply(ctx, new Config({ openBrowser: false, printUrl: false, surfaceContext: true })) - await ctx.plugin(SystemPrompt, { personaPrefix: '' }) await new Promise(resolve => setTimeout(resolve, 0)) expect(log).not.toHaveBeenCalled() expect(openBrowser).not.toHaveBeenCalled() - const assembly = await ctx.systemPrompt.assemble() - expect(assembly.sections.find(entry => entry.name === 'app:web-surface')?.text) - .toContain('rebuilding the affected Web artifacts') await ctx.fiber.dispose() }) - it('skips the surface context when disabled (the one-shot layer): no prompt section, no bash variables', async () => { + it('skips the surface context when disabled (the one-shot layer): no bash variables', async () => { stageDist() const ctx = new Context() ctx.provide('webServer', fakeHttpServer().server) @@ -221,11 +207,7 @@ describe('web-app runtime glue', () => { }, } as never) apply(ctx, new Config({ openBrowser: false, printUrl: false, surfaceContext: false })) - await ctx.plugin(SystemPrompt, { personaPrefix: '' }) await new Promise(resolve => setTimeout(resolve, 0)) - const assembly = await ctx.systemPrompt.assemble() - expect(assembly.sections.some(entry => entry.name === 'app:web-surface')).toBe(false) - expect(assembly.sections.some(entry => entry.name === 'harness:source')).toBe(false) expect(contributions).toEqual([]) await ctx.fiber.dispose() }) @@ -465,22 +447,6 @@ describe('web-app runtime glue', () => { } }) - it('fails loud when the prompt section resolves against a portless webserver', async () => { - stageDist() - const ctx = new Context() - // A webserver whose bound port is gone (torn down mid-request): the - // section must throw, never render a URL with an undefined port. - const { server } = fakeHttpServer() - Object.defineProperty(server, 'port', { get: () => undefined }) - ctx.provide('webServer', server) - provideConnection(ctx) - apply(ctx, new Config({ openBrowser: false, printUrl: false, surfaceContext: true })) - await ctx.plugin(SystemPrompt, { personaPrefix: '' }) - await new Promise(resolve => setTimeout(resolve, 0)) - await expect(ctx.systemPrompt.assemble()).rejects.toThrow('webServer service missing') - await ctx.fiber.dispose() - }) - it('anchors the dist index on the frontend package manifest without requiring a built dist', () => { // The production resolver (not the test hook): the anchor resolves on any // checkout, built or not — dist existence is the fallback owner's diff --git a/packages/bundle/web-app/tsconfig.json b/packages/bundle/web-app/tsconfig.json index d4a74c09ba..2f7f148e60 100644 --- a/packages/bundle/web-app/tsconfig.json +++ b/packages/bundle/web-app/tsconfig.json @@ -35,9 +35,6 @@ { "path": "../../util/launch-environment" }, - { - "path": "../../core/system-prompt" - }, { "path": "../../shell/shell-env" }, diff --git a/packages/client/AGENTS.md b/packages/client/AGENTS.md index 3d38aa71fd..16956ca71d 100644 --- a/packages/client/AGENTS.md +++ b/packages/client/AGENTS.md @@ -69,6 +69,7 @@ Npm sections describe installation and development relationships; each build fac 5. **Only Host runtime libraries require installation dependencies.** Browser-only third-party implementations belong in `devDependencies`, whether inlined into `lib/client.js` or retained as bare imports in a static browser library. Static libraries are Web-shell build inputs; the final Vite build merges their imports and styles. Distributed browser code remains subject to runtime license disclosure ([reference](../../.agents/notes/implemented/process/2026-08-26-published-dependency-faces.md)). 6. **Browser and Node build faces declare externality independently.** A dynamic browser half uses the baseline plus `dsh.client.external`; a statically linked face externalizes every bare specifier; a Node face externalizes its production dependencies ([`tsdown.client.ts`](tsdown.client.ts)). Moving a name between npm sections must not silently change bundle contents. 7. **Keep the published payload closed.** Every relative runtime import and emitted asset must be covered by `files`; the repository publint pass checks the exact publication view. +8. **Only the root manifest declares React type packages.** `@types/react` and `@types/react-dom` belong only in the root `package.json` `devDependencies`, and `verify-package-dependencies` rejects them in every manifest under `apps/*`, `packages/*/*`, and `vendor/*`; packages still declare the `react` and `react-dom` they import. Each package-local pnpm link is one more location TypeScript scans when it computes module specifiers for every JSX file ([rationale](../../.agents/notes/implemented/process/2026-10-11-lossless-build-speedups.md#1-only-the-root-manifest-declares-react-type-packages)). ## Build-time browser environment @@ -142,7 +143,7 @@ If `test:gui` is red on code you did not touch, neither silently fix nor ignore Bringing up a new `packages/client/` plugin package (ui-workspace is a complete example; ui-sidebar/ui-user-questions are minimal skeletons): -1. **Package skeleton**: `package.json` (`@deepseek-ai/dsh-client-`, exports `.`/`./client`/`./src/*`/`./package.json`, `dsh.client` manifest, `files` list), `tsconfig.json` (extends `tsconfig.base.client.json`, one `references` entry per workspace dependency), `tsdown.config.ts` (`clientBundle(id, ['lib/types/index.js'])`), `src/index.ts` (empty node-half apply), `src/css-modules.d.ts` when using CSS Modules, and `README.md` with the Model Experience section. +1. **Package skeleton**: `package.json` (`@deepseek-ai/dsh-client-`, exports `.`/`./client`/`./src/*`/`./package.json`, `dsh.client` manifest, `files` list), `tsconfig.json` (extends `tsconfig.base.client.json`, one `references` entry per workspace dependency), `tsdown.config.ts` (`clientBundle(import.meta.dirname, ['lib/types/index.js'])`), `src/index.ts` (empty node-half apply), `src/css-modules.d.ts` when using CSS Modules, and `README.md` with the Model Experience section. 2. **Three registration surfaces, all required** (missing any one fails at a different, later point): the `tsconfig.client.json` aggregate `references` entry; a `dsh.client` row in `packages/bundle/web-app/cordis.patch.yml`; a `packages/bundle/web-app/package.json` dependency (profile boots resolve bare row names through the computed runtime resolution, which is installed as an interception on Node's resolvers — a row whose package no manifest declares fails to import). `pnpm-workspace.yaml` already globs `packages/*/*`. 3. **dsh.client manifest semantics**: `platform: 'web'` always, and the declaration requires a `./client` export (the scan throws without one); `immediately: true` only for stage-one-prefetch infrastructure rows. `inject` lists package-name dependency edges — they are **informational only** (preflight display, HMR diffing); they do not sequence entry activation or apply order. Activation order is Cordis fiber inject waiting on *services*, nothing else. A non-baseline `external` request sequences its dynamic supplier ahead of the consumer — see [shared modules](#shared-modules-and-the-module-graph). 4. **Registering into another package's slot**: apply order is unconstrained, and a business service is not a declaration barrier. Use `ctx.slots.inject(name, () => ctx.slots.register(...))`; it waits on the actual declaration, removes the contribution when that declaration collapses, reruns after redeclaration, and leaves with the caller's plugin fiber. Return a generator yielding each registration when several contributions must install and roll back atomically. A bare `slots.register` into an undeclared slot remains an error; keep service edges only for services the contribution actually reads. diff --git a/packages/client/README.i18n.yaml b/packages/client/README.i18n.yaml index 766b7d16d0..f4e03603ff 100644 --- a/packages/client/README.i18n.yaml +++ b/packages/client/README.i18n.yaml @@ -15,8 +15,8 @@ en: 1eb35b015a6c423f zh: a8a3dceee2c9e943 /client-web-gui-browser-half/packages: - en: 1aa93b8088fb1962 - zh: 10feaed78bc6408c + en: e0bdfa58440be810 + zh: a006bb2e73f3f264 /client-web-gui-browser-half/related-documentation: en: d1d506e68fab7333 zh: 1a90fe6f3f8f24a9 diff --git a/packages/client/README.md b/packages/client/README.md index 04eb19d8f7..19ed68cdbc 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -63,6 +63,7 @@ The kernel packages boot and serve the page; the UI feature packages present it. | [`ui-input-trigger/`](ui-input-trigger/README.md) | Coordinates inline command and reference suggestions | — | | [`ui-skill/`](ui-skill/README.md) | Adds skill references to inline suggestions | — | | [`ui-reference/`](ui-reference/README.md) | Unified Web `@file` / `@session` reference source | — | +| [`ui-secret-mask/`](ui-secret-mask/README.md) | Masks recognized credentials in sent user text | — | | [`ui-subagent/`](ui-subagent/README.md) | Provides subagent navigation, child transcript states, and inline references | — | | [`ui-jobs/`](ui-jobs/README.md) | Lists this session's background jobs with on-demand streaming record panels | — | | [`ui-schedule/`](ui-schedule/README.md) | Lists the current Session's active reminders in a read-only header catalog | — | diff --git a/packages/client/README.zh.md b/packages/client/README.zh.md index 2ebc91a3e1..1ef85c42b5 100644 --- a/packages/client/README.zh.md +++ b/packages/client/README.zh.md @@ -63,6 +63,7 @@ kind: "package-group" | [`ui-input-trigger/`](ui-input-trigger/README.zh.md) | 协调内联命令与引用建议 | — | | [`ui-skill/`](ui-skill/README.zh.md) | 向内联建议添加 skill(技能)引用 | — | | [`ui-reference/`](ui-reference/README.zh.md) | 统一的 Web `@file` / `@session` 引用 source | — | +| [`ui-secret-mask/`](ui-secret-mask/README.zh.md) | 遮挡已发送用户文本中可识别的凭据 | — | | [`ui-subagent/`](ui-subagent/README.zh.md) | 提供 subagent 导航、子级 transcript(文本记录)状态与内联引用 | — | | [`ui-jobs/`](ui-jobs/README.zh.md) | 列出本会话的后台任务,并提供按需展开的流式输出面板 | — | | [`ui-schedule/`](ui-schedule/README.zh.md) | 在只读标题栏目录中列出当前会话中生效的提醒 | — | diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index 9efedba955..851c94ecbc 100644 --- a/packages/client/connection/README.i18n.yaml +++ b/packages/client/connection/README.i18n.yaml @@ -18,8 +18,8 @@ en: 13b54969ff1d00a4 zh: 4d2ffff0445472ed /deepseek-ai-dsh-client-connection/browser-authentication-and-request-trust: - en: 44956be3cc11908a - zh: d518f765d58f3558 + en: dbd48251ac03d968 + zh: 6323c09d75e56ecc /deepseek-ai-dsh-client-connection/connection-generation: en: 116f94e44c0d87e7 zh: 59ef48984154d858 diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index d6cb58d42c..a5b4d4cc85 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -44,7 +44,7 @@ HTTPS cookie names and signed audiences include the `https:` scheme, so HTTP coo Before authentication, every request passes `src/api-request-trust.ts`. Its `Host` must be loopback, equal the listener's bind IP literal on any port, or match a `trustedHosts` entry: exact on `host:port`, any port on port-less entries. Host and configured entries use the listener's scheme for WHATWG default-port normalization, not a forwarded header or `Origin`. An attached HTTP(S) `Origin` must name the same authority, with Host parsed under that Origin's scheme to support HTTPS proxies over HTTP upstreams; `sec-fetch-site: cross-site` is refused. Malformed configured authorities fail plugin load. `allowsRemoteAuthorities` reports whether `trustedHosts` permits a non-loopback authority; accepting the bind IP does not change that list. These checks defend DNS rebinding and cross-site browser requests; they never establish identity. A failed Host/Origin check returns 403, while a trusted but unauthenticated request returns 401. `dsh web --host 0.0.0.0` is unsupported. Decision records: [browser request trust](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md) and [browser token authentication](../../../.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.md). -Every admitted request speaks for one Peer, the operator. `ctx.connection.operator` is that `PeerScope`: its `ctx` is a Cordis scope that owns connection-lifetime registrations and is disposed with the Connection. `ctx.connection.admit(request)` runs the trust and authentication checks and answers with the rejection status or the operator; the `/api` route and the Gateway's WebSocket upgrade admit through it, and every RPC handler receives the Peer of its call. `OperatorPeer` is exported so a composition without Connection, such as the Gateway's in-process carrier, owns an operator scope with the same contract. +Every request speaks for one Peer, a `PeerScope` whose `principal` names the caller and whose `signal` aborts when its scope ends. `ctx.connection.browserPeer` is the Peer every authenticated browser connection shares; its principal is `{ kind: 'owner' }` and its scope is disposed with the Connection. `ctx.connection.admit(request)` runs the trust and authentication checks and answers with the rejection status or `browserPeer`; the `/api` route and the Gateway's WebSocket upgrade admit through it, and every RPC handler receives the Peer of its call. `ctx.connection.createPeer({ principal })` creates an independent in-process Peer, disposed with the Context that read `ctx.connection` or earlier by its own `dispose()`; no route, header, query, or cookie can create one or set its principal. The exported `Peer` class implements both, and a composition without Connection constructs it directly as `new Peer(owner, { principal })`. `fetch(request, peer)` and the HTTP bridge take the Peer explicitly; an exact route answers 403 to a Peer whose principal is not the owner. Method-level access for other principals belongs to [`dsh-api-remote-access`](../../api/remote-access/README.md). Authenticated shared HTTP requests pass through the `connection/request` waterfall before body transfer. A listener may refuse new requests or await `next()` through response completion; removing its owning fiber removes admission behavior. Desktop uses this hook to lock new API work during an approved installation without canceling already-admitted work. Client disconnection aborts the handler signal; the bridge stops socket writes and drains any remaining response chunks. WebSocket stream ownership remains with API Gateway. diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index 6ec954f84d..cb23e015bd 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -44,7 +44,7 @@ HTTPS cookie 名称与签名 audience 包含 `https:` scheme,因此即使 host 认证之前,每个请求都经过 `src/api-request-trust.ts`。其 `Host` 必须是 loopback、等于监听器的绑定 IP 字面量(端口不限),或与 `trustedHosts` 条目匹配:带端口的 `host:port` 精确匹配,不带端口的条目匹配任意端口。Host 与配置条目按监听器的 scheme 做 WHATWG 默认端口归一化,不采信转发 header 或 `Origin`。附带的 HTTP(S) `Origin` 必须指向同一 authority;此时按 Origin 的 scheme 解析 Host,以支持 HTTPS 代理转发到 HTTP 上游;`sec-fetch-site: cross-site` 一律拒绝。畸形配置 authority 会让插件加载失败。`allowsRemoteAuthorities` 报告 `trustedHosts` 是否允许非 loopback authority;接受绑定 IP 不会改变该列表。这些检查防御 DNS rebinding 与跨站浏览器请求,绝不建立身份。Host/Origin 校验失败返回 403;Host 可信但未认证的请求返回 401。`dsh web --host 0.0.0.0` 不受支持。决策记录:[浏览器请求信任](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md)与[浏览器令牌认证](../../../.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.zh.md)。 -每个被接纳的请求都代表同一个 Peer——操作者。`ctx.connection.operator` 就是这个 `PeerScope`:其 `ctx` 是拥有连接期注册的 Cordis scope,随 Connection 一起释放。`ctx.connection.admit(request)` 执行信任与认证检查,以拒绝状态或操作者作答;`/api` 路由与 Gateway 的 WebSocket 升级都经它接纳,每个 RPC 处理器都收到本次调用的 Peer。`OperatorPeer` 对外导出,供没有 Connection 的组合(例如 Gateway 的进程内载体)以同一约定拥有一个操作者 scope。 +每个请求都代表一个 Peer:一个 `PeerScope`,其 `principal` 指明调用方,其 `signal` 在 scope 结束时中止。`ctx.connection.browserPeer` 是所有通过认证的浏览器连接共享的 Peer;其 principal 为 `{ kind: 'owner' }`,scope 随 Connection 一起释放。`ctx.connection.admit(request)` 执行信任与认证检查,以拒绝状态或 `browserPeer` 作答;`/api` 路由与 Gateway 的 WebSocket 升级都经它接纳,每个 RPC 处理器都收到本次调用的 Peer。`ctx.connection.createPeer({ principal })` 创建一个独立的进程内 Peer,随读取 `ctx.connection` 的 Context 释放,或更早经其自身的 `dispose()` 释放;任何路由、header、query 或 cookie 都无法创建它或设置其 principal。导出的 `Peer` 类实现这两者,没有 Connection 的组合直接以 `new Peer(owner, { principal })` 构造。`fetch(request, peer)` 与 HTTP bridge 显式接收 Peer;对 principal 不是 owner 的 Peer,exact route 返回 403。其他 principal 的方法级访问归 [`dsh-api-remote-access`](../../api/remote-access/README.zh.md)。 通过认证的共享 HTTP 请求在传输请求体之前经过 `connection/request` waterfall。监听器可以拒绝新请求,或等待 `next()` 直到响应完成;释放所属 fiber 会移除准入行为。Desktop 使用此扩展点,在已批准的安装期间锁住新的 API 工作,而不取消已接纳的工作。客户端断开会中止处理函数的信号;桥接器停止写入 socket,并排空剩余响应块。WebSocket 流仍由 API Gateway 负责。 diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index e9d80a64f4..910cea58c5 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -96,7 +96,7 @@ export interface ClientTransportHooks { /** * The transport owner declares the page owns the Host outright: the Host * runs inside a worker this page spawned, so no other party can reach it and - * the loopback stand-in for "the operator's own machine" is vacuous. + * the loopback stand-in for "the owner's own machine" is vacuous. * `ctx.connection.isLoopback` then reports the privileged surface reachable * regardless of the page authority. Only a shell that assembles its own * transport can set this; served pages never carry the global at all. diff --git a/packages/client/connection/src/http-bridge.ts b/packages/client/connection/src/http-bridge.ts index 713f53dcea..2343b1c38b 100644 --- a/packages/client/connection/src/http-bridge.ts +++ b/packages/client/connection/src/http-bridge.ts @@ -5,6 +5,7 @@ import type { IncomingMessage } from 'node:http' import { Readable } from 'node:stream' +import type { PeerScope } from '@deepseek-ai/dsh-typert-protocol' import type { ConnectionFetchHandler } from './rpc.ts' /** Default carrier cap for all HTTP RPC bodies: sized for the default @@ -30,12 +31,14 @@ interface BridgeServerResponse { * @param req - incoming node:http request. * @param res - node:http response the bridge writes and owns to completion. * @param apiHandler - fetch-shaped API carrier the request is dispatched to. + * @param peer - Peer the carrier admitted the request as. * @param maxRequestBodyBytes - maximum bytes buffered for a buffered route. */ export async function bridge( req: IncomingMessage, res: BridgeServerResponse, apiHandler: ConnectionFetchHandler, + peer: PeerScope, maxRequestBodyBytes = DEFAULT_MAX_REQUEST_BODY_BYTES, ): Promise { const abort = new AbortController() @@ -91,7 +94,7 @@ export async function bridge( duplex: 'half', } as RequestInit & { duplex: 'half' }) } - const response = await apiHandler.fetch(request) + const response = await apiHandler.fetch(request, peer) const requestUnread = bodyMode === 'streaming' && !req.readableEnded const responseHeaders = Object.fromEntries(response.headers.entries()) res.writeHead(response.status, requestUnread ? { ...responseHeaders, connection: 'close' } : responseHeaders) diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index 34e15f90a6..ce58da1606 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -36,9 +36,10 @@ export type { RpcMessage, ServerResponse, } from './rpc.ts' -export type { PeerId, PeerScope, RemoteInvocation } from '@deepseek-ai/dsh-typert-protocol' +export type { PeerId, PeerScope, Principal, RemoteInvocation } from '@deepseek-ai/dsh-typert-protocol' export { RpcId, transportError } from './rpc.ts' -export { OperatorPeer } from './operator-peer.ts' +export { Peer } from './peer.ts' +export type { PeerOptions } from './peer.ts' export { clientRequestSchema, rpcErrorSchema, @@ -150,7 +151,7 @@ export async function apply(ctx: Context, config?: ConnectionConfig): Promise bridge(req, res, fetchHandler, maxRequestBodyBytes)) + await webCtx.waterfall('connection/request', req, res, () => bridge(req, res, fetchHandler, admission.peer, maxRequestBodyBytes)) }, } webCtx.effect(() => webCtx.webServer.register(route), 'client-connection: /api route') diff --git a/packages/client/connection/src/operator-peer.ts b/packages/client/connection/src/operator-peer.ts deleted file mode 100644 index 83325f78e7..0000000000 --- a/packages/client/connection/src/operator-peer.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * The operator Peer: the one party this Host answers to. Connection owns it - * for its own lifetime, admits every request as it, and hands it to each - * Remote call as `invocation.peer`. - * @module @deepseek-ai/dsh-client-connection/src/operator-peer - */ - -import { randomUUID } from 'node:crypto' -import type { Context } from '@deepseek-ai/cordis' -import { createScope, type Scope } from '@deepseek-ai/dsh-scope' -import type { PeerId, PeerScope } from '@deepseek-ai/dsh-typert-protocol' - -/** - * The operator's scope. The instance is its own scope key, so `scopeOf(peer.ctx)` - * returns it and events dispatched with `scopeTarget(subject, peer)` reach - * listeners registered through `peer.ctx` and nobody else. - */ -export class OperatorPeer implements PeerScope { - readonly id: PeerId = randomUUID() as PeerId - readonly ctx: Context - private readonly scope: Scope - - /** @param owner - Connection plugin context the scope fiber hangs under. */ - constructor(owner: Context) { - this.scope = createScope(owner, this) - this.ctx = this.scope.ctx - } - - /** Tear down every connection-lifetime registration; racing calls share one completion. */ - dispose(): Promise { - return this.scope.dispose() - } -} diff --git a/packages/client/connection/src/peer.ts b/packages/client/connection/src/peer.ts new file mode 100644 index 0000000000..991ac521ba --- /dev/null +++ b/packages/client/connection/src/peer.ts @@ -0,0 +1,49 @@ +/** + * The Peer: one party admitted to this Host. Its creator fixes the principal + * it speaks for and owns its lifetime; Gateway hands it to each Remote call as + * `invocation.peer`. + * @module @deepseek-ai/dsh-client-connection/src/peer + */ + +import { randomUUID } from 'node:crypto' +import type { Context } from '@deepseek-ai/cordis' +import { createScope, type Scope } from '@deepseek-ai/dsh-scope' +import type { PeerId, PeerScope, Principal } from '@deepseek-ai/dsh-typert-protocol' + +/** Construction options for one {@link Peer}. */ +export interface PeerOptions { + /** Caller identity every call through this Peer speaks for. */ + readonly principal: Principal +} + +/** + * One Peer's scope. The instance is its own scope key, so `scopeOf(peer.ctx)` + * returns it and events dispatched with `scopeTarget(subject, peer)` reach + * listeners registered through `peer.ctx` and nobody else. + */ +export class Peer implements PeerScope { + readonly id: PeerId = randomUUID() as PeerId + readonly ctx: Context + readonly principal: Principal + readonly signal: AbortSignal + private readonly scope: Scope + + /** + * @param owner - Context the scope fiber hangs under; disposing it disposes the Peer. + * @param options - the principal fixed for this Peer's lifetime. + */ + constructor(owner: Context, options: PeerOptions) { + this.principal = options.principal + this.scope = createScope(owner, this) + this.ctx = this.scope.ctx + const lifetime = new AbortController() + this.signal = lifetime.signal + // Runs however the scope ends: dispose() or the owner Context unloading. + this.ctx.effect(() => () => { lifetime.abort(new Error(`Peer ${this.id} was disposed`)) }, 'client-connection: Peer lifetime') + } + + /** Tear down every connection-lifetime registration; racing calls share one completion. */ + dispose(): Promise { + return this.scope.dispose() + } +} diff --git a/packages/client/connection/src/rpc-host.ts b/packages/client/connection/src/rpc-host.ts index 44862c770a..33562d790f 100644 --- a/packages/client/connection/src/rpc-host.ts +++ b/packages/client/connection/src/rpc-host.ts @@ -2,7 +2,7 @@ import { Context, Service } from '@deepseek-ai/cordis' import type { WebRoute } from '@deepseek-ai/dsh-host-webserver' -import type { PeerScope } from '@deepseek-ai/dsh-typert-protocol' +import type { PeerScope, Principal } from '@deepseek-ai/dsh-typert-protocol' import { RpcId, type ClientRequest, @@ -13,7 +13,7 @@ import { bridge } from './http-bridge.ts' import { isRemoteAuthority, isTrustedApiRequest } from './api-request-trust.ts' import { API_PATH } from './api-path.ts' import type { BrowserAuth } from './browser-auth.ts' -import { OperatorPeer } from './operator-peer.ts' +import { Peer } from './peer.ts' import type { PeerAdmission, ConnectionIndexRequest, @@ -61,8 +61,8 @@ declare module '@deepseek-ai/cordis' { /** Host Connection service whose channel registrations belong to the caller fiber. */ export class HostConnectionService extends Service implements HostConnectionHandle { - /** The operator Peer every admitted request speaks for. */ - readonly operator: PeerScope + /** The Peer every authenticated browser connection shares. */ + readonly browserPeer: PeerScope private readonly interceptors = new Map() private readonly fetchRoutes = new Map() @@ -80,8 +80,8 @@ export class HostConnectionService extends Service implements HostConnectionHand private readonly browserAuth: BrowserAuth, ) { super(ctx, 'connection') - this.operator = new OperatorPeer(ctx) - ctx.effect(() => () => this.operator.dispose(), 'client-connection: operator Peer') + this.browserPeer = new Peer(ctx, { principal: { kind: 'owner' } }) + ctx.effect(() => () => this.browserPeer.dispose(), 'client-connection: browser Peer') this.allowsRemoteAuthorities = trustedHosts.some(isRemoteAuthority) } @@ -110,10 +110,15 @@ export class HostConnectionService extends Service implements HostConnectionHand return this.browserAuth.isAuthenticated(request, carrier?.protocol === 'https:') ? undefined : 401 } - /** A request that passes the fence and authentication speaks for the operator. */ + /** A request that passes the fence and authentication speaks for the shared browser Peer. */ admit(request: ConnectionTrustRequest): PeerAdmission { const rejection = this.requestRejection(request) - return rejection === undefined ? { peer: this.operator } : { rejection } + return rejection === undefined ? { peer: this.browserPeer } : { rejection } + } + + /** Create one in-process Peer owned by the Context that read this service. */ + createPeer(options: { readonly principal: Principal }): PeerScope { + return new Peer(this.ctx, options) } /** @@ -141,16 +146,21 @@ export class HostConnectionService extends Service implements HostConnectionHand const route = this.fetchRoutes.get(url.pathname) return route?.methods.has(method) === true ? route.requestBody : 'buffered' }, - fetch: (request) => { + fetch: (request, peer) => { const pathname = new URL(request.url).pathname const route = this.fetchRoutes.get(pathname) - if (route?.methods.has(request.method) === true) return route.fetch(request) + if (route?.methods.has(request.method) === true) { + // Exact routes bypass Gateway access checks, so only the owner reaches them. + // oxlint-disable-next-line typescript/no-unnecessary-condition -- other packages merge non-owner kinds into PrincipalMap. + if (peer.principal.kind !== 'owner') return Promise.resolve(new Response('forbidden', { status: 403 })) + return route.fetch(request) + } const endpoint = endpointFromPath(channel, pathname) const interceptor = this.interceptors.get(channel) if (endpoint === undefined || interceptor === undefined || !interceptor.matches(endpoint)) { return Promise.resolve(new Response('not found', { status: 404 })) } - return interceptor.fetchHandler.fetch(request) + return interceptor.fetchHandler.fetch(request, peer) }, } } @@ -180,7 +190,7 @@ export class HostConnectionService extends Service implements HostConnectionHand handler: ConnectionRpcHandler, ): () => Promise { assertChannel(channel) - const fetchHandler = rpcFetchHandler(channel, handler, this.operator) + const fetchHandler = rpcFetchHandler(channel, handler) const route: WebRoute = { kind: 'prefix', path: channel, @@ -191,7 +201,7 @@ export class HostConnectionService extends Service implements HostConnectionHand res.end(admission.rejection === 401 ? 'unauthorized' : 'forbidden') return } - await bridge(req, res, fetchHandler) + await bridge(req, res, fetchHandler, admission.peer) }, } return owner.effect( @@ -211,7 +221,7 @@ export class HostConnectionService extends Service implements HostConnectionHand } const interceptor: ConnectionRpcInterceptor = { matches, - fetchHandler: rpcFetchHandler(channel, handler, this.operator), + fetchHandler: rpcFetchHandler(channel, handler), } return owner.effect(() => { if (this.interceptors.has(channel)) { @@ -228,11 +238,10 @@ export class HostConnectionService extends Service implements HostConnectionHand function rpcFetchHandler( channel: string, handler: ConnectionRpcHandler, - peer: PeerScope, ): ConnectionFetchHandler { return { requestBodyMode: () => 'buffered', - async fetch(request: Request): Promise { + async fetch(request: Request, peer: PeerScope): Promise { const endpoint = endpointFromPath(channel, new URL(request.url).pathname) if (request.method !== 'POST' || endpoint === undefined) { return new Response('not found', { status: 404 }) diff --git a/packages/client/connection/src/rpc.ts b/packages/client/connection/src/rpc.ts index cf30df6204..2d1cfda387 100644 --- a/packages/client/connection/src/rpc.ts +++ b/packages/client/connection/src/rpc.ts @@ -1,7 +1,7 @@ /** Generic unary RPC contracts shared by the Host and Client Connection halves. */ import type { Branded } from '@deepseek-ai/dsh-brand' -import type { PeerScope } from '@deepseek-ai/dsh-typert-protocol' +import type { PeerScope, Principal } from '@deepseek-ai/dsh-typert-protocol' /** Correlation id minted by a caller and echoed by the Connection response. */ export type RpcId = Branded<'rpc-id'> @@ -115,14 +115,14 @@ export interface ConnectionIndexResponse { end(body?: string): unknown } -/** Outcome of admitting one request: the operator Peer it speaks for, or the status refusing it. */ +/** Outcome of admitting one request: the Peer it speaks for, or the status refusing it. */ export type PeerAdmission = | { readonly peer: PeerScope } | { readonly rejection: 401 | 403 } /** * Handler invoked after Connection has decoded the transport envelope. - * `peer` is the Peer the request was admitted as: the operator. + * `peer` is the Peer the carrier admitted or the in-process caller supplied. */ export type ConnectionRpcHandler = ( endpoint: string, @@ -148,7 +148,11 @@ export interface ConnectionFetchRoute { readonly methods: readonly ConnectionFetchMethod[] /** Buffered requests obey the configured JSON cap; streaming requests arrive with backpressure and no aggregate cap. */ readonly requestBody: ConnectionRequestBodyMode - /** Handle one request after the physical carrier has applied its trust and authentication policy. */ + /** + * Handle one request after the physical carrier has applied its trust and + * authentication policy. Only owner Peers reach an exact route; the shared + * handler answers 403 for every other principal. + */ readonly fetch: (request: Request) => Promise } @@ -195,8 +199,11 @@ export interface HostConnectionHandle { readonly rpc: HostConnectionRpc /** Exact Fetch routes for streaming or browser-native responses. */ readonly fetch: HostConnectionFetch - /** The operator Peer every admitted request speaks for; its scope lives as long as Connection. */ - readonly operator: PeerScope + /** + * The Peer every authenticated browser connection shares; its principal is + * the owner and its scope lives as long as Connection. + */ + readonly browserPeer: PeerScope /** * Whether this deployment accepts authorities beyond loopback: true only @@ -220,13 +227,23 @@ export interface HostConnectionHandle { requestRejection(request: ConnectionTrustRequest): ConnectionRequestRejection /** - * Admit one request: it passes {@link requestRejection} and speaks for the - * operator, or it is refused with that status. + * Admit one network request: it passes {@link requestRejection} and speaks + * for {@link browserPeer}, or it is refused with that status. * @param request - request headers from the HTTP or upgrade request. - * @returns the operator Peer, or the rejection status. + * @returns the admitted Peer, or the rejection status. */ admit(request: ConnectionTrustRequest): PeerAdmission + /** + * Create an independent Peer. In-process only: no route, header, query, or + * cookie reaches it. Disposed with the Context that read `ctx.connection`, + * or earlier by its own `dispose()`. + * @param options - caller identity the Peer speaks for. + * @param options.principal - principal fixed for the Peer's lifetime. + * @returns a new Peer distinct from every other Peer. + */ + createPeer(options: { readonly principal: Principal }): PeerScope + /** * Authenticate one frontend index request, owning a token redirect or 401. * The cookie this mints is `Secure` when the mounted Web carrier serves TLS, @@ -257,9 +274,10 @@ export interface ConnectionFetchHandler { /** * Dispatch one already-authenticated request. * @param request - Fetch request below the shared channel. - * @returns the registered response or a 404 response. + * @param peer - Peer to dispatch as; required, never defaulted. + * @returns the registered response, a 403 response for an exact route and a non-owner Peer, or a 404 response. */ - fetch(request: Request): Promise + fetch(request: Request, peer: PeerScope): Promise } /** Client caller for logical RPC channels carried by the current transport. */ diff --git a/packages/client/connection/tests/binary-rpc.host.spec.ts b/packages/client/connection/tests/binary-rpc.host.spec.ts index 05ec270545..4431199ec6 100644 --- a/packages/client/connection/tests/binary-rpc.host.spec.ts +++ b/packages/client/connection/tests/binary-rpc.host.spec.ts @@ -35,7 +35,7 @@ describe('Connection binary RPC', () => { attachments: [{ path: ['data'], bytes: data }], })) const handler = connection.createSharedFetchHandler('/api') - ctx.webServer.register({ kind: 'prefix', path: '/api', handler: (req, res) => bridge(req, res, handler) }) + ctx.webServer.register({ kind: 'prefix', path: '/api', handler: (req, res) => bridge(req, res, handler, connection.browserPeer) }) let transferred = 0 let encoding: string | undefined const rpc = createWebConnectionRpc((path, init) => new Promise((resolve, reject) => { @@ -111,7 +111,7 @@ describe('Connection binary RPC', () => { const shared = connection.createSharedFetchHandler('/api') const mediaTypes: (string | null)[] = [] const rpc = createWebConnectionRpc(async (url, init) => { - const response = await shared.fetch(new Request(new URL(url, 'http://host'), init)) + const response = await shared.fetch(new Request(new URL(url, 'http://host'), init), connection.browserPeer) mediaTypes.push(response.headers.get('content-type')) return response }) @@ -144,7 +144,7 @@ describe('Connection binary RPC', () => { attachments: [{ path: ['content'], bytes: content }], })) const handler = connection.createSharedFetchHandler('/api') - const rpc = createWebConnectionRpc((url, init) => handler.fetch(new Request(new URL(url, 'http://host'), init))) + const rpc = createWebConnectionRpc((url, init) => handler.fetch(new Request(new URL(url, 'http://host'), init), connection.browserPeer)) expect(await rpc.call('/api', 'fixture/read', {})).toEqual({ ok: true, value: { content: new Uint8Array([0, 128, 255]), metadata: { count: 1 } }, }) @@ -211,7 +211,7 @@ describe('Connection binary RPC', () => { const connection = ctx.get('connection') as HostConnectionService connection.rpc.intercept('/api', () => true, async () => next) const handler = connection.createSharedFetchHandler('/api') - const rpc = createWebConnectionRpc((url, init) => handler.fetch(new Request(new URL(url, 'http://host'), init))) + const rpc = createWebConnectionRpc((url, init) => handler.fetch(new Request(new URL(url, 'http://host'), init), connection.browserPeer)) const expected = { files: [{ name: 'a.png', content: data }, { name: 'b.png', content: new Uint8Array([2, 3]) }, { name: 'empty' }], 'dots./[]': [new Uint8Array(), null, [data]], diff --git a/packages/client/connection/tests/fetch-routes.host.spec.ts b/packages/client/connection/tests/fetch-routes.host.spec.ts index ddb9b3b264..a6b6d159c6 100644 --- a/packages/client/connection/tests/fetch-routes.host.spec.ts +++ b/packages/client/connection/tests/fetch-routes.host.spec.ts @@ -1,4 +1,5 @@ import { Context } from '@deepseek-ai/cordis' +import type { PeerScope, Principal } from '@deepseek-ai/dsh-typert-protocol' import { describe, expect, it, vi } from 'vitest' import type { BrowserAuth } from '../src/browser-auth.ts' import { HostConnectionService } from '../src/rpc-host.ts' @@ -18,6 +19,15 @@ async function mounted(): Promise<{ } } +const GUEST: Principal = { kind: 'test-guest', guest: 'connection' } + +declare module '@deepseek-ai/dsh-typert-protocol' { + interface PrincipalMap { + /** Test-only non-owner caller shared by the access specs. */ + 'test-guest': { readonly kind: 'test-guest'; readonly guest: string } + } +} + describe('Connection exact Fetch routes', () => { it('dispatches owned methods and returns 404 for unclaimed requests', async () => { const { connection, dispose: disposeFiber } = await mounted() @@ -33,7 +43,7 @@ describe('Connection exact Fetch routes', () => { const response = await shared.fetch(new Request( 'http://host/api/session.export?sessionId=session-1', - )) + ), connection.browserPeer) expect(shared.requestBodyMode({ method: 'POST', url: new URL('http://host/api/session.export'), })).toBe('streaming') @@ -43,12 +53,12 @@ describe('Connection exact Fetch routes', () => { expect(response.status).toBe(200) expect(await response.json()).toEqual({ query: 'session-1' }) expect(route).toHaveBeenCalledOnce() - const post = await shared.fetch(new Request('http://host/api/session.export', { method: 'POST' })) + const post = await shared.fetch(new Request('http://host/api/session.export', { method: 'POST' }), connection.browserPeer) expect(post.status).toBe(200) expect(route).toHaveBeenCalledTimes(2) await dispose() - const withdrawn = await shared.fetch(new Request('http://host/api/session.export')) + const withdrawn = await shared.fetch(new Request('http://host/api/session.export'), connection.browserPeer) expect(withdrawn.status).toBe(404) await disposeFiber() }) @@ -80,4 +90,76 @@ describe('Connection exact Fetch routes', () => { })).not.toThrow() await disposeFiber() }) + + it('refuses exact routes to non-owner Peers while the RPC interceptor still receives them', async () => { + const { connection, dispose: disposeFiber } = await mounted() + const route = vi.fn(async () => new Response('route')) + const disposeRoute = connection.fetch.register({ + path: '/api/session.export', methods: ['GET'], requestBody: 'buffered', fetch: route, + }) + const peers: PeerScope[] = [] + const disposeInterceptor = connection.rpc.intercept('/api', () => true, async (_endpoint, _payload, _signal, peer) => { + peers.push(peer) + return { ok: true, value: null } + }) + const guest = connection.createPeer({ principal: GUEST }) + const shared = connection.createSharedFetchHandler('/api') + + const refused = await shared.fetch(new Request('http://host/api/session.export'), guest) + expect(refused.status).toBe(403) + expect(route).not.toHaveBeenCalled() + expect((await shared.fetch(new Request('http://host/api/session.export'), connection.browserPeer)).status).toBe(200) + + const rpc = await shared.fetch(new Request('http://host/api/goals/get', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ type: 'client-request', rpcId: 'rpc-1', method: 'goals/get', payload: { args: {} } }), + }), guest) + expect(rpc.status).toBe(200) + expect(peers).toEqual([guest]) + await disposeInterceptor() + await disposeRoute() + await disposeFiber() + }) +}) + +describe('Connection in-process Peers', () => { + it('creates independent Peers that keep their principal and leave with the reading Context', async () => { + const { connection, dispose } = await mounted() + const first = connection.createPeer({ principal: GUEST }) + const second = connection.createPeer({ principal: GUEST }) + const owner = connection.createPeer({ principal: { kind: 'owner' } }) + expect(new Set([first.id, second.id, owner.id, connection.browserPeer.id]).size).toBe(4) + expect(first.principal).toBe(GUEST) + expect(owner.principal).toEqual({ kind: 'owner' }) + expect(first.ctx).not.toBe(second.ctx) + + const released = vi.fn() + first.ctx.effect(() => released, 'first Peer registration') + await first.dispose() + expect(released).toHaveBeenCalledOnce() + expect(() => first.ctx.effect(() => () => {})).toThrow() + // Disposing one Peer leaves the others usable. + expect(() => second.ctx.effect(() => () => {})()).not.toThrow() + + await dispose() + }) + + it('disposes a created Peer with the plugin that read the Connection', async () => { + const ctx = new Context() + const provider = ctx.plugin((pluginCtx) => { new HostConnectionService(pluginCtx, [], {} as BrowserAuth) }) + await provider.await() + let created: PeerScope | undefined + const consumer = ctx.plugin({ + inject: ['connection'], + apply: (consumerCtx: Context) => { created = consumerCtx.connection.createPeer({ principal: GUEST }) }, + }) + await consumer.await() + const lingering = vi.fn() + created!.ctx.effect(() => lingering, 'consumer Peer registration') + await consumer.dispose() + expect(lingering).toHaveBeenCalledOnce() + expect(() => created!.ctx.effect(() => () => {})).toThrow() + await provider.dispose() + }) }) diff --git a/packages/client/connection/tests/http-bridge.host.spec.ts b/packages/client/connection/tests/http-bridge.host.spec.ts index e9fa9df364..e612a0608d 100644 --- a/packages/client/connection/tests/http-bridge.host.spec.ts +++ b/packages/client/connection/tests/http-bridge.host.spec.ts @@ -1,8 +1,12 @@ import { EventEmitter } from 'node:events' import { Readable } from 'node:stream' import type { IncomingMessage, ServerResponse } from 'node:http' +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' import { bridge } from '../src/http-bridge.ts' +import { Peer } from '../src/peer.ts' + +const PEER = new Peer(new Context(), { principal: { kind: 'owner' } }) describe('HTTP bridge abort', () => { it('destroys a declared-oversize request instead of draining it', async () => { @@ -26,7 +30,7 @@ describe('HTTP bridge abort', () => { await bridge(request, response, { requestBodyMode: () => 'buffered', fetch: () => { throw new Error('a rejected request must never reach the handler') }, - }, 1000) + }, PEER, 1000) // The socket must not stay parked draining a body the client can trickle // at will after the rejection — same discipline as the chunked overrun. expect(status).toBe(413) @@ -68,7 +72,7 @@ describe('HTTP bridge abort', () => { } return Response.json({ aborted: fetchRequest.signal.aborted }) }, - }, Number.MAX_SAFE_INTEGER) + }, PEER, Number.MAX_SAFE_INTEGER) await started response.emit('close') await pending @@ -102,7 +106,7 @@ describe('HTTP bridge abort', () => { const pending = bridge(request, response, { requestBodyMode: () => 'buffered', fetch: async () => new Response(body), - }).then(() => { settled = true }) + }, PEER).then(() => { settled = true }) try { await expect.poll(() => settled).toBe(true) expect(writes).toBe(1) @@ -134,7 +138,7 @@ describe('HTTP bridge abort', () => { const pending = bridge(request, response, { requestBodyMode: () => 'buffered', fetch: async () => new Response(body), - }) + }, PEER) await started.promise response.emit('close') controller.enqueue(Uint8Array.of(1)) @@ -172,7 +176,7 @@ describe('HTTP bridge abort', () => { for await (const chunk of input.body) received.push(chunk) return new Response('stored') }, - }, 1) + }, PEER, 1) await started expect(received).toEqual([]) @@ -206,7 +210,7 @@ describe('HTTP bridge abort', () => { await bridge(request, response, { requestBodyMode: () => 'streaming', fetch: () => Promise.resolve(new Response(null, { status: 415 })), - }, 1) + }, PEER, 1) expect(status).toBe(415) expect(headers).toMatchObject({ connection: 'close' }) expect(destroyed).toEqual([true]) diff --git a/packages/client/connection/tests/node-half.host.spec.ts b/packages/client/connection/tests/node-half.host.spec.ts index 9a16fe0c43..ce37c41383 100644 --- a/packages/client/connection/tests/node-half.host.spec.ts +++ b/packages/client/connection/tests/node-half.host.spec.ts @@ -555,7 +555,7 @@ describe('connection node half', () => { await fiber.dispose() }) - it('admits every trusted, authenticated request as the operator Peer and hands each call that Peer', async () => { + it('admits every trusted, authenticated request as the owner browser Peer and hands each call that Peer', async () => { const { connection, routes, dispose } = await mounted({ trustedHosts: ['harness.example'] }) const peers: PeerScope[] = [] const remove = connection.rpc.intercept( @@ -569,7 +569,7 @@ describe('connection node half', () => { expect(connection.admit(fakeRequest({ host: 'other.example' }))).toEqual({ rejection: 403 }) expect(connection.admit(fakeRequest({ host: '127.0.0.1:3080' }))).toEqual({ rejection: 401 }) const cookie = browserCookie(connection, '127.0.0.1:3080') - expect(connection.admit(fakeRequest({ host: '127.0.0.1:3080', cookie }))).toEqual({ peer: connection.operator }) + expect(connection.admit(fakeRequest({ host: '127.0.0.1:3080', cookie }))).toEqual({ peer: connection.browserPeer }) const route = routes.find(candidate => candidate.path === API_PATH)! const request: ClientRequest = { @@ -581,17 +581,18 @@ describe('connection node half', () => { const answered = fakeResponse() await route.handler(fakePost({ host: '127.0.0.1:3080', cookie }, '/api/goals/create', request), answered.response) expect(JSON.parse(String(answered.state.body))).toMatchObject({ result: { ok: true, value: null } }) - // A shell-owned carrier dispatches the shared handler without the bridge and speaks for the operator too. + // A shell-owned carrier dispatches the shared handler without the bridge and speaks for the browser Peer too. const direct = await connection.createSharedFetchHandler('/api').fetch(new Request('http://127.0.0.1:3080/api/goals/create', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(request), - })) + }), connection.browserPeer) expect(direct.status).toBe(200) - expect(peers).toEqual([connection.operator, connection.operator]) + expect(peers).toEqual([connection.browserPeer, connection.browserPeer]) + expect(connection.browserPeer.principal).toEqual({ kind: 'owner' }) // Racing disposals share one completion, and the scope goes with the Connection. - await Promise.all([connection.operator.dispose(), connection.operator.dispose()]) + await Promise.all([connection.browserPeer.dispose(), connection.browserPeer.dispose()]) await remove() await dispose() }) diff --git a/packages/client/connection/tsconfig.host.json b/packages/client/connection/tsconfig.host.json index 66d9844f17..0a60bb86ea 100644 --- a/packages/client/connection/tsconfig.host.json +++ b/packages/client/connection/tsconfig.host.json @@ -14,7 +14,7 @@ "src/http-bridge.ts", "src/index.ts", "src/loopback-hostname.ts", - "src/operator-peer.ts", + "src/peer.ts", "src/recovery-config.ts", "src/rpc-host.ts", "src/rpc-schema.ts", diff --git a/packages/client/connection/tsdown.config.ts b/packages/client/connection/tsdown.config.ts index 69cd7ce36d..7aa03cebc1 100644 --- a/packages/client/connection/tsdown.config.ts +++ b/packages/client/connection/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-connection', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/file-upload/tsdown.config.ts b/packages/client/file-upload/tsdown.config.ts index 97292cd3cc..7aa03cebc1 100644 --- a/packages/client/file-upload/tsdown.config.ts +++ b/packages/client/file-upload/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-file-upload', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/hmr/tsdown.config.ts b/packages/client/hmr/tsdown.config.ts index 69b96b4229..7aa03cebc1 100644 --- a/packages/client/hmr/tsdown.config.ts +++ b/packages/client/hmr/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-hmr', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/locale/package.json b/packages/client/locale/package.json index e04ed4efd9..6cc7ea6e2a 100644 --- a/packages/client/locale/package.json +++ b/packages/client/locale/package.json @@ -52,7 +52,6 @@ "@deepseek-ai/dsh-client-ui-settings": "workspace:*", "@deepseek-ai/dsh-client-ui-slots": "workspace:*", "@deepseek-ai/dsh-package-manifest": "workspace:*", - "@types/react": "~18.3.1", "react": "^18.2.0", "@deepseek-ai/dsh-client-connection": "workspace:*", "@deepseek-ai/dsh-settings": "workspace:*" diff --git a/packages/client/locale/tests/language-row.client.spec.tsx b/packages/client/locale/tests/language-row.client.spec.tsx index 776542c0f2..ecf23dd6e9 100644 --- a/packages/client/locale/tests/language-row.client.spec.tsx +++ b/packages/client/locale/tests/language-row.client.spec.tsx @@ -5,7 +5,7 @@ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' -import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' import { LanguageRow } from '../src/client/LanguageRow.tsx' import type { LanguageRowComponentProps } from '../src/client/LanguageRow.tsx' import { createLanguageRowStore } from '../src/client/settings-store.ts' @@ -44,6 +44,7 @@ function mount(active = 'en') { useSessionStatus, usePanelInfo, useSessionRetainInfo: () => undefined, useResource, useWorkspaces: emptyWorkspaces(), + ...allowAllAccess, useStore: bindSnapshotSelector(store), actions: store.actions, t: (key: string) => key === 'language.title' ? 'Language' : key, diff --git a/packages/client/locale/tsdown.config.ts b/packages/client/locale/tsdown.config.ts index 8babf9f1eb..7aa03cebc1 100644 --- a/packages/client/locale/tsdown.config.ts +++ b/packages/client/locale/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-locale', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/modules/tsdown.config.ts b/packages/client/modules/tsdown.config.ts index 06db107f32..3a0694bdf9 100644 --- a/packages/client/modules/tsdown.config.ts +++ b/packages/client/modules/tsdown.config.ts @@ -1,6 +1,6 @@ import { clientBundle } from '../tsdown.client.ts' export default clientBundle( - '@deepseek-ai/dsh-client-modules', + import.meta.dirname, ['lib/types/index.js'], ) diff --git a/packages/client/product-analytics/tsdown.config.ts b/packages/client/product-analytics/tsdown.config.ts index 4479f52fe2..620261533c 100644 --- a/packages/client/product-analytics/tsdown.config.ts +++ b/packages/client/product-analytics/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-product-analytics', ['lib/types/index.js'], { hostPhase: true }) +export default clientBundle(import.meta.dirname, ['lib/types/index.js'], { hostPhase: true }) diff --git a/packages/client/resources/tsdown.config.ts b/packages/client/resources/tsdown.config.ts index a6e81e749c..7aa03cebc1 100644 --- a/packages/client/resources/tsdown.config.ts +++ b/packages/client/resources/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-resources', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/shortcuts/tsdown.config.ts b/packages/client/shortcuts/tsdown.config.ts index 4f32cd61c0..bae160bb21 100644 --- a/packages/client/shortcuts/tsdown.config.ts +++ b/packages/client/shortcuts/tsdown.config.ts @@ -1,7 +1,7 @@ import { clientBundle } from '../tsdown.client.ts' export default clientBundle( - '@deepseek-ai/dsh-client-shortcuts', + import.meta.dirname, ['lib/types/index.js', 'lib/types/protocol.js'], { hostPhase: true }, ) diff --git a/packages/client/store/tsdown.config.ts b/packages/client/store/tsdown.config.ts index b505d0388d..a6264e0b21 100644 --- a/packages/client/store/tsdown.config.ts +++ b/packages/client/store/tsdown.config.ts @@ -1,6 +1,6 @@ import { staticLinked } from '../tsdown.client.ts' export default staticLinked( - '@deepseek-ai/dsh-client-store', + import.meta.dirname, ['lib/types/index.js'], ) diff --git a/packages/client/tsdown.client.ts b/packages/client/tsdown.client.ts index 9d9053ded3..888519a890 100644 --- a/packages/client/tsdown.client.ts +++ b/packages/client/tsdown.client.ts @@ -11,7 +11,7 @@ * inputs, including origins recorded by chained source maps. */ import { readFile, stat, utimes } from 'node:fs/promises' -import { existsSync, globSync, readFileSync } from 'node:fs' +import { existsSync, readFileSync } from 'node:fs' import { createRequire, isBuiltin } from 'node:module' import { basename, dirname, isAbsolute, relative, resolve as resolvePath, sep } from 'node:path' import { fileURLToPath } from 'node:url' @@ -100,22 +100,25 @@ function browserSourcePath(source: string, sourcemapPath: string): string { * the package without lib/index.js and the host Loader cannot import its node * half. The Client build consumes `lib/types` and chains those tsc maps, with * original source content, into the standalone plugin map. - * @param id - plugin id (package name), stamped into the __ModuleLoader__.load - * handoff and onto the injected style tags. + * @param packageDir - the package's own directory, normally the config's + * `import.meta.dirname`. Its `package.json` name is the plugin id stamped into + * the __ModuleLoader__.load handoff and onto the injected style tags. * @param libEntry - node-half entries. * @param options - phase placement, lib overrides, companion Node configs, and optional per-file Client banner. * @returns ENV-selected tsdown config for the current build face. + * @throws {Error} when `/package.json` is unreadable or declares no name. */ export function clientBundle( - id: string, + packageDir: string, libEntry: readonly string[], options: ClientBundleOptions = {}, ): BuildFaceConfig { - const lib = clientLibraryConfig(id, libEntry, options.lib) + const manifest = readPackageManifest(packageDir) + const lib = clientLibraryConfig(manifest, libEntry, options.lib) return ({ env }) => { const face = buildFace(env?.DSH_BUILD_FACE) const clientEntry = face === undefined ? 'src/client/index.ts' : 'lib/types/client/index.js' - const client = clientConfig(id, clientEntry, options.clientBanner) + const client = clientConfig(manifest, clientEntry, options.clientBanner) const node = [lib, ...(options.companions ?? [])] if (face === 'host') return options.hostPhase === true ? node : [SKIP_WORKSPACE_BUILD] if (face === 'client') { @@ -147,13 +150,16 @@ export function clientBundle( * 4. stylesheets ship with the package: a relative `.css` import survives as a * relative external and the sheet is emitted under `lib/` at its * `src`-relative path, so vite stays the only owner of class hashing. - * @param id - package name, used in tsdown diagnostics. + * @param packageDir - the package's own directory, normally the config's + * `import.meta.dirname`; its `package.json` name is used in tsdown diagnostics. * @param libEntry - emitted JavaScript entries consumed from `lib/types`, one * bundle each: a multi-entry build would emit a hash-named shared chunk that * the exact `files` list cannot publish. * @returns ENV-selected tsdown config for the Client build face. + * @throws {Error} when `/package.json` is unreadable or declares no name. */ -export function staticLinked(id: string, libEntry: readonly string[]): BuildFaceConfig { +export function staticLinked(packageDir: string, libEntry: readonly string[]): BuildFaceConfig { + const id = readPackageManifest(packageDir).name // Each entry names its own output file, so two entries with the same basename // would overwrite one artifact instead of emitting two. const names = new Set(libEntry.map(entry => basename(entry, '.js'))) @@ -177,12 +183,14 @@ export function isStaticLinkedConfig(configs: readonly UserConfig[]): boolean { /** * Build a Client-only Node library during the Client pass. - * @param id - Package name used in tsdown diagnostics. + * @param packageDir - The package's own directory, normally the config's + * `import.meta.dirname`; its `package.json` name is used in tsdown diagnostics. * @param libEntry - Emitted JavaScript entries consumed from `lib/types`. * @returns ENV-selected tsdown config for the Client build face. + * @throws {Error} when `/package.json` is unreadable or declares no name. */ -export function clientLibrary(id: string, libEntry: readonly string[]): BuildFaceConfig { - const lib = clientLibraryConfig(id, libEntry) +export function clientLibrary(packageDir: string, libEntry: readonly string[]): BuildFaceConfig { + const lib = clientLibraryConfig(readPackageManifest(packageDir), libEntry) return clientOnly([lib]) } @@ -218,14 +226,15 @@ function buildFace(value: unknown): BuildFace { } function clientLibraryConfig( - id: string, + manifest: PackageManifest, libEntry: readonly string[], overrides: UserConfig = {}, ): UserConfig { + let externals: readonly RegExp[] | undefined const isProductionDependency = (specifier: string): boolean => - matchesSpecifier(productionExternals(id), specifier) + matchesSpecifier(externals ??= productionExternals(manifest), specifier) return { - name: id, + name: manifest.name, entry: [...libEntry], outDir: 'lib', format: ['esm'], @@ -330,9 +339,10 @@ function stylesheetAsset(source: string, importer: string): { readonly file: str return { file, fileName: file.slice(boundary + SOURCE_MARKER.length).split(sep).join('/') } } -/** The manifest fields the build faces read to state their own module edges. */ -interface WorkspaceManifest { - readonly name?: string +/** The manifest fields the build faces read to name the package and state its own module edges. */ +interface PackageManifest { + /** Package name: the tsdown config name, the Client loader id, and the diagnostic subject. */ + readonly name: string /** Sections a real install materializes on disk next to the built package. */ readonly dependencies?: Record readonly peerDependencies?: Record @@ -340,52 +350,44 @@ interface WorkspaceManifest { readonly dsh?: { readonly client?: { readonly external?: unknown } } } -const manifestCache = new Map() -const productionExternalCache = new Map() -const clientExternalCache = new Map>() - /** - * Read one workspace package's manifest. Located by package name rather than by - * cwd, because tsdown evaluates every package config with the repository root as - * `process.cwd()` during a workspace build. Callers read it on the first - * resolveId of a build, not while a config is built, so selecting a build face - * never touches a manifest. - * @param id - package name, as spelled at the preset call site. + * Read the manifest of the package whose config calls a preset. The caller + * passes its directory because tsdown evaluates every package config with the + * repository root as `process.cwd()` during a workspace build. + * @param packageDir - the package's own directory. * @returns the parsed manifest. - * @throws {Error} when no workspace package declares that name. + * @throws {Error} when `package.json` is unreadable, is not JSON, or declares no string name. */ -function workspaceManifest(id: string): WorkspaceManifest { - const cached = manifestCache.get(id) - if (cached !== undefined) return cached - for (const manifestPath of globSync('packages/*/*/package.json', { cwd: REPOSITORY_ROOT })) { - const manifest = JSON.parse( - readFileSync(resolvePath(REPOSITORY_ROOT, manifestPath), 'utf8'), - ) as WorkspaceManifest - if (manifest.name !== id) continue - manifestCache.set(id, manifest) - return manifest +function readPackageManifest(packageDir: string): PackageManifest { + const manifestPath = resolvePath(packageDir, 'package.json') + let parsed: unknown + try { + parsed = JSON.parse(readFileSync(manifestPath, 'utf8')) + } catch (error) { + throw new Error( + `tsdown: cannot read ${manifestPath}; pass the package directory (the import.meta.dirname of its tsdown.config.ts)`, + { cause: error }, + ) } - throw new Error(`tsdown: no packages/*/*/package.json declares the name ${id}`) + if (typeof parsed !== 'object' || parsed === null || !('name' in parsed) || typeof parsed.name !== 'string') { + throw new Error(`tsdown: ${manifestPath} declares no string package name`) + } + return parsed as PackageManifest } /** * External patterns for one package's Node half: its own production sections, * subpaths included. - * @param id - package name, as spelled at the preset call site. + * @param manifest - the package's own manifest. * @returns one `^name(/|$)` pattern per production dependency, name-sorted. */ -function productionExternals(id: string): readonly RegExp[] { - const cached = productionExternalCache.get(id) - if (cached !== undefined) return cached - const manifest = workspaceManifest(id) +function productionExternals(manifest: PackageManifest): readonly RegExp[] { const names = new Set([ ...Object.keys(manifest.dependencies ?? {}), ...Object.keys(manifest.peerDependencies ?? {}), ...Object.keys(manifest.optionalDependencies ?? {}), ]) - const patterns = [...names].sort().map(name => new RegExp(`^${escapeSpecifier(name)}(/|$)`)) - productionExternalCache.set(id, patterns) - return patterns + return [...names].sort().map(name => new RegExp(`^${escapeSpecifier(name)}(/|$)`)) } /** @@ -408,19 +410,15 @@ export function requestedExternals( * Module-table specifiers one package requests. The shell baseline is implicit * for every dynamic bundle; `dsh.client.external` only adds package-specific * dynamic rows or subpaths. - * @param id - package name, as spelled at the preset call site. + * @param manifest - the package's own manifest, whose `dsh.client` declaration adds requests. * @returns the baseline plus the package's explicit requests. */ -function clientExternals(id: string): ReadonlySet { - const cached = clientExternalCache.get(id) - if (cached !== undefined) return cached - const externals = new Set([ +function clientExternals(manifest: PackageManifest): ReadonlySet { + return new Set([ ...PLATFORM_MODULES, ...PRELOADED_CLIENT_EXTERNALS, - ...requestedExternals(id, workspaceManifest(id).dsh?.client ?? {}), + ...requestedExternals(manifest.name, manifest.dsh?.client ?? {}), ]) - clientExternalCache.set(id, externals) - return externals } /** Escape a package name for literal use inside a RegExp source. */ @@ -469,8 +467,10 @@ function asyncChunkRequirePlugin(): TsdownPlugin { } } -function clientConfig(id: string, entry: string, clientBanner?: (fileName: string) => string | undefined): UserConfig { - const isRequested = (specifier: string): boolean => clientExternals(id).has(specifier) +function clientConfig(manifest: PackageManifest, entry: string, clientBanner?: (fileName: string) => string | undefined): UserConfig { + const id = manifest.name + let requested: ReadonlySet | undefined + const isRequested = (specifier: string): boolean => (requested ??= clientExternals(manifest)).has(specifier) const isolation = clientInputIsolation(id) return { name: `${id}/client`, diff --git a/packages/client/ui-agent-preset/README.i18n.yaml b/packages/client/ui-agent-preset/README.i18n.yaml index 4205a9cf81..6027efa51f 100644 --- a/packages/client/ui-agent-preset/README.i18n.yaml +++ b/packages/client/ui-agent-preset/README.i18n.yaml @@ -15,11 +15,11 @@ en: d152484eb41ac6b4 zh: b3ad7867f23dad1c /deepseek-ai-dsh-client-ui-agent-preset/use-this-package: - en: a21296842d6e18da - zh: 75f3a6a79691884f + en: e015215c32d78b52 + zh: e8a8029903de7515 /deepseek-ai-dsh-client-ui-agent-preset/understand-the-implementation: - en: b3221ef8bc6d8096 - zh: 68cb05566249da10 + en: a90bf7539dbce636 + zh: d77c1fecac2c6a4b /deepseek-ai-dsh-client-ui-agent-preset/further-exploration: en: bbae8d79844bc481 zh: 4302107fd1f460e6 diff --git a/packages/client/ui-agent-preset/README.md b/packages/client/ui-agent-preset/README.md index 75f778ff62..fdade6a4af 100644 --- a/packages/client/ui-agent-preset/README.md +++ b/packages/client/ui-agent-preset/README.md @@ -31,7 +31,7 @@ With Coding Tools off in General Settings, the new-session picker and Settings r Turning Coding Tools off clears all unapplied choices through the existing Developer tools subscription. Once the off value is accepted, every authenticated browser resets a saved built-in PTC or Minimal default to Standard. Saved Standard, Creator, and custom defaults (including named PTC or Minimal overrides) stay unchanged. The user can select an available preset again after turning Coding Tools off. Existing sessions keep their selected mode; New Session uses the current default. An existing mode's label remains visible even when that mode is absent from the menu. Turning Coding Tools back on does not restore the old default or cleared choice. If Standard is missing or broken, the default remains unchanged and an error is shown. -Known shipped presets offer mode details and usage examples in a read-only dialog. Its tabs preserve each page's scroll position; closing returns focus to the opening action. Help does not change the new-task default. The default badge replaces the card's group badge, and the preset id appears beside the title. Guide copy and examples belong to this package. +Known shipped presets offer mode details and usage examples in a read-only dialog. Minimal's guide explains image reading, automatic history compaction, and the idle-session `/compact` command. Its tabs preserve each page's scroll position; closing returns focus to the opening action. Help does not change the new-task default. The default badge replaces the card's group badge, and the preset id appears beside the title. Guide copy and examples belong to this package. **Let the agent create a plugin** in the Plugins page's **Add plugin** arrow menu enters the same Creator flow as Settings. It submits no message and preserves unsent drafts. While the roster loads or Creator is unavailable, the menu item is disabled with an explanation. Both entries show a Toast with the reason if the Host refuses the preset switch. @@ -45,7 +45,7 @@ Entering Creator selects `cordis` for the receiving blank Session without changi `agentPresets/list` supplies the roster and marks the current default, and `agentPresets/read` one declaration's YAML for the viewer; default changes write the `agent-preset-registry` settings namespace. The picker, blank-session synchronization and read-only session label use recorded preset identities. Connection resets and settings updates refresh the roster. -`CreatePluginMenuItem` contributes to `plugins.add.actions` using the shared roster store. Its selection calls the owner's `onDismiss()` before the same `startCreatorDraft` callback exposed to Settings as `creatorDraft`. That callback stages `cordis` through `AgentPresetSeatController` and opens the task through `uiWorkspace.startSession` with explicit draft preservation; it can reuse a blank Session and applies the staged choice when a Session binding becomes available. The choice is consumed once applied. Both Coding Tools states use the same picker and selection store, and `AgentPresetSeat` renders refusals through the shared `Toast` component. +`CreatePluginMenuItem` contributes to `plugins.add.actions` using the shared roster store. Its selection calls the owner's `onDismiss()` before the same `startCreatorDraft` callback exposed to Settings as `creatorDraft`. That callback stages `cordis` through `AgentPresetSeatController` and opens the task through `uiWorkspace.startSession` with explicit draft preservation and `agentPreset: 'cordis'`, so the Host composes the reused blank or new Session under Creator; without a target Workspace, the staged choice applies when a Session binding becomes available. The choice is consumed once applied. Both Coding Tools states use the same picker and selection store, and `AgentPresetSeat` renders refusals through the shared `Toast` component.
diff --git a/packages/client/ui-agent-preset/README.zh.md b/packages/client/ui-agent-preset/README.zh.md index 735d008239..eacce41712 100644 --- a/packages/client/ui-agent-preset/README.zh.md +++ b/packages/client/ui-agent-preset/README.zh.md @@ -31,7 +31,7 @@ kind: "package-reference" 关闭代码工作工具时,沿用现有 Developer tools 订阅清空所有尚未应用的选择。关闭状态被接受后,每个已认证浏览器都会将已保存的内置 PTC 或极简默认值回退为标准模式。已保存的标准、创造和自定义默认值(包括有名称的 PTC 或极简覆盖项)保持不变。关闭后用户仍可重新选择可用预设。已有会话保留所选模式;新会话使用当前默认值。即使已有会话的模式不在菜单中,其标签仍会显示。重新开启代码工作工具不会恢复原默认值或已清除的选择。标准模式缺失或损坏时,默认值保持不变,并显示错误。 -已知的内置预设提供只读的模式说明与使用示例对话框。各页签保留各自的滚动位置;关闭后焦点回到打开它的操作。帮助不会改变新任务默认值。默认徽标取代卡片的分组徽标,预设 id 显示在标题旁。指南文案与示例归本包所有。 +已知的内置预设提供只读的模式说明与使用示例对话框。极简模式指南介绍图片读取、自动历史压缩以及会话空闲时的 `/compact` 命令。各页签保留各自的滚动位置;关闭后焦点回到打开它的操作。帮助不会改变新任务默认值。默认徽标取代卡片的分组徽标,预设 id 显示在标题旁。指南文案与示例归本包所有。 插件页**添加插件**箭头菜单中的**让 Agent 创建插件**进入与设置页相同的创造流程,不发送消息,并保留未发送的草稿。读取预设列表或创造模式不可用时,菜单项禁用并说明原因。Host 拒绝切换预设时,两处入口均通过 Toast 显示原因。 @@ -45,7 +45,7 @@ kind: "package-reference" `agentPresets/list` 提供列表并标记当前默认值,`agentPresets/read` 为查看器提供一条声明的 YAML;默认值的修改写入 `agent-preset-registry` settings 命名空间。选择器、空白会话同步和只读会话标签使用记录的 preset 标识。连接重置和设置更新会刷新列表。 -`CreatePluginMenuItem` 使用共享的预设列表 store,向 `plugins.add.actions` 贡献菜单项。选择时先调用页面传入的 `onDismiss()`,再调用与设置页 `creatorDraft` 相同的 `startCreatorDraft` 回调。该回调通过 `AgentPresetSeatController` 暂存 `cordis`,再通过显式保留草稿的 `uiWorkspace.startSession` 调用打开任务;它可以复用空白 Session,并在 Session 绑定可用时应用暂存选择。暂存选择应用后即清除。代码工作工具开启或关闭时均使用同一个选择器和选择 store,`AgentPresetSeat` 通过共享的 `Toast` 组件显示拒绝原因。 +`CreatePluginMenuItem` 使用共享的预设列表 store,向 `plugins.add.actions` 贡献菜单项。选择时先调用页面传入的 `onDismiss()`,再调用与设置页 `creatorDraft` 相同的 `startCreatorDraft` 回调。该回调通过 `AgentPresetSeatController` 暂存 `cordis`,再通过显式保留草稿并传入 `agentPreset: 'cordis'` 的 `uiWorkspace.startSession` 调用打开任务,由 Host 以创造模式组合被复用的空白 Session 或新 Session;没有目标工作区时,暂存选择在 Session 绑定可用时应用。暂存选择应用后即清除。代码工作工具开启或关闭时均使用同一个选择器和选择 store,`AgentPresetSeat` 通过共享的 `Toast` 组件显示拒绝原因。
diff --git a/packages/client/ui-agent-preset/package.json b/packages/client/ui-agent-preset/package.json index 3549bf1ad5..93a5319006 100644 --- a/packages/client/ui-agent-preset/package.json +++ b/packages/client/ui-agent-preset/package.json @@ -68,7 +68,6 @@ "@deepseek-ai/dsh-client-ui-workspace": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-util-values": "workspace:*", - "@types/react": "~18.3.1", "react": "^18.2.0" }, "files": [ diff --git a/packages/client/ui-agent-preset/src/client/guide-locales.ts b/packages/client/ui-agent-preset/src/client/guide-locales.ts index c41b55aef7..2d8320f298 100644 --- a/packages/client/ui-agent-preset/src/client/guide-locales.ts +++ b/packages/client/ui-agent-preset/src/client/guide-locales.ts @@ -42,7 +42,7 @@ export const guideEn = { guideMinimalIntro: 'Choose Minimal mode for a new task. For a comparison, hold the model, permissions, input, and starting workspace state constant across runs.', guideMinimalExplanation: [ '### What is included', - 'One persistent shell tool and a fixed system prompt. The built-in preset does not load Skills, planning, context compaction, or the standard runtime context.', + 'One persistent shell tool, read_image, and a fixed system prompt. Image reading requires an image-capable model. Older history is compacted automatically under context pressure; use /compact while the agent is idle to compact it manually. The built-in preset does not load Skills, planning, or the standard runtime context.', '### When to choose it', 'Use it as a baseline for experiments and comparisons. It can still read files and execute scripts through shell commands, but offers fewer built-in ways to manage a long task. Fewer tools does not necessarily make it easier for a beginner.', ].join('\n\n'), @@ -120,7 +120,7 @@ export const guideZh: Record = { guideMinimalIntro: '新建任务时选择「极简模式」。做对照测试时,保持模型、权限、任务输入和工作区起始状态一致。', guideMinimalExplanation: [ '### 保留哪些能力', - '仅提供一个持久 Shell 工具,并使用固定系统提示词。内置预设不加载 Skills、计划、上下文压缩,也不注入标准运行时上下文。', + '提供持久 Shell 和 read_image 工具,并使用固定系统提示词。读取图片需要支持图片输入的模型。上下文压力达到阈值时自动压缩较早历史;Agent 空闲时可输入 /compact 手动压缩。内置预设不加载 Skills、计划,也不注入标准运行时上下文。', '### 什么时候选', '适合作为实验和对照测试的基线。Agent 仍能通过终端命令读写文件、运行脚本,但缺少管理长任务的内置辅助能力。工具少,不代表对新手更容易。', ].join('\n\n'), diff --git a/packages/client/ui-agent-preset/src/client/index.ts b/packages/client/ui-agent-preset/src/client/index.ts index 1abeb855be..6fa0e3e4ad 100644 --- a/packages/client/ui-agent-preset/src/client/index.ts +++ b/packages/client/ui-agent-preset/src/client/index.ts @@ -5,8 +5,8 @@ * 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 + * A running session keeps the composition it began with (the host switches a + * session's preset only before its first turn). That is what splits * the choice from the display: the hero chip is before-the-fact, while the * header only reports what a session already runs. The default preset is * edited where the roster is visible — the settings section's "make default" @@ -198,10 +198,10 @@ export function apply(ctx: ClientContext): void { }) const startCreatorDraft = () => { + // The Host composes the reused draft or new Session; the stage only drives the chip's display and introduction. const seat = mainBlankSeat() ?? unboundSeat seat.stage('cordis', true) - scope.uiWorkspace.startSession(undefined, { clearPreviousDraft: false }) - void seat.apply() + scope.uiWorkspace.startSession(undefined, { clearPreviousDraft: false, agentPreset: 'cordis' }) } scope.effect(() => { diff --git a/packages/client/ui-agent-preset/src/client/locales.ts b/packages/client/ui-agent-preset/src/client/locales.ts index 1863c2e108..49178ca524 100644 --- a/packages/client/ui-agent-preset/src/client/locales.ts +++ b/packages/client/ui-agent-preset/src/client/locales.ts @@ -55,7 +55,7 @@ export const en: Record = { 'Includes all Standard mode capabilities. Better suited to tasks that call tools in batches and then filter, organize, deduplicate, count, or summarize the results.', presetMinimalName: 'Minimal mode', presetMinimalDescription: - 'The agent works using only a terminal tool. Useful for testing and comparing its basic performance.', + 'The agent uses a terminal and image reading, with context compaction. Useful for testing and comparing its basic performance.', presetCordisName: 'Creator mode', presetCordisDescription: 'Customize DSH through conversation. Let the agent write plugins that add features or UI, or combine tools and prompts to create your own mode.', @@ -97,7 +97,7 @@ export const zh: Record = { presetPtcName: 'PTC 模式', presetPtcDescription: '包含标准模式的所有能力,更适合批量调用工具,并对结果进行筛选、整理、去重、统计或汇总的任务。', presetMinimalName: '极简模式', - presetMinimalDescription: 'Agent 仅使用终端工具完成任务,适合测试和对比其基础表现。', + presetMinimalDescription: 'Agent 使用终端和图片读取工具,并支持上下文压缩,适合测试和对比其基础表现。', presetCordisName: '创造模式', presetCordisDescription: '用对话定制 DSH:让 Agent 编写插件,添加新功能或界面;也能组合工具和提示词,创建自己的模式。', diff --git a/packages/client/ui-agent-preset/src/client/seat-store.ts b/packages/client/ui-agent-preset/src/client/seat-store.ts index dfa2c69945..0a1860ba86 100644 --- a/packages/client/ui-agent-preset/src/client/seat-store.ts +++ b/packages/client/ui-agent-preset/src/client/seat-store.ts @@ -3,8 +3,10 @@ * * The new-session screen has no session, so a pick is staged rather than * applied. It reaches a session when one becomes current and is still blank — - * whether the workspace connect created it or reused an existing blank one, - * which is why staging cannot simply ride along on `sessions.create`. + * whether the workspace connect created it or reused an existing blank one. A + * flow that knows its preset before it starts (the settings section's creator + * entry) passes it to session creation instead, and its stage only drives the + * chip's display. * * The stage is forgotten once applied. The next new session starts from the * Host-effective default again. diff --git a/packages/client/ui-agent-preset/tests/apply.client.spec.ts b/packages/client/ui-agent-preset/tests/apply.client.spec.ts index aa52b2c3d7..c891d1d6d4 100644 --- a/packages/client/ui-agent-preset/tests/apply.client.spec.ts +++ b/packages/client/ui-agent-preset/tests/apply.client.spec.ts @@ -212,7 +212,7 @@ function declareConversation(slots: SlotRegistry): () => void { /** A Workspace UI double recording new-session starts. */ function uiWorkspaceDouble() { - const starts: unknown[] = [] + const starts: { workspaceId: unknown; options: unknown }[] = [] return { starts, startSession: (workspaceId?: unknown, options?: unknown) => { starts.push({ workspaceId, options }) }, @@ -1066,7 +1066,7 @@ describe('ui-agent-preset apply', () => { // new-session flow began. expect(section.startCreatorDraft).toBeDefined() expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('cordis') - expect(uiWorkspace.starts).toHaveLength(1) + expect(uiWorkspace.starts).toEqual([{ workspaceId: undefined, options: { clearPreviousDraft: false, agentPreset: 'cordis' } }]) // A cross-screen stage carries the introduce cue; the chip acknowledges // it once, and a repeat acknowledgement leaves the snapshot untouched. @@ -1079,15 +1079,16 @@ describe('ui-agent-preset apply', () => { conversation() }) - it('applies the creator preset to an existing blank main Session', async () => { + it('lets the new-session flow compose the creator preset on a blank main Session', async () => { const { ctx, slots, calls } = await bench() declareRoot(slots) const conversation = declareConversation(slots) ctx.provide('conversation', {} as never) - const sessions = sessionsDouble(ctx, { + const state = { current: 's1', byId: { s1: { id: 's1', blank: true, projectionValues: { agentPreset: 'standard' } } }, - }) + } + const sessions = sessionsDouble(ctx, state) ctx.provide('sessions', sessions as never) const uiWorkspace = uiWorkspaceDouble() ctx.provide('uiWorkspace', uiWorkspace as never) @@ -1100,10 +1101,13 @@ describe('ui-agent-preset apply', () => { await seat.load() await section.load() section.startCreatorDraft?.() + expect(uiWorkspace.starts).toEqual([{ workspaceId: undefined, options: { clearPreviousDraft: false, agentPreset: 'cordis' } }]) - await vi.waitFor(() => { expect(calls).toContain('select:cordis') }) - expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('cordis') - expect(uiWorkspace.starts).toEqual([{ workspaceId: undefined, options: { clearPreviousDraft: false } }]) + // The Host composes the reused Session; the chip only follows its projection. + state.byId.s1 = { id: 's1', blank: true, projectionValues: { agentPreset: 'cordis' } } + sessions.notify() + await vi.waitFor(() => { expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('cordis') }) + expect(calls).not.toContain('select:cordis') conversation() }) diff --git a/packages/client/ui-agent-preset/tests/create-plugin-menu-item.client.spec.tsx b/packages/client/ui-agent-preset/tests/create-plugin-menu-item.client.spec.tsx index d1ecedaa66..578234c898 100644 --- a/packages/client/ui-agent-preset/tests/create-plugin-menu-item.client.spec.tsx +++ b/packages/client/ui-agent-preset/tests/create-plugin-menu-item.client.spec.tsx @@ -2,7 +2,7 @@ import { useState, type ComponentProps } from 'react' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' -import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { Menu, MenuItemButton } from '@deepseek-ai/dsh-client-ui-primitives' import { CreatePluginMenuItem } from '../src/client/CreatePluginMenuItem.tsx' @@ -29,6 +29,7 @@ function view(roster: Partial = {}, inMenu = false) { useAgentPresets: bindSnapshotSelector(agentPresets), usePanelInfo: unusedHook, useSessions: unusedHook, useSessionStatus: unusedHook, useSessionRetainInfo: unusedHook, useWorkspaces: unusedHook, useResource: unusedHook, + ...allowAllAccess, load, startCreatorDraft, onDismiss, } function TestMenu() { diff --git a/packages/client/ui-agent-preset/tests/section.client.spec.tsx b/packages/client/ui-agent-preset/tests/section.client.spec.tsx index 6505bceb7f..78a4f599e3 100644 --- a/packages/client/ui-agent-preset/tests/section.client.spec.tsx +++ b/packages/client/ui-agent-preset/tests/section.client.spec.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { afterEach, expect, it, vi } from 'vitest' -import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { Modal } from '@deepseek-ai/dsh-client-ui-primitives' import { AgentPresetSection, type AgentPresetSectionProps } from '../src/client/AgentPresetSection.tsx' @@ -23,6 +23,7 @@ function view(partial: Partial = {}, startCreatorDraft? ...(startCreatorDraft === undefined ? {} : { startCreatorDraft }), usePanelInfo: unusedHook, useSessions: unusedHook, useSessionStatus: unusedHook, useSessionRetainInfo: unusedHook, useWorkspaces: unusedHook, useResource: unusedHook, + ...allowAllAccess, useAgentPresetSection: bindSnapshotSelector(store), useDeveloperTools: bindSnapshotSelector(developerTools), t: key => translations.get(key) ?? key } diff --git a/packages/client/ui-agent-preset/tsdown.config.ts b/packages/client/ui-agent-preset/tsdown.config.ts index 3f5cc0a823..7aa03cebc1 100644 --- a/packages/client/ui-agent-preset/tsdown.config.ts +++ b/packages/client/ui-agent-preset/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-agent-preset', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-approval/package.json b/packages/client/ui-approval/package.json index 19e91693f8..8c86cc1348 100644 --- a/packages/client/ui-approval/package.json +++ b/packages/client/ui-approval/package.json @@ -60,7 +60,6 @@ "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-typert-protocol": "workspace:*", - "@types/react": "~18.3.1", "react": "^18.2.0", "@deepseek-ai/dsh-client-shortcuts": "workspace:*" }, diff --git a/packages/client/ui-approval/tsdown.config.ts b/packages/client/ui-approval/tsdown.config.ts index 4ddd1fdffe..7aa03cebc1 100644 --- a/packages/client/ui-approval/tsdown.config.ts +++ b/packages/client/ui-approval/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-approval', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-attachment/package.json b/packages/client/ui-attachment/package.json index 4937ea4263..3b2483a7e6 100644 --- a/packages/client/ui-attachment/package.json +++ b/packages/client/ui-attachment/package.json @@ -46,8 +46,6 @@ "license": "MIT", "devDependencies": { "@deepseek-ai/cordis": "workspace:~", - "@types/react": "~18.3.1", - "@types/react-dom": "~18.3.0", "@deepseek-ai/dsh-client-locale": "workspace:*", "@deepseek-ai/dsh-client-ui-chat": "workspace:*", "@deepseek-ai/dsh-client-ui-conversation": "workspace:*", diff --git a/packages/client/ui-attachment/tsdown.config.ts b/packages/client/ui-attachment/tsdown.config.ts index 2c903ce9af..3a0694bdf9 100644 --- a/packages/client/ui-attachment/tsdown.config.ts +++ b/packages/client/ui-attachment/tsdown.config.ts @@ -1,6 +1,6 @@ import { clientBundle } from '../tsdown.client.ts' export default clientBundle( - '@deepseek-ai/dsh-client-ui-attachment', + import.meta.dirname, ['lib/types/index.js'], ) diff --git a/packages/client/ui-brand-official/package.json b/packages/client/ui-brand-official/package.json index 263c0eef6a..e21a4838eb 100644 --- a/packages/client/ui-brand-official/package.json +++ b/packages/client/ui-brand-official/package.json @@ -49,7 +49,6 @@ "@deepseek-ai/dsh-client-ui-sidebar": "workspace:*", "@deepseek-ai/cordis": "workspace:~", "@testing-library/react": "^16.3.3", - "@types/react": "~18.3.1", "react": "^18.2.0", "react-dom": "^18.2.0" }, diff --git a/packages/client/ui-brand-official/tsdown.config.ts b/packages/client/ui-brand-official/tsdown.config.ts index 9edc90cbc9..7aa03cebc1 100644 --- a/packages/client/ui-brand-official/tsdown.config.ts +++ b/packages/client/ui-brand-official/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-brand-official', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index 3077ce4752..3f4ae6e1ae 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/README.i18n.yaml @@ -33,11 +33,11 @@ en: 4cd7c81d70d2f365 zh: 18eca6d16e44b6c2 /deepseek-ai-dsh-client-ui-chat/grouped-rendering: - en: 106438891c5175ce - zh: 34dd30092f34f49b + en: 81d7ca7595c34d39 + zh: 29eb8248c75e9ea6 /deepseek-ai-dsh-client-ui-chat/scroll-ownership: - en: 3f4e6c161b4583bb - zh: e4265bb2bce8eb1c + en: 284737900cb75668 + zh: 12e0844082ff79ce /deepseek-ai-dsh-client-ui-chat/model-experience: en: c17b4dd43a61c569 zh: f7414309810c3a08 diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index b80ec590d6..0438804975 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -103,7 +103,7 @@ Chat registers its process Group Definition through `uiConversation.groups`. Rea `groupPart` selects reasoning or response in the Assistant renderer without copying Node payloads. A Tool node owns its preparing, dispatched, and result stages under one callId. Each part has a distinct DOM anchor for reading-position restoration; Turn navigation addresses the original Node key and lands on its first visible part. Group sources, member parents, and keys survive display-mode changes and newly loaded prefixes that extend an intact group. The source Node Store remains the only Node-data owner, and a replaced Builder rebinds keyed subscriptions without remounting seats. Mode changes retain size observers and reuse the Turn-state selector. -The Chat Builder publishes `bottomSource` per Node: true for visible Nodes belonging to the last Turn in the loaded timeline, not just the final row. Advancing the bottom Turn notifies only Nodes in the old and new bottom Turns. Groups read their first member's position. Pending-input deduplication and collapse decisions remain in the Chat view, outside the Builder. +The Chat Builder publishes `bottomSource` per Node: true for visible Nodes belonging to the latest loaded exchange, not just the final row. Nodes declare their group-tree position through `groupTree`; a Turn holding an `exchange` Node opens an exchange, and the exchange lasts until the next opening Turn. The positions, from outermost inward `exchange`, `input`, `turn`, and `process`, are defined in [group-tree.ts](src/client/contract/group-tree.ts); process grouping, whole-Turn folding, and Turn-input detection read them, so an external Definition places its Chat rows by declaring one. Moving the exchange start notifies only Nodes in Turns that entered or left it. Groups read their first member's position. Pending-input deduplication and collapse decisions remain in the Chat view, outside the Builder. Live tool deltas share reasoning's frame-batched publication; durable calls and results publish immediately. Native and nested results retain event presentation metadata, so historical file and command labels use the operation’s location. Repeated deltas retain the Tool node when observed argument answers, the anchor, location, and visibility are unchanged. The Definition supplies the same lazy argument reader to tool rows and group detail, without tool-specific registration. @@ -138,11 +138,11 @@ The transcript root uses `overflow-x: visible; overflow-y: clip`: vertical overf Outer transcript following and each open capped group's following are independent. Native animation progress retains follow intent; only that scrollport's own `scrollend` settles its animation. Browsers without `scrollend` may retain the native follow target until a later bottom-aligned update or reader gesture. A reader gesture interrupts the animation, and actual movement determines whether following remains enabled. Scroll chaining can move the outer transcript, which then applies its own distance threshold. The back-to-bottom button restores only outer following. -Next-message folding combines row closure and tail following in one cancellable task, with browser scroll anchoring disabled during the fold. If the removed height exceeds the new content's height, the difference remains as bottom space rather than reducing the scroll offset to chase the unpadded content floor. This applies even when the conversation already exceeds the viewport. New content fills the retained space before following advances again; a smaller fold leaves only the remaining forward distance to scroll. Reader gestures cancel the task and active native following; resize cannot revive a cancelled task in the same fold, but new submitted input can. Off-bottom readers retain their position, and acknowledged scrolling releases only space that can be removed without clamping them. Unloading Chat cancels waiting callbacks and row transitions. Reduced-motion users receive immediate visibility and positioning changes. +Next-message folding moves every batch of closing rows and the scrollport on one frame clock, with browser scroll anchoring disabled during the fold; there is no separate reserve-then-follow path. A row that starts closing or revealing while a batch runs keeps its size until that batch settles and then changes in the next batch; a batch continues each row from the size it already has. The batch lands at the tail when this client's own input caused it (including a subagent child's continuation) or when the reader was following; otherwise the reader's visible content stays still, whoever sent the message. Content above the reading line never slides back down: when the retained answer and the new content cannot fill the viewport, only that deficit remains as bottom space, and new content fills it before following advances again. Reader gestures cancel the automatic target and active native following; resize cannot revive it in the same fold, but new submitted input can. Acknowledged scrolling releases only space that can be removed without clamping the reader. Unloading Chat cancels waiting callbacks and row transitions. Reduced-motion users receive immediate visibility and positioning changes. Changing Work details clears reserved bottom space after active folds finish and reconciles the scroll range with the selected mode. Readers already following the tail stay at the new bottom; other readers are not forced to follow. -Fold coordination is viewport-local: reservations and idle callbacks never broadcast to other Chat views. ChatView supplies its motion capability once to `conversation.chat.flow` through the render's `hookContext`. Flow binds the group and header behavior hooks, owns node dispatch, supplies Factory rendering for local echoes, and shares the existing Chat store and Session node sources. The image entry Factory owns the replaceable image Slot. Layout nodes and inline reasoning receive the bound hooks without receiving the motion controller or changing content parents. Generic searchable hiding applies before paint and owns focus protection and beforematch handling; pending-input reconciliation remains in ChatView. +Fold coordination is viewport-local: fold batches, bottom space, and idle callbacks never broadcast to other Chat views. ChatView supplies its motion capability once to `conversation.chat.flow` through the render's `hookContext`. Flow binds the group and header behavior hooks, owns node dispatch, supplies Factory rendering for local echoes, and shares the existing Chat store and Session node sources. The image entry Factory owns the replaceable image Slot. Layout nodes and inline reasoning receive the bound hooks without receiving the motion controller or changing content parents. Generic searchable hiding applies before paint and owns focus protection and beforematch handling; pending-input reconciliation remains in ChatView. | Outer follows | Open group follows | Back-to-bottom button | New content | |---|---|---|---| diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index c07cd9c21c..27d61eaa9a 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -103,7 +103,7 @@ Chat 通过 `uiConversation.groups` 注册过程 Group Definition。React 通过 `groupPart` 在 Assistant 渲染器中选择推理或回复,不复制 Node 载荷。同一个 callId 的准备、派发与结果阶段由 Tool 节点自己拥有。每个部分有独立的 DOM 锚点用于恢复阅读位置;轮次导航使用原 Node key,落到它的第一个可见部分。展示模式切换以及为完整旧组补入更早成员时,保留组来源、成员父级及 key。原 Node Store 仍是唯一节点数据所有者,替换 Builder 时重新绑定按键订阅,不重挂载容器。模式变化保留尺寸观察器,并复用整轮状态选择器。 -Chat Builder 按节点发布 `bottomSource`:节点可见且属于已加载时间线的最后一轮时为 true,而不只是列表最后一行。底部轮次推进时,只有旧、新底部轮次的节点收到位置变化通知。Group 读取其首个成员的位置。待发送消息去重与收起判断仍由 Chat 视图处理,不进入 Builder。 +Chat Builder 按节点发布 `bottomSource`:节点可见且属于已加载的最新交互段时为 true,而不只是列表最后一行。节点通过 `groupTree` 声明自己在分组树中的位置;含 `exchange` 节点的轮次开启一个交互段,交互段持续到下一个开启轮次。这些位置由外到内依次为 `exchange`、`input`、`turn`、`process`,定义在 [group-tree.ts](src/client/contract/group-tree.ts);过程分组、整轮折叠和轮次输入判断都读取它们,外部 Definition 声明位置即可放置自己的 Chat 行。交互段起点移动时,只有进入或离开该交互段的轮次中的节点收到通知。Group 读取其首个成员的位置。待发送消息去重与收起判断仍由 Chat 视图处理,不进入 Builder。 实时工具 delta 与推理共用按帧合并的发布节奏;持久调用和结果立即发布。原生与嵌套结果保留事件中的展示元数据,使历史文件与命令标签使用执行时的位置。当已读取参数的答案、锚点、位置及可见性均未变化时,重复 delta 保持 Tool 节点不变。Definition 向工具行和分组详情提供同一个懒计算参数读器,无需按工具注册。 @@ -138,11 +138,11 @@ Chat 会在历史前插与 renderer 重新挂载时恢复语义锚点,并且 外层文本记录与每个已展开限高组的跟随状态彼此独立。原生动画的中间滚动保留跟随意图;只有该滚动容器自身的 `scrollend` 才结束其动画。不支持 `scrollend` 的浏览器可能保留原生跟随目标,直到后续贴底更新或读者手势将其结束。读者手势中断动画,是否继续跟随由实际位移决定。滚动传递可以带动外层,外层再按自身离底距离判断。回到底部按钮只恢复外层跟随。 -下次消息触发的折叠与跟尾属于同一个可取消任务,折叠期间禁用浏览器滚动锚定。如果移除的高度大于新增内容的高度,差值保留为底部空间,不通过减小滚动偏移追到未含预留空间的内容底部;对话总高度已经超过视口时也遵循此规则。后续新增内容先填充保留区域,填满后才继续跟尾;折叠高度较小时,仅滚动剩余的向前距离。读者手势取消该任务及进行中的原生跟随;同一次折叠中的尺寸变化不能恢复已取消的任务,但新提交的输入可以。离底读者保留位置,已确认的滚动只释放不会造成位置钳制的多余空间。卸载 Chat 时取消等待回调和行过渡。启用减少动态效果偏好时,可见性和定位立即变化。 +下次消息触发的折叠中,每一批收起行与滚动容器在同一个帧时钟上推进,折叠期间禁用浏览器滚动锚定;不存在「先预留、再跟随」的另一条路径。批次运行期间开始收起或展开的行先保持当前尺寸,等当前批次落定后在下一批中变化;批次从每一行已有的尺寸继续。由本客户端自己的输入(包括子代理会话的 continuation)触发、或读者原本在跟尾时,这一批落到底部;其余情况下,无论消息由谁发出,读者正在看的内容保持不动。阅读线以上的内容不会回滑:保留的正文和新内容填不满视口时,只把差额保留为底部空间,后续新增内容先填满它,再继续跟尾。读者手势取消自动目标及进行中的原生跟随;同一次折叠中的尺寸变化不能恢复它,但新提交的输入可以。已确认的滚动只释放不会造成位置钳制的多余空间。卸载 Chat 时取消等待回调和行过渡。启用减少动态效果偏好时,可见性和定位立即变化。 切换工作步骤展示档位时,当前折叠结束后清空底部预留空间,并按所选档位调整滚动范围。原本跟随底部的读者保持在新的底部;其他读者不会被强制拉回底部。 -折叠协调状态由各自的 viewport 持有,空间预留和空闲回调不会广播到其他 Chat 视图。ChatView 通过本次渲染的 `hookContext`,向 `conversation.chat.flow` 交接一次动画能力。Flow 绑定组内容与组头的行为 hook,负责节点分发,为本地回显提供 Factory renderer,并共享既有 Chat store 与 Session 节点来源。图片入口 Factory 拥有可替换的图片 Slot。布局节点和行内思考接收绑定后的 hook,不接收动画控制器,也不改变内容的父级。通用的可搜索隐藏在绘制前生效,并负责焦点保护及 beforematch;pending 输入对账仍留在 ChatView。 +折叠协调状态由各自的 viewport 持有,折叠批次、底部空间和空闲回调不会广播到其他 Chat 视图。ChatView 通过本次渲染的 `hookContext`,向 `conversation.chat.flow` 交接一次动画能力。Flow 绑定组内容与组头的行为 hook,负责节点分发,为本地回显提供 Factory renderer,并共享既有 Chat store 与 Session 节点来源。图片入口 Factory 拥有可替换的图片 Slot。布局节点和行内思考接收绑定后的 hook,不接收动画控制器,也不改变内容的父级。通用的可搜索隐藏在绘制前生效,并负责焦点保护及 beforematch;pending 输入对账仍留在 ChatView。 | 外层跟随 | 已展开小组跟随 | 回到底部按钮 | 新增内容 | |---|---|---|---| diff --git a/packages/client/ui-chat/package.json b/packages/client/ui-chat/package.json index 911f15930d..1eb2d42930 100644 --- a/packages/client/ui-chat/package.json +++ b/packages/client/ui-chat/package.json @@ -89,8 +89,6 @@ "@deepseek-ai/dsh-util-workspace-path": "workspace:*", "@deepseek-ai/dsh-util-values": "workspace:*", "@tanstack/react-virtual": "3.14.10", - "@types/react": "~18.3.1", - "@types/react-dom": "~18.3.0", "react": "^18.2.0", "react-dom": "^18.2.0", "@deepseek-ai/dsh-settings": "workspace:*", diff --git a/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx b/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx index 9b076678a3..9721b4a0a8 100644 --- a/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx @@ -5,7 +5,8 @@ import type { ConversationLocationDataStore, ConversationTurnDataMap } from '@de import type { ChatNodeHookContext, ChatNodeOwnerProps, ChatFlowSlotProps, UsePresentation } from '../contract/slots.ts' import type { ChatNode } from '../contract/chat-nodes.ts' import type { ChatNodeStore } from '../contract/snapshot.ts' -import { TURN_PROCESS_INDEPENDENT_KINDS, turnProcessAlwaysOpen } from '../contract/turn-process.ts' +import { isAtOrOutside } from '../contract/group-tree.ts' +import { turnProcessAlwaysOpen } from '../contract/turn-process.ts' import { turnProcessOpen } from '../stores.ts' import { hiddenOrCollapsing } from './flow-motion.ts' import css from './ChatView.module.css' @@ -76,7 +77,7 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({ && (processPresentation.turnStarted || processPresentation.turnClosed) const processMember = routedNode !== undefined && processWindowReady - && !TURN_PROCESS_INDEPENDENT_KINDS.has(routedNode.kind) + && !isAtOrOutside(routedNode, 'turn') && routedNode.anchorSeq >= processSpec.processStartSeq && (liveProcess || processSpec.answerAnchorSeq === null || routedNode.anchorSeq < processSpec.answerAnchorSeq || (groupPart === 'reasoning' && routedNode.kind === 'assistant-step' && routedNode.data.step === processSpec.answerStep)) diff --git a/packages/client/ui-chat/src/client/chat/ChatView.tsx b/packages/client/ui-chat/src/client/chat/ChatView.tsx index 3b528dc47a..1e0741f31f 100644 --- a/packages/client/ui-chat/src/client/chat/ChatView.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatView.tsx @@ -10,6 +10,7 @@ import type { PendingSubmission } from '@deepseek-ai/dsh-api-session-controller/ import { Button, IconChevronDownOutlineRegular, MarkdownDelegateProvider, Modal, } from '@deepseek-ai/dsh-client-ui-primitives' +import { isAtOrOutside } from '../contract/group-tree.ts' import type { ChatFlowHookContext, ChatViewSlotProps, OpenFileOptions } from '../contract/slots.ts' import type { ChatSnapshot } from '../contract/snapshot.ts' import { TurnNavigator } from './TurnNavigator.tsx' @@ -37,13 +38,12 @@ function observedInputs( let lastInputTurn: number | undefined for (const key of order) { const node = nodes.get(key) - if (node === undefined || (node.kind !== 'user' && node.kind !== 'steering' && node.kind !== 'turn-trigger')) continue + if (node === undefined || !isAtOrOutside(node, 'input')) continue if (node.location.kind === 'turn' || node.location.kind === 'step') lastInputTurn = node.location.turn.turn - if (node.kind === 'turn-trigger') continue - const source = (node.data as { readonly source?: unknown }).source as - | { readonly kind?: unknown; readonly rpcId?: unknown } - | undefined - if (source?.kind === 'user' && typeof source.rpcId === 'string') observed.add(source.rpcId) + const data: unknown = node.data + const source = typeof data === 'object' && data !== null && 'source' in data ? data.source : undefined + if (typeof source === 'object' && source !== null && 'kind' in source && source.kind === 'user' + && 'rpcId' in source && typeof source.rpcId === 'string') observed.add(source.rpcId) } return { rpcIds: observed, lastInputTurn } } diff --git a/packages/client/ui-chat/src/client/chat/TurnTailNodeView.tsx b/packages/client/ui-chat/src/client/chat/TurnTailNodeView.tsx index a3d58a8cfe..c29b831ce8 100644 --- a/packages/client/ui-chat/src/client/chat/TurnTailNodeView.tsx +++ b/packages/client/ui-chat/src/client/chat/TurnTailNodeView.tsx @@ -26,9 +26,10 @@ function lastContent(snapshot: ChatSnapshot, turn: number, skipWarning: boolean) /** Turn-local actions and feature tail over the Location index, independent of Assistant placement. */ export const TurnTailNodeView = memo(function TurnTailNodeView({ - node, openFile, forkAt, renderSlot, t, useChat, usePerformanceUsage, + node, openFile, forkAt, renderSlot, t, useAccess, useChat, usePerformanceUsage, }: TurnTailNodeViewProps) { const detailed = usePerformanceUsage(mode => mode) === 'detailed' + const forkAllowed = useAccess('session/fork') const data = node.data const hasLaterChatNode = useChat(snapshot => (lastContent(snapshot, data.turn, true)?.anchorSeq ?? -1) > (data.closing?.finalNode.seq ?? data.seq)) @@ -66,7 +67,7 @@ export const TurnTailNodeView = memo(function TurnTailNodeView({ clock="end" // The branch action owns boundary resolution: it sends the real // turn/end seq it already has, and the Host cuts exactly there. - onBranch={() => { forkAt(data.seq) }} + onBranch={forkAllowed ? () => { forkAt(data.seq) } : undefined} branchUnavailable={data.branchUnavailable || hasLaterChatNode} className={css.actions} extraActions={assistantActions} diff --git a/packages/client/ui-chat/src/client/chat/flow-motion.ts b/packages/client/ui-chat/src/client/chat/flow-motion.ts index 9705f1f22a..6d941ecfdc 100644 --- a/packages/client/ui-chat/src/client/chat/flow-motion.ts +++ b/packages/client/ui-chat/src/client/chat/flow-motion.ts @@ -1,7 +1,8 @@ /** - * Fold transitions owned by one Chat viewport. Input folds coordinate box sizes and scrolling on - * one frame clock; other folds use CSS transitions. Motion is opt-in under `[data-chat-motion]`, - * respects reduced motion, and clears temporary styles. + * Fold transitions owned by one Chat viewport. Every batch containing a closing row moves its boxes and + * the scrollport on one frame clock; a batch that only reveals rows, or a viewport without a reading + * policy, uses CSS transitions. Motion is opt-in under `[data-chat-motion]`, respects reduced motion, + * and clears temporary styles. */ import { useCallback, useLayoutEffect, useRef, type RefObject } from 'react' import { useSearchableHidden, type HiddenTransition } from './searchable-hidden.ts' @@ -24,7 +25,7 @@ export interface FlowMotionRows { cancelMotion(element: HTMLElement): void } -/** Geometry captured before one input-owned fold changes its boxes. */ +/** Geometry captured before one fold batch changes its boxes. */ export interface InputFoldBox { readonly element: HTMLElement readonly height: number @@ -32,29 +33,33 @@ export interface InputFoldBox { readonly closing: boolean } -/** The viewport follows the same progress that changes the folded boxes. */ +/** The viewport moves on the same progress that changes the folded boxes. */ export interface InputFoldDriver { /** - * Capture geometry before the frame clock changes the boxes. + * Capture geometry and choose the landing before the frame clock changes the boxes. * @param boxes - non-nested boxes whose layout changes on this clock. + * @returns closing boxes that scroll with the content and stay opaque; the rest fade in place. The driver + * may add boxes while the batch runs, when a newer tail request takes it over. */ - begin(boxes: readonly InputFoldBox[]): void + begin(boxes: readonly InputFoldBox[]): ReadonlySet /** - * Reserve scroll range before box writes; omit when the driver needs no pre-write compensation. + * Reserve scroll range before box writes. * @param progress - shared eased progress from zero to one. - * @param cancelled - automatic following has stopped; reader-position compensation still applies. + * @param cancelled - a reading gesture stopped a tail landing's automatic target; a landing that keeps + * the reader's content still continues and includes the reader's own movement. */ - prepare?(progress: number, cancelled: boolean): void + prepare(progress: number, cancelled: boolean): void /** - * Apply viewport geometry after box writes, even when automatic following is cancelled. + * Apply viewport geometry after box writes. * @param progress - shared eased progress from zero to one. - * @param cancelled - preserve the reader's position rather than applying the automatic target. + * @param cancelled - a reading gesture stopped a tail landing's automatic target. * @returns actual scrollTop displacement from the batch start, for closing-content visual compensation. */ frame(progress: number, cancelled: boolean): number /** - * Release temporary geometry after the last frame or cancellation. - * @param cancelled - clean up without reacquiring follow ownership or committing an obsolete landing. + * Install the final geometry after the last frame or cancellation. + * @param cancelled - a reading gesture stopped the automatic target, every closing row reopened, or the + * viewport detached; the driver commits no obsolete landing. */ finish(cancelled: boolean): void } @@ -65,11 +70,11 @@ export interface FlowMotion extends FlowMotionRows { foldActive(): boolean /** @param listener - callback after the last fold. @returns cancellation for a pending callback. */ onFoldIdle(listener: () => void): () => void - /** @param driver - one input-owned viewport motion. @returns whether an active fold was adopted. */ - followInput(driver: InputFoldDriver): boolean - /** Stop input-owned scrolling while letting its closing rows finish. */ + /** Stop the running batch's automatic target while letting its boxes finish. */ cancelInput(): void - /** @returns whether a coordinated fold currently owns layout and scrolling. */ + /** Restore the running batch's automatic target after a newer request takes it over. */ + resumeInput(): void + /** @returns whether a fold batch runs, or one is scheduled for this commit's closing rows. */ inputActive(): boolean /** Cancel owned animations and pending work when the viewport detaches. */ clear(): void @@ -94,113 +99,191 @@ export function hiddenOrCollapsing(element: HTMLElement): boolean { return element.hasAttribute('hidden') || element.dataset.chatMotion === 'collapse' } +/** + * Opacity of a revealing box at batch progress, continuing from the share its own transition reached. + * @param box - revealing box. + * @param progress - shared eased progress from zero to one. + * @returns opacity from zero to one. + */ +function revealedShare(box: OwnedBox, progress: number): number { + const start = box.height === 0 ? 0 : box.fromHeight / box.height + return start + (1 - start) * progress +} + +/** A box this viewport owns until it finishes or is cancelled. */ +interface OwnedBox { + readonly element: HTMLElement + /** + * Full size: a closing box shrinks from it, a revealing box grows to it. A batch re-measures a closing + * box's current size when it takes the box over. + */ + height: number + marginTop: number + /** Size a revealing box had already reached on its own transition when a batch took it over. */ + fromHeight: number + fromMarginTop: number + readonly closing: boolean + /** The box changed while a batch ran; it waits without a CSS transition until the next batch. */ + held: boolean + /** Start a held revealing box's own transition when no batch will drive it. */ + release(): void + /** Stop the box's own transition and fallback timer before a batch drives it. */ + take(): void + finish(): void +} + /** * Create animation coordination without cross-viewport broadcasts. - * @param reserve - reserve the deduplicated removed height in this viewport. + * @param driver - the viewport's driver for the next batch; null while detached. * @param foldingChanged - disable or restore this viewport's native scroll anchoring. * @returns stable callbacks and cleanup for this viewport's animations. */ -export function createFlowMotion(reserve: (px: number) => void, foldingChanged: (active: boolean) => void): FlowMotion { +export function createFlowMotion(driver: () => InputFoldDriver | null, foldingChanged: (active: boolean) => void): FlowMotion { const running = new Map void>() let folding = 0 const foldIdleListeners = new Set<() => void>() - const pendingReserve = new Set() - let pendingGrowth = 0 - let reserveScheduled = false - const boxes = new Map void; finish: () => void }>() - let inputDriver: InputFoldDriver | null = null - let inputFrame: number | null = null - let inputBatch = false + let batchScheduled = false + const boxes = new Map() + let frameRequest: number | null = null + let batch = false + let cancelled = false let clearEpoch = 0 - let inputCleanup: (() => void) | null = null - let activeDriver: InputFoldDriver | null = null + let batchCleanup: (() => void) | null = null let cancelEmptyBatch: (() => void) | null = null + /** The batch is committing hidden rows and its final geometry; idle listeners wait until both land. */ + let settling = false + let anchoringDisabled = false - function cancelInput(): void { inputDriver = null } - - function followInput(driver: InputFoldDriver): boolean { - if (folding === 0) return false - if (inputBatch) { - inputDriver = activeDriver - return true - } - inputDriver = driver - scheduleReserve() - return true + function cancelInput(): void { + if (batch) cancelled = true } - function runInputBatch(): void { - const driver = inputDriver - if (driver === null || inputBatch) return - const owned = [...boxes.values()] - const batch = owned.filter(box => box.element.parentElement?.closest('[data-chat-motion="collapse"]') === null) - .sort((left, right) => left.element.compareDocumentPosition(right.element) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1) - if (!batch.some(box => box.closing)) return - inputBatch = true - activeDriver = driver - driver.begin(batch) - inputCleanup = () => { driver.finish(true) } - for (const box of owned) box.take() - for (const box of batch) { - box.element.style.transition = 'none' - box.element.style.height = `${box.closing ? box.height : 0}px` - box.element.style.marginTop = `${box.closing ? box.marginTop : 0}px` - box.element.style.opacity = box.closing ? '1' : '0' + function resumeInput(): void { + if (batch) cancelled = false + } + + /** Rows inside a closing ancestor change with it and never join a batch on their own. */ + function batchMember(box: InputFoldBox): boolean { + return box.element.parentElement?.closest('[data-chat-motion="collapse"]') === null + } + + function runBatch(current: InputFoldDriver): void { + // Every member continues from the size it has now, so the driver's measured layout and its change + // amounts describe the same boxes: a closing box may have moved on its own transition or sit inside a + // row that finished closing, and a revealing box may have grown part of the way on its own transition. + for (const box of [...boxes.values()]) { + if (!batchMember(box)) continue + const height = box.element.offsetHeight + const marginTop = Number.parseFloat(getComputedStyle(box.element).marginTop) || 0 + if (!box.closing) { + box.fromHeight = Math.min(height, box.height) + box.fromMarginTop = Math.min(marginTop, box.marginTop) + continue + } + box.height = height + box.marginTop = marginTop + if (height === 0 && marginTop === 0) box.finish() } + const owned = [...boxes.values()] + const members = owned.filter(batchMember) + .sort((left, right) => left.element.compareDocumentPosition(right.element) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1) + if (!members.some(box => box.closing)) return + batch = true + cancelled = false + const travelling = current.begin(members.map(box => box.closing ? box : { + element: box.element, + height: box.height - box.fromHeight, + marginTop: box.marginTop - box.fromMarginTop, + closing: false, + })) + batchCleanup = () => { current.finish(true) } + for (const box of owned) box.take() + for (const box of members) { + box.element.style.transition = 'none' + box.element.style.height = `${box.closing ? box.height : box.fromHeight}px` + box.element.style.marginTop = `${box.closing ? box.marginTop : box.fromMarginTop}px` + box.element.style.opacity = box.closing ? '1' : String(revealedShare(box, 0)) + } + const memberSet = new Set(members) + const settledTravel = new Set() + // A travelling box clips its content as it shrinks, so nested closing rows keep their size until it ends. + const freezeTravelling = (): void => { + for (const element of travelling) { + if (settledTravel.has(element)) continue + settledTravel.add(element) + element.style.translate = '' + for (const box of owned) { + if (memberSet.has(box) || !element.contains(box.element)) continue + box.element.style.transition = 'none' + box.element.style.height = `${box.height}px` + box.element.style.marginTop = `${box.marginTop}px` + box.element.style.opacity = '1' + } + } + } + freezeTravelling() const reduced = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches let started: number | undefined let displacement = 0 - cancelEmptyBatch = () => { - if (batch.some(box => box.closing && boxes.get(box.element) === box)) return - if (inputFrame !== null) cancelAnimationFrame(inputFrame) - inputFrame = null - inputBatch = false - activeDriver = null - inputDriver = null - inputCleanup = null + const settle = (finishCancelled: boolean): void => { + batch = false + batchCleanup = null cancelEmptyBatch = null - for (const box of owned) if (boxes.get(box.element) === box) box.finish() - driver.finish(true) + settling = true + try { + for (const box of owned) if (boxes.get(box.element) === box) box.finish() + current.finish(finishCancelled) + } finally { + settling = false + } + if (folding === 0) notifyIdle() + // Rows that changed while this batch ran wait in place; the next flush drives or releases them. + if ([...boxes.values()].some(box => box.held)) flushBatch() + } + cancelEmptyBatch = () => { + if (members.some(box => box.closing && boxes.get(box.element) === box)) return + if (frameRequest !== null) cancelAnimationFrame(frameRequest) + frameRequest = null + settle(true) } const tick = (now: number): void => { - inputFrame = null + frameRequest = null started ??= now const elapsed = reduced ? CLOSE_DELAY_MS + CLOSE_MS : now - started const linear = Math.min(1, Math.max(0, (elapsed - CLOSE_DELAY_MS) / CLOSE_MS)) const progress = linear * linear * (3 - 2 * linear) - driver.prepare?.(progress, inputDriver !== driver) - for (const box of batch) { + current.prepare(progress, cancelled) + freezeTravelling() + for (const box of members) { if (boxes.get(box.element) !== box) continue - const remaining = box.closing ? 1 - progress : progress - box.element.style.height = `${box.height * remaining}px` - box.element.style.marginTop = `${box.marginTop * remaining}px` - box.element.style.opacity = box.closing ? String(Math.max(0, 1 - elapsed / FADE_OUT_MS)) : String(progress) + if (box.closing) { + box.element.style.height = `${box.height * (1 - progress)}px` + box.element.style.marginTop = `${box.marginTop * (1 - progress)}px` + box.element.style.opacity = travelling.has(box.element) ? '1' : String(Math.max(0, 1 - elapsed / FADE_OUT_MS)) + } else { + box.element.style.height = `${box.fromHeight + (box.height - box.fromHeight) * progress}px` + box.element.style.marginTop = `${box.fromMarginTop + (box.marginTop - box.fromMarginTop) * progress}px` + box.element.style.opacity = String(revealedShare(box, progress)) + } } - displacement = driver.frame(progress, inputDriver !== driver) + displacement = current.frame(progress, cancelled) let removedBefore = 0 - for (const box of batch) { + for (const box of members) { if (boxes.get(box.element) !== box) continue if (box.closing) { // Transforming the closing box changes its pixels, not its flow contribution. - box.element.style.translate = `0 ${displacement + removedBefore}px` + if (!travelling.has(box.element)) box.element.style.translate = `0 ${displacement + removedBefore}px` removedBefore += (box.height + box.marginTop) * progress - } else removedBefore -= (box.height + box.marginTop) * progress + } else removedBefore -= (box.height - box.fromHeight + box.marginTop - box.fromMarginTop) * progress } if (linear < 1) { - inputFrame = requestAnimationFrame(tick) + frameRequest = requestAnimationFrame(tick) return } - for (const box of owned) box.finish() - inputBatch = false - activeDriver = null - inputCleanup = null - cancelEmptyBatch = null - const cancelled = inputDriver !== driver - if (!cancelled) inputDriver = null - driver.finish(cancelled) + settle(cancelled) } if (reduced) tick(0) - else inputFrame = requestAnimationFrame(tick) + else frameRequest = requestAnimationFrame(tick) } function foldActive(): boolean { return folding > 0 } @@ -211,54 +294,58 @@ export function createFlowMotion(reserve: (px: number) => void, foldingChanged: return () => { foldIdleListeners.delete(listener) } } - function flushReserve(): void { - reserveScheduled = false - if (inputDriver !== null && !inputBatch) { - pendingReserve.clear() - pendingGrowth = 0 - runInputBatch() + function flushBatch(): void { + batchScheduled = false + // Rows that change during a batch are held until it settles, which flushes again. + if (batch) return + const current = [...boxes.values()].some(box => box.closing) ? driver() : null + if (current !== null) { + runBatch(current) return } - if (typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches) { - pendingReserve.clear() - pendingGrowth = 0 - for (const box of [...boxes.values()]) box.finish() - return + const reduced = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches + // Without a batch, a held closing row finishes at once and a held revealing row starts its own transition. + for (const box of [...boxes.values()]) { + if (reduced || (box.held && box.closing)) box.finish() + else if (box.held) box.release() } - let total = -pendingGrowth - pendingGrowth = 0 - for (const element of pendingReserve) { - // A member inside a closing group is already part of the group's own box. - if (element.parentElement?.closest('[data-chat-motion="collapse"]') !== null) continue - total += element.offsetHeight + (Number.parseFloat(getComputedStyle(element).marginTop) || 0) - } - pendingReserve.clear() - if (total > 0) reserve(total) } - function scheduleReserve(): void { - if (reserveScheduled) return - reserveScheduled = true + function scheduleBatch(): void { + if (batchScheduled) return + batchScheduled = true // After the whole commit's collapses and reveals have started, before any box changes size. const epoch = clearEpoch - queueMicrotask(() => { if (epoch === clearEpoch) flushReserve() }) + queueMicrotask(() => { if (epoch === clearEpoch) flushBatch() }) } function foldStarted(): void { folding += 1 - if (folding === 1) foldingChanged(true) + if (folding !== 1 || anchoringDisabled) return + anchoringDisabled = true + foldingChanged(true) } function foldEnded(): void { folding = Math.max(0, folding - 1) - if (folding > 0) return - foldingChanged(false) + if (folding === 0 && !settling) notifyIdle() + } + + function notifyIdle(): void { + if (anchoringDisabled) { + anchoringDisabled = false + foldingChanged(false) + } const listeners = [...foldIdleListeners] foldIdleListeners.clear() for (const listener of listeners) listener() } - function clearMotion(element: HTMLElement, onEnd: (event: TransitionEvent) => void, timer: ReturnType): void { + function clearMotion( + element: HTMLElement, + onEnd: (event: TransitionEvent) => void, + timer: ReturnType | undefined, + ): void { element.removeEventListener('transitionend', onEnd) clearTimeout(timer) running.delete(element) @@ -274,7 +361,6 @@ export function createFlowMotion(reserve: (px: number) => void, foldingChanged: } function cancelMotion(element: HTMLElement): void { - pendingReserve.delete(element) running.get(element)?.() cancelEmptyBatch?.() } @@ -288,20 +374,23 @@ export function createFlowMotion(reserve: (px: number) => void, foldingChanged: return } const { style } = element + // A running batch owns the scrollport; a row closing now keeps its size until the next batch drives it. + const held = batch element.dataset.chatMotion = 'collapse' foldStarted() - pendingReserve.add(element) - scheduleReserve() + scheduleBatch() style.transition = 'none' style.overflow = 'hidden' style.height = `${height}px` style.marginTop = `${marginTop}px` - void element.offsetHeight - style.transition = `opacity ${FADE_OUT_MS}ms ${EASING}, ` - + `height ${CLOSE_MS}ms ${EASING} ${CLOSE_DELAY_MS}ms, margin-top ${CLOSE_MS}ms ${EASING} ${CLOSE_DELAY_MS}ms` - style.opacity = '0' - style.height = '0px' - style.marginTop = '0px' + if (!held) { + void element.offsetHeight + style.transition = `opacity ${FADE_OUT_MS}ms ${EASING}, ` + + `height ${CLOSE_MS}ms ${EASING} ${CLOSE_DELAY_MS}ms, margin-top ${CLOSE_MS}ms ${EASING} ${CLOSE_DELAY_MS}ms` + style.opacity = '0' + style.height = '0px' + style.marginTop = '0px' + } // The hidden state must land before fold-idle listeners measure the flow. const finish = (): void => { @@ -313,14 +402,15 @@ export function createFlowMotion(reserve: (px: number) => void, foldingChanged: const onEnd = (event: TransitionEvent): void => { if (event.target === element && event.propertyName === 'height') finish() } - const timer = setTimeout(finish, CLOSE_DELAY_MS + CLOSE_MS + SETTLE_SLACK_MS) + const timer = held ? undefined : setTimeout(finish, CLOSE_DELAY_MS + CLOSE_MS + SETTLE_SLACK_MS) const cancel = (): void => { clearMotion(element, onEnd, timer) foldEnded() } - element.addEventListener('transitionend', onEnd) + if (!held) element.addEventListener('transitionend', onEnd) running.set(element, cancel) - boxes.set(element, { element, height, marginTop, closing: true, finish, + boxes.set(element, { element, height, marginTop, fromHeight: height, fromMarginTop: marginTop, closing: true, held, finish, + release: () => {}, take: () => { element.removeEventListener('transitionend', onEnd); clearTimeout(timer) } }) } @@ -329,8 +419,7 @@ export function createFlowMotion(reserve: (px: number) => void, foldingChanged: const height = element.offsetHeight if (height === 0) return const marginTop = Number.parseFloat(getComputedStyle(element).marginTop) || 0 - pendingGrowth += height + marginTop - scheduleReserve() + scheduleBatch() const { style } = element element.dataset.chatMotion = 'reveal' style.transition = 'none' @@ -338,12 +427,6 @@ export function createFlowMotion(reserve: (px: number) => void, foldingChanged: style.opacity = '0' style.height = '0px' style.marginTop = '0px' - void element.offsetHeight - style.transition = `height ${CLOSE_MS}ms ${EASING} ${CLOSE_DELAY_MS}ms, margin-top ${CLOSE_MS}ms ${EASING} ${CLOSE_DELAY_MS}ms, ` - + `opacity ${CLOSE_MS}ms ${EASING} ${CLOSE_DELAY_MS}ms` - style.height = `${height}px` - style.marginTop = `${marginTop}px` - style.opacity = '1' const finish = (): void => { if (running.get(element) !== finish) return clearMotion(element, onEnd, timer) @@ -351,31 +434,48 @@ export function createFlowMotion(reserve: (px: number) => void, foldingChanged: const onEnd = (event: TransitionEvent): void => { if (event.target === element && event.propertyName === 'height') finish() } - const timer = setTimeout(finish, CLOSE_DELAY_MS + CLOSE_MS + SETTLE_SLACK_MS) - element.addEventListener('transitionend', onEnd) + let timer: ReturnType | undefined + const startTransition = (): void => { + void element.offsetHeight + style.transition = `height ${CLOSE_MS}ms ${EASING} ${CLOSE_DELAY_MS}ms, margin-top ${CLOSE_MS}ms ${EASING} ${CLOSE_DELAY_MS}ms, ` + + `opacity ${CLOSE_MS}ms ${EASING} ${CLOSE_DELAY_MS}ms` + style.height = `${height}px` + style.marginTop = `${marginTop}px` + style.opacity = '1' + timer = setTimeout(finish, CLOSE_DELAY_MS + CLOSE_MS + SETTLE_SLACK_MS) + element.addEventListener('transitionend', onEnd) + } + // A running batch owns the scrollport; a row revealed now waits at zero size until the next flush. + const held = batch + if (!held) startTransition() running.set(element, finish) - boxes.set(element, { element, height, marginTop, closing: false, finish, - take: () => { element.removeEventListener('transitionend', onEnd); clearTimeout(timer) } }) + const box: OwnedBox = { element, height, marginTop, fromHeight: 0, fromMarginTop: 0, closing: false, held, finish, + release: () => { + box.held = false + startTransition() + }, + take: () => { element.removeEventListener('transitionend', onEnd); clearTimeout(timer) } } + boxes.set(element, box) } function clear(): void { clearEpoch++ - reserveScheduled = false - inputDriver = null - activeDriver = null - inputBatch = false - if (inputFrame !== null) cancelAnimationFrame(inputFrame) - inputFrame = null - inputCleanup?.() - inputCleanup = null + batchScheduled = false + batch = false + cancelled = false + if (frameRequest !== null) cancelAnimationFrame(frameRequest) + frameRequest = null + batchCleanup?.() + batchCleanup = null cancelEmptyBatch = null foldIdleListeners.clear() - pendingReserve.clear() - pendingGrowth = 0 for (const cancel of [...running.values()]) cancel() } - return { collapse, reveal, cancelMotion, foldActive, onFoldIdle, followInput, cancelInput, inputActive: () => inputBatch, clear } + return { + collapse, reveal, cancelMotion, foldActive, onFoldIdle, cancelInput, resumeInput, clear, + inputActive: () => batch || (batchScheduled && [...boxes.values()].some(box => box.closing && batchMember(box))), + } } function useFlowTransition(motion: FlowMotionRows | undefined, grow: boolean): HiddenTransition | undefined { diff --git a/packages/client/ui-chat/src/client/chat/use-chat-reading.ts b/packages/client/ui-chat/src/client/chat/use-chat-reading.ts index 3d3c6ae048..cca1c11a39 100644 --- a/packages/client/ui-chat/src/client/chat/use-chat-reading.ts +++ b/packages/client/ui-chat/src/client/chat/use-chat-reading.ts @@ -88,13 +88,26 @@ export class ChatReading { } /** - * Follow submitted input on the active fold's animation clock. - * @returns whether this input joined a closing batch instead of starting another scroll animation. + * Follow the tail on the active fold's animation clock. + * @returns whether a fold batch owns the tail movement instead of a separate scroll animation. */ followInputFold(): boolean { this.cancelPending() - return this.viewport.followInputFold(this.follow, (landing) => { - this.commit(landing, true, this.viewport.latestTurn) + return this.viewport.followInputFold(this.follow) + } + + /** + * Let fold batches read follow intent and commit their tail landings. + * @returns a disposer that disconnects this reading policy from the viewport. + */ + bindFolds(): () => void { + return this.viewport.bindReader({ + follow: this.follow, + landedAtTail: (landing) => { + this.cancelPending() + this.commit(landing, true, this.viewport.latestTurn) + }, + pending: () => this.pending, }) } @@ -263,6 +276,7 @@ export function useChatReading( const follow = useScrollFollow(state.followingTail, FOLLOW_THRESHOLD + 1) const [reading] = useState(() => new ChatReading(viewport, store, state, setState, follow)) useLayoutEffect(() => { reading.setStore(store) }, [reading, store]) + useLayoutEffect(() => reading.bindFolds(), [reading]) useLayoutEffect(() => () => { reading.dispose() }, [reading]) return { reading, state } } diff --git a/packages/client/ui-chat/src/client/chat/use-chat-scroll.ts b/packages/client/ui-chat/src/client/chat/use-chat-scroll.ts index 8f867de148..0fd20082e3 100644 --- a/packages/client/ui-chat/src/client/chat/use-chat-scroll.ts +++ b/packages/client/ui-chat/src/client/chat/use-chat-scroll.ts @@ -56,30 +56,9 @@ export function useChatScroll(input: ChatScrollInput): ChatScrollState { const content = useRef<{ input: ChatScrollInput; applied: ChatScrollInput | null; opened: boolean }>({ input, applied: null, opened: false, }) - // A fold larger than the new content leaves bottom room rather than reversing the scrollport. - // Growth consumes that room before automatic following advances toward newer content. - const cancelFollow = useRef<(() => void) | null>(null) - const clearPendingFollow = useCallback(() => { - cancelFollow.current?.() - cancelFollow.current = null - }, []) - const cancelPendingFollow = useCallback(() => { - viewport.motion.cancelInput() - clearPendingFollow() - // Retain a cancelled wait until this fold ends, so resize cannot reacquire follow ownership. - if (viewport.motion.foldActive()) { - cancelFollow.current = viewport.motion.onFoldIdle(() => { cancelFollow.current = null }) - } - }, [clearPendingFollow, viewport]) - const followAfterFold = useCallback(() => { - if (cancelFollow.current !== null || viewport.motion.inputActive()) return - reading.interruptFollow() - cancelFollow.current = viewport.motion.onFoldIdle(() => { - cancelFollow.current = null - viewport.reclaimBelow() - if (content.current.input.deferCompletedTurns) reading.followTail('smooth') - }) - }, [reading, viewport]) + // A fold batch owns layout and scrolling until it lands: at the tail for own input or a following + // reader, otherwise with the reader's content still. Growth consumes any room the landing installed. + const cancelPendingFollow = useCallback(() => { viewport.motion.cancelInput() }, [viewport]) const processContent = useCallback(() => { const current = content.current.input @@ -99,7 +78,6 @@ export function useChatScroll(input: ChatScrollInput): ChatScrollState { return } if (ownInput) { - clearPendingFollow() navigation.cancel() if (current.deferCompletedTurns && reading.followInputFold()) return reading.followTail(current.deferCompletedTurns ? 'smooth' : 'instant') @@ -115,10 +93,10 @@ export function useChatScroll(input: ChatScrollInput): ChatScrollState { || current.steeringId !== previous.steeringId || current.submissionId !== previous.submissionId if (tipChanged && reading.followingTail) { navigation.cancel() - if (current.deferCompletedTurns && viewport.motion.foldActive()) followAfterFold() - else reading.followTail() + if (current.deferCompletedTurns && reading.followInputFold()) return + reading.followTail() } else navigation.reconcile() - }, [viewport, reading, navigation, followAfterFold, clearPendingFollow]) + }, [reading, navigation]) useLayoutEffect(() => { const disconnectViewport = viewport.connect({ @@ -139,9 +117,7 @@ export function useChatScroll(input: ChatScrollInput): ChatScrollState { navigation.reconcile() return } - if (content.current.input.deferCompletedTurns && viewport.motion.foldActive()) { - if (!reading.pending && reading.followingTail) followAfterFold() - } else reading.onResize() + if (!content.current.input.deferCompletedTurns || !viewport.motion.foldActive()) reading.onResize() navigation.reconcile() }, }) @@ -150,13 +126,12 @@ export function useChatScroll(input: ChatScrollInput): ChatScrollState { processContent() }) return () => { - clearPendingFollow() disconnectViewport() disconnectReading() content.current.opened = false content.current.applied = null } - }, [viewport, reading, navigation, processContent, followAfterFold, cancelPendingFollow, clearPendingFollow]) + }, [viewport, reading, navigation, processContent, cancelPendingFollow]) useLayoutEffect(() => { const previous = content.current.input diff --git a/packages/client/ui-chat/src/client/chat/use-chat-viewport.ts b/packages/client/ui-chat/src/client/chat/use-chat-viewport.ts index ad6de91901..40ea51de98 100644 --- a/packages/client/ui-chat/src/client/chat/use-chat-viewport.ts +++ b/packages/client/ui-chat/src/client/chat/use-chat-viewport.ts @@ -2,7 +2,7 @@ import { useLayoutEffect, useRef, useState, type RefObject } from 'react' import type { ChatScrollPosition } from '../contract/slots.ts' import type { ChatSnapshot } from '../contract/snapshot.ts' -import { createFlowMotion } from './flow-motion.ts' +import { createFlowMotion, type InputFoldBox, type InputFoldDriver } from './flow-motion.ts' import { scrollMetrics, ScrollFollow, type ViewportMetrics } from './use-scroll-follow.ts' export type { ViewportMetrics } from './use-scroll-follow.ts' @@ -37,6 +37,16 @@ interface ViewportElements { readonly spacer: HTMLElement | null } +/** Reading policy that fold batches consult and report tail landings to. */ +export interface FoldReader { + /** Follow intent at batch start selects the tail landing; tail landings commit it. */ + readonly follow: ScrollFollow + /** @param landing - the actual geometry after a batch landed at the tail. */ + readonly landedAtTail: (landing: ViewportLanding) => void + /** @returns whether reader movement awaits sampling; until then follow intent is undecided. */ + readonly pending: () => boolean +} + interface PagingPosition { readonly row: HTMLElement readonly position: ChatScrollPosition @@ -54,9 +64,14 @@ export class ChatViewport { private turns: ReturnType = [] private observation: { top: number; landing: ViewportLanding | null } = { top: 0, landing: null } private paging: PagingPosition | null = null + private reader: FoldReader | null = null + /** The pending or running fold batch lands at the tail. */ + private tailRequested = false + /** Switches the running batch to a tail landing; null while no batch runs. */ + private retarget: (() => void) | null = null /** Transition state and row callbacks owned by this viewport's attachment. */ readonly motion = createFlowMotion( - (px) => { this.reserveBelow(px) }, + () => this.foldDriver(), (active) => { if (this.elements !== null) this.elements.scroller.style.overflowAnchor = active ? 'none' : '' }, @@ -103,6 +118,8 @@ export class ChatViewport { this.observer = null this.elements = null this.events = null + this.tailRequested = false + this.retarget = null this.turns = [] this.observation = { top: 0, landing: null } } @@ -151,114 +168,241 @@ export class ChatViewport { } /** - * Add room below the flow before rows above the reader close, so the scrollport never clamps: - * the closing gap pulls the content below it upward while everything above stays put. - * @param px - height about to leave the flow. + * Connect the reading policy that fold batches consult. + * @param reader - follow intent and the tail-landing commit. + * @returns a disposer that disconnects only this reader. */ - reserveBelow(px: number): void { - if (px <= 0) return - this.setSpacer(this.spacerHeight() + px) + bindReader(reader: FoldReader): () => void { + this.reader = reader + return () => { if (this.reader === reader) this.reader = null } } /** - * Join an input-caused fold and its tail movement on the fold's frame clock. - * @param follow - the existing reader's follow controller. - * @param settled - commits follow ownership after the final geometry is installed. - * @returns whether a closing batch owns this input. + * Land the pending or running fold batch at the tail instead of starting a separate scroll. + * @param follow - follow controller whose native animation yields to the batch. + * @returns whether a fold owns the tail movement. */ - followInputFold(follow: ScrollFollow, settled: (landing: ViewportLanding) => void): boolean { - const elements = this.elements - if (elements === null) return false + followInputFold(follow: ScrollFollow): boolean { + // Rows still closing on CSS transitions after a batch have no batch to consume the request. + if (this.elements === null || this.reader === null || !this.motion.inputActive()) return false this.interruptFollow(follow) + this.tailRequested = true + this.retarget?.() + return true + } + + /** + * Drive one fold batch on its frame clock. A tail landing ends at the floor, never above the reader's + * compensated position; a reader landing keeps the visible content still. Both install only the room + * the landing lacks. + * @returns the batch driver, or null while detached or without a reading policy. + */ + private foldDriver(): InputFoldDriver | null { + const elements = this.elements + const reader = this.reader + if (elements === null || reader === null) return null + const { follow } = reader + let tail = false let start = 0 + let base = 0 let target = 0 let removed = 0 - let initialHeight = 0 - let originalRoom = 0 - let finalRoom = 0 let growth = 0 - let base = 0 + let initialHeight = 0 + let finalRoom = 0 let previousColumnHeight = 0 let previousProgress = 0 let segmentProgress = 0 let segmentTop = 0 - return this.motion.followInput({ + let written = 0 + const closing: InputFoldBox[] = [] + const travelling = new Set() + // A visible row outside the batch measures spacing that snaps when the batch starts and ends. + let reference: HTMLElement | null = null + let referenceTop = 0 + let viewportTop = 0 + const screenTop = (element: HTMLElement): number => element.getBoundingClientRect().top - viewportTop + const aim = (): void => { + const finalHeight = initialHeight - removed + growth + const height = elements.scroller.clientHeight + target = tail ? Math.max(base, finalHeight - height, 0) : base + finalRoom = target === 0 ? 0 : Math.max(0, target + height - finalHeight) + } + const restart = (): void => { + segmentTop = elements.scroller.scrollTop + segmentProgress = previousProgress + } + const automatic = (cancelled: boolean): boolean => !(cancelled && tail) + const position = (progress: number, cancelled: boolean): number => { + if (!automatic(cancelled)) return elements.scroller.scrollTop + const remaining = segmentProgress === 1 ? 1 : (progress - segmentProgress) / (1 - segmentProgress) + return segmentTop + (target - segmentTop) * remaining + } + const room = (top: number, progress: number, cancelled: boolean): number => Math.max( + automatic(cancelled) ? finalRoom : 0, + top + elements.scroller.clientHeight - (initialHeight + (growth - removed) * progress), + ) + return { begin: (boxes) => { - const viewport = elements.scroller.getBoundingClientRect() - const metrics = scrollMetrics(elements.scroller) - start = metrics.top - originalRoom = this.spacerHeight() + // Own input lands at the tail; otherwise an unsampled reading movement outranks follow intent. + tail = this.tailRequested || (follow.active && !reader.pending()) + this.tailRequested = false + viewportTop = elements.scroller.getBoundingClientRect().top + start = elements.scroller.scrollTop + const originalRoom = this.spacerHeight() initialHeight = Math.max(elements.column.offsetHeight, elements.scroller.scrollHeight - originalRoom) let above = 0 + const below: HTMLElement[] = [] for (const box of boxes) { const amount = box.height + box.marginTop + const top = box.element.getBoundingClientRect().top - box.marginTop if (box.closing) { removed += amount - const top = box.element.getBoundingClientRect().top - box.marginTop - above += Math.max(0, Math.min(amount, viewport.top - top)) - } else growth += amount + above += Math.max(0, Math.min(amount, viewportTop - top)) + if (top >= viewportTop) below.push(box.element) + closing.push(box) + } else { + growth += amount + if (top < viewportTop) above -= amount + } + } + if (!tail) reference = this.readingReference() + const referenceBefore = reference === null ? 0 : screenTop(reference) + elements.column.dataset.inputFold = '' + if (reference !== null) { + referenceTop = screenTop(reference) + above -= referenceTop - referenceBefore } - const finalHeight = initialHeight - removed + growth base = Math.max(0, start - above) segmentTop = start + written = start previousColumnHeight = elements.column.offsetHeight - target = Math.max(base, Math.max(0, finalHeight - metrics.height)) - finalRoom = target === 0 ? 0 : Math.max(0, target + metrics.height - finalHeight) - elements.column.dataset.inputFold = '' + aim() // This reserve only prevents an intermediate clamp; the final frame installs the exact deficit. this.setSpacer(Math.max(originalRoom, finalRoom, removed)) - follow.setFollowing(true) + if (tail) follow.setFollowing(true) + // A newer tail request also takes over a batch whose target a reading gesture cancelled. + this.retarget = () => { + tail = true + follow.setFollowing(true) + this.motion.resumeInput() + aim() + restart() + if (target <= segmentTop) return + for (const box of closing) { + // A box fading in place carries a visual translate; its layout position excludes it. + const shift = Number.parseFloat(box.element.style.translate.split(' ')[1] ?? '') || 0 + if (box.element.getBoundingClientRect().top - shift - box.marginTop >= viewportTop) travelling.add(box.element) + } + } + // Scrolling forward past closing rows shows them shrinking; faded rows would leave their space empty. + if (tail && target > start) for (const element of below) travelling.add(element) + return travelling }, prepare: (progress, cancelled) => { if (this.elements !== elements) return + if (!tail) { + // Reader scrolling between frames moves the reading position the batch keeps still. + const drift = elements.scroller.scrollTop - written + if (Math.abs(drift) > 0.5) { + base = Math.max(0, base + drift) + segmentTop += drift + // The reference moves on screen with the reader's scroll; only layout changes are corrected. + referenceTop -= drift + aim() + } + } const added = elements.column.offsetHeight - previousColumnHeight if (added !== 0) { initialHeight += added - const finalHeight = initialHeight - removed + growth - target = Math.max(base, finalHeight - elements.scroller.clientHeight, 0) - finalRoom = target === 0 ? 0 : Math.max(0, target + elements.scroller.clientHeight - finalHeight) - segmentTop = elements.scroller.scrollTop - segmentProgress = previousProgress + aim() + restart() } - const remaining = segmentProgress === 1 ? 1 : (progress - segmentProgress) / (1 - segmentProgress) - const top = cancelled ? elements.scroller.scrollTop : segmentTop + (target - segmentTop) * remaining - const currentHeight = initialHeight + (growth - removed) * progress - const room = Math.max(cancelled ? 0 : finalRoom, top + elements.scroller.clientHeight - currentHeight) + const top = position(progress, cancelled) // Growing the room before shrinking boxes avoids a native clamp between writes. - this.setSpacer(Math.max(this.spacerHeight(), room)) + this.setSpacer(Math.max(this.spacerHeight(), room(top, progress, cancelled))) }, frame: (progress, cancelled) => { if (this.elements !== elements) return 0 - const remaining = segmentProgress === 1 ? 1 : (progress - segmentProgress) / (1 - segmentProgress) - const top = cancelled ? elements.scroller.scrollTop : segmentTop + (target - segmentTop) * remaining - const currentHeight = initialHeight + (growth - removed) * progress - const room = Math.max(cancelled ? 0 : finalRoom, top + elements.scroller.clientHeight - currentHeight) + const top = position(progress, cancelled) elements.scroller.scrollTop = top - this.setSpacer(room) - if (!cancelled) this.observation = { top: elements.scroller.scrollTop, landing: null } + this.setSpacer(room(top, progress, cancelled)) + written = elements.scroller.scrollTop + // Earlier frames keep the reference still; only its final position is compared at finish. + if (reference !== null && progress === 1) referenceTop = screenTop(reference) + if (automatic(cancelled)) this.observation = { top: written, landing: null } previousColumnHeight = elements.column.offsetHeight previousProgress = progress - return elements.scroller.scrollTop - start + return written - start }, finish: (cancelled) => { + this.retarget = null + this.tailRequested = false if (this.elements !== elements) return - // Commit the final sibling margins while their input-fold transition override still applies. + // Commit the final sibling margins while their fold transition override still applies. void elements.column.offsetHeight delete elements.column.dataset.inputFold - if (cancelled) { + if (!tail || cancelled) { + if (!tail && reference?.isConnected === true && reference.closest('[hidden]') === null) { + // Committing the hidden rows settles their neighbours' spacing; keep the reference still. + const shift = screenTop(reference) - referenceTop + if (Math.abs(shift) > 0.5) { + const top = elements.scroller.scrollTop + shift + const content = elements.scroller.scrollHeight - this.spacerHeight() + this.setSpacer(Math.max(this.spacerHeight(), top + elements.scroller.clientHeight - content)) + elements.scroller.scrollTop = top + } + } const metrics = scrollMetrics(elements.scroller) const height = elements.scroller.scrollHeight - this.spacerHeight() this.setSpacer(metrics.top === 0 ? 0 : Math.max(0, metrics.top + metrics.height - height)) - this.invalidate() + if (tail) this.invalidate() + else this.observation = { top: elements.scroller.scrollTop, landing: null } return } - this.setSpacer(finalRoom) + // The estimate can differ from the committed layout, for example settled sibling spacing. + // Land on the actual content: never above the target, and at the real floor when content reaches past it. + const content = elements.scroller.scrollHeight - this.spacerHeight() + const clientHeight = elements.scroller.clientHeight + const top = Math.max(target, content - clientHeight, 0) + this.setSpacer(top === 0 ? 0 : Math.max(0, top + clientHeight - content)) + elements.scroller.scrollTop = top const metrics = scrollMetrics(elements.scroller) const landing = { metrics, position: null, turn: this.latestTurn } this.observation = { top: metrics.top, landing } - settled(landing) + reader.landedAtTail(landing) }, - }) + } + } + + /** + * Find the first visible top-level row at or below the scrollport top that holds no batch box. + * Binary search over the ordered top-level rows reads O(log n) boxes. + * @returns the reference row, or null when every visible row folds. + */ + private readingReference(): HTMLElement | null { + const elements = this.elements + if (elements === null) return null + const viewport = elements.scroller.getBoundingClientRect() + // Slot anchors use display:contents: their children are the top-level rows. + const rows = (elements.column.querySelector(':scope > [data-slot="conversation.chat.flow"]') ?? elements.column).children + let low = 0 + let high = rows.length + while (low < high) { + const middle = (low + high) >>> 1 + if ((rows[middle] as Element).getBoundingClientRect().bottom > viewport.top) high = middle + else low = middle + 1 + } + for (let index = low; index < rows.length; index++) { + const row = rows[index] as HTMLElement + if (row.hasAttribute('hidden')) continue + const rect = row.getBoundingClientRect() + if (rect.top >= viewport.bottom) return null + if (rect.height === 0 || rect.bottom <= viewport.top) continue + if (row.hasAttribute('data-chat-motion') || row.querySelector('[data-chat-motion]') !== null) continue + return row + } + return null } /** Clear fold reservations after a display-mode change, allowing the scroll range to shrink. */ diff --git a/packages/client/ui-chat/src/client/contract/chat-nodes.ts b/packages/client/ui-chat/src/client/contract/chat-nodes.ts index cade646b1c..ea65a14834 100644 --- a/packages/client/ui-chat/src/client/contract/chat-nodes.ts +++ b/packages/client/ui-chat/src/client/contract/chat-nodes.ts @@ -3,6 +3,7 @@ import type { ConversationLocation, ConversationViewNode, ModelRetryNode, RunningToolCall, ToolCallBlock, } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { ChatGroupTree } from './group-tree.ts' /** Final Chat render unit produced by a Chat business Definition. */ export interface ChatConversationViewNode extends ConversationViewNode { @@ -10,6 +11,13 @@ export interface ChatConversationViewNode extends ConversationViewNode { readonly anchorSeq: number readonly location: ConversationLocation readonly visibility: 'visible' | 'hidden' + /** + * Group-tree position, visible or hidden; absence joins the surrounding step + * run. An `exchange` Node's Turn and every later Turn before the next opening + * Turn form one exchange; the latest loaded exchange starts at the last loaded + * opening Turn, or at the first loaded Turn when none is loaded. + */ + readonly groupTree?: ChatGroupTree | undefined } /** Merge-extensible payload registry keyed by final Chat renderer kind. */ diff --git a/packages/client/ui-chat/src/client/contract/group-tree.ts b/packages/client/ui-chat/src/client/contract/group-tree.ts new file mode 100644 index 0000000000..961a0ebb8f --- /dev/null +++ b/packages/client/ui-chat/src/client/contract/group-tree.ts @@ -0,0 +1,37 @@ +import type { ChatConversationViewNode } from './chat-nodes.ts' + +/** + * Chat group-tree positions from the outermost inward; a Node at a position + * also has the effect of every inner position. + * - `exchange`: the Node's Turn opens an exchange, the run of Turns that lasts + * until the next opening Turn. + * - `input`: the Node is an input of its Turn. An input before the Turn's + * process is the Turn's opening input; a later one is interleaved and keeps + * the whole Turn open. + * - `turn`: the Node stays in its Turn outside the Turn's process disclosure. + * - `process`: the Node stays in the process disclosure outside step runs. + * - absent: the Node joins the step run around it. + * + * The Definition that builds a Node chooses its position, and step runs, the + * process disclosure, and Turn inputs are selected by position. An + * `assistant-step` Node places its rows without one: its reasoning row joins + * the step run and its reply row stands outside it. + * + * Whole-Turn folding also exempts the finalized answer's reply. That exemption + * comes from the Turn's process specification (`answerAnchorSeq` and + * `answerStep`), not from a position, because which step answers changes as + * later steps arrive. + */ +export type ChatGroupTree = 'exchange' | 'input' | 'turn' | 'process' + +const DEPTH: Readonly> = { exchange: 0, input: 1, turn: 2, process: 3 } + +/** + * Test whether a Node stands at a group-tree position or outside it. + * @param node - Chat Node. + * @param position - group-tree position to compare against. + * @returns whether the Node's position is `position` or an outer one. + */ +export function isAtOrOutside(node: Pick, position: ChatGroupTree): boolean { + return node.groupTree !== undefined && DEPTH[node.groupTree] <= DEPTH[position] +} diff --git a/packages/client/ui-chat/src/client/contract/slots.ts b/packages/client/ui-chat/src/client/contract/slots.ts index 9bce65619b..a85e28798d 100644 --- a/packages/client/ui-chat/src/client/contract/slots.ts +++ b/packages/client/ui-chat/src/client/contract/slots.ts @@ -17,6 +17,8 @@ import type { import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { MarkdownFileMentions, MarkdownLabels } from '@deepseek-ai/dsh-client-ui-primitives' import type {} from '@deepseek-ai/dsh-client-ui-layout/client' +// Type-only: the Session-scope access Hook (`useAccess`) the Chat entries read. +import type {} from '@deepseek-ai/dsh-client-ui-session/client' import type { createChatStore } from '../stores.ts' import type { ChatPresentationPolicy } from '../presentation-policy.ts' import type { FlowMotionRows } from '../chat/flow-motion.ts' diff --git a/packages/client/ui-chat/src/client/contract/snapshot.ts b/packages/client/ui-chat/src/client/contract/snapshot.ts index b337c929bf..7e5f5038d3 100644 --- a/packages/client/ui-chat/src/client/contract/snapshot.ts +++ b/packages/client/ui-chat/src/client/contract/snapshot.ts @@ -35,7 +35,10 @@ export interface ChatNodeStore { get(key: string): ChatConversationViewNode | undefined /** @param key - stable Conversation Context key. @returns its identity-stable observable source. */ source(key: string): ChatNodeSource - /** @param key - Chat Node identity. @returns whether the visible Node belongs to the last Turn in the loaded timeline. */ + /** + * @param key - Chat Node identity. + * @returns whether the visible Node belongs to the latest loaded exchange (see `ChatConversationViewNode.groupTree`). + */ bottomSource(key: string): ObservableSnapshot /** * Observe one Turn's data for a single Node kind, including hidden Nodes, in anchor order. diff --git a/packages/client/ui-chat/src/client/contract/turn-process.ts b/packages/client/ui-chat/src/client/contract/turn-process.ts index 8b7d89f64e..63c0e56af6 100644 --- a/packages/client/ui-chat/src/client/contract/turn-process.ts +++ b/packages/client/ui-chat/src/client/contract/turn-process.ts @@ -17,22 +17,6 @@ export interface TurnProcessSpec { readonly subagentCount: number } -const TURN_PROCESS_INDEPENDENT_KIND_LIST = [ - 'system-prompt', - 'user', - 'steering', - 'turn-trigger', - 'turn-process', - 'turn-error', - 'turn-max-tokens', - 'turn-tail', -] as const satisfies readonly ChatNode['kind'][] - -/** Chat Node kinds that remain independent of a Turn's process disclosure. */ -export const TURN_PROCESS_INDEPENDENT_KINDS: ReadonlySet = new Set( - TURN_PROCESS_INDEPENDENT_KIND_LIST, -) - /** * Compare immutable Turn-process specifications by their published fields. * @param left - previous specification. diff --git a/packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml b/packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml index 5a6a4824ad..d40f82715f 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml +++ b/packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml @@ -24,8 +24,8 @@ en: 91b35cbebe8c2a1d zh: a4fee6d7102bc874 /chat-conversation-node-rules/whole-turn-folding: - en: 6feeb1527191633f - zh: 401656a7b9a4a4ed + en: 42c6f6b9e2433263 + zh: 651ceb5c22314259 /chat-conversation-node-rules/display-modes: en: ada5a60d2c46f6a8 zh: f3eaeb5cb4d9b3de @@ -33,14 +33,14 @@ en: b25bf00ce3a6a119 zh: 0ab47a4014ea5e1e /chat-conversation-node-rules/display-modes/collapse-timing: - en: d87667e4d1729e51 - zh: 014a14ebb16ac798 + en: ad74c1a610fc50d2 + zh: 13de05ffedf63f0c /chat-conversation-node-rules/display-modes/group-title-rules: en: 2392290662bc69e8 zh: 8872d651c07a8f4c /chat-conversation-node-rules/process-grouping: - en: c2650489ded57503 - zh: f2554d74e75ae0f9 + en: 3d28b3bb8757bf55 + zh: 3b0f451b48db6048 /chat-conversation-node-rules/process-grouping/examples: en: 2a550f7e04bce4b0 zh: b8f95b687448cd77 diff --git a/packages/client/ui-chat/src/client/conversation-nodes/README.md b/packages/client/ui-chat/src/client/conversation-nodes/README.md index 02bb1f08d4..2efd2681af 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/README.md +++ b/packages/client/ui-chat/src/client/conversation-nodes/README.md @@ -70,7 +70,7 @@ If steering, a User message, or a trigger notice follows process output, the Tur Whole-Turn folding controls the loaded process range, independently of secondary groups, in Compact, Standard, and Detailed. Verbose keeps that range visible and retains the duration/status header without a collapse action. A recorded Turn end makes that range eligible even when paging has not loaded the Turn start. -The rules below use the default completion-time folding. The [collapse-timing preference](#collapse-timing) can defer the latest Turn's historical presentation without changing whole-Turn eligibility. +The rules below use the default completion-time folding. The [collapse-timing preference](#collapse-timing) can defer the latest exchange's historical presentation without changing whole-Turn eligibility. The Turn control follows all its opening inputs, including human steering and non-human trigger notices, while waiting for the first Assistant output and after that output arrives. Consecutive inputs before the first process evidence are opening inputs, anchored by the last one. Later inputs retain their positions: even when paging has not loaded their inbox insertions and they temporarily appear as ordinary User messages, preceding process content must not move after them. @@ -94,7 +94,7 @@ Trigger titles and icons use the recorded `source.kind`: `schedule`, `tool-jobs` | Neither start nor end is loaded | Keep process rows visible and withhold the whole-Turn control. | | A Turn control exists, but there is no process content | Retain its title, without a collapse action. | -The final answer is the latest Step's settled Assistant reply, provided it has visible reply content and no tool-call block. Its response remains outside whole-Turn folding; its reasoning remains process content. User and steering inputs, trigger notices, terminal errors, max-token notices, and the completed-turn footer remain independent. Secondary grouping treats model retries as separators, but whole-Turn folding still includes retry rows. +The final answer is the latest Step's settled Assistant reply, provided it has visible reply content and no tool-call block. Its response remains outside whole-Turn folding; its reasoning remains process content. A Node whose [group-tree position](../contract/group-tree.ts) is `turn` or outer remains independent of whole-Turn folding. Turn inputs stand at `input` or `exchange`: user and steering messages and trigger notices; an input after the process start keeps the whole Turn open. Terminal errors, max-token notices, the completed-turn footer, and the Turn control stand at `turn`. A `process` Node, such as a model retry, separates secondary groups but whole-Turn folding still includes it. Loading an older page preserves the reader's group-opening choices. Newly loaded process content follows the same Turn state while the final answer is unchanged and whole-Turn folding remains eligible. If the page reveals an intervening input, individual group disclosures replace whole-Turn hiding. When the real start arrives, the duration becomes available; loading all history is not an additional folding condition. When new content only extends an existing group at its beginning, that group and its old message rows retain their identities and opening choices. Replies, steering, and other real boundaries in the new page still separate groups; not every new row joins the old group. @@ -138,9 +138,9 @@ Reading a default does not automatically save configuration. When the user chang Settings → General → Collapse work details appears directly below Work details and is independent of the display mode. On completion is the default and retains immediate folding and tail positioning. On next message enables delayed folding and its coordinated transitions. The choice is saved as `ui-chat.collapseTiming` through the same Host profile settings as Work details and is restored after reload or reopening the app. Profiles without a saved value use On completion without writing that default. It does not write browser storage or Session data. -With On next message, the latest Turn keeps its running presentation after completion: Compact and Standard retain their process headers, and Detailed retains its flat process rows. A pending ordinary transcript submission or a newer Turn ends this exemption without clearing the reader's manual whole-Turn choice. Inner disclosures reset only when their outer process actually hides. Queued input and steering alone do not end it. The latest Turn of a reopened Session also receives the exemption; manual whole-Turn choices still take precedence. Switching back to On completion ends the exemption immediately. +With On next message, the latest exchange keeps its running presentation after completion: Compact and Standard retain their process headers, and Detailed retains its flat process rows. An exchange starts with the Turn whose first Step admits human input and includes every later Turn until the next such Turn, so a Turn woken by a notice (a settled subagent, goal round, schedule, or job) leaves the preceding Turns of its exchange expanded. A pending ordinary transcript submission or a Turn that opens a newer exchange ends this exemption without clearing the reader's manual whole-Turn choice; a bare Turn start does not, because the host claims the waking input before it logs Step 1. Inner disclosures reset only when their outer process actually hides. Queued input and steering alone do not end it. The latest exchange of a reopened Session also receives the exemption, starting at the first loaded Turn when the loaded window holds no opening Turn; manual whole-Turn choices still take precedence. Switching back to On completion ends the exemption immediately. -When a new message triggers a fold, process details close and the transcript follows the new message in one upward movement, without a second scroll after the fold. Off-screen steps do not add bottom space; only the space needed to preserve the visible movement remains when the retained answer and new message cannot fill it. Content added during the fold updates its remaining movement. Reopening every closing row cancels that movement and releases obsolete bottom space. A reading gesture interrupts automatic following; finishing the fold does not resume it. +When a new message triggers a fold, process details close in one movement with no second scroll after the fold. If this client sent the message, or the reader was following the tail, the transcript lands on the new message; otherwise, such as a message from another tab or a queued message delivered after the previous Turn, the content the reader is looking at stays still. Off-screen steps do not add bottom space; only the space needed to preserve the visible movement remains when the retained answer and new message cannot fill it. Content added during the fold updates its remaining movement. Reopening every closing row cancels that movement and releases obsolete bottom space. A reading gesture interrupts automatic following; finishing the fold does not resume it. Verbose retains its flat process rows under either timing. Failed, stopped, and interleaved-input Turns retain their existing whole-Turn eligibility rules; delayed folding can still postpone Detailed's conversion from flat rows to historical groups. @@ -198,15 +198,14 @@ Switching modes retains manually opened groups and inner disclosures. It changes A group collects adjacent process content within one Turn. Step-number changes alone do not split it. All four modes use the same grouping result. A group's `closed` flag means its content segment has ended, not that its UI disclosure is collapsed. -When `question-reply` and `turn-trigger` project the same message id, grouping references only the question reply. Both Nodes stay in the Node Store; the duplicate trigger neither renders nor splits a group. The Turn control precedes the group containing an opening reply. This aggregation also applies before the owning Turn is loaded. +Grouping reads each Node's [group-tree position](../contract/group-tree.ts), which its Definition sets, rather than its kind. | Input | Membership and segmentation | |---|---| | Non-blank Assistant reasoning | Append one `reasoning` reference for that Assistant to the current group, creating a group if necessary. | | Assistant reply | End the preceding group and emit an independent `response` reference. If the same Node has reasoning, append that reasoning before ending the group. | -| `user`, `steering`, `turn-trigger`, `model-retry`, `turn-error`, `turn-max-tokens`, `turn-tail` | End the preceding group and retain the Node as an independent root. | -| `turn-process` | Retain the control as an independent root without ending the current group. | -| Other visible Nodes owned by a Turn, including tools | Append the whole Node to the current group, creating a group if necessary. | +| Node at `process` or an outer position: `model-retry`, Turn inputs, `turn-error`, `turn-max-tokens`, `turn-tail`, and the `turn-process` control | End the preceding group and retain the Node as an independent root. | +| Other visible Nodes owned by a Turn without a position, including tools | Append the whole Node to the current group, creating a group if necessary. | | Node from another Turn or without a Turn | Break the same-Turn sequence. A Node without a Turn remains independent, including an unsplit Assistant. | | Hidden Nodes, system prompts, ordinary Context injection, and `permission` commands | Excluded from the grouping input; they neither join nor split a group. | diff --git a/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md b/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md index 600b02c266..e121f62439 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md +++ b/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md @@ -70,7 +70,7 @@ description: "Chat 过程分组与活动摘要的明细业务规则。" 简洁、标准、详细模式的整轮折叠控制已加载的过程范围,独立于二级组。完全展开模式保持该范围可见,保留时长或状态抬头,但不支持收起。已经记录轮次结束时,即使向前分页尚未加载到轮次起点,也可以对该范围折叠。 -以下规则采用默认的完成时收起。[收起时机设置](#collapse-timing) 可以推迟最新轮次恢复历史呈现的时间,不改变整轮折叠资格。 +以下规则采用默认的完成时收起。[收起时机设置](#collapse-timing) 可以推迟最新交互段恢复历史呈现的时间,不改变整轮折叠资格。 整轮控件位于该轮所有起始输入之后,包括人工 steering 和非人工触发通知;等待首个 Assistant 输出及输出到达后均保持这一顺序。首个过程证据之前的连续输入都属于开场,以最后一条为准;过程证据之后的输入留在原位,即使分页尚未加载其入队记录、消息暂时归为普通 User,也不能把前面的过程移到它后面。 @@ -94,7 +94,7 @@ description: "Chat 过程分组与活动摘要的明细业务规则。" | 起点与结束记录均未加载 | 保留过程行可见,不显示整轮控件。 | | 存在整轮控件,但没有过程内容 | 保留标题,不提供折叠操作。 | -最终答案取最后一个 Step 中已结算的 Assistant 回复,要求有可见回复内容且没有工具调用块。最终答案的回复正文不参与整轮折叠,推理仍属于过程。用户与 steering 输入、触发通知、终止错误、最大 token 提示及已完成轮次页脚保持独立。二级分组把模型重试视为分隔节点,但整轮折叠仍包含重试行。 +最终答案取最后一个 Step 中已结算的 Assistant 回复,要求有可见回复内容且没有工具调用块。最终答案的回复正文不参与整轮折叠,推理仍属于过程。[分组树位置](../contract/group-tree.ts)为 `turn` 或更外层的 Node 不参与整轮折叠。轮次输入位于 `input` 或 `exchange`:用户与 steering 消息、触发通知;过程开始后出现的输入使整个轮次保持展开。终止错误、最大 token 提示、已完成轮次页脚和整轮控件位于 `turn`。`process` 位置的 Node(如模型重试)分隔二级分组,但整轮折叠仍包含它。 加载更早的一页保留用户的组级开合选择。最终答案未变化且仍符合整轮折叠条件时,新加载的过程内容沿用该轮的开合状态;若新页揭示中途输入,则改用各组开合,不再整轮隐藏。真正的起点到达后才显示时长,是否已加载完全部历史不是额外折叠条件。若新内容只是接在旧组前面,旧组和旧消息行保留身份及已有展开状态。新页出现回复、steering 等真实边界时仍按规则分组,不把所有新增内容强并进旧组。 @@ -138,9 +138,9 @@ description: "Chat 过程分组与活动摘要的明细业务规则。" 设置 → 通用设置 → 过程收起时机位于工作步骤展示正下方,与展示模式独立。「完成时收起」是默认值,保留立即折叠和即时跟尾定位;「下次发送时收起」启用延后折叠及其协调过渡。选择以 `ui-chat.collapseTiming` 保存到与工作步骤展示相同的 Host profile 设置,刷新或重新打开应用后会恢复。未保存此值的 profile 使用「完成时收起」,不会自动写入默认值。此偏好不写入浏览器存储或 Session 数据。 -选择「下次发送时收起」后,最新轮次完成时保留运行中的呈现:简洁和标准保留过程组头,详细保留平铺过程行。普通 transcript 输入进入待发送状态,或出现更新轮次时,取消这项例外,但保留用户对整轮的手动开合选择。只有外层过程实际隐藏时,才重置内部开合。排队输入和单独的 steering 不会取消例外。重新打开 Session 时,其最新轮次也享有例外;整轮的手动开合选择仍优先。切回「完成时收起」会立即取消例外。 +选择「下次发送时收起」后,最新交互段完成时保留运行中的呈现:简洁和标准保留过程组头,详细保留平铺过程行。交互段从首个 Step 接收真人输入的轮次开始,包含之后的所有轮次,直到下一个这样的轮次,因此由通知(子 agent 完成、Goal Round、定时任务或后台作业)唤醒的轮次不会收起同一交互段里更早的轮次。普通 transcript 输入进入待发送状态,或出现开启更新交互段的轮次时,取消这项例外,但保留用户对整轮的手动开合选择;仅出现新轮次的开始不会取消,因为 Host 先认领唤醒输入,再记录 Step 1。只有外层过程实际隐藏时,才重置内部开合。排队输入和单独的 steering 不会取消例外。重新打开 Session 时,其最新交互段也享有例外,已加载窗口中没有开启轮次时从第一个已加载轮次算起;整轮的手动开合选择仍优先。切回「完成时收起」会立即取消例外。 -新消息触发折叠时,过程收起与跟随新消息合为一次向上运动,折叠结束后不再追加滚动。屏幕外的步骤不增加底部补白;只有保留的正文和新消息无法填满可见内容移动所需空间时,才保留必要差额。折叠期间新增的内容会更新剩余运动;重新展开全部正在收起的行时,取消该运动并释放失效的底部补白。阅读手势会中断自动跟随,折叠结束不会重新接管。 +新消息触发折叠时,过程收起为一次运动,折叠结束后不再追加滚动。消息由本客户端发出、或读者原本在跟尾时,对话落到新消息;其余情况(例如另一个标签页发来的消息,或上一轮结束后才投递的排队消息)下,读者正在看的内容保持不动。屏幕外的步骤不增加底部补白;只有保留的正文和新消息无法填满可见内容移动所需空间时,才保留必要差额。折叠期间新增的内容会更新剩余运动;重新展开全部正在收起的行时,取消该运动并释放失效的底部补白。阅读手势会中断自动跟随,折叠结束不会重新接管。 完全展开模式在两种时机下都保留平铺过程行。失败、停止和含插入输入的轮次沿用原有整轮折叠资格;延后折叠仍可推迟详细模式从平铺行转换为历史分组的时机。 @@ -198,15 +198,14 @@ description: "Chat 过程分组与活动摘要的明细业务规则。" 一组收集同一轮次内相邻的过程内容,仅 Step 编号变化不会拆组。四档模式使用同一分组结果。组的 `closed` 表示这段内容已经结束,不表示 UI 开合控件处于收起状态。 -当 `question-reply` 和 `turn-trigger` 投影自同一个消息 id 时,分组只引用问答回复。两个 Node 都保留在 Node Store 中;重复的触发节点既不显示,也不切开分组。整轮控件位于包含开场回复的分组之前。所属轮次尚未加载时也执行这一聚合。 +分组读取每个 Node 由其 Definition 设定的[分组树位置](../contract/group-tree.ts),而不是它的 kind。 | 输入 | 归属与切分 | |---|---| | 非空白 Assistant 推理 | 将该 Assistant 的一个 `reasoning` 引用加入当前组,必要时创建组。 | | Assistant 回复 | 结束前面的组,输出独立的 `response` 引用。同一 Node 含有推理时,先将推理加入组,再结束该组。 | -| `user`、`steering`、`turn-trigger`、`model-retry`、`turn-error`、`turn-max-tokens`、`turn-tail` | 结束前面的组,Node 作为独立根引用保留。 | -| `turn-process` | 控件作为独立根引用保留,不结束当前组。 | -| 轮次内其他可见 Node,包括工具 | 将整个 Node 加入当前组,必要时创建组。 | +| 位于 `process` 或更外层位置的 Node:`model-retry`、轮次输入、`turn-error`、`turn-max-tokens`、`turn-tail` 及 `turn-process` 控件 | 结束前面的组,Node 作为独立根引用保留。 | +| 轮次内没有位置的其他可见 Node,包括工具 | 将整个 Node 加入当前组,必要时创建组。 | | 另一轮次或没有轮次归属的 Node | 截断同轮次序列。无轮次 Node 保持独立,Assistant 也不拆分。 | | 隐藏 Node、系统提示词、普通 Context 注入及 `permission` 命令 | 不进入分组输入,既不入组,也不截断组。 | diff --git a/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts b/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts index 0423e1ff20..933ae8f760 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts @@ -11,8 +11,10 @@ import type { ChatLocationNodeIndex, ChatNodeProcessSource, ChatNodeSource, ChatNodeStore, ChatSnapshot, ChatTurnNavigationIndex, ChatTurnProcessPresentation, LegacyConversationSlice, TurnNavigationItem, } from '../contract/snapshot.ts' -import { TURN_PROCESS_INDEPENDENT_KINDS } from '../contract/turn-process.ts' +import { isAtOrOutside } from '../contract/group-tree.ts' +import { locationCoordinates } from './common.ts' import { sessionRecallLabels, skillInvocationName } from './event-projection.ts' +import { GroupTreeSegments } from './group-tree-segments.ts' import { sameTurnNavigationItem, turnNavigationItem } from './turn-navigation.ts' import { ChatTurnProcessProjector } from './turn-process-presentation.ts' @@ -108,7 +110,8 @@ class MutableChatNodeStore implements ChatNodeStore { private readonly sources = new Map>() private readonly processSources = new Map>() private readonly bottomSources = new Map>() - private bottomTurn: number | undefined + /** Bottom membership: the latest loaded exchange. */ + private readonly exchanges = new GroupTreeSegments('exchange') private readonly dirtyKeys = new Set() private readonly dirtyBottomKeys = new Set() private readonly dirtyProcessKeys = new Set() @@ -131,19 +134,21 @@ class MutableChatNodeStore implements ChatNodeStore { bottomSource(key: string): ObservableSnapshot { return cachedSource(this.bottomSources, key, () => new MutableChatSource(() => { const node = this.get(key) as ChatNode | undefined - return this.bottomTurn !== undefined && node !== undefined && isVisibleChatNode(node) - && locationCoordinates(node.location).turn === this.bottomTurn + return node !== undefined && isVisibleChatNode(node) + && this.exchanges.inLatest(locationCoordinates(node.location).turn) }, `[ui-chat] node bottom source ${key}`)) } - setBottomTurn(turn: number | undefined, locations: ChatLocationNodeIndex): void { - if (turn === this.bottomTurn) return - for (const changed of [this.bottomTurn, turn]) { - if (changed !== undefined) { - for (const key of locations.getTurn(changed)) this.dirtyBottomKeys.add(key) - } + /** + * Settle the latest exchange for the loaded Turns and mark the Nodes of + * Turns that entered or left it. + * @param turnOrder - loaded Turns in ascending timeline order. + * @param locations - current Chat Location index. + */ + settleBottom(turnOrder: readonly number[], locations: ChatLocationNodeIndex): void { + for (const turn of this.exchanges.settle(turnOrder)) { + for (const key of locations.getTurn(turn)) this.dirtyBottomKeys.add(key) } - this.bottomTurn = turn } turnDataSource(turn: number, kind: Kind): ObservableSnapshot { @@ -196,6 +201,7 @@ class MutableChatNodeStore implements ChatNodeStore { this.byKey.set(node.key, node) if (previous.get(node.key) !== node) { this.updateTurnKind(previous.get(node.key), node) + this.exchanges.track(previous.get(node.key), node) this.dirtyKeys.add(node.key) this.dirtyProcessKeys.add(node.key) } @@ -203,6 +209,7 @@ class MutableChatNodeStore implements ChatNodeStore { } for (const key of previous.keys()) { this.updateTurnKind(previous.get(key), undefined) + this.exchanges.track(previous.get(key), undefined) this.dirtyKeys.add(key) this.dirtyProcessKeys.add(key) } @@ -215,6 +222,7 @@ class MutableChatNodeStore implements ChatNodeStore { for (const node of nodes) { if (this.byKey.get(node.key) === node) continue this.updateTurnKind(this.byKey.get(node.key), node) + this.exchanges.track(this.byKey.get(node.key), node) this.byKey.set(node.key, node) this.dirtyKeys.add(node.key) this.dirtyProcessKeys.add(node.key) @@ -408,12 +416,6 @@ function stepKey(turn: number, step: number): string { return `${turn}:${step}` } -function locationCoordinates(location: ConversationLocation): { turn?: number; step?: number } { - if (location.kind === 'step') return { turn: location.turn.turn, step: location.step.step } - if (location.kind === 'turn') return { turn: location.turn.turn } - return {} -} - function locationTurnStatus(location: ConversationLocation): string | undefined { return location.kind === 'turn' || location.kind === 'step' ? location.turn.status : undefined } @@ -455,7 +457,7 @@ function turnProcessPresentations( if (location.kind !== 'turn' && location.kind !== 'step') continue const current: TurnProcessPresentation = presentations.get(location.turn.turn) ?? {} const controlAnchor = current.control?.data.controlAnchorSeq - if ((node.kind === 'user' || node.kind === 'turn-trigger' || node.kind === 'steering') + if (isAtOrOutside(node, 'input') && controlAnchor !== undefined && (controlAnchor === location.turn.start?.seq || node.anchorSeq < controlAnchor)) { presentations.set(location.turn.turn, { @@ -464,7 +466,7 @@ function turnProcessPresentations( }) continue } - if (TURN_PROCESS_INDEPENDENT_KINDS.has(node.kind)) continue + if (isAtOrOutside(node, 'turn')) continue presentations.set(location.turn.turn, { ...current, earliestProcessAnchor: Math.min(current.earliestProcessAnchor ?? node.anchorSeq, node.anchorSeq), @@ -495,7 +497,7 @@ function presentationPosition( const openingInputAnchor = presentation.openingInputAnchor if (openingInputAnchor !== undefined && node.anchorSeq < openingInputAnchor - && !TURN_PROCESS_INDEPENDENT_KINDS.has(node.kind)) { + && !isAtOrOutside(node, 'turn')) { return { anchor: openingInputAnchor, rank: 2, originalAnchor: node.anchorSeq } } if (presentation.control !== undefined && node.key === presentation.control.key) { @@ -1114,7 +1116,7 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder node.key) this.locations.rebuild(this.order, this.store) this.store.replaceProcesses(this.order, this.locations) - this.store.setBottomTurn(input.timeline.turnOrder.at(-1), this.locations) + this.store.settleBottom(input.timeline.turnOrder, this.locations) this.navigation.rebuild(input.timeline, this.locations, this.store) this.timeline = input.timeline this.latestGroupInput = { @@ -1146,6 +1148,7 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder( @@ -45,6 +57,7 @@ export function chatNode( options: { readonly location?: ConversationLocation readonly visibility?: 'visible' | 'hidden' + readonly groupTree?: ChatGroupTree | undefined } = {}, ): ChatNode { return { @@ -55,6 +68,7 @@ export function chatNode( anchorSeq, location: options.location ?? contextLocation(context), visibility: options.visibility ?? 'visible', + groupTree: options.groupTree, data, } } diff --git a/packages/client/ui-chat/src/client/conversation-nodes/group-tree-segments.ts b/packages/client/ui-chat/src/client/conversation-nodes/group-tree-segments.ts new file mode 100644 index 0000000000..738b79ef29 --- /dev/null +++ b/packages/client/ui-chat/src/client/conversation-nodes/group-tree-segments.ts @@ -0,0 +1,91 @@ +import type { ChatConversationViewNode } from '../contract/chat-nodes.ts' +import { isAtOrOutside, type ChatGroupTree } from '../contract/group-tree.ts' +import { locationCoordinates } from './common.ts' + +const NO_TURNS: readonly number[] = [] + +/** + * Loaded Turns split into consecutive segments at the Turns holding a Node at + * one group-tree position, visible or hidden. The latest segment starts at the + * last such Turn, or at the first loaded Turn when none is loaded. Node changes + * update per-Turn counts without scanning the loaded Nodes; `settle` returns at + * once while the start holds and otherwise reads loaded Turns only from the + * earlier of the previous and new starts. + */ +export class GroupTreeSegments { + /** Opening Node count by owning Turn. */ + private readonly openings = new Map() + private latestOpening: number | undefined + private latestStart: number | undefined + + /** @param position - group-tree position whose Nodes open a segment. */ + constructor(private readonly position: ChatGroupTree) {} + + /** + * Account one Node change. + * @param previous - replaced Node; absent for an addition. + * @param next - replacing Node; absent for a removal. + */ + track(previous: ChatConversationViewNode | undefined, next: ChatConversationViewNode | undefined): void { + const before = this.openingTurn(previous) + const after = this.openingTurn(next) + if (before === after) return + if (before !== undefined && this.adjust(before, -1) === 0 && before === this.latestOpening) { + this.latestOpening = this.lastOpeningTurn() + } + if (after !== undefined) { + this.adjust(after, 1) + if (this.latestOpening === undefined || after > this.latestOpening) this.latestOpening = after + } + } + + /** + * Test latest-segment membership as of the last `settle`. + * @param turn - owning Turn, when resolved. + * @returns whether the Turn belongs to the latest segment. + */ + inLatest(turn: number | undefined): boolean { + return turn !== undefined && this.latestStart !== undefined && turn >= this.latestStart + } + + /** + * Move the latest segment start for the currently loaded Turns. + * @param turnOrder - loaded Turns in ascending timeline order. + * @returns loaded Turns that entered or left the latest segment, in ascending order. + */ + settle(turnOrder: readonly number[]): readonly number[] { + const previous = this.latestStart + const start = this.latestOpening ?? turnOrder[0] + if (start === previous) return NO_TURNS + this.latestStart = start + const bounded = previous !== undefined && start !== undefined + const low = bounded ? Math.min(previous, start) : Number.NEGATIVE_INFINITY + const high = bounded ? Math.max(previous, start) : Number.POSITIVE_INFINITY + const changed: number[] = [] + for (const turn of turnOrder.slice(turnOrder.findLastIndex(turn => turn < low) + 1)) { + if (turn >= high) break + changed.push(turn) + } + return changed + } + + private openingTurn(node: ChatConversationViewNode | undefined): number | undefined { + return node !== undefined && isAtOrOutside(node, this.position) ? locationCoordinates(node.location).turn : undefined + } + + private lastOpeningTurn(): number | undefined { + let last: number | undefined + for (const turn of this.openings.keys()) { + if (last === undefined || turn > last) last = turn + } + return last + } + + /** @returns the Turn's opening count after the change; zero removes the Turn. */ + private adjust(turn: number, delta: number): number { + const next = (this.openings.get(turn) ?? 0) + delta + if (next > 0) this.openings.set(turn, next) + else this.openings.delete(turn) + return next + } +} diff --git a/packages/client/ui-chat/src/client/conversation-nodes/message.ts b/packages/client/ui-chat/src/client/conversation-nodes/message.ts index ddfffb0cca..8739466722 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/message.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/message.ts @@ -4,7 +4,7 @@ import type { } from '@deepseek-ai/dsh-client-ui-conversation/client' import { isAppendSurfaceEvent, isReplacementSurfaceEvent } from '@deepseek-ai/dsh-session/surface' import type { InboxState } from './inbox.ts' -import { chatNode } from './common.ts' +import { chatNode, contextLocation } from './common.ts' import { contextForm, contextProducer } from './event-projection.ts' interface ReferencedUserMessageNode extends UserMessageNode { @@ -115,7 +115,18 @@ export const messageDefinition: ConversationNodeDefinition = { const waking = context.state.kind === 'context' && context.start?.event.type === 'user/message' && context.state.waking === true - return chatNode(context, waking ? 'turn-trigger' : context.state.kind, context.state.seq, context.state) + // Human input claimed for Step 1 opens an exchange; Notice-woken Turns and + // later steering stay in the current exchange, so they never fold earlier Turns. + const location = contextLocation(context) + const human = context.state.kind === 'user' || context.state.kind === 'steering' + const opensExchange = human && location.kind === 'step' && location.step.step === 1 + return chatNode( + context, + waking ? 'turn-trigger' : context.state.kind, + context.state.seq, + context.state, + { location, groupTree: opensExchange ? 'exchange' : (human || waking) ? 'input' : undefined }, + ) }, } diff --git a/packages/client/ui-chat/src/client/conversation-nodes/process-groups.ts b/packages/client/ui-chat/src/client/conversation-nodes/process-groups.ts index 09edc21006..201569449f 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/process-groups.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/process-groups.ts @@ -8,9 +8,9 @@ import type { ChatConversationViewNode, ChatNode } from '../contract/chat-nodes. import type { ProcessActivitySummary, ProcessGroupData } from '../contract/process-groups.ts' import { hasAssistantReplyContent } from '../contract/assistant-content.ts' import { isVisibleChatNode } from '../contract/chat-visibility.ts' +import { isAtOrOutside } from '../contract/group-tree.ts' import { processActivity } from './process-activity.ts' -const INDEPENDENT = new Set(['user', 'steering', 'turn-trigger', 'model-retry', 'turn-error', 'turn-max-tokens', 'turn-tail']) type ProcessInput = ConversationGroupInput function turnOf(node: ChatNode): number | undefined { @@ -44,6 +44,7 @@ function structureChanged(previous: ChatNode | undefined, current: ChatNode): bo return previous === undefined || previous.kind !== current.kind || turnOf(previous) !== turnOf(current) || isVisibleChatNode(previous) !== isVisibleChatNode(current) + || previous.groupTree !== current.groupTree || reasoning(previous) !== reasoning(current) || reply(previous) !== reply(current) } @@ -53,15 +54,6 @@ function readNode(input: ProcessInput, key: NodeKey): ChatNode { return node } -function questionReplyIds(input: ProcessInput, keys: readonly NodeKey[]): ReadonlySet { - const ids = new Set() - for (const key of keys) { - const node: ChatConversationViewNode = readNode(input, key) - if (node.kind === 'question-reply') ids.add(node.id) - } - return ids -} - /** One group's members and cached summary, refreshed together when its content changes. */ class ProcessGroup { private nodes: readonly ChatNode[] = [] @@ -152,23 +144,17 @@ class TurnGroups { } let previous: NodeKey | undefined let followed = false - const keys = input.readTurn(this.turn) - const replies = questionReplyIds(input, keys) - for (const key of keys) { + for (const key of input.readTurn(this.turn)) { const position = readPosition(input, key) if (previous !== undefined && position.previous !== previous) flush(true) previous = key followed = position.next !== undefined const node = readNode(input, key) - // Both Definitions retain the same message id; only its question presentation renders. - if (node.kind === 'turn-trigger' && replies.has(node.id)) continue - if (INDEPENDENT.has(node.kind)) { + if (isAtOrOutside(node, 'process')) { flush(true) emit(key, { kind: 'node', key }) - } else if (node.kind === 'turn-process') { - // A coalesced opening reply can sort before the control that folds its group. - emit(pending[0]?.key ?? key, { kind: 'node', key }) } else if (node.kind === 'assistant-step') { + // One Node, two rows: reasoning joins the step run, a reply stands outside it. if (reasoning(node)) pending.push({ kind: 'node', key, groupPart: 'reasoning' }) if (reply(node)) { flush(true) @@ -291,14 +277,9 @@ export class ProcessState { } private rootEntries(input: ProcessInput): RenderEntry[] { - const unscoped = input.order.filter(key => readPosition(input, key).turn === undefined) - const replies = questionReplyIds(input, unscoped) return input.order.flatMap((key) => { const turn = readPosition(input, key).turn - if (turn === undefined) { - const node = readNode(input, key) - return node.kind === 'turn-trigger' && replies.has(node.id) ? [] : [{ kind: 'node', key }] - } + if (turn === undefined) return [{ kind: 'node', key }] const groups = this.turns.get(turn) if (groups === undefined) throw new Error(`Chat grouping order is missing Turn ${turn}`) return groups.references(key) diff --git a/packages/client/ui-chat/src/client/conversation-nodes/retry.ts b/packages/client/ui-chat/src/client/conversation-nodes/retry.ts index d721821f22..644690f789 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/retry.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/retry.ts @@ -84,7 +84,7 @@ export const retryDefinition: ConversationNodeDefinition = { const current = attempts.at(-1) if (current === undefined) return null const data: RetryChatData = { attempts, current } - return chatNode(context, 'model-retry', attempts[0]?.seq ?? current.seq, data) + return chatNode(context, 'model-retry', attempts[0]?.seq ?? current.seq, data, { groupTree: 'process' }) }, } diff --git a/packages/client/ui-chat/src/client/conversation-nodes/turn-error.ts b/packages/client/ui-chat/src/client/conversation-nodes/turn-error.ts index 445d073639..1f8f190fc5 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/turn-error.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/turn-error.ts @@ -91,7 +91,7 @@ export const turnErrorDefinition: ConversationNodeDefinition = { message: failure.message, ...failure.code === undefined ? {} : { code: failure.code }, } - return chatNode(context, 'turn-error', node.seq, node) + return chatNode(context, 'turn-error', node.seq, node, { groupTree: 'turn' }) }, } diff --git a/packages/client/ui-chat/src/client/conversation-nodes/turn-max-tokens.ts b/packages/client/ui-chat/src/client/conversation-nodes/turn-max-tokens.ts index 746d00bc95..03127b85ae 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/turn-max-tokens.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/turn-max-tokens.ts @@ -69,7 +69,7 @@ export const turnMaxTokensDefinition: ConversationNodeDefinition openingHumanAnchor)) { hasInterleavedInput = true if (spec.answerAnchorSeq === null || node.anchorSeq < spec.answerAnchorSeq) compactAnswer = false } - if (TURN_PROCESS_INDEPENDENT_KINDS.has(node.kind) + if (isAtOrOutside(node, 'turn') || node.anchorSeq < spec.processStartSeq || (spec.answerAnchorSeq !== null && node.anchorSeq >= spec.answerAnchorSeq)) continue if (node.kind !== 'assistant-step' || spec.answerStep === null || node.data.step !== spec.answerStep) { diff --git a/packages/client/ui-chat/src/client/conversation-nodes/turn-process.ts b/packages/client/ui-chat/src/client/conversation-nodes/turn-process.ts index f151676c6b..b38b937fea 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/turn-process.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/turn-process.ts @@ -296,6 +296,7 @@ export const turnProcessDefinition: ConversationNodeDefinition 'turn-process', data.controlAnchorSeq + CHAT_SYNTHETIC_SEQ_OFFSETS.processControl, data, + { groupTree: 'turn' }, ) }, } diff --git a/packages/client/ui-chat/src/client/conversation-nodes/turn-tail.ts b/packages/client/ui-chat/src/client/conversation-nodes/turn-tail.ts index c1bfdd0c4d..4ca63d5375 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/turn-tail.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/turn-tail.ts @@ -135,7 +135,7 @@ export const turnTailDefinition: ConversationNodeDefinition = { buildViewNode: (context) => { const turn = turnLocation(context) const data = turn?.data.get('turn-tail') - return data === undefined ? null : chatNode(context, 'turn-tail', data.seq + CHAT_SYNTHETIC_SEQ_OFFSETS.finalizedFollowup, data) + return data === undefined ? null : chatNode(context, 'turn-tail', data.seq + CHAT_SYNTHETIC_SEQ_OFFSETS.finalizedFollowup, data, { groupTree: 'turn' }) }, } diff --git a/packages/client/ui-chat/src/client/index.ts b/packages/client/ui-chat/src/client/index.ts index 09edabb684..275d76b96b 100644 --- a/packages/client/ui-chat/src/client/index.ts +++ b/packages/client/ui-chat/src/client/index.ts @@ -27,6 +27,7 @@ export type { FinalAssistantChatData, ManualCompactionChatData, RetryChatData, ToolChatData, TurnProcessChatData, TurnTailChatData, } from './contract/chat-nodes.ts' +export type { ChatGroupTree } from './contract/group-tree.ts' export type { ChatStoreState, ToolCallId, TurnProcessViewEntry } from './contract/store.ts' export type { TranscriptViewRowInjected, TranscriptViewRowProps } from './settings/TranscriptViewRow.tsx' export type { TranscriptViewMode } from '../chat-settings.ts' diff --git a/packages/client/ui-chat/tests/chat-apply.client.spec.tsx b/packages/client/ui-chat/tests/chat-apply.client.spec.tsx index c449d30d89..6eced3135c 100644 --- a/packages/client/ui-chat/tests/chat-apply.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-apply.client.spec.tsx @@ -28,7 +28,7 @@ import type { QuotaNoticeInjected } from '@deepseek-ai/dsh-client-ui-chat/client import type { PerformanceUsageRowInjected } from '../src/client/settings/PerformanceUsageRow.tsx' import { CHAT_SETTINGS_NAMESPACE, type ChatSettings } from '../src/chat-settings.ts' import { ActivityPill, UsagePill } from '../src/client/chat/StatsPills.tsx' -import { createFlowMotion } from '../src/client/chat/flow-motion.ts' +import { createFlowMotion, type InputFoldDriver } from '../src/client/chat/flow-motion.ts' import { CHAT_FLOW_INJECT, CHAT_NODE_INJECT } from '../src/client/apply.ts' import { UserContent } from '../src/client/chat/UserContent.tsx' @@ -376,10 +376,11 @@ describe('Chat apply wiring', () => { type UseGroupAction = ReturnType type UseGroupHeaderAction = ReturnType const standard = { sessionId: SID } as Parameters[0] - const reserveFirst = vi.fn<(px: number) => void>() - const reserveSecond = vi.fn<(px: number) => void>() - const first = createFlowMotion(reserveFirst, () => {}) - const second = createFlowMotion(reserveSecond, () => {}) + // Each viewport asks only its own driver for a batch; null keeps the rows on CSS transitions. + const driverFirst = vi.fn<() => InputFoldDriver | null>(() => null) + const driverSecond = vi.fn<() => InputFoldDriver | null>(() => null) + const first = createFlowMotion(driverFirst, () => {}) + const second = createFlowMotion(driverSecond, () => {}) const useFirst = spec.inject.hooks.groupAction(standard, { motion: first }) const useSecond = spec.inject.hooks.groupAction(standard, { motion: second }) const useFirstHeader = spec.inject.hooks.groupHeaderAction(standard, { motion: first }) @@ -422,13 +423,13 @@ describe('Chat apply wiring', () => { expect(first.foldActive()).toBe(true) expect(second.foldActive()).toBe(false) await act(async () => { await Promise.resolve() }) - expect(reserveFirst).toHaveBeenCalledExactlyOnceWith(80) - expect(reserveSecond).not.toHaveBeenCalled() + expect(driverFirst).toHaveBeenCalledTimes(1) + expect(driverSecond).not.toHaveBeenCalled() view.rerender(rows(true, true)) expect(second.foldActive()).toBe(true) await act(async () => { await Promise.resolve() }) - expect(reserveFirst).toHaveBeenCalledExactlyOnceWith(80) - expect(reserveSecond).toHaveBeenCalledExactlyOnceWith(90) + expect(driverFirst).toHaveBeenCalledTimes(1) + expect(driverSecond).toHaveBeenCalledTimes(1) const finished = new Event('transitionend', { bubbles: true }) Object.defineProperty(finished, 'propertyName', { value: 'height' }) act(() => { firstRow.dispatchEvent(finished) }) diff --git a/packages/client/ui-chat/tests/chat-node-bottom.client.spec.ts b/packages/client/ui-chat/tests/chat-node-bottom.client.spec.ts index ef52f7a258..d72295e212 100644 --- a/packages/client/ui-chat/tests/chat-node-bottom.client.spec.ts +++ b/packages/client/ui-chat/tests/chat-node-bottom.client.spec.ts @@ -18,15 +18,20 @@ function node(index: number, owner = turn(index), text = ''): ChatConversationVi } } +/** A Node whose Turn opens an exchange. */ +function opening(index: number, owner = turn(index), text = ''): ChatConversationViewNode { + return { ...node(index, owner, text), groupTree: 'exchange' } +} + function timeline(turns: readonly TurnLocation[]): ConversationTimelineSnapshot { return { turnOrder: turns.map(value => value.turn), turns: new Map(turns.map(value => [value.turn, value])) } } describe('assembler-owned Chat bottom membership', () => { - it('notifies only old and new bottom keys among 4,000 observed historical Nodes', () => { + it('notifies only the Turns leaving or entering the latest exchange among 4,000 observed historical Nodes', () => { const builder = new ChatSnapshotBuilder() const turns = Array.from({ length: 4_000 }, (_, index) => turn(index + 1)) - const nodes = turns.map(value => node(value.turn, value)) + const nodes = turns.map(value => opening(value.turn, value)) const snapshot = builder.replace({ nodes, timeline: timeline(turns) }) builder.publish() const sources = nodes.map(value => snapshot.nodes.bottomSource(value.key)) @@ -36,7 +41,7 @@ describe('assembler-owned Chat bottom membership', () => { return listener }) const nextTurn = turn(4_001) - const nextNode = node(4_001, nextTurn) + const nextNode = opening(4_001, nextTurn) const nextSource = snapshot.nodes.bottomSource(nextNode.key) const nextChanged = vi.fn() nextSource.subscribe(nextChanged) @@ -55,17 +60,17 @@ describe('assembler-owned Chat bottom membership', () => { expect(snapshot.nodes.bottomSource(nextNode.key)).toBe(nextSource) nextChanged.mockClear() - builder.apply({ upserts: [node(4_001, nextTurn, 'updated')], timeline: timeline([...turns, nextTurn]) }) + builder.apply({ upserts: [opening(4_001, nextTurn, 'updated')], timeline: timeline([...turns, nextTurn]) }) builder.publish() expect(nextChanged).not.toHaveBeenCalled() expect(listeners.at(-1)).toHaveBeenCalledOnce() }) - it('advances on a Turn boundary before its Node materializes and retains source identity across replacement', () => { + it('keeps a bare Turn boundary and continuation Turns in the latest exchange until an opening Node arrives', () => { const builder = new ChatSnapshotBuilder() const first = turn(1) const second = turn(2) - const initial = builder.replace({ nodes: [node(1, first)], timeline: timeline([first]) }) + const initial = builder.replace({ nodes: [opening(1, first)], timeline: timeline([first]) }) builder.publish() const oldBottom = initial.nodes.bottomSource('user:1') const nextBottom = initial.nodes.bottomSource('user:2') @@ -73,26 +78,92 @@ describe('assembler-owned Chat bottom membership', () => { const unsubscribe = oldBottom.subscribe(changed) builder.apply({ upserts: [], timeline: timeline([first, second]) }) builder.publish() - expect(changed).toHaveBeenCalledOnce() - expect(oldBottom.getSnapshot()).toBe(false) - expect(nextBottom.getSnapshot()).toBe(false) + expect(changed).not.toHaveBeenCalled() + expect(oldBottom.getSnapshot()).toBe(true) + builder.apply({ upserts: [node(2, second)], timeline: timeline([first, second]) }) builder.publish() - expect(nextBottom.getSnapshot()).toBe(true) + expect(changed).not.toHaveBeenCalled() + expect([oldBottom.getSnapshot(), nextBottom.getSnapshot()]).toEqual([true, true]) + + builder.apply({ upserts: [opening(2, second)], timeline: timeline([first, second]) }) + builder.publish() + expect(changed).toHaveBeenCalledOnce() + expect([oldBottom.getSnapshot(), nextBottom.getSnapshot()]).toEqual([false, true]) unsubscribe() - const replaced = builder.replace({ nodes: [node(1, first)], timeline: timeline([first]) }) + + const replaced = builder.replace({ nodes: [opening(1, first)], timeline: timeline([first]) }) builder.publish() expect(replaced.nodes.bottomSource('user:1')).toBe(oldBottom) - expect(oldBottom.getSnapshot()).toBe(true) - expect(nextBottom.getSnapshot()).toBe(false) - expect(changed).toHaveBeenCalledOnce() + expect([oldBottom.getSnapshot(), nextBottom.getSnapshot()]).toEqual([true, false]) + + builder.replace({ nodes: [node(1, first), node(2, second)], timeline: timeline([first, second]) }) + builder.publish() + expect([oldBottom.getSnapshot(), nextBottom.getSnapshot()]).toEqual([true, true]) + }) + + it('starts the latest exchange at the first loaded Turn while no opening Node is loaded', () => { + const builder = new ChatSnapshotBuilder() + const turns = [turn(3), turn(4), turn(5)] + const initial = builder.replace({ nodes: turns.map(value => node(value.turn, value)), timeline: timeline(turns) }) + builder.publish() + const bottoms = ['user:3', 'user:4', 'user:5'].map(key => initial.nodes.bottomSource(key)) + expect(bottoms.map(source => source.getSnapshot())).toEqual([true, true, true]) + + const older = [turn(1), turn(2), ...turns] + const loaded = builder.replace({ nodes: older.map(value => node(value.turn, value)), timeline: timeline(older) }) + builder.publish() + expect(['user:1', 'user:2'].map(key => loaded.nodes.bottomSource(key).getSnapshot())).toEqual([true, true]) + + builder.replace({ nodes: [], timeline: timeline([]) }) + builder.publish() + expect(bottoms.map(source => source.getSnapshot())).toEqual([false, false, false]) + }) + + it('follows the latest remaining opening Turn when opening Nodes leave, move, or repeat in one Turn', () => { + const builder = new ChatSnapshotBuilder() + const turns = [turn(1), turn(2), turn(3), turn(4)] + const second: ChatConversationViewNode = { ...opening(22, turns[1]), key: 'user:2b' } + const nodes = [opening(1, turns[0]), opening(2, turns[1]), second, node(3, turns[2]), opening(4, turns[3])] + const initial = builder.replace({ nodes, timeline: timeline(turns) }) + builder.publish() + const bottom = (key: string): boolean => initial.nodes.bottomSource(key).getSnapshot() + const membership = (): boolean[] => ['user:1', 'user:2', 'user:3', 'user:4'].map(bottom) + expect(membership()).toEqual([false, false, false, true]) + + const before = vi.fn() + const unchanged = vi.fn() + initial.nodes.bottomSource('user:2').subscribe(before) + initial.nodes.bottomSource('user:1').subscribe(unchanged) + builder.apply({ upserts: [node(4, turns[3])], timeline: initial.timeline }) + builder.publish() + expect(membership()).toEqual([false, true, true, true]) + expect(before).toHaveBeenCalledOnce() + expect(unchanged).not.toHaveBeenCalled() + + builder.apply({ upserts: [node(2, turns[1])], timeline: initial.timeline }) + builder.publish() + expect(membership()).toEqual([false, true, true, true]) + + builder.apply({ upserts: [{ ...second, location: { kind: 'session' } }], timeline: initial.timeline }) + builder.publish() + expect(membership()).toEqual([true, true, true, true]) + + builder.apply({ upserts: [{ ...opening(3, turns[2]), visibility: 'hidden' }], timeline: initial.timeline }) + builder.publish() + expect(membership()).toEqual([false, false, false, true]) + expect(bottom('user:2b')).toBe(false) + + builder.replace({ nodes: [opening(1, turns[0]), node(2, turns[1]), node(4, turns[3])], timeline: initial.timeline }) + builder.publish() + expect(membership()).toEqual([true, true, false, true]) }) it('updates same-key Turn moves without changing root order and excludes unplaced or hidden Nodes', () => { const builder = new ChatSnapshotBuilder() const first = turn(1) const second = turn(2) - const initial = builder.replace({ nodes: [node(1, first)], timeline: timeline([first, second]) }) + const initial = builder.replace({ nodes: [node(1, first), opening(2, second)], timeline: timeline([first, second]) }) builder.publish() const bottom = initial.nodes.bottomSource('user:1') expect(bottom.getSnapshot()).toBe(false) diff --git a/packages/client/ui-chat/tests/chat-scroll.client.spec.tsx b/packages/client/ui-chat/tests/chat-scroll.client.spec.tsx index b212f91940..6eb4ac4520 100644 --- a/packages/client/ui-chat/tests/chat-scroll.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-scroll.client.spec.tsx @@ -92,10 +92,14 @@ function mountScroll(deferCompletedTurns = true, initial: Partial { motion.collapse(row, () => { row.hidden = true }) }) }, + /** Run the fold's frame clock to its end (a row outside a batch finishes on its transition). */ finishFold: () => { const event = new Event('transitionend', { bubbles: true }) Object.defineProperty(event, 'propertyName', { value: 'height' }) - act(() => { row.dispatchEvent(event) }) + act(() => { + row.dispatchEvent(event) + vi.advanceTimersByTime(200) + }) }, } } @@ -369,6 +373,8 @@ describe('Chat scroll collapse timing', () => { expect(h.scrollTo).not.toHaveBeenCalled() expect(h.scroller.scrollTop).toBe(0) await act(async () => { await Promise.resolve() }) + // The folded row leaves the committed layout; the landing reads the actual content height. + h.grow(920) act(() => { vi.advanceTimersByTime(400) }) expect(h.scrollTo).not.toHaveBeenCalled() expect(h.scroller.scrollTop).toBe(520) @@ -476,6 +482,7 @@ describe('Chat scroll collapse timing', () => { h.resize() h.update({ order: ['streaming-change'], running: true }) if (newInput) h.update({ submissionId: 'second' }) + h.grow(920) h.finishFold() await act(async () => { await Promise.resolve() }) act(() => { vi.advanceTimersByTime(400) }) diff --git a/packages/client/ui-chat/tests/chat-snapshot-fixture.client.ts b/packages/client/ui-chat/tests/chat-snapshot-fixture.client.ts index 1a0ed20d21..780d2e1f49 100644 --- a/packages/client/ui-chat/tests/chat-snapshot-fixture.client.ts +++ b/packages/client/ui-chat/tests/chat-snapshot-fixture.client.ts @@ -16,9 +16,10 @@ import { orderedVisibleChatNodes } from '../src/client/conversation-nodes/chat-s import { ChatTurnProcessProjector } from '../src/client/conversation-nodes/turn-process-presentation.ts' import { hasAssistantReplyContent } from '../src/client/contract/assistant-content.ts' import { - isSubagentDelegationTool, sameTurnProcessSpec, TURN_PROCESS_INDEPENDENT_KINDS, + isSubagentDelegationTool, sameTurnProcessSpec, type TurnProcessSpec, } from '../src/client/contract/turn-process.ts' +import { isAtOrOutside, type ChatGroupTree } from '../src/client/contract/group-tree.ts' const EMPTY: readonly never[] = [] @@ -106,7 +107,8 @@ class FixtureNodeStore implements ChatNodeStore { private readonly sources = new Map>() private readonly processSources = new Map>() private readonly bottomSources = new Map>() - bottomTurn: number | undefined + /** First Turn of the latest loaded exchange. */ + bottomStart: number | undefined private readonly dirtyKeys = new Set() private readonly dirtyProcessKeys = new Set() private list: readonly ChatConversationViewNode[] = EMPTY @@ -122,8 +124,8 @@ class FixtureNodeStore implements ChatNodeStore { bottomSource(key: string): ReturnType { return cachedSource(this.bottomSources, key, () => new FixtureSource(() => { const location = this.get(key)?.location - return this.bottomTurn !== undefined && (location?.kind === 'turn' || location?.kind === 'step') - && location.turn.turn === this.bottomTurn + return this.bottomStart !== undefined && (location?.kind === 'turn' || location?.kind === 'step') + && location.turn.turn >= this.bottomStart })) } @@ -243,6 +245,19 @@ class FixtureTurnDataStore implements ConversationLocationDataStore !TURN_PROCESS_INDEPENDENT_KINDS.has(candidate.kind)) + const processStart = inTurn.find(candidate => !isAtOrOutside(candidate, 'turn')) ?? controlAnchor const inlineReasoning = answer?.blocks.some(block => block.kind === 'reasoning' && block.text.trim() !== '') === true const candidate: TurnProcessSpec = { @@ -459,6 +475,7 @@ export function chatSnapshotFixture(input: { anchorSeq: spec.controlAnchorSeq - 0.1, location: { kind: 'turn', turn }, visibility: 'visible', + groupTree: 'turn', data: spec, }) } @@ -499,6 +516,7 @@ export function chatSnapshotFixture(input: { anchorSeq: endSeq, location: { kind: 'turn', turn }, visibility: 'visible', + groupTree: 'turn', data: tailData, }) } @@ -527,7 +545,11 @@ export function chatSnapshotFixture(input: { && previous.legacy.turnEnds === legacy.turnEnds ? previous.timeline : { turnOrder: [...turns.keys()], turns } - store.bottomTurn = timeline.turnOrder.at(-1) + const openingTurns = store.values().flatMap((node) => { + const location = node.location + return isAtOrOutside(node, 'exchange') && (location.kind === 'turn' || location.kind === 'step') ? [location.turn.turn] : [] + }) + store.bottomStart = openingTurns.length === 0 ? timeline.turnOrder[0] : Math.max(...openingTurns) const derived = timeline.turnOrder .map(turn => turnNavigationItem(turn, locations, store)) .filter((item): item is TurnNavigationItem => item !== undefined) diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx index d248901ed5..e97d0e56dc 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -27,7 +27,7 @@ import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controlle import { SessionSeq, type SessionId } from '@deepseek-ai/dsh-session/types' import type { SessionStatusSnapshot } from '@deepseek-ai/dsh-client-ui-session/client' import type { KeyedSnapshotSelectorHook, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' -import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { createSnapshotStore, type ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import { EMPTY_CONVERSATION_SNAPSHOT } from '@deepseek-ai/dsh-client-ui-conversation/client' import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts' @@ -479,6 +479,7 @@ function makeHarness( createSnapshotStore(new Map()), ), useWorkspaces: emptyWorkspaces(), + ...allowAllAccess, useProjection: (key: string) => { const inbox = useTestSession(snapshot => snapshot.testInbox) return key === 'inbox' ? inbox : outlineValue @@ -2005,6 +2006,23 @@ describe('ChatView', () => { expect(renderedFlowKinds(view.container)).toEqual(['user', 'turn-process']) }) + it('matches echoes past an input Node whose payload carries no message source', () => { + const base = chatSnapshotFixture({ nodes: [user(1, 'question')] }) + const snapshot = new ChatSnapshotBuilder().replace({ + nodes: [...base.nodes.values(), { + key: 'plugin-input', id: 'plugin-input', kind: 'plugin-input', target: 'chat', anchorSeq: 2, + location: { kind: 'session' }, visibility: 'visible', groupTree: 'input', data: null, + }], + timeline: base.timeline, + }) + const h = makeHarness({}, { pendingSubmissions: [{ + requestId: 'pending' as never, placement: 'transcript', time: 1_000, text: 'pending text', attachments: [], + }] }, snapshot) + const view = render() + expect(view.getByText('pending text')).toBeTruthy() + expect(view.container.querySelector('[data-submission-echo]')).not.toBeNull() + }) + it.each(['ABC', 'ACB', 'BAC', 'BCA', 'CAB', 'CBA'])( 'uses the first local opening echo until Host admission identifies the input (%s)', (hostOrder) => { const pending: SessionSnapshot['pendingSubmissions'] = ['A', 'B', 'C'].map(text => ({ @@ -3359,6 +3377,17 @@ describe('ChatView', () => { expect(h.forkAt).not.toHaveBeenCalled() }) + it('omits the Turn Tail branch action when the Session access snapshot excludes session/fork', () => { + const h = makeHarness({ nodes: [user(1, 'question'), assistant(2, 'answer')], turnEnds: new Map([[1, 3]]) }) + const useAccess = vi.fn((endpoint: string) => endpoint !== 'session/fork') + // Chat Node renderers read the harness's standard props object, so the override goes there. + Object.assign(h.props, { useAccess }) + const view = render() + expect(view.getAllByRole('button', { name: '复制' }).length).toBeGreaterThan(0) + expect(view.queryByRole('button', { name: '在新对话中分支' })).toBeNull() + expect(useAccess).toHaveBeenCalledWith('session/fork') + }) + it('renders assistant Markdown across history, streaming, final, and interrupted states while user text stays literal', () => { const markdown = '# Rendered\n\n- **one**\n- `two`' const h = makeHarness({ nodes: [user(1, markdown), assistant(2, markdown)] }) diff --git a/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts b/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts index 3823a0dbd5..19083ffac8 100644 --- a/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts +++ b/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts @@ -659,6 +659,62 @@ describe('built-in conversation node Definitions', () => { } }) + it('opens exchanges only for human input claimed for Step 1; every other input stays an input', () => { + const human = textMessage('block-human', 'first task') + const steer = textMessage('block-steer', 'adjust course') + const notice = { ...textMessage('block-notice', 'background work finished'), source: { kind: 'schedule' } } + const idle = textMessage('block-idle', 'next task') + const bottomTurns = (current: ChatSnapshot): number[] => [...new Set(current.order.flatMap((key) => { + const location = current.nodes.get(key)?.location + return current.nodes.bottomSource(key).getSnapshot() && (location?.kind === 'turn' || location?.kind === 'step') + ? [location.turn.turn] + : [] + }))] + const inputs = (current: ChatSnapshot) => current.order + .map(key => current.nodes.get(key)) + .filter(candidate => candidate?.kind === 'user' || candidate?.kind === 'steering' || candidate?.kind === 'turn-trigger') + .map(candidate => [candidate?.kind, candidate?.groupTree ?? null]) + const value = assembler([ + at(1, 'agent/inbox/spliced', { target: 'next-turn', start: 0, inserted: [human] }), + at(2, 'turn/start', { turn: 1 }), + at(3, 'agent/inbox/spliced', { target: 'next-turn', start: 0, removedCount: 1, inserted: [] }), + at(4, 'step/start', { turn: 1, step: 1 }), + at(5, 'user/message', human, { surfaceOp: 'append' }), + at(6, 'step/end', { turn: 1, step: 1 }), + at(7, 'agent/inbox/spliced', { target: 'next-step', start: 0, inserted: [steer] }), + at(8, 'agent/inbox/spliced', { target: 'next-step', start: 0, removedCount: 1, inserted: [] }), + at(9, 'step/start', { turn: 1, step: 2 }), + at(10, 'user/message', steer, { surfaceOp: 'append' }), + at(11, 'assistant/message', { turn: 1, step: 2, message: assistantMessage('block-answer-1', 'done') }, { surfaceOp: 'append' }), + at(12, 'step/end', { turn: 1, step: 2 }), + at(13, 'turn/end', { turn: 1, reason: { kind: 'completed' } }), + // The host assembles the system prompt between the waking claim and Step 1. + at(14, 'agent/inbox/spliced', { target: 'next-turn', start: 0, inserted: [notice] }), + at(15, 'turn/start', { turn: 2 }), + at(16, 'agent/inbox/spliced', { target: 'next-turn', start: 0, removedCount: 1, inserted: [] }), + ]) + expect(inputs(snapshot(value))).toEqual([['user', 'exchange'], ['steering', 'input']]) + expect(bottomTurns(snapshot(value))).toEqual([1, 2]) + + value.append(at(17, 'step/start', { turn: 2, step: 1 })) + value.append(at(18, 'user/message', notice, { surfaceOp: 'append' })) + value.append(at(19, 'assistant/message', { turn: 2, step: 1, message: assistantMessage('block-answer-2', 'noted') }, { surfaceOp: 'append' })) + value.append(at(20, 'step/end', { turn: 2, step: 1 })) + value.append(at(21, 'turn/end', { turn: 2, reason: { kind: 'completed' } })) + value.flush() + expect(inputs(snapshot(value))).toEqual([['user', 'exchange'], ['steering', 'input'], ['turn-trigger', 'input']]) + expect(bottomTurns(snapshot(value))).toEqual([1, 2]) + + value.append(at(22, 'agent/inbox/spliced', { target: 'next-step', start: 0, inserted: [idle] })) + value.append(at(23, 'turn/start', { turn: 3 })) + value.append(at(24, 'agent/inbox/spliced', { target: 'next-step', start: 0, removedCount: 1, inserted: [] })) + value.append(at(25, 'step/start', { turn: 3, step: 1 })) + value.append(at(26, 'user/message', idle, { surfaceOp: 'append' })) + value.flush() + expect(inputs(snapshot(value)).at(-1)).toEqual(['steering', 'exchange']) + expect(bottomTurns(snapshot(value))).toEqual([3]) + }) + it('replays pending splice chains and scopes steering to the current claim', () => { const first = textMessage('claim-first', 'first') const second = textMessage('claim-second', 'second') diff --git a/packages/client/ui-chat/tests/flow-motion-scope.client.spec.tsx b/packages/client/ui-chat/tests/flow-motion-scope.client.spec.tsx index 4d8cc20f45..ad337a426e 100644 --- a/packages/client/ui-chat/tests/flow-motion-scope.client.spec.tsx +++ b/packages/client/ui-chat/tests/flow-motion-scope.client.spec.tsx @@ -3,6 +3,7 @@ import { act, cleanup, render } from '@testing-library/react' import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { ChatViewport } from '../src/client/chat/use-chat-viewport.ts' +import { ScrollFollow } from '../src/client/chat/use-scroll-follow.ts' import { useSearchableHidden } from '../src/client/chat/searchable-hidden.ts' beforeEach(() => { vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) }) @@ -20,10 +21,11 @@ function viewport() { document.body.append(list) const owner = new ChatViewport() owner.attach(list, column) - return { list, row, spacer, owner, dispose: () => { owner.detach(); list.remove() } } + const unbind = owner.bindReader({ follow: new ScrollFollow(false, 25), landedAtTail: vi.fn(), pending: () => false }) + return { list, row, spacer, owner, dispose: () => { unbind(); owner.detach(); list.remove() } } } -it('isolates fold anchoring and reservation between viewports', async () => { +it('isolates fold anchoring and batch room between viewports', async () => { const first = viewport() const second = viewport() try { diff --git a/packages/client/ui-chat/tests/flow-motion.client.spec.tsx b/packages/client/ui-chat/tests/flow-motion.client.spec.tsx index 5eb8c2f853..f2f2e4a6c1 100644 --- a/packages/client/ui-chat/tests/flow-motion.client.spec.tsx +++ b/packages/client/ui-chat/tests/flow-motion.client.spec.tsx @@ -5,21 +5,33 @@ import { StrictMode, useRef } from 'react' import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' import { createFlowMotion, hiddenOrCollapsing, motionEnabled, useFlowHidden, useMotionHidden, - type FlowMotion, type FlowMotionRows, + type FlowMotion, type FlowMotionRows, type InputFoldBox, type InputFoldDriver, } from '../src/client/chat/flow-motion.ts' const ownedRows = new Set() const subscriptions = new Set<() => void>() let motion: FlowMotion -let reserve: Mock<(px: number) => void> +let driver: Mock<() => InputFoldDriver | null> let changed: Mock<(active: boolean) => void> beforeEach(() => { vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) - reserve = vi.fn<(px: number) => void>() + // Without a driver, rows keep their CSS transitions; batch tests supply one. + driver = vi.fn<() => InputFoldDriver | null>(() => null) changed = vi.fn<(active: boolean) => void>() - motion = createFlowMotion(reserve, changed) + motion = createFlowMotion(driver, changed) }) + +function batchDriver(travelling: readonly HTMLElement[] = []) { + const calls = { + begin: vi.fn<(boxes: readonly InputFoldBox[]) => ReadonlySet>(() => new Set(travelling)), + prepare: vi.fn<(progress: number, cancelled: boolean) => void>(), + frame: vi.fn<(progress: number, cancelled: boolean) => number>(() => -20), + finish: vi.fn<(cancelled: boolean) => void>(), + } + driver.mockReturnValue(calls) + return calls +} afterEach(async () => { try { for (const unsubscribe of subscriptions) unsubscribe() @@ -104,7 +116,7 @@ describe('flow motion', () => { expect(motion.foldActive()).toBe(false) expect(vi.getTimerCount()).toBe(0) await Promise.resolve() - expect(reserve).not.toHaveBeenCalled() + expect(driver).not.toHaveBeenCalled() expectCleared(row) }) @@ -121,7 +133,7 @@ describe('flow motion', () => { expect(row.style.opacity).toBe('0') expect(row.style.height).toBe('0px') await Promise.resolve() - expect(reserve).toHaveBeenCalledExactlyOnceWith(80) + expect(driver).toHaveBeenCalledTimes(1) transitionEnd(row, 'opacity') transitionEnd(child, 'height') expect(done).not.toHaveBeenCalled() @@ -180,7 +192,7 @@ describe('flow motion', () => { expect(idle).toHaveBeenCalledTimes(1) }) - it('cancels before reserve delivery without hiding the row or retaining callbacks', async () => { + it('cancels before the batch starts without hiding the row or retaining callbacks', async () => { const row = rowWithHeight(80) const done = vi.fn() motion.collapse(row, done) @@ -190,14 +202,15 @@ describe('flow motion', () => { expect(hiddenOrCollapsing(row)).toBe(false) expectCleared(row) await Promise.resolve() - expect(reserve).not.toHaveBeenCalled() + expect(driver).not.toHaveBeenCalled() transitionEnd(row, 'height') vi.runAllTimers() expect(done).not.toHaveBeenCalled() expect(row.hasAttribute('hidden')).toBe(false) }) - it('reserves nested closing content once and subtracts newly revealed height', async () => { + it('drives non-nested closing and revealing boxes in document order on one batch', async () => { + const calls = batchDriver() const parent = rowWithHeight(180) const child = rowWithHeight(80, parent) const sibling = rowWithHeight(50) @@ -206,26 +219,276 @@ describe('flow motion', () => { motion.collapse(parent, vi.fn()) motion.collapse(sibling, vi.fn()) motion.reveal(opening) + // A fresh reveal has not grown yet when the batch measures it. + Object.defineProperty(opening, 'offsetHeight', { value: 0, configurable: true }) await Promise.resolve() - expect(reserve).toHaveBeenCalledExactlyOnceWith(200) + expect(calls.begin).toHaveBeenCalledTimes(1) + expect(calls.begin.mock.calls[0]?.[0].map(box => [box.element, box.height, box.closing])).toEqual([ + [parent, 180, true], [sibling, 50, true], [opening, 30, false], + ]) + expect(motion.inputActive()).toBe(true) }) - it('does not rewrite a reopened row after its input-fold ownership is cancelled', async () => { + it('leaves a reveal-only commit to its CSS transition', async () => { + batchDriver() + const row = rowWithHeight(30) + motion.reveal(row) + await Promise.resolve() + expect(driver).not.toHaveBeenCalled() + expect(row.dataset.chatMotion).toBe('reveal') + vi.runAllTimers() + }) + + it('lands a batch on its frame clock and notifies idle after the final geometry', async () => { vi.useFakeTimers() + const calls = batchDriver() + const row = rowWithHeight(180) + const order: string[] = [] + const done = vi.fn(() => { order.push('hidden'); row.setAttribute('hidden', '') }) + calls.finish.mockImplementation(() => { order.push('finish') }) + motion.collapse(row, done) + subscriptions.add(motion.onFoldIdle(() => { order.push('idle') })) + await Promise.resolve() + vi.advanceTimersByTime(96) + expect(row.style.translate).toBe('0 -20px') + expect(Number.parseFloat(row.style.height)).toBeLessThan(180) + vi.advanceTimersByTime(400) + expect(order).toEqual(['hidden', 'finish', 'idle']) + expect(calls.finish).toHaveBeenCalledExactlyOnceWith(false) + expect(changed.mock.calls).toEqual([[true], [false]]) + expect(motion.inputActive()).toBe(false) + expectCleared(row) + }) + + it('keeps a travelling box opaque and in the flow while it shrinks', async () => { + vi.useFakeTimers() + const row = rowWithHeight(180) + const nested = rowWithHeight(60, row) + batchDriver([row]) + motion.collapse(nested, vi.fn()) + motion.collapse(row, vi.fn()) + await Promise.resolve() + expect(nested.style.height).toBe('60px') + expect(nested.style.transition).toBe('none') + vi.advanceTimersByTime(96) + expect(row.style.opacity).toBe('1') + expect(row.style.translate).toBe('') + expect(nested.style.opacity).toBe('1') + vi.advanceTimersByTime(400) + }) + + it('freezes a box that starts travelling after the batch began', async () => { + vi.useFakeTimers() + const travelling = new Set() + const calls = batchDriver() + calls.begin.mockReturnValue(travelling) + const row = rowWithHeight(180) + const nested = rowWithHeight(60, row) + motion.collapse(nested, vi.fn()) + motion.collapse(row, vi.fn()) + await Promise.resolve() + vi.advanceTimersByTime(96) + expect(row.style.translate).toBe('0 -20px') + travelling.add(row) + vi.advanceTimersByTime(16) + expect(row.style.opacity).toBe('1') + expect(row.style.translate).toBe('') + expect(nested.style.height).toBe('60px') + expect(nested.style.transition).toBe('none') + vi.advanceTimersByTime(400) + }) + + it('reports a batch as input-owned while it is scheduled or running, not for reveals alone', async () => { + vi.useFakeTimers() + batchDriver() + const opening = rowWithHeight(30) + motion.reveal(opening) + expect(motion.inputActive()).toBe(false) + const row = rowWithHeight(180) + motion.collapse(row, vi.fn()) + expect(motion.inputActive()).toBe(true) + await Promise.resolve() + expect(motion.inputActive()).toBe(true) + vi.advanceTimersByTime(400) + expect(motion.inputActive()).toBe(false) + motion.resumeInput() + expect(motion.inputActive()).toBe(false) + }) + + it('does not report a batch when every closing row sits inside an ancestor that closes on its own', async () => { + const calls = batchDriver() + const parent = rowWithHeight(180) + // The ancestor's motion is not owned by this batch, so its descendants cannot start one. + parent.dataset.chatMotion = 'collapse' + const child = rowWithHeight(60, parent) + motion.collapse(child, vi.fn()) + expect(motion.inputActive()).toBe(false) + await Promise.resolve() + expect(calls.begin).not.toHaveBeenCalled() + motion.cancelMotion(child) + delete parent.dataset.chatMotion + }) + + it('holds a row that closes during a batch in place and drives it in the next batch', async () => { + vi.useFakeTimers() + const calls = batchDriver() + const first = rowWithHeight(180) + const idle = vi.fn() + motion.collapse(first, vi.fn()) + await Promise.resolve() + subscriptions.add(motion.onFoldIdle(idle)) + vi.advanceTimersByTime(32) + const late = rowWithHeight(60) + const lateDone = vi.fn() + motion.collapse(late, lateDone) + expect(late.style.transition).toBe('none') + expect(late.style.height).toBe('60px') + expect(late.style.opacity).toBe('') + await Promise.resolve() + expect(calls.begin).toHaveBeenCalledTimes(1) + vi.advanceTimersByTime(200) + expect(calls.begin).toHaveBeenCalledTimes(2) + expect(calls.begin.mock.calls[1]?.[0].map(box => box.element)).toEqual([late]) + expect(idle).not.toHaveBeenCalled() + expect(motion.inputActive()).toBe(true) + vi.advanceTimersByTime(400) + expect(lateDone).toHaveBeenCalledTimes(1) + expect(idle).toHaveBeenCalledTimes(1) + expect(calls.finish).toHaveBeenCalledTimes(2) + }) + + it('finishes a held row at once when the next flush has no driver', async () => { + vi.useFakeTimers() + batchDriver() + const first = rowWithHeight(180) + motion.collapse(first, vi.fn()) + await Promise.resolve() + vi.advanceTimersByTime(32) + const late = rowWithHeight(60) + const lateDone = vi.fn() + motion.collapse(late, lateDone) + driver.mockReturnValue(null) + vi.advanceTimersByTime(400) + expect(lateDone).toHaveBeenCalledTimes(1) + expectCleared(late) + }) + + it('takes over a closing row at its current size and finishes one that no longer has any', async () => { + vi.useFakeTimers() + const moving = rowWithHeight(180) + motion.collapse(moving, vi.fn()) + await Promise.resolve() + // No driver yet: the row closes on its own transition and shrinks to 120 before a batch takes it over. + Object.defineProperty(moving, 'offsetHeight', { value: 120, configurable: true }) + const calls = batchDriver() + const gone = rowWithHeight(60) + const goneDone = vi.fn() + motion.collapse(gone, goneDone) + // Before the batch starts, the row's ancestor finished closing, so the row has no size left. + Object.defineProperty(gone, 'offsetHeight', { value: 0, configurable: true }) + expect(goneDone).not.toHaveBeenCalled() + await Promise.resolve() + expect(goneDone).toHaveBeenCalledTimes(1) + expectCleared(gone) + expect(calls.begin.mock.calls[0]?.[0].map(box => [box.element, box.height])).toEqual([[moving, 120]]) + vi.advanceTimersByTime(400) + }) + + it('counts only the remaining growth of a reveal a batch takes over mid-transition', async () => { + vi.useFakeTimers() + const opening = rowWithHeight(120) + motion.reveal(opening) + await Promise.resolve() + // The reveal ran alone on its own transition and reached 45px before a closing row starts a batch. + Object.defineProperty(opening, 'offsetHeight', { value: 45, configurable: true }) + const calls = batchDriver() + const closing = rowWithHeight(80) + motion.collapse(closing, vi.fn()) + await Promise.resolve() + expect(calls.begin.mock.calls[0]?.[0].map(box => [box.element, box.height, box.closing])).toEqual([ + [opening, 75, false], [closing, 80, true], + ]) + expect(opening.style.height).toBe('45px') + vi.advanceTimersByTime(400) + }) + + it('holds a row revealed during a batch and starts its own transition when no batch follows', async () => { + vi.useFakeTimers() + batchDriver() + const closing = rowWithHeight(180) + motion.collapse(closing, vi.fn()) + await Promise.resolve() + vi.advanceTimersByTime(32) + const opening = rowWithHeight(40) + motion.reveal(opening) + expect(opening.style.transition).toBe('none') + expect(opening.style.height).toBe('0px') + vi.advanceTimersByTime(200) + expect(opening.style.transition).toContain('height') + expect(opening.style.height).toBe('40px') + vi.runAllTimers() + expectCleared(opening) + }) + + it('reports reader cancellation to the driver and lets a newer request resume the target', async () => { + vi.useFakeTimers() + const calls = batchDriver() + const row = rowWithHeight(180) + motion.cancelInput() + motion.collapse(row, vi.fn()) + await Promise.resolve() + motion.cancelInput() + vi.advanceTimersByTime(96) + expect(calls.frame.mock.calls.at(-1)?.[1]).toBe(true) + motion.resumeInput() + vi.advanceTimersByTime(16) + expect(calls.frame.mock.calls.at(-1)?.[1]).toBe(false) + motion.cancelInput() + vi.advanceTimersByTime(400) + expect(calls.finish).toHaveBeenCalledExactlyOnceWith(true) + }) + + it('finishes a batch synchronously under reduced motion', async () => { + vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: true }))) + const calls = batchDriver() + const row = rowWithHeight(180) + const done = vi.fn() + motion.collapse(row, done) + await Promise.resolve() + expect(done).toHaveBeenCalledTimes(1) + expect(calls.finish).toHaveBeenCalledExactlyOnceWith(false) + }) + + it('does not rewrite a reopened row after its batch is cancelled', async () => { + vi.useFakeTimers() + const calls = batchDriver() const row = rowWithHeight(180) const done = vi.fn() motion.collapse(row, done) - motion.followInput({ begin: vi.fn(), frame: () => -20, finish: vi.fn() }) await Promise.resolve() vi.advanceTimersByTime(96) motion.cancelMotion(row) expectCleared(row) + expect(calls.finish).toHaveBeenCalledExactlyOnceWith(true) vi.advanceTimersByTime(400) expectCleared(row) expect(row.style.translate).toBe('') expect(done).not.toHaveBeenCalled() }) + it('cancels a running batch with its owner', async () => { + vi.useFakeTimers() + const calls = batchDriver() + const row = rowWithHeight(180) + motion.collapse(row, vi.fn()) + await Promise.resolve() + vi.advanceTimersByTime(32) + motion.clear() + expect(calls.finish).toHaveBeenCalledExactlyOnceWith(true) + expect(motion.inputActive()).toBe(false) + expectCleared(row) + }) + it('clears pending reservations and idle work with its owner', async () => { const row = rowWithHeight(80) const idle = vi.fn() @@ -233,7 +496,7 @@ describe('flow motion', () => { motion.onFoldIdle(idle) motion.clear() await Promise.resolve() - expect(reserve).not.toHaveBeenCalled() + expect(driver).not.toHaveBeenCalled() expect(idle).not.toHaveBeenCalled() expectCleared(row) }) @@ -292,7 +555,7 @@ describe('flow motion', () => { act(() => { vi.advanceTimersByTime(100) }) view.rerender(
) expect(changed.mock.calls).toEqual([[true]]) - expect(reserve).toHaveBeenCalledExactlyOnceWith(80) + expect(driver).toHaveBeenCalledTimes(1) act(() => { vi.advanceTimersByTime(140) }) expect(row.getAttribute('hidden')).toBe('until-found') expect(changed.mock.calls).toEqual([[true], [false]]) @@ -347,7 +610,7 @@ describe.each([ expect(motion.foldActive()).toBe(false) expectCleared(row) await Promise.resolve() - expect(reserve).not.toHaveBeenCalled() + expect(driver).not.toHaveBeenCalled() act(() => { vi.runAllTimers() }) transitionEnd(row, 'height') expect(row.hasAttribute('hidden')).toBe(false) diff --git a/packages/client/ui-chat/tests/fold-follow.client.spec.ts b/packages/client/ui-chat/tests/fold-follow.client.spec.ts index 08eab35fcd..19a3dea326 100644 --- a/packages/client/ui-chat/tests/fold-follow.client.spec.ts +++ b/packages/client/ui-chat/tests/fold-follow.client.spec.ts @@ -20,7 +20,41 @@ afterEach(async () => { } }) -function fixture() { +const frames = new Map() +let frameClock = 0 +let nextFrame = 0 + +/** Run a fixed number of queued animation frames. */ +function runFrameCount(count: number): void { + for (let index = 0; index < count && frames.size > 0; index++) { + const pending = [...frames.values()] + frames.clear() + frameClock += 16 + for (const callback of pending) callback(frameClock) + } +} + +/** Run queued animation frames until the batch stops requesting more. */ +function runFrames(): void { + for (let guard = 0; frames.size > 0 && guard < 100; guard++) { + const pending = [...frames.values()] + frames.clear() + frameClock += 16 + for (const callback of pending) callback(frameClock) + } +} + +/** + * Earlier content, one closing row, and later content in a 400px scrollport. + * @param layout - heights above and below the row and the starting scroll position. + */ +function fixture(layout: { earlier: number; later: number; top: number; following: boolean }) { + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + const id = ++nextFrame + frames.set(id, callback) + return id + }) + vi.stubGlobal('cancelAnimationFrame', (id: number) => { frames.delete(id) }) const list = document.createElement('div') const column = document.createElement('div') const row = document.createElement('div') @@ -29,8 +63,8 @@ function fixture() { column.append(row) list.append(column, spacer) document.body.append(list) - let contentHeight = 2_400 - let top = 2_000 + let contentHeight = layout.earlier + 1_800 + layout.later + let top = layout.top const room = () => Number.parseFloat(spacer.style.height) || 0 const floor = () => Math.max(0, contentHeight + room() - 400) const scrollTo = vi.fn((options: ScrollToOptions) => { @@ -45,14 +79,20 @@ function fixture() { set: (value: number) => { top = Math.max(0, Math.min(value, floor())) }, }, scrollTo: { value: scrollTo }, + getBoundingClientRect: { value: () => ({ top: 0, bottom: 400, height: 400 }) }, + }) + Object.defineProperty(column, 'offsetHeight', { get: () => contentHeight }) + Object.defineProperties(row, { + offsetHeight: { value: 1_800 }, + getBoundingClientRect: { value: () => ({ top: layout.earlier - top, bottom: layout.earlier - top + 1_800, height: 1_800 }) }, }) - Object.defineProperty(row, 'offsetHeight', { value: 1_800 }) const viewport = new ChatViewport() viewport.attach(list, column) - const follow = new ScrollFollow(true, 25) + const follow = new ScrollFollow(layout.following, 25) const reading = new ChatReading(viewport, { read: () => null, save: vi.fn() }, { - initialized: true, followingTail: true, activeTurn: 1, + initialized: true, followingTail: layout.following, activeTurn: 1, }, vi.fn(), follow) + const unbind = reading.bindFolds() viewport.connect({ scroll: reading.onScroll, scrollEnd: reading.onScrollEnd, @@ -61,21 +101,22 @@ function fixture() { intent: () => { reading.interruptFollow() }, }) disposers.add(() => { + unbind() reading.dispose() viewport.detach() list.remove() + frames.clear() }) return { list, spacer, row, viewport, follow, reading, scrollTo, grow: (height: number) => { contentHeight = height }, close: async () => { - viewport.motion.collapse(row, () => { row.hidden = true }) + viewport.motion.collapse(row, () => { + row.hidden = true + contentHeight -= 1_800 + }) await Promise.resolve() - contentHeight -= 1_800 - expect(list.scrollTop).toBe(2_000) - const event = new Event('transitionend') - Object.defineProperty(event, 'propertyName', { value: 'height' }) - row.dispatchEvent(event) + runFrames() }, scroll: (value: number) => { list.scrollTop = value @@ -84,59 +125,103 @@ function fixture() { } } -it('retains fold room and lets content growth consume it without reversing the scrollport', async () => { - const h = fixture() +/** The row is partly on screen: content above the reading line never slides back, so room remains. */ +const VISIBLE_ROW = { earlier: 600, later: 0, top: 2_000, following: true } + +it('lands a following reader at the tail and lets content growth consume the room it keeps', async () => { + const h = fixture(VISIBLE_ROW) await h.close() - h.viewport.reclaimBelow() - h.reading.followTail('smooth') - expect(h.list.scrollTop).toBe(2_000) - expect(h.spacer.style.height).toBe('1800px') + expect(h.list.scrollTop).toBe(600) + expect(h.spacer.style.height).toBe('400px') + expect(h.reading.followingTail).toBe(true) expect(h.scrollTo).not.toHaveBeenCalled() + h.grow(800) + h.viewport.reclaimBelow() + h.reading.onResize() + expect(h.list.scrollTop).toBe(600) + expect(h.spacer.style.height).toBe('200px') h.grow(1_000) h.viewport.reclaimBelow() h.reading.onResize() - expect(h.list.scrollTop).toBe(2_000) - expect(h.spacer.style.height).toBe('1400px') - h.grow(2_400) - h.viewport.reclaimBelow() - h.reading.onResize() - expect(h.list.scrollTop).toBe(2_000) + expect(h.list.scrollTop).toBe(600) expect(h.spacer.style.height).toBe('') - h.grow(2_520) + h.grow(1_120) h.reading.onResize() - expect(h.list.scrollTop).toBe(2_120) + expect(h.list.scrollTop).toBe(720) expect(h.follow.animating).toBe(false) }) +it('adds a reader\'s own scrolling during the fold to the position it keeps still', async () => { + const h = fixture({ earlier: 100, later: 500, top: 2_000, following: false }) + h.viewport.motion.collapse(h.row, () => { h.row.hidden = true; h.grow(600) }) + await Promise.resolve() + runFrameCount(6) + h.list.scrollTop -= 50 + runFrames() + expect(h.list.scrollTop).toBe(150) + expect(h.reading.followingTail).toBe(false) +}) + +it('keeps the reader landing while reader movement awaits sampling', async () => { + const h = fixture({ earlier: 100, later: 500, top: 2_000, following: true }) + h.list.dispatchEvent(new Event('wheel')) + h.scroll(1_990) + expect(h.reading.pending).toBe(true) + h.viewport.motion.collapse(h.row, () => { h.row.hidden = true; h.grow(600) }) + await Promise.resolve() + runFrames() + expect(h.list.scrollTop).toBe(190) + vi.advanceTimersByTime(500) +}) + +it('lands a tail batch on the committed content when it reaches past the estimate', async () => { + const h = fixture(VISIBLE_ROW) + // Content arriving with the hidden commit is not in the estimate; the landing reads the real floor. + h.viewport.motion.collapse(h.row, () => { h.row.hidden = true; h.grow(1_200) }) + await Promise.resolve() + runFrames() + expect(h.list.scrollTop).toBe(800) + expect(h.spacer.style.height).toBe('') + expect(h.reading.followingTail).toBe(true) +}) + +it('keeps a reader below folding rows on the same content without room or follow', async () => { + const h = fixture({ earlier: 100, later: 500, top: 2_000, following: false }) + await h.close() + expect(h.list.scrollTop).toBe(200) + expect(h.spacer.style.height).toBe('') + expect(h.reading.followingTail).toBe(false) + expect(h.reading.pending).toBe(false) +}) + it('keeps manual off-bottom ownership and reclaims only space below the reader', async () => { - const h = fixture() + const h = fixture(VISIBLE_ROW) await h.close() - h.reading.followTail('smooth') - h.scroll(1_000) h.list.dispatchEvent(new Event('wheel')) - h.scroll(900) + h.scroll(300) vi.advanceTimersByTime(500) expect(h.reading.followingTail).toBe(false) - expect(h.spacer.style.height).toBe('700px') + expect(h.spacer.style.height).toBe('100px') h.viewport.reclaimBelow() h.reading.onResize() - expect(h.list.scrollTop).toBe(900) - expect(h.spacer.style.height).toBe('700px') + runFrames() + expect(h.list.scrollTop).toBe(300) + expect(h.spacer.style.height).toBe('100px') expect(h.follow.animating).toBe(false) }) -it('retains existing fold room when reduced-motion following has no forward distance', async () => { - const h = fixture() - await h.close() +it('lands immediately under reduced motion', async () => { vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: true }))) - h.reading.followTail('smooth') - expect(h.list.scrollTop).toBe(2_000) - expect(h.spacer.style.height).toBe('1800px') + const h = fixture(VISIBLE_ROW) + h.viewport.motion.collapse(h.row, () => { h.row.hidden = true; h.grow(600) }) + await Promise.resolve() + expect(h.list.scrollTop).toBe(600) + expect(h.spacer.style.height).toBe('400px') expect(h.follow.animating).toBe(false) }) it.each([1_999.5, 2_000, 2_000.5])('restores follow immediately at a rounded floor sample (%s)', (top) => { - const h = fixture() + const h = fixture(VISIBLE_ROW) h.reading.pauseFollowing() h.reading.onScroll({ metrics: { top, floor: 2_000, height: 400 }, movedByReader: true }) expect(h.reading.followingTail).toBe(true) @@ -144,7 +229,7 @@ it.each([1_999.5, 2_000, 2_000.5])('restores follow immediately at a rounded flo }) it.each([1_999, 2_001])('keeps movement at least one pixel from the floor pending (%s)', (top) => { - const h = fixture() + const h = fixture(VISIBLE_ROW) h.reading.pauseFollowing() h.reading.onScroll({ metrics: { top, floor: 2_000, height: 400 }, movedByReader: true }) expect(h.reading.followingTail).toBe(false) @@ -152,7 +237,7 @@ it.each([1_999, 2_001])('keeps movement at least one pixel from the floor pendin }) it('does not measure geometry to interrupt an idle follow controller', () => { - const h = fixture() + const h = fixture(VISIBLE_ROW) const reads = (['clientHeight', 'scrollHeight', 'scrollTop'] as const).map(property => vi.spyOn(h.list, property, 'get')) h.viewport.interruptFollow(h.follow) diff --git a/packages/client/ui-chat/tests/group-tree-segments.client.spec.ts b/packages/client/ui-chat/tests/group-tree-segments.client.spec.ts new file mode 100644 index 0000000000..d1d948c7b4 --- /dev/null +++ b/packages/client/ui-chat/tests/group-tree-segments.client.spec.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from 'vitest' +import type { TurnLocation } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { ChatConversationViewNode } from '../src/client/contract/chat-nodes.ts' +import { isAtOrOutside, type ChatGroupTree } from '../src/client/contract/group-tree.ts' +import { GroupTreeSegments } from '../src/client/conversation-nodes/group-tree-segments.ts' + +function turn(index: number): TurnLocation { + return { + turn: index, start: undefined, end: undefined, status: 'closed', steps: [], + data: { get: () => undefined, source: () => ({ getSnapshot: () => undefined, subscribe: () => () => {} }) }, + } +} + +function node(key: string, owner: number | undefined, groupTree?: ChatGroupTree): ChatConversationViewNode { + return { + key, id: key, kind: 'user', target: 'chat', anchorSeq: 0, visibility: 'hidden', groupTree, + location: owner === undefined ? { kind: 'session' } : { kind: 'turn', turn: turn(owner) }, + data: null, + } +} + +function members(segments: GroupTreeSegments, turns: readonly number[]): number[] { + return turns.filter(value => segments.inLatest(value)) +} + +describe('Chat group tree', () => { + it('orders positions from the outermost exchange inward', () => { + const at = (groupTree?: ChatGroupTree) => (['exchange', 'input', 'turn', 'process'] as const) + .map(position => isAtOrOutside({ groupTree }, position)) + expect(at('exchange')).toEqual([true, true, true, true]) + expect(at('input')).toEqual([false, true, true, true]) + expect(at('turn')).toEqual([false, false, true, true]) + expect(at('process')).toEqual([false, false, false, true]) + expect(at(undefined)).toEqual([false, false, false, false]) + }) + + it('starts the latest segment at the last opening Turn and reports only the Turns that moved', () => { + const segments = new GroupTreeSegments('exchange') + const order = [1, 2, 3, 4, 5] + expect(segments.inLatest(1)).toBe(false) + expect(segments.settle(order)).toEqual(order) + expect(members(segments, order)).toEqual(order) + + segments.track(undefined, node('a', 2, 'exchange')) + expect(segments.settle(order)).toEqual([1]) + expect(members(segments, order)).toEqual([2, 3, 4, 5]) + expect(segments.settle(order)).toEqual([]) + + segments.track(undefined, node('b', 4, 'exchange')) + expect(segments.settle(order)).toEqual([2, 3]) + expect(members(segments, order)).toEqual([4, 5]) + expect(segments.inLatest(undefined)).toBe(false) + + segments.track(node('b', 4, 'exchange'), undefined) + expect(segments.settle(order)).toEqual([2, 3]) + expect(members(segments, order)).toEqual([2, 3, 4, 5]) + }) + + it('counts every opening Node of a Turn and falls back as openings leave', () => { + const segments = new GroupTreeSegments('exchange') + const order = [1, 2, 3] + const first = node('first', 1, 'exchange') + const twin = node('twin', 3, 'exchange') + const late = node('late', 3, 'exchange') + for (const value of [late, first, twin]) segments.track(undefined, value) + segments.settle(order) + expect(members(segments, order)).toEqual([3]) + + segments.track(late, node('late', 3)) + segments.settle(order) + expect(members(segments, order)).toEqual([3]) + + segments.track(first, undefined) + segments.settle(order) + expect(members(segments, order)).toEqual([3]) + + segments.track(twin, { ...twin, location: { kind: 'session' } }) + expect(segments.settle(order)).toEqual([1, 2]) + expect(members(segments, order)).toEqual(order) + }) + + it('falls back to the latest remaining opening Turn whatever order the openings arrived in', () => { + const segments = new GroupTreeSegments('exchange') + const order = [1, 2, 3, 4, 5] + const latest = node('latest', 5, 'exchange') + for (const value of [node('middle', 3, 'exchange'), node('earliest', 1, 'exchange'), latest]) { + segments.track(undefined, value) + } + segments.settle(order) + expect(members(segments, order)).toEqual([5]) + + segments.track(latest, undefined) + expect(segments.settle(order)).toEqual([3, 4]) + expect(members(segments, order)).toEqual([3, 4, 5]) + }) + + it('opens segments at Nodes of the configured position or an outer one', () => { + const segments = new GroupTreeSegments('turn') + const order = [1, 2, 3] + segments.track(undefined, node('process', 3, 'process')) + segments.track(undefined, node('turn', 2, 'turn')) + segments.track(undefined, node('exchange', 1, 'exchange')) + segments.track(node('none', 3), node('none', 3)) + segments.settle(order) + expect(members(segments, order)).toEqual([2, 3]) + }) + + it('clears the latest segment when no Turn is loaded', () => { + const segments = new GroupTreeSegments('exchange') + segments.track(undefined, node('a', 1, 'exchange')) + segments.settle([1]) + segments.track(node('a', 1, 'exchange'), undefined) + expect(segments.settle([])).toEqual([]) + expect(segments.inLatest(1)).toBe(false) + }) +}) diff --git a/packages/client/ui-chat/tests/process-groups.client.spec.ts b/packages/client/ui-chat/tests/process-groups.client.spec.ts index 947932fc00..f3d2ba643d 100644 --- a/packages/client/ui-chat/tests/process-groups.client.spec.ts +++ b/packages/client/ui-chat/tests/process-groups.client.spec.ts @@ -45,23 +45,13 @@ function separator(seq: number, inTurn: boolean): ChatNode<'user'> | ChatNode<'s const user: ChatNode<'user'> = { key: `user:${seq}`, id: `user:${seq}`, kind: 'user', target: 'chat', anchorSeq: seq, location: inTurn ? { kind: 'turn', turn } : { kind: 'session' }, visibility: 'visible', + // Neither location is the Step 1 that opens an exchange. + groupTree: 'input', data: { kind: 'user', seq, time: seq, content: [{ type: 'text', text: 'continue' }], source: null }, } return inTurn ? { ...user, kind: 'steering', data: { ...user.data, kind: 'steering', messageId: 'steer' as MessageId } } : user } -function replyNodes(inTurn: boolean): readonly [ChatConversationViewNode, ChatConversationViewNode] { - const trigger: ChatConversationViewNode = { - key: 'generic-reply', id: 'reply-message', kind: 'turn-trigger', target: 'chat', anchorSeq: 6, - location: inTurn ? { kind: 'turn', turn } : { kind: 'session' }, visibility: 'visible', - data: { - kind: 'context', seq: 6, time: 6, content: [{ type: 'text', text: 'answer' }], - source: { kind: 'user-question-reply' }, producer: { role: 'inject', label: null }, form: null, - }, - } - return [trigger, { ...trigger, key: 'question-reply', kind: 'question-reply', data: {} }] -} - function harness(nodes: readonly ChatConversationViewNode[], currentTimeline = timeline) { const builder = new ChatSnapshotBuilder() const state = new ProcessState() @@ -243,69 +233,51 @@ describe('Definition-owned Chat process groups', () => { expect(source.getSnapshot()?.data.summary).toEqual({ counts: [{ kind, count: 1 }], running: undefined, runningDetail: '' }) }) - it.each(['a-trigger', 'z-trigger'])('renders one question reply without splitting its group for duplicate %s', (key) => { - const [generic, reply] = replyNodes(true) - const trigger = { ...generic, key } - const h = harness([tool('before', 4), trigger, reply, tool('after', 8)]) - expect(h.store.entries.map(entry => entry.kind)).toEqual(['group']) - const group = h.store.entries[0]! - if (group.kind !== 'group') throw new Error('expected group') - expect(h.store.groupSource(group.key).getSnapshot()?.members.map(member => member.key)) - .toEqual(['before', reply.key, 'after']) - expect(h.snapshot.nodes.get(trigger.key)).toBeDefined() - expect(h.snapshot.nodes.get(reply.key)).toBeDefined() + it.each(['process', 'turn', 'input'] as const)('ends the group at a Node declared at %s, whatever its kind', (position) => { + const h = harness([tool('before', 4), { ...tool('standalone', 5), groupTree: position }, tool('after', 8)]) + expect(h.store.entries.map(entry => entry.kind === 'group' ? 'group' : entry.key)).toEqual(['group', 'standalone', 'group']) }) - it.each([0.9, 5])('keeps the Turn control before its coalesced opening reply at seq %s', (anchorSeq) => { + it('regroups when a Node changes its position without changing its kind', () => { + const middle = tool('middle', 5) + const h = harness([tool('before', 4), middle, tool('after', 8)]) + expect(h.store.entries.map(entry => entry.kind)).toEqual(['group']) + h.builder.apply({ upserts: [{ ...middle, groupTree: 'process' }], timeline }) + h.commit() + expect(h.store.entries.map(entry => entry.kind === 'group' ? 'group' : entry.key)).toEqual(['group', 'middle', 'group']) + }) + + it('keeps the Turn control as an independent root before the process it folds', () => { const control: ChatNode<'turn-process'> = { - key: 'control', id: '1', kind: 'turn-process', target: 'chat', anchorSeq, - location: { kind: 'turn', turn }, visibility: 'visible', + key: 'control', id: '1', kind: 'turn-process', target: 'chat', anchorSeq: 0.9, + location: { kind: 'turn', turn }, visibility: 'visible', groupTree: 'turn', data: { turn: 1, controlAnchorSeq: 1, processStartSeq: 1, answerAnchorSeq: null, answerStep: null, inlineReasoning: false, messageCount: 0, toolCallCount: 1, subagentCount: 0, }, } - const h = harness([control, ...replyNodes(true), tool('after', 8)]) + const h = harness([control, tool('first', 6), tool('after', 8)]) expect(h.store.entries.map(entry => entry.kind)).toEqual(['node', 'group']) expect(h.store.entries[0]).toEqual({ kind: 'node', key: control.key }) }) - it('aggregates a newly projected reply with its existing trigger', () => { - const [trigger, reply] = replyNodes(true) - const h = harness([tool('before', 4), trigger, tool('after', 8)]) - h.builder.apply({ upserts: [reply], timeline }) - h.commit() - expect(h.store.entries.map(entry => entry.kind)).toEqual(['group']) - const group = h.store.entries[0]! + it('sorts a later-anchored Turn control before the process of a Turn without opening input', () => { + // A Turn opened by a reply that declares no position: the control's own anchor follows the reply. + const control: ChatNode<'turn-process'> = { + key: 'control', id: '1', kind: 'turn-process', target: 'chat', anchorSeq: 6.9, + location: { kind: 'turn', turn }, visibility: 'visible', groupTree: 'turn', + data: { + turn: 1, controlAnchorSeq: 6, processStartSeq: 1, answerAnchorSeq: null, + answerStep: null, inlineReasoning: false, messageCount: 0, toolCallCount: 1, subagentCount: 0, + }, + } + const h = harness([tool('reply', 6), control, tool('after', 8)]) + expect(h.snapshot.order).toEqual(['control', 'reply', 'after']) + expect(h.store.entries.map(entry => entry.kind)).toEqual(['node', 'group']) + expect(h.store.entries[0]).toEqual({ kind: 'node', key: control.key }) + const group = h.store.entries[1]! if (group.kind !== 'group') throw new Error('expected group') - expect(h.store.groupSource(group.key).getSnapshot()?.members.map(member => member.key)) - .toEqual(['before', reply.key, 'after']) - - h.builder.apply({ upserts: [{ ...reply, visibility: 'hidden' }], timeline }) - h.commit() - expect(h.store.entries.map(entry => entry.kind)).toEqual(['group', 'node', 'group']) - expect(h.store.entries[1]).toEqual({ kind: 'node', key: trigger.key }) - }) - - it('aggregates reply projections before their Turn has been loaded', () => { - const [trigger, reply] = replyNodes(false) - const h = harness([trigger, reply]) - expect(h.store.entries).toEqual([{ kind: 'node', key: reply.key }]) - expect(h.snapshot.nodes.get(trigger.key)).toBeDefined() - }) - - it('keeps unscoped aggregation current when a reply arrives', () => { - const [trigger, reply] = replyNodes(false) - const h = harness([trigger]) - h.builder.apply({ upserts: [reply], timeline }) - h.commit() - expect(h.store.entries).toEqual([{ kind: 'node', key: reply.key }]) - }) - - it.each([true, false])('does not merge identical text from different messages, scoped: %s', (inTurn) => { - const [trigger, reply] = replyNodes(inTurn) - const h = harness([trigger, { ...reply, id: 'different-message', data: trigger.data }]) - expect(h.store.entries).toContainEqual({ kind: 'node', key: trigger.key }) + expect(h.store.groupSource(group.key).getSnapshot()?.members.map(member => member.key)).toEqual(['reply', 'after']) }) it('retains a group through repeated prepends and a later append', () => { diff --git a/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx b/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx index d195d284e2..b8f3b5abd0 100644 --- a/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx +++ b/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx @@ -6,7 +6,7 @@ import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controlle import type { SessionStatusSnapshot } from '@deepseek-ai/dsh-client-ui-session/client' import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' -import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { TranscriptViewRow, type TranscriptViewRowProps } from '../src/client/settings/TranscriptViewRow.tsx' import { PerformanceUsageRow } from '../src/client/settings/PerformanceUsageRow.tsx' import type { LinkOpening, PerformanceUsageMode, TranscriptViewMode } from '../src/chat-settings.ts' @@ -47,6 +47,7 @@ function mount(mode: TranscriptViewMode = 'standard', dictionary: typeof en | ty useWorkspaces: emptyWorkspaces(), useSessionRetainInfo: () => undefined, useResource, + ...allowAllAccess, useTranscriptView: bindSnapshotSelector(source), useCollapseTiming: bindSnapshotSelector(collapseTiming), setTranscriptView, diff --git a/packages/client/ui-chat/tsdown.config.ts b/packages/client/ui-chat/tsdown.config.ts index 7787b8fe34..0364805433 100644 --- a/packages/client/ui-chat/tsdown.config.ts +++ b/packages/client/ui-chat/tsdown.config.ts @@ -2,7 +2,7 @@ import { clientBundle } from '../tsdown.client.ts' import { readFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' -const bundle = clientBundle('@deepseek-ai/dsh-client-ui-chat', ['lib/types/index.js']) +const bundle = clientBundle(import.meta.dirname, ['lib/types/index.js']) const stylesheet = fileURLToPath(new URL('./src/client/chat/ChatView.module.css', import.meta.url)).replaceAll('\\', '/') const whaleImage = fileURLToPath(new URL('./src/client/chat/running-whale@2x.png', import.meta.url)) diff --git a/packages/client/ui-commands/package.json b/packages/client/ui-commands/package.json index c78a7e0495..d7bfa638d8 100644 --- a/packages/client/ui-commands/package.json +++ b/packages/client/ui-commands/package.json @@ -55,7 +55,6 @@ "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:*", "@deepseek-ai/dsh-client-ui-slots": "workspace:*", "@deepseek-ai/dsh-commands": "workspace:*", - "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:~", "react": "^18.2.0", "@deepseek-ai/dsh-api-session-controller": "workspace:*", diff --git a/packages/client/ui-commands/tsdown.config.ts b/packages/client/ui-commands/tsdown.config.ts index 83f95f8a8c..7aa03cebc1 100644 --- a/packages/client/ui-commands/tsdown.config.ts +++ b/packages/client/ui-commands/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-commands', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 05bc1f68f4..67d9820b89 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -15,14 +15,14 @@ en: eef4f18c3972ccc4 zh: 6be65c6ac84ce497 /deepseek-ai-dsh-client-ui-conversation/conversation-assembly: - en: 6aefa09bb22fdf81 - zh: 0641c4196437cf53 + en: ef43c4f149195939 + zh: 49fc6c83fd3a05ce /deepseek-ai-dsh-client-ui-conversation/sent-user-text: en: 9912b286384c41d7 zh: 3e994887c1948677 /deepseek-ai-dsh-client-ui-conversation/shell-and-standard-props: - en: f2239c9860c1511d - zh: 2a4caf5ecb1829c7 + en: 430169d17066c95a + zh: 4d02895d82ce06d9 /deepseek-ai-dsh-client-ui-conversation/temporary-composer-entries: en: bb1244310f5c0382 zh: 824b84077432b559 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index a62bdaeba6..50c59a25e6 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -38,7 +38,7 @@ Definitions may share the same read-only Match and Location for one input and li Registration accepts the existing callable `match` or a read-only event-type-to-handler table. The Registry copies table entries and precomputes ordered candidate Sets on registration changes. Function-form Definitions keep their callable API and receive every event; table handlers receive only their declared types. Changing a table requires unregistering and registering again. Legacy assembler adapters with only `entries()` and `fallbackEntry()` retain their original dispatch path. -All accepted Matches update State in order. Repeated updates to one Context are coalesced between flushes. A Context retains its key while kind and id match; Definition-facing Context snapshots remain distinct objects. The fallback handles only inputs not claimed by an ordinary Definition for its target. +All accepted Matches update State in order. Repeated updates to one Context are coalesced between flushes. A Context retains its key while kind and id match; Definition-facing Context snapshots remain distinct objects. The fallback handles only inputs that no ordinary Definition of its target matched. A Definition can return `exclusive: true` from `match()` to claim an event on its target: other Definitions of that target do not receive the event and the target's fallback does not run ([semantics](../../../docs/subsystems/conversation.md#definition-and-typed-chat-payload)). Location lookup indexes Steps by Turn and Step number, so a non-boundary append does not scan the Turn's Step array. Each input's numeric coordinates and resolved Location share one private index record; published Location values remain immutable. Boundary updates, replacement, and prepend keep the indexes aligned with the current timeline. @@ -102,7 +102,7 @@ Clicking unused space inside an editable composer preserves its current caret or Each composer attempt carries its original occurrence time, gesture, delivery intent, and Session model/run-state snapshot through asynchronous command arbitration. The ordinary-message notification fires once before reference serialization, including attachment-only submissions; handled or claimed commands do not notify. Snapshot and notification failures cannot block sending. -Default sends commit optimistically: Enter clears the draft, occurrence table, and undo history in the same transaction, keeps the composer in `plain`, and runs the send as a detached attempt, so typing and further sends continue during the flight. `sendSession` registers a Session submission echo (`session.beginSubmission`) with the delivery mode before serializing, preserving selected image and file order in `pendingSubmissions`; Session derives the placement from that mode and its current running state, so idle sends use the transcript, busy Queue sends use QueueDock, and busy Steer sends use the pending-steering surface. It then yields one paint, encodes images through the browser's native `FileReader` data-URL path, and cites staged file receipts. Command submissions use the same receipts for generic files, so sending `/goal` or `/plan` never reads those browser files again. The prompt reuses the submission `requestId`; Session retires the echo once at its display handoff, correlated by that `rpcId`. Concurrent failures are restored together in submission order until the user edits the restored content; command submissions keep the frozen `submitting` phase. Detached attempts retain their attachment ids through admission and Session scope disposal. An observed retirement immediately exposes each image preview through the durable cache, replaces it with the canonical URL after fetching the admitted attachment, revokes each URL after its use ends, and releases file cards. Selected generic files enter one FIFO background-upload queue; `maxConcurrentFileUploads` defaults to two active Worker transports, the Conversation service retains queued and active operations plus byte progress across Session navigation, and removing a draft skips its queued transfer or aborts its active transport. Where the browser shell exposes `__DSH_HOST_PATHS__` (the Desktop application), dropped or pasted folders and dropped, picked, or pasted non-image files with a real path become `@path` chips; images keep uploading. Drag-and-drop and paste identify directories through the browser entry API; when that API is absent or returns no entry, pasted items retain ordinary file handling. The file picker cannot select directories. References require the `ui-reference` plugin and the original paths to remain readable by the model's file tools. Paths inside the workspace are relative; others remain absolute. The whole batch is validated before insertion, retains source order and selected text, and uses whitespace-separated, closed-quote mentions. A browser without the bridge refuses dropped or pasted folders; a Desktop folder without a reported path is rejected separately. Continuable subagents accept pasted and dropped PNG, JPEG, WebP, and GIF images with the same projected limits; a batch containing a generic file, unsupported image format, or directory is refused before intake. They skip local echoes because their transport does not preserve the browser request id. +Default sends commit optimistically: Enter clears the draft, occurrence table, and undo history in the same transaction, keeps the composer in `plain`, and runs the send as a detached attempt, so typing and further sends continue during the flight. `sendSession` registers a Session submission echo (`session.beginSubmission`) with the delivery mode before serializing, preserving selected image and file order in `pendingSubmissions`; Session derives the placement from that mode and its current running state, so idle sends use the transcript, busy Queue sends use QueueDock, and busy Steer sends use the pending-steering surface. It then yields one paint, encodes images through the browser's native `FileReader` data-URL path, and cites staged file receipts. Command submissions use the same receipts for generic files, so sending `/goal` or `/plan` never reads those browser files again. The prompt reuses the submission `requestId`; Session retires the echo once at its display handoff, correlated by that `rpcId`. Concurrent failures are restored together in submission order until the user edits the restored content; command submissions keep the frozen `submitting` phase. Detached attempts retain their attachment ids through admission and Session scope disposal. An observed retirement immediately exposes each image preview through the durable cache, replaces it with the canonical URL after fetching the admitted attachment, revokes each URL after its use ends, and releases file cards. Selected generic files enter one FIFO background-upload queue; `maxConcurrentFileUploads` defaults to two active Worker transports, the Conversation service retains queued and active operations plus byte progress across Session navigation, and removing a draft skips its queued transfer or aborts its active transport. Where the browser shell exposes `__DSH_HOST_PATHS__` (the Desktop application), dropped or pasted folders and dropped, picked, or pasted non-image files with a real path become `@path` chips; images keep uploading. Drag-and-drop and paste identify directories through the browser entry API; when that API is absent or returns no entry, pasted items retain ordinary file handling. The file picker cannot select directories. References require the `ui-reference` plugin and the original paths to remain readable by the model's file tools. Paths inside the workspace are relative; others remain absolute. The whole batch is validated before insertion, retains source order and selected text, and uses whitespace-separated, closed-quote mentions. A browser without the bridge refuses dropped or pasted folders; a Desktop folder without a reported path is rejected separately. Continuable subagents accept pasted and dropped PNG, JPEG, WebP, and GIF images with the same projected limits; a batch containing a generic file, unsupported image format, or directory is refused before intake. Their sends register the same local echo; the continuation request carries the echo's `requestId`, which the Host records as the child message's `rpcId`. Queued submission echoes show “Sending…” beside disabled edit, remove, and steer buttons; a collapsed dock keeps the sending status in its header. A matching Host queue row replaces the echo and enables each action according to its normal text-content and running-state requirements. Prompt acknowledgement alone does not enable queue actions. A failed submission removes its echo and displays an error; the composer restores the failed draft when it is empty or still contains the previous automatic restoration, preserving subsequently typed text. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index cf5ea3e753..4476403305 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -38,7 +38,7 @@ kind: "package-reference" 注册入口接受原有可调用的 `match`,或只读的事件类型到处理函数表。Registry 在注册关系变化时复制表条目并预计算有序候选 Set。函数形式的 Definition 保持原有可调用 API,接收全部事件;表中的处理函数只接收已声明类型。修改表需要注销后重新注册。只有 `entries()` 与 `fallbackEntry()` 的旧 assembler 适配器保留原有分发路径。 -所有接受的 Match 按顺序更新 State。两次 flush 之间,同一 Context 的重复更新合批发布。kind 和 id 相同时,Context 保持原有 key;传给 Definition 的 Context 快照仍为独立对象。fallback 只处理未被其目标的普通 Definition 认领的输入。 +所有接受的 Match 按顺序更新 State。两次 flush 之间,同一 Context 的重复更新合批发布。kind 和 id 相同时,Context 保持原有 key;传给 Definition 的 Context 快照仍为独立对象。fallback 只处理其目标上没有普通 Definition 匹配的输入。Definition 可以从 `match()` 返回 `exclusive: true`,在其目标上认领一个事件:该目标的其他 Definition 收不到这个事件,该目标的 fallback 也不运行([语义](../../../docs/subsystems/conversation.zh.md#definition-and-typed-chat-payload))。 Location 查询按 Turn 和 Step 编号索引 Step,因此非边界 append 不扫描 Turn 的 Step 数组。每个输入的数值坐标与解析出的 Location 共用一条私有索引记录;已发布的 Location 值保持不可变。边界更新、完整替换和向前补页使这些索引与当前时间线保持一致。 @@ -102,7 +102,7 @@ Session 首次绑定或缓存的 Session 成为 current 时,shell 会在渲染 每次输入框提交都携带原始发生时间、操作来源、投递意图以及会话模型和运行状态快照,贯穿异步命令裁决。普通消息通知在引用序列化前发出一次,纯附件提交也遵循这一规则;已处理或认领的命令不发出通知。快照读取与通知消费异常不能阻断发送。 -默认发送采用乐观提交:Enter 在同一事务里清空草稿、occurrence 表和撤销历史,composer 保持 `plain`,发送作为 detached attempt 运行,发送期间可以继续输入和提交。`sendSession` 在序列化之前用投递模式注册 Session 提交回显(`session.beginSubmission`),并在 `pendingSubmissions` 中保留图片与文件的选择顺序;Session 根据该模式与当前运行状态推导位置,因此空闲发送进入 transcript(文本记录),繁忙时 Queue 进入 QueueDock,繁忙时 Steer 进入 pending-steering 区域。随后让出一帧,图片经浏览器原生 `FileReader` data-URL 路径编码,文件则引用已暂存凭证。命令提交也用同一凭证表示通用文件,因此发送 `/goal` 或 `/plan` 时不会再次读取这些浏览器文件。提示词复用提交 `requestId`;Session 按同一 `rpcId` 关联展示接管,并仅退休回显一次。多个并发发送失败时,在用户编辑还原内容之前按提交顺序合并还原;命令提交保持冻结的 `submitting` 阶段。Detached attempt 持有附件 id,直到 admission 完成或 Session scope 销毁。回显以 observed 退休时,durable 图片缓存立即公开每个预览 URL,读取 admitted 附件后用规范化 URL 替换预览,并在各 URL 停止使用后撤销,同时释放文件卡。选中的通用文件进入同一个先进先出的后台上传队列;`maxConcurrentFileUploads` 默认允许两个 Worker transport 同时运行,Conversation 服务在切换 Session 时继续持有排队和运行中的传输操作及字节进度,移除草稿会跳过排队中的传输或中止正在运行的传输。浏览器 shell 暴露 `__DSH_HOST_PATHS__` 时(桌面应用),拖入或粘贴的文件夹以及拖入、选择或粘贴的带真实路径的非图片文件会成为 `@路径` chip;图片仍然上传。拖放和粘贴通过浏览器 entry API 识别目录;该 API 不可用或没有返回 entry 时,粘贴项沿用普通文件处理。文件选择器不能选择目录。引用需要启用 `ui-reference` 插件,原路径也必须仍可由模型的文件工具读取。工作区内的路径使用相对形式,其他路径保留绝对形式。整批文件先校验再插入,保留来源顺序和已选中的文字,引用之间有空白分隔,含空格的路径使用闭合引号。没有该桥的浏览器会拒绝拖入或粘贴的文件夹,桌面端无法获取文件夹路径时单独报错。continuable 子代理允许粘贴和拖入 PNG、JPEG、WebP、GIF 图片,并沿用相同的投影限制;包含通用文件、不支持的图片格式或目录的批次会在进入草稿前整批拒绝。它们不创建本地回显,因为其 transport 不保留浏览器 request id。 +默认发送采用乐观提交:Enter 在同一事务里清空草稿、occurrence 表和撤销历史,composer 保持 `plain`,发送作为 detached attempt 运行,发送期间可以继续输入和提交。`sendSession` 在序列化之前用投递模式注册 Session 提交回显(`session.beginSubmission`),并在 `pendingSubmissions` 中保留图片与文件的选择顺序;Session 根据该模式与当前运行状态推导位置,因此空闲发送进入 transcript(文本记录),繁忙时 Queue 进入 QueueDock,繁忙时 Steer 进入 pending-steering 区域。随后让出一帧,图片经浏览器原生 `FileReader` data-URL 路径编码,文件则引用已暂存凭证。命令提交也用同一凭证表示通用文件,因此发送 `/goal` 或 `/plan` 时不会再次读取这些浏览器文件。提示词复用提交 `requestId`;Session 按同一 `rpcId` 关联展示接管,并仅退休回显一次。多个并发发送失败时,在用户编辑还原内容之前按提交顺序合并还原;命令提交保持冻结的 `submitting` 阶段。Detached attempt 持有附件 id,直到 admission 完成或 Session scope 销毁。回显以 observed 退休时,durable 图片缓存立即公开每个预览 URL,读取 admitted 附件后用规范化 URL 替换预览,并在各 URL 停止使用后撤销,同时释放文件卡。选中的通用文件进入同一个先进先出的后台上传队列;`maxConcurrentFileUploads` 默认允许两个 Worker transport 同时运行,Conversation 服务在切换 Session 时继续持有排队和运行中的传输操作及字节进度,移除草稿会跳过排队中的传输或中止正在运行的传输。浏览器 shell 暴露 `__DSH_HOST_PATHS__` 时(桌面应用),拖入或粘贴的文件夹以及拖入、选择或粘贴的带真实路径的非图片文件会成为 `@路径` chip;图片仍然上传。拖放和粘贴通过浏览器 entry API 识别目录;该 API 不可用或没有返回 entry 时,粘贴项沿用普通文件处理。文件选择器不能选择目录。引用需要启用 `ui-reference` 插件,原路径也必须仍可由模型的文件工具读取。工作区内的路径使用相对形式,其他路径保留绝对形式。整批文件先校验再插入,保留来源顺序和已选中的文字,引用之间有空白分隔,含空格的路径使用闭合引号。没有该桥的浏览器会拒绝拖入或粘贴的文件夹,桌面端无法获取文件夹路径时单独报错。continuable 子代理允许粘贴和拖入 PNG、JPEG、WebP、GIF 图片,并沿用相同的投影限制;包含通用文件、不支持的图片格式或目录的批次会在进入草稿前整批拒绝。它们的发送同样注册本地回显;continuation 请求携带回显的 `requestId`,Host 把它记录为子会话消息的 `rpcId`。 排队提交的本地回显在禁用的编辑、删除、插话按钮旁显示“发送中…”;折叠后的队列在标题栏保留发送状态。匹配的 Host 队列行替换回显后,各操作按原有的纯文本内容和运行状态要求启用。仅收到提示词确认不会启用队列操作。提交失败会移除回显并显示错误;输入框为空或仍保留上一次自动恢复的内容时,composer 恢复失败草稿,保留用户随后输入的文字。 diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index 1576ea9f49..48a3f73af5 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -83,11 +83,9 @@ "@deepseek-ai/dsh-workspace": "workspace:*", "@deepseek-ai/dsh-file-reference": "workspace:*", "@deepseek-ai/dsh-util-workspace-path": "workspace:*", - "@types/react": "~18.3.1", "react": "^18.2.0", "@lexical/headless": "^0.49.0", "react-dom": "^18.2.0", - "@types/react-dom": "~18.3.0", "zod": "4.4.3", "lexical": "^0.49.0", "clsx": "^2.0.0", diff --git a/packages/client/ui-conversation/src/client/contract/conversation.ts b/packages/client/ui-conversation/src/client/contract/conversation.ts index 8230bda46e..2d871871fc 100644 --- a/packages/client/ui-conversation/src/client/contract/conversation.ts +++ b/packages/client/ui-conversation/src/client/contract/conversation.ts @@ -8,6 +8,16 @@ import type { export interface ConversationMatchResult { readonly id: string readonly role: 'start' | 'update' + /** + * Claim this event on the Definition's target: no other Definition of the + * same target receives it, and that target's unmatched-event fallback does + * not run. Definitions of other targets and Definitions without a target + * still receive it. Two claims on one target, or a claim from a Definition + * without a target, throw during dispatch. A claim returned by the fallback + * itself has no effect, because the fallback runs only when no Definition of + * its target matched. + */ + readonly exclusive?: true } /** Merge-extensible business values published against one Turn. */ diff --git a/packages/client/ui-conversation/src/client/conversation/assembler.ts b/packages/client/ui-conversation/src/client/conversation/assembler.ts index 6ead68c793..d50d7d5eaf 100644 --- a/packages/client/ui-conversation/src/client/conversation/assembler.ts +++ b/packages/client/ui-conversation/src/client/conversation/assembler.ts @@ -3,7 +3,7 @@ import type { } from '@deepseek-ai/dsh-api-session-controller/client' import type { LlmAttemptId } from '@deepseek-ai/dsh-llm/brand' import type { - ConversationContextReader, ConversationLocationData, ConversationMatch, + ConversationContextReader, ConversationLocationData, ConversationMatch, ConversationMatchResult, ConversationNodeContext, ConversationNodeDefinition, ConversationPreviousContext, ConversationLocationDataScope, ConversationPublication, ConversationViewBuilder, ConversationStartMatch, @@ -186,6 +186,9 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore private readonly groups = new Map() private readonly pendingGroupStores = new Set>() private readonly activeTargets = new Set() + /** Matches of the event in dispatch, kept by index; dispatch is not reentrant and reuses both arrays. */ + private readonly matchedDefinitions: ConversationNodeDefinition[] = [] + private readonly matchedResults: ConversationMatchResult[] = [] private hasMore = false private replacePending = true private timelineDirty = true @@ -493,37 +496,58 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore ? startMatch ??= conversationMatch(input, role, location) : updateMatch ??= conversationMatch(input, role, location) } - let firstTarget: string | undefined - let secondTarget: string | undefined - let otherTargets: Set | undefined - let publication: ConversationPublication = 'none' + const definitions = this.matchedDefinitions + const results = this.matchedResults + definitions.length = 0 + results.length = 0 const routes = this.eventDefinitions.forEvent?.(event.type) if (routes === undefined) { for (const definition of this.eventDefinitions.entries()) { const result = definition.match(event) if (result === null) continue - // Inline target tracking avoids an additional closure allocation for each event. - /* jscpd:ignore-start */ - if (definition.target !== undefined && definition.target !== firstTarget && definition.target !== secondTarget) { - if (firstTarget === undefined) firstTarget = definition.target - else if (secondTarget === undefined) secondTarget = definition.target - else (otherTargets ??= new Set()).add(definition.target) - } - publication = maximumPublication(publication, accept.call(this, definition, result.id, matchFor(result.role))) - /* jscpd:ignore-end */ + definitions.push(definition) + results.push(result) } } else { for (const { definition, match } of routes) { const result = match(event) if (result === null) continue - if (definition.target !== undefined && definition.target !== firstTarget && definition.target !== secondTarget) { - if (firstTarget === undefined) firstTarget = definition.target - else if (secondTarget === undefined) secondTarget = definition.target - else (otherTargets ??= new Set()).add(definition.target) - } - publication = maximumPublication(publication, accept.call(this, definition, result.id, matchFor(result.role))) + definitions.push(definition) + results.push(result) } } + // Claims are resolved before any acceptance, so the outcome does not depend on registration order. + let claims: Map | undefined + for (let index = 0; index < results.length; index++) { + if ((results[index] as ConversationMatchResult).exclusive !== true) continue + const definition = definitions[index] as ConversationNodeDefinition + const target = definition.target + if (target === undefined) { + throw new Error(`conversation Definition "${definition.kind}" claims ${event.type} at seq ${String(event.seq)} without a target`) + } + const claimant = claims?.get(target) + if (claimant !== undefined) { + throw new Error(`conversation Definitions "${claimant}" and "${definition.kind}" both claim ${event.type} at seq ${String(event.seq)} on target "${target}"`) + } + (claims ??= new Map()).set(target, definition.kind) + } + let firstTarget: string | undefined + let secondTarget: string | undefined + let otherTargets: Set | undefined + let publication: ConversationPublication = 'none' + for (let index = 0; index < results.length; index++) { + const definition = definitions[index] as ConversationNodeDefinition + const result = results[index] as ConversationMatchResult + const target = definition.target + if (target !== undefined && result.exclusive !== true && claims?.has(target) === true) continue + // Inline target tracking avoids an additional closure allocation for each event. + if (target !== undefined && target !== firstTarget && target !== secondTarget) { + if (firstTarget === undefined) firstTarget = target + else if (secondTarget === undefined) secondTarget = target + else (otherTargets ??= new Set()).add(target) + } + publication = maximumPublication(publication, accept.call(this, definition, result.id, matchFor(result.role))) + } const fallback = this.eventDefinitions.fallbackEntry() const target = fallback?.target if (fallback !== undefined && target !== undefined diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 4703ae3440..f1f708073b 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -312,6 +312,8 @@ export const zh = { 'row.input': '输入', 'row.output': '输出', 'row.inspect': '查看', + 'row.imageSize': '{width}x{height}px,{bytes} 字节', + 'row.imageSizeDownscaled': '{width}x{height}px(由 {originalWidth}x{originalHeight}px 缩小),{bytes} 字节', 'tool.title.search': '搜索', 'tool.title.read': '读取', 'tool.title.bash': '运行命令', @@ -690,6 +692,8 @@ export const en = { 'row.input': 'IN', 'row.output': 'OUT', 'row.inspect': 'Inspect', + 'row.imageSize': '{width}x{height}px, {bytes} bytes', + 'row.imageSizeDownscaled': '{width}x{height}px (downscaled from {originalWidth}x{originalHeight}px), {bytes} bytes', 'tool.title.search': 'Search', 'tool.title.read': 'Read', 'tool.title.bash': 'Bash', diff --git a/packages/client/ui-conversation/src/client/service.ts b/packages/client/ui-conversation/src/client/service.ts index 2ef2309ccf..fc76701be6 100644 --- a/packages/client/ui-conversation/src/client/service.ts +++ b/packages/client/ui-conversation/src/client/service.ts @@ -261,13 +261,6 @@ export class ConversationController extends Service implements IConversation { ? { type: 'image' as const, ...await this.encodeImage(attachment.file) } : { type: 'file' as const, receiptId: uploadFor(attachment).receiptId }), ) - const snapshot = session.getSnapshot() - if (snapshot.subagent !== null) { - const uploaded = await serializeAttachments() - const content = [...uploaded, ...(text === '' ? [] : [{ type: 'text' as const, text }])] - const result = await session.prompt(content, mode, signal) - return result.ok ? { kind: 'success' } : { kind: 'error' } - } let finishRetirement: ((retirement: PendingSubmissionRetirement) => void) | undefined const retirement = attachments.length === 0 ? undefined diff --git a/packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts b/packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts index a069733cbc..0825bc1267 100644 --- a/packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts +++ b/packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts @@ -1809,6 +1809,112 @@ describe('ConversationNodeAssembler', () => { expect(testSnapshot(assembler)?.order).toEqual([]) }) + describe('exclusive matches', () => { + const run = (seq: number, name: string) => + input(at(SessionSeq(seq), 'command/run', { commandId: `c${String(seq)}`, name })) + const commandName = (event: SessionEventLike): string | undefined => (event.type as string) === 'command/run' + ? (event.data as { readonly name: string }).name + : undefined + + function genericDefinition(start = vi.fn(() => 'generic'), target = 'test'): ConversationNodeDefinition { + return { + kind: `generic-${target}`, + target, + match: event => commandName(event) === undefined ? null : { id: String(event.seq), role: 'start' }, + start, + update: context => context.state, + buildViewNode: context => target === 'test' ? node(context, context.state) : null, + } + } + + // `null` builds a Definition without a target; an omitted argument keeps the test target. + function claimingDefinition(kind = 'specific', target: string | null = 'test'): ConversationNodeDefinition { + return { + kind, + ...target === null ? {} : { target }, + match: event => commandName(event) === 'claimed' + ? { id: String(event.seq), role: 'start', exclusive: true } + : null, + start: () => kind, + update: context => context.state, + ...target === null ? {} : { buildViewNode: (context: ConversationNodeContext) => node(context, context.state) }, + } + } + + function nodeData(assembler: ConversationNodeAssembler): unknown[] { + return [...testSnapshot(assembler)?.nodes.values() ?? []].map(value => value.data) + } + + it.each(['claim first', 'claim last'] as const)( + 'withholds the claimed event from other Definitions of the target when registered %s', + (order) => { + const genericStart = vi.fn(() => 'generic') + const definitions = [claimingDefinition(), genericDefinition(genericStart)] + const assembler = new ConversationNodeAssembler( + new TestEventDefinitions(order === 'claim first' ? definitions : [...definitions].reverse()), + new TestViewDefinitions([testView()]), + ) + + assembler.replaceWindow([run(1, 'claimed'), run(2, 'other')], false) + assembler.flush() + assembler.append(run(3, 'claimed')) + assembler.flush() + + expect(genericStart).toHaveBeenCalledOnce() + expect(nodeData(assembler)).toEqual(['specific', 'generic', 'specific']) + }, + ) + + it('keeps delivering the claimed event to other targets, target-less Definitions, and no fallback', () => { + const otherTargetStart = vi.fn(() => 'generic') + const stateOnlyStart = vi.fn(() => null) + const stateOnly: ConversationNodeDefinition = { + kind: 'state-only', + match: event => commandName(event) === undefined ? null : { id: String(event.seq), role: 'start' }, + start: stateOnlyStart, + update: context => context.state, + } + const fallbackStart = vi.fn(() => 'fallback') + const assembler = new ConversationNodeAssembler( + new TestEventDefinitions( + [claimingDefinition(), genericDefinition(otherTargetStart, 'trajectory'), stateOnly], + fallbackDefinition(fallbackStart), + ), + new TestViewDefinitions([testView()]), + ) + + assembler.replaceWindow([run(1, 'claimed')], false) + assembler.flush() + + expect(otherTargetStart).toHaveBeenCalledOnce() + expect(stateOnlyStart).toHaveBeenCalledOnce() + expect(fallbackStart).not.toHaveBeenCalled() + expect(nodeData(assembler)).toEqual(['specific']) + }) + + it('rejects two claims on one target', () => { + const assembler = new ConversationNodeAssembler( + new TestEventDefinitions([claimingDefinition('first'), claimingDefinition('second')]), + new TestViewDefinitions([testView()]), + ) + + expect(() => assembler.replaceWindow([run(1, 'claimed')], false)).toThrow( + 'conversation Definitions "first" and "second" both claim command/run at seq 1 on target "test"', + ) + }) + + it('rejects a claim from a Definition without a target', () => { + const assembler = new ConversationNodeAssembler( + new TestEventDefinitions([claimingDefinition('untargeted', null)]), + new TestViewDefinitions([testView()]), + ) + + expect(() => assembler.replaceWindow([run(1, 'claimed')], false)).toThrow( + 'conversation Definition "untargeted" claims command/run at seq 1 without a target', + ) + }) + }) + it('rejects withdrawing a previously materialized Node during an incremental update', () => { const definition: ConversationNodeDefinition = { kind: 'toggle', diff --git a/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx b/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx index 3d1b833896..f4b80c891c 100644 --- a/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx +++ b/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx @@ -2,7 +2,7 @@ import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' -import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client' import type { SessionStatusSnapshot } from '@deepseek-ai/dsh-client-ui-session/client' @@ -46,6 +46,7 @@ function mount() { useSessionRetainInfo: () => undefined, useResource, useWorkspaces: emptyWorkspaces(), + ...allowAllAccess, useBusyEnter: bindSnapshotSelector(policy.busyEnter), setBusyEnter, t: makeTranslate(en), diff --git a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx index f1f88f914b..4ee04fe8a7 100644 --- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx @@ -16,7 +16,7 @@ import { act, cleanup, fireEvent, render } from '@testing-library/react' import { createPortal } from 'react-dom' import { $getRoot, $isTextNode } from 'lexical' import { - bindSnapshotSelector, conversationSnapshot as conversationFixture, makeTranslate, RemoteError, + allowAllAccess, bindSnapshotSelector, conversationSnapshot as conversationFixture, makeTranslate, RemoteError, sessionSnapshot as sessionFixture, } from '@deepseek-ai/dsh-client-test-runtime' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' @@ -191,6 +191,7 @@ function bench(over?: BenchOptions) { useWorkspaces: bindSnapshotSelector(createSnapshotStore({ items: [], archivedSessionIds: [], pinnedSessionIds: [], state: 'idle', phase: 'ready', error: null, })), + ...allowAllAccess, useProjection: ((key: string, selector?: (v: unknown) => unknown) => (selector ?? (v => v))(key === 'plan' ? over?.plan diff --git a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx index 81bbe030b3..16e468dccb 100644 --- a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx @@ -13,7 +13,7 @@ import type { Context } from '@deepseek-ai/cordis' import type { SessionSnapshot } from '@deepseek-ai/dsh-api-session-controller/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { - bindSnapshotSelector, conversationSnapshot, sessionSnapshot, + allowAllAccess, bindSnapshotSelector, conversationSnapshot, sessionSnapshot, } from '@deepseek-ai/dsh-client-test-runtime' import type { SessionStatusSnapshot } from '@deepseek-ai/dsh-client-ui-session/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' @@ -66,6 +66,7 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled items: [], archivedSessionIds: [], pinnedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, })), + ...allowAllAccess, useProjection: (() => undefined), useConversation: bindSnapshotSelector(createSnapshotStore(conversationSnapshot())), useInput: bindSnapshotSelector(shell.state), diff --git a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx index 905b348dc8..5a0f2d6fba 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx @@ -26,7 +26,7 @@ import type { CommandClaim, PickOutcome, SubmitAttachment, SubmitOutcome, } from '../src/client/contract/input.ts' import { - bindSnapshotSelector, conversationSnapshot, makeTranslate, sessionSnapshot, SlotTestRuntime, + allowAllAccess, bindSnapshotSelector, conversationSnapshot, makeTranslate, sessionSnapshot, SlotTestRuntime, } from '@deepseek-ai/dsh-client-test-runtime' import type { SessionStatusSnapshot } from '@deepseek-ai/dsh-client-ui-session/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' @@ -168,6 +168,7 @@ async function scopedBench(register?: (inputTriggers: InputTriggerService) => vo items: [], archivedSessionIds: [], pinnedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, })), + ...allowAllAccess, useProjection: (() => undefined), useConversation: bindSnapshotSelector(createSnapshotStore(conversationSnapshot())), useInput: bindSnapshotSelector(shell.state), diff --git a/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx b/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx index be31f70fb4..ec93676ff5 100644 --- a/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx +++ b/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx @@ -17,7 +17,7 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { - bindSnapshotSelector, conversationSnapshot, makeTranslate, + allowAllAccess, bindSnapshotSelector, conversationSnapshot, makeTranslate, } from '@deepseek-ai/dsh-client-test-runtime' import type { SessionStatusSnapshot } from '@deepseek-ai/dsh-client-ui-session/client' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' @@ -117,6 +117,7 @@ function kitFor(snapshot: SessionSnapshot, injected: Partial createSnapshotStore(new Map()), ), useWorkspaces: (() => { throw new Error('unused') }) as never, + ...allowAllAccess, useProjection: (() => undefined) as never, useConversation: bindSnapshotSelector(createSnapshotStore(conversationSnapshot())), useChat: (() => { throw new Error('unused') }) as QueueDockProps['useChat'], diff --git a/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts b/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts index 6665284ae4..d784e37b90 100644 --- a/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts +++ b/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts @@ -755,7 +755,7 @@ describe('sendSession submission echo', () => { await b.runtime.dispose() }) - it('sends a subagent continuation without registering an unobservable echo', async () => { + it('echoes a subagent continuation and sends it under the echo request id', async () => { const b = await bench() const session = b.runtime.sessions.binding('s1')!.session const snapshot = session.getSnapshot() @@ -768,8 +768,11 @@ describe('sendSession submission echo', () => { }) const prompt = vi.spyOn(session, 'prompt').mockResolvedValue({ ok: true, value: { accepted: true } }) await expect(b.root.sendSession(session, '继续', [], 'queue')).resolves.toEqual({ kind: 'success' }) - expect(beginSubmission).not.toHaveBeenCalled() - expect(prompt).toHaveBeenCalledWith([{ type: 'text', text: '继续' }], 'queue', undefined) + expect(beginSubmission).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ mode: 'queue', text: '继续' })) + const echo = beginSubmission.mock.results[0] + const requestId = echo?.type === 'return' ? echo.value.requestId : undefined + expect(requestId).toEqual(expect.any(String)) + expect(prompt).toHaveBeenCalledWith([{ type: 'text', text: '继续' }], 'queue', undefined, requestId) await b.runtime.dispose() }) }) diff --git a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx index 7f0225ff33..00a784ac96 100644 --- a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx @@ -8,7 +8,7 @@ import type { SessionListState, SessionSnapshot } from '@deepseek-ai/dsh-api-ses import type { WorkspaceSnapshot, WorkspaceView } from '@deepseek-ai/dsh-api-workspace-controller/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { - bindSnapshotSelector, makeTranslate, RemoteError, sessionSnapshot as sessionFixture, + allowAllAccess, bindSnapshotSelector, makeTranslate, RemoteError, sessionSnapshot as sessionFixture, } from '@deepseek-ai/dsh-client-test-runtime' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { SessionStatusSnapshot } from '@deepseek-ai/dsh-client-ui-session/client' @@ -223,6 +223,7 @@ function mount( useSessionStatus={useSessionStatus} useSessionRetainInfo={() => undefined} useWorkspaces={props.useWorkspaces} + {...allowAllAccess} useProjection={(() => undefined)} useInput={useInput} inputActions={inputActions} @@ -252,6 +253,7 @@ function mount( useSessionStatus={useSessionStatus} useSessionRetainInfo={() => undefined} useWorkspaces={props.useWorkspaces} + {...allowAllAccess} useProjection={(() => undefined)} useInput={useInput} inputActions={inputActions} @@ -279,6 +281,7 @@ function mount( useSessionStatus={useSessionStatus} useSessionRetainInfo={() => undefined} useWorkspaces={props.useWorkspaces} + {...allowAllAccess} useProjection={(() => undefined)} useInput={useInput} inputActions={inputActions} @@ -337,6 +340,7 @@ function mount( useSessionStatus, useSessionRetainInfo: () => undefined, useWorkspaces: bindSnapshotSelector(workspaces), + ...allowAllAccess, useProjection: (() => undefined), useComposerBlock: select => select(options.composerBlock), useInput, @@ -367,6 +371,7 @@ function mount( useSessionRetainInfo: () => undefined, useResource, useWorkspaces: bindSnapshotSelector(workspaces), + ...allowAllAccess, useProjection: (() => undefined), useInput, inputActions, diff --git a/packages/client/ui-conversation/tsdown.config.ts b/packages/client/ui-conversation/tsdown.config.ts index 6ccfee9612..7aa03cebc1 100644 --- a/packages/client/ui-conversation/tsdown.config.ts +++ b/packages/client/ui-conversation/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-conversation', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-deliverables/package.json b/packages/client/ui-deliverables/package.json index be986d9f63..8fb7a29a1b 100644 --- a/packages/client/ui-deliverables/package.json +++ b/packages/client/ui-deliverables/package.json @@ -62,7 +62,6 @@ "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", "@deepseek-ai/dsh-client-ui-sidebar-right": "workspace:*", "@deepseek-ai/dsh-client-ui-slots": "workspace:*", - "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:~", "react": "^18.2.0", "@deepseek-ai/dsh-client-ui-primitives": "workspace:*", diff --git a/packages/client/ui-deliverables/tests/changes-open.host.spec.ts b/packages/client/ui-deliverables/tests/changes-open.host.spec.ts index 6fda6fed4a..9921990174 100644 --- a/packages/client/ui-deliverables/tests/changes-open.host.spec.ts +++ b/packages/client/ui-deliverables/tests/changes-open.host.spec.ts @@ -69,10 +69,13 @@ async function fixture() { apply: registerPresentOpen, }) const handler = connection.createSharedFetchHandler('/api') - const open = (query = '?sessionId=owner&seq=9&index=0') => handler.fetch(new Request(`http://localhost${CHANGES_OPEN_PATH}${query}`, { method: 'POST' })) - const read = (query = '?sessionId=owner&seq=9') => handler.fetch(new Request(`http://localhost${CHANGED_FILES_PATH}${query}`)) - const compare = (query = '?sessionId=owner&seq=9&index=0') => handler.fetch(new Request(`http://localhost${CHANGES_DIFF_PATH}${query}`)) - return { handler, applications, root, cwd, ctx, data, readEvent, open, read, compare, comparison, diff, opener, outside, summary } + const open = (query = '?sessionId=owner&seq=9&index=0') => handler.fetch(new Request(`http://localhost${CHANGES_OPEN_PATH}${query}`, { method: 'POST' }), connection.browserPeer) + const read = (query = '?sessionId=owner&seq=9') => handler.fetch(new Request(`http://localhost${CHANGED_FILES_PATH}${query}`), connection.browserPeer) + const compare = (query = '?sessionId=owner&seq=9&index=0') => handler.fetch(new Request(`http://localhost${CHANGES_DIFF_PATH}${query}`), connection.browserPeer) + return { + browserPeer: connection.browserPeer, handler, applications, root, cwd, ctx, data, readEvent, open, read, compare, comparison, diff, + opener, outside, summary, + } } describe('change summary route', () => { @@ -203,17 +206,17 @@ describe('changed files native open route', () => { it('queries handlers only after file authorization and forwards explicit application choices', async () => { - const { handler, applications, opener, open, ctx } = await fixture() + const { browserPeer, handler, applications, opener, open, ctx } = await fixture() const url = 'http://localhost/api/changes.open?sessionId=owner&seq=9&index=0' - const response = await handler.fetch(new Request(url)) + const response = await handler.fetch(new Request(url), browserPeer) expect(response.status).toBe(200) expect(await response.json()).toEqual([{ id: 'player', name: 'Player', default: true, icon: null }]) expect(opener).not.toHaveBeenCalled() expect((await open('?sessionId=owner&seq=9&index=0&application=player')).status).toBe(204) expect(opener.mock.lastCall?.[0]).toMatchObject({ application: 'player' }) - expect((await handler.fetch(new Request(url.replace('index=0', 'index=999')))).status).toBe(404) + expect((await handler.fetch(new Request(url.replace('index=0', 'index=999')), browserPeer)).status).toBe(404) vi.spyOn(ctx.fs, 'processPathFromHostPath').mockReturnValue(undefined) - expect((await handler.fetch(new Request(url))).status).toBe(422) + expect((await handler.fetch(new Request(url), browserPeer)).status).toBe(422) expect(applications).toHaveBeenCalledOnce() }) diff --git a/packages/client/ui-deliverables/tests/present-open.host.spec.ts b/packages/client/ui-deliverables/tests/present-open.host.spec.ts index b313ee0aa8..6d6057d204 100644 --- a/packages/client/ui-deliverables/tests/present-open.host.spec.ts +++ b/packages/client/ui-deliverables/tests/present-open.host.spec.ts @@ -54,17 +54,19 @@ async function fixture() { const handler = connection.createSharedFetchHandler('/api') const open = (query = '?sessionId=owner&seq=7&index=0', signal?: AbortSignal) => handler.fetch(new Request( `http://localhost${PRESENT_OPEN_PATH}${query}`, { method: 'POST', signal: signal ?? null }, - )) - return { applications, root, cwd, ctx, fiber, file, session, readEvent, open, opener, handler, resolveAgent } + ), connection.browserPeer) + return { + browserPeer: connection.browserPeer, applications, root, cwd, ctx, fiber, file, session, readEvent, open, opener, handler, resolveAgent, + } } describe('Presented workspace file native open route', () => { it('opens the source itself with current bytes and leaves it intact at disposal', async () => { - const { cwd, open, file, fiber, opener, handler, ctx } = await fixture() + const { browserPeer, cwd, open, file, fiber, opener, handler, ctx } = await fixture() const source = await realpath(join(cwd, file.path)) expect(presentedFileUrl(SessionId('owner'), 7, 0)).toBe('api/present.open?sessionId=owner&seq=7&index=0') - expect((await handler.fetch(new Request(`http://localhost${PRESENT_OPEN_PATH}`))).status).toBe(400) - expect((await handler.fetch(new Request('http://localhost/api/present.download?sessionId=owner&seq=7&index=0'))).status).toBe(404) + expect((await handler.fetch(new Request(`http://localhost${PRESENT_OPEN_PATH}`), browserPeer)).status).toBe(400) + expect((await handler.fetch(new Request('http://localhost/api/present.download?sessionId=owner&seq=7&index=0'), browserPeer)).status).toBe(404) for (const contents of ['current source', 'edited source']) { await writeFile(source, contents) const response = await open() @@ -189,8 +191,8 @@ describe('Presented workspace file native open route', () => { it('reports the serving desktop and reveals only an authorized declared source', async () => { - const { cwd, file, open, opener, handler } = await fixture() - const info = await handler.fetch(new Request('http://localhost/api/present.host')) + const { browserPeer, cwd, file, open, opener, handler } = await fixture() + const info = await handler.fetch(new Request('http://localhost/api/present.host'), browserPeer) expect(await info.json()).toEqual({ name: 'desktop', available: true, fileManager: 'finder' }) expect((await open('?sessionId=owner&seq=7&index=0&action=reveal')).status).toBe(204) expect(opener).toHaveBeenCalledWith({ path: await realpath(join(cwd, file.path)), action: 'reveal' }, expect.any(AbortSignal)) @@ -201,9 +203,9 @@ it('reports the serving desktop and reveals only an authorized declared source', }) it('refuses native actions when the configured Host desktop is unavailable', async () => { - const { ctx, open, opener, handler } = await fixture() + const { browserPeer, ctx, open, opener, handler } = await fixture() vi.spyOn(ctx.sessionController, 'workspaceDesktop').mockReturnValue({ name: 'desktop', available: false, fileManager: 'finder' }) - expect(await (await handler.fetch(new Request('http://localhost/api/present.host'))).json()).toMatchObject({ available: false }) + expect(await (await handler.fetch(new Request('http://localhost/api/present.host'), browserPeer)).json()).toMatchObject({ available: false }) for (const action of ['open', 'reveal']) { expect((await open(`?sessionId=owner&seq=7&index=0&action=${action}`)).status).toBe(409) } @@ -237,16 +239,16 @@ it('uses the deployment workspace root when the viewed Session has no cwd', asyn it('queries handlers only after file authorization and forwards explicit application choices', async () => { - const { handler, applications, opener, open, ctx } = await fixture() + const { browserPeer, handler, applications, opener, open, ctx } = await fixture() const url = 'http://localhost/api/present.open?sessionId=owner&seq=7&index=0' - const response = await handler.fetch(new Request(url)) + const response = await handler.fetch(new Request(url), browserPeer) expect(response.status).toBe(200) expect(await response.json()).toEqual([{ id: 'player', name: 'Player', default: true, icon: null }]) expect(opener).not.toHaveBeenCalled() expect((await open('?sessionId=owner&seq=7&index=0&application=player')).status).toBe(204) expect(opener.mock.lastCall?.[0]).toMatchObject({ application: 'player' }) - expect((await handler.fetch(new Request(url.replace('index=0', 'index=999')))).status).toBe(404) + expect((await handler.fetch(new Request(url.replace('index=0', 'index=999')), browserPeer)).status).toBe(404) vi.spyOn(ctx.fs, 'processPathFromHostPath').mockReturnValue(undefined) - expect((await handler.fetch(new Request(url))).status).toBe(422) + expect((await handler.fetch(new Request(url), browserPeer)).status).toBe(422) expect(applications).toHaveBeenCalledOnce() }) diff --git a/packages/client/ui-deliverables/tsdown.config.ts b/packages/client/ui-deliverables/tsdown.config.ts index 51d0ff8a1e..7aa03cebc1 100644 --- a/packages/client/ui-deliverables/tsdown.config.ts +++ b/packages/client/ui-deliverables/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-deliverables', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-directory-picker-browse/package.json b/packages/client/ui-directory-picker-browse/package.json index f8802bca27..0b24a8a9eb 100644 --- a/packages/client/ui-directory-picker-browse/package.json +++ b/packages/client/ui-directory-picker-browse/package.json @@ -54,7 +54,6 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:*", "@deepseek-ai/dsh-client-ui-workspace": "workspace:*", "@testing-library/react": "^16.3.3", - "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:~", "react": "^18.2.0", "react-dom": "^18.2.0", diff --git a/packages/client/ui-directory-picker-browse/tsdown.config.ts b/packages/client/ui-directory-picker-browse/tsdown.config.ts index 840d57706f..7aa03cebc1 100644 --- a/packages/client/ui-directory-picker-browse/tsdown.config.ts +++ b/packages/client/ui-directory-picker-browse/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-directory-picker-browse', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-directory-picker-native/package.json b/packages/client/ui-directory-picker-native/package.json index c1d7acd92d..9a80407630 100644 --- a/packages/client/ui-directory-picker-native/package.json +++ b/packages/client/ui-directory-picker-native/package.json @@ -48,7 +48,6 @@ "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", "@deepseek-ai/dsh-client-ui-workspace": "workspace:*", "@testing-library/react": "^16.3.3", - "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:~", "react": "^18.2.0", "react-dom": "^18.2.0" diff --git a/packages/client/ui-directory-picker-native/tsdown.config.ts b/packages/client/ui-directory-picker-native/tsdown.config.ts index 23e5247c7e..7aa03cebc1 100644 --- a/packages/client/ui-directory-picker-native/tsdown.config.ts +++ b/packages/client/ui-directory-picker-native/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-directory-picker-native', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-dockkit/package.json b/packages/client/ui-dockkit/package.json index 30b92e8b1b..13bfb47625 100644 --- a/packages/client/ui-dockkit/package.json +++ b/packages/client/ui-dockkit/package.json @@ -27,8 +27,6 @@ "@deepseek-ai/dsh-brand": "workspace:*", "@deepseek-ai/dsh-client-ui-primitives": "workspace:*", "@testing-library/react": "^16.3.3", - "@types/react": "~18.3.1", - "@types/react-dom": "~18.3.0", "react-dom": "^18.2.0", "react": "^18.2.0", "clsx": "^2.0.0" diff --git a/packages/client/ui-dockkit/tsdown.config.ts b/packages/client/ui-dockkit/tsdown.config.ts index 7e4e7ba807..a6264e0b21 100644 --- a/packages/client/ui-dockkit/tsdown.config.ts +++ b/packages/client/ui-dockkit/tsdown.config.ts @@ -1,6 +1,6 @@ import { staticLinked } from '../tsdown.client.ts' export default staticLinked( - '@deepseek-ai/dsh-client-ui-dockkit', + import.meta.dirname, ['lib/types/index.js'], ) diff --git a/packages/client/ui-goal/package.json b/packages/client/ui-goal/package.json index 4408b4ce11..43497a51fe 100644 --- a/packages/client/ui-goal/package.json +++ b/packages/client/ui-goal/package.json @@ -62,7 +62,6 @@ "@deepseek-ai/dsh-commands": "workspace:*", "@deepseek-ai/dsh-goal": "workspace:*", "@testing-library/react": "^16.3.3", - "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:~", "react": "^18.2.0", "react-dom": "^18.2.0", diff --git a/packages/client/ui-goal/tsdown.config.ts b/packages/client/ui-goal/tsdown.config.ts index 8b16051076..7aa03cebc1 100644 --- a/packages/client/ui-goal/tsdown.config.ts +++ b/packages/client/ui-goal/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-goal', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-input-trigger/package.json b/packages/client/ui-input-trigger/package.json index 2cbaa42e92..e2f1fa71e3 100644 --- a/packages/client/ui-input-trigger/package.json +++ b/packages/client/ui-input-trigger/package.json @@ -52,7 +52,6 @@ "@deepseek-ai/dsh-client-test-runtime": "workspace:*", "@deepseek-ai/dsh-client-ui-primitives": "workspace:*", "@deepseek-ai/dsh-client-ui-slots": "workspace:*", - "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:~", "react": "^18.2.0", "@deepseek-ai/dsh-file-reference": "workspace:*", diff --git a/packages/client/ui-input-trigger/tsdown.config.ts b/packages/client/ui-input-trigger/tsdown.config.ts index 86f4bfad7d..7aa03cebc1 100644 --- a/packages/client/ui-input-trigger/tsdown.config.ts +++ b/packages/client/ui-input-trigger/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-input-trigger', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-jobs/README.i18n.yaml b/packages/client/ui-jobs/README.i18n.yaml index c037d0003a..460e719a36 100644 --- a/packages/client/ui-jobs/README.i18n.yaml +++ b/packages/client/ui-jobs/README.i18n.yaml @@ -24,8 +24,8 @@ en: c34dad62fee63306 zh: 167d9da7928a9488 /deepseek-ai-dsh-client-ui-jobs/understand-the-implementation: - en: 4f18681e6209c2d7 - zh: 4472b22583ce8d39 + en: 4b520a2726f1a600 + zh: e0324fd12713be6e /deepseek-ai-dsh-client-ui-jobs/further-exploration: en: 01eaaecb5850f391 zh: 2eabd14cde191ab8 diff --git a/packages/client/ui-jobs/README.md b/packages/client/ui-jobs/README.md index 524c9d14b5..740ecc1876 100644 --- a/packages/client/ui-jobs/README.md +++ b/packages/client/ui-jobs/README.md @@ -45,7 +45,7 @@ Expanding an observable row opens that job's output observation stream from `ctx
Implementation internals — click to expand -One slot entry in the header actions band (after the preset label) renders the trigger and popover; the popover portals to the document body, so the clipping header column and the right sidebar cannot cut it off, and is placed from its anchor and clamped inside the viewport. All data arrives through `ctx.jobs` — the component holds no transport state. The roster follows the mount: one `useEffect` keeps the session's `job.list` stream open while the control lives. Observation follows visibility: another `useEffect` opens the stream for the expanded row's job and closes it on collapse, unmount, or popover close. +One slot entry in the header actions band (after the preset label) renders the trigger; its popover uses a body-mounted `MenuSurface` with fixed positioning so dock panels and clipping ancestors cannot cover it. The shared anchoring hook tracks scrolling, resizing, and panel-size changes. All data arrives through `ctx.jobs` — the component holds no transport state. The roster follows the mount: one `useEffect` keeps the session's `job.list` stream open while the control lives. Observation follows visibility: another `useEffect` opens the stream for the expanded row's job and closes it on collapse, unmount, or popover close. | File | Role | |---|---| diff --git a/packages/client/ui-jobs/README.zh.md b/packages/client/ui-jobs/README.zh.md index a749206857..25b792f58c 100644 --- a/packages/client/ui-jobs/README.zh.md +++ b/packages/client/ui-jobs/README.zh.md @@ -45,7 +45,7 @@ kind: "package-reference"
实现内幕——点击展开 -头部操作带里的一个 slot 条目(preset 标签之后)渲染触发器与弹出层;弹出层挂载到 document body,因此不会被裁剪的头部列或右侧边栏截断,并按锚点定位、限制在视口内。所有数据经 `ctx.jobs` 到达——组件不持有任何传输状态。名册跟随挂载:一个 `useEffect` 在控件存活期间保持会话的 `job.list` 流打开。观测跟随可见性:另一个 `useEffect` 为展开行的 job 打开流,并在收起、卸载或弹出层关闭时关闭它。 +头部操作带里的一个 slot 条目(preset 标签之后)渲染触发器;弹出层通过挂载到 body 的 `MenuSurface` 固定定位,避免被停靠面板遮挡或被祖先容器裁剪。共享锚点 hook 跟踪滚动、窗口尺寸及面板尺寸变化。所有数据经 `ctx.jobs` 到达——组件不持有任何传输状态。名册跟随挂载:一个 `useEffect` 在控件存活期间保持会话的 `job.list` 流打开。观测跟随可见性:另一个 `useEffect` 为展开行的 job 打开流,并在收起、卸载或弹出层关闭时关闭它。 | 文件 | 角色 | |---|---| diff --git a/packages/client/ui-jobs/package.json b/packages/client/ui-jobs/package.json index d69c2c231e..5eee006551 100644 --- a/packages/client/ui-jobs/package.json +++ b/packages/client/ui-jobs/package.json @@ -46,8 +46,6 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:*", "@deepseek-ai/dsh-jobs": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", - "@types/react": "~18.3.1", - "@types/react-dom": "~18.3.0", "react": "^18.2.0", "react-dom": "^18.2.0" }, diff --git a/packages/client/ui-jobs/src/client/JobListAction.module.css b/packages/client/ui-jobs/src/client/JobListAction.module.css index f3f2286163..a2cee8bf77 100644 --- a/packages/client/ui-jobs/src/client/JobListAction.module.css +++ b/packages/client/ui-jobs/src/client/JobListAction.module.css @@ -43,24 +43,27 @@ box-sizing: border-box; display: flex; flex-direction: column; - gap: 1px; width: 500px; max-width: min(560px, calc(100vw - 32px)); max-height: min(480px, calc(100vh - 140px)); - margin: 0; - padding: 4px; - overflow: auto; - list-style: none; - border: 0; - border-radius: var(--dsw-radius-lg); - background: var(--dsw-specific-menu); - backdrop-filter: var(--dsw-menu-backdrop-filter); + overflow: hidden; --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); --dsw-elevation-stroke-color: var(--dsw-alias-border-l1); box-shadow: var(--dsw-elevation-prominent); } +.menuBody { + display: flex; + flex-direction: column; + gap: 1px; + min-height: 0; + margin: 0; + padding: 4px; + overflow: auto; + list-style: none; +} + /* Section labels over the live rows and the settled tail. */ .sectionHeader { display: flex; diff --git a/packages/client/ui-jobs/src/client/JobListAction.tsx b/packages/client/ui-jobs/src/client/JobListAction.tsx index ed60e3ca5b..7027483b96 100644 --- a/packages/client/ui-jobs/src/client/JobListAction.tsx +++ b/packages/client/ui-jobs/src/client/JobListAction.tsx @@ -3,7 +3,8 @@ import { createPortal } from 'react-dom' import type { JobsSnapshot, JobView, ObservedJob } from '@deepseek-ai/dsh-api-job-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { - IconChevronDownOutlineRegular, IconStopFillRegular, StateDot, TerminalBlock, useAnchoredPosition, useDismissOnOutsidePointer, + IconChevronDownOutlineRegular, IconStopFillRegular, MenuSurface, StateDot, TerminalBlock, + useAnchoredPosition, useDismissOnOutsidePointer, type StateDotState, type TerminalBlockLabels, } from '@deepseek-ai/dsh-client-ui-primitives' import type { InjectFace, PropsLocale, PropsRuntime, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' @@ -349,7 +350,7 @@ export function JobListAction({ sessionId, useJobs, watchRows, observe, killJob, const [killPhase, setKillPhase] = useState<{ key: string; state: Exclude } | undefined>(undefined) const rootRef = useRef(null) const triggerRef = useRef(null) - const menuRef = useRef(null) + const menuRef = useRef(null) const rows = useMemo(() => ordered(jobs), [jobs]) const liveRows = useMemo(() => rows.filter(isLive), [rows]) @@ -537,36 +538,33 @@ export function JobListAction({ sessionId, useJobs, watchRows, observe, killJob, {open ? createPortal( -
    - {liveRows.length > 0 - ? - : null} - {liveRows.map(item)} - {settledRows.length > 0 - ? ( -
  • - - -
  • - ) - : null} - {settledExpanded ? settledRows.map(item) : null} -
, + +
    + {liveRows.length > 0 + ? + : null} + {liveRows.map(item)} + {settledRows.length > 0 + ? ( +
  • + + +
  • + ) + : null} + {settledExpanded ? settledRows.map(item) : null} +
+
, document.body, ) : null} diff --git a/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx b/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx index ab673c2274..e6866c4182 100644 --- a/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx +++ b/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx @@ -10,6 +10,7 @@ import { zh } from '../src/client/locales.ts' afterEach(() => { cleanup() vi.restoreAllMocks() + vi.unstubAllGlobals() vi.useRealTimers() }) @@ -282,19 +283,86 @@ describe('JobListAction observation', () => { expect(writeText).toHaveBeenCalledWith('pnpm run build') }) - it('portals the open list below its trigger and keeps it open on pointer presses inside', () => { - vi.spyOn(HTMLDivElement.prototype, 'getBoundingClientRect').mockReturnValue({ left: 344, bottom: 40 } as DOMRect) + it('anchors the portaled list in viewport coordinates and follows resize and ancestor scroll', () => { + vi.stubGlobal('innerWidth', 700) + vi.stubGlobal('innerHeight', 600) + vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(440) + vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(120) const { container } = render() - openList() + const trigger = screen.getByRole('button', { name: '1 个后台任务运行中' }) + const anchor = vi.spyOn(trigger.parentElement!, 'getBoundingClientRect') + .mockReturnValue(new DOMRect(344, 40, 180, 28)) + fireEvent.click(trigger) + const list = screen.getByRole('list', { name: zh['list.aria'] }) + const menu = list.closest('[data-menu-material]') + expect(menu?.parentElement).toBe(document.body) + expect(container.contains(list)).toBe(false) + expect(menu?.style.left).toBe('248px') + expect(menu?.style.top).toBe('73px') + + vi.stubGlobal('innerWidth', 900) + fireEvent(window, new Event('resize')) + expect(menu?.style.left).toBe('344px') + anchor.mockReturnValue(new DOMRect(80, 110, 180, 28)) + fireEvent.scroll(container) + expect(menu?.style.left).toBe('80px') + expect(menu?.style.top).toBe('143px') + }) + + it('keeps a near-edge anchored list inside both viewport margins', () => { + vi.stubGlobal('innerWidth', 700) + vi.stubGlobal('innerHeight', 600) + vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(600) + vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(120) + render() + const trigger = screen.getByRole('button', { name: '1 个后台任务运行中' }) + vi.spyOn(trigger.parentElement!, 'getBoundingClientRect').mockReturnValue(new DOMRect(4, 540, 180, 28)) + fireEvent.click(trigger) const menu = screen.getByRole('list', { name: zh['list.aria'] }) - // Outside the header's clipping column, so the right sidebar cannot cover it. - expect(container.contains(menu)).toBe(false) - expect(menu.style.left).toBe('344px') - expect(menu.style.top).toBe('45px') - fireEvent.pointerDown(menu) - expect(screen.getByRole('list', { name: zh['list.aria'] })).toBeDefined() + .closest('[data-menu-material]') + expect(menu?.style.left).toBe('12px') + expect(menu?.style.top).toBe('468px') + }) + + it('keeps portaled controls interactive and releases observation on dismissal and unmount', () => { + const stop = vi.fn() + const observe = vi.fn(() => stop) + const { container, unmount } = render() + const trigger = screen.getByRole('button', { name: '1 个后台任务运行中' }) + trigger.focus() + fireEvent.click(trigger) + const list = screen.getByRole('list', { name: zh['list.aria'] }) + expect(container.contains(list)).toBe(false) + const row = screen.getByRole('button', { + name: zh['row.expandAria'].replace('{label}', 'pnpm run build'), + }) + fireEvent.pointerDown(row) + fireEvent.click(row) + expect(screen.getByRole('list', { name: zh['list.aria'] })).toBe(list) + expect(observe).toHaveBeenCalledTimes(1) + expect(stop).not.toHaveBeenCalled() + const kill = screen.getByTitle(zh['kill.stop'].replace('{label}', 'pnpm run build')) + fireEvent.pointerDown(kill) + fireEvent.click(kill) + expect(kill.getAttribute('data-kill-state')).toBe('armed') + expect(screen.getByRole('list', { name: zh['list.aria'] })).toBe(list) + + row.focus() + fireEvent.keyDown(row, { key: 'Escape' }) + expect(screen.queryByRole('list', { name: zh['list.aria'] })).toBeNull() + expect(document.activeElement).toBe(trigger) + expect(stop).toHaveBeenCalledTimes(1) + fireEvent.click(trigger) + expect(observe).toHaveBeenCalledTimes(2) fireEvent.pointerDown(document.body) expect(screen.queryByRole('list', { name: zh['list.aria'] })).toBeNull() + expect(stop).toHaveBeenCalledTimes(2) + fireEvent.click(trigger) + expect(observe).toHaveBeenCalledTimes(3) + const reopened = screen.getByRole('list', { name: zh['list.aria'] }) + unmount() + expect(document.body.contains(reopened)).toBe(false) + expect(stop).toHaveBeenCalledTimes(3) }) it('ignores other keys while open and closes once the roster empties', () => { @@ -342,7 +410,7 @@ describe('JobListAction observation', () => { render() openList() fireEvent.click(screen.getByRole('button', { name: zh['row.expandAria'].replace('{label}', 'pnpm run build') })) - expect(document.body.querySelector('[data-running]')).not.toBeNull() + expect(screen.getByRole('list', { name: zh['list.aria'] }).querySelector('[data-running]')).not.toBeNull() expect(screen.queryByText(zh['terminal.noOutput'])).toBeNull() }) diff --git a/packages/client/ui-jobs/tsdown.config.ts b/packages/client/ui-jobs/tsdown.config.ts index 550a1fcf58..7aa03cebc1 100644 --- a/packages/client/ui-jobs/tsdown.config.ts +++ b/packages/client/ui-jobs/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-jobs', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-layout/package.json b/packages/client/ui-layout/package.json index 3ecf9b5ae2..8eb5ba21d3 100644 --- a/packages/client/ui-layout/package.json +++ b/packages/client/ui-layout/package.json @@ -54,7 +54,6 @@ "@deepseek-ai/dsh-client-ui-session": "workspace:*", "@deepseek-ai/dsh-client-ui-slots": "workspace:*", "@deepseek-ai/dsh-client-ui-theme": "workspace:*", - "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:~", "react": "^18.2.0", "@deepseek-ai/dsh-client-shortcuts": "workspace:*" diff --git a/packages/client/ui-layout/tests/app-frame.client.spec.tsx b/packages/client/ui-layout/tests/app-frame.client.spec.tsx index f82cec372a..1dc7cc85b8 100644 --- a/packages/client/ui-layout/tests/app-frame.client.spec.tsx +++ b/packages/client/ui-layout/tests/app-frame.client.spec.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom /** Frame interactions with a real store and explicitly driven browser measurements. */ import type { GlobalStandardProps, RenderOpts } from '@deepseek-ai/dsh-client-ui-slots' -import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, render } from '@testing-library/react' import { AppFrame } from '../src/client/AppFrame.tsx' @@ -99,6 +99,7 @@ function mountFrame(windowWidth = frameWidth) { useSessionRetainInfo={() => undefined} useResource={useResource} useWorkspaces={sel => sel(workspaceState)} + {...allowAllAccess} t={key => key === 'brand.localBuild' ? 'DSH Local Build' : key} /> ) diff --git a/packages/client/ui-layout/tsdown.config.ts b/packages/client/ui-layout/tsdown.config.ts index 27224c5803..7aa03cebc1 100644 --- a/packages/client/ui-layout/tsdown.config.ts +++ b/packages/client/ui-layout/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-layout', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-message-feedback/package.json b/packages/client/ui-message-feedback/package.json index 35dad7870b..e186780ee2 100644 --- a/packages/client/ui-message-feedback/package.json +++ b/packages/client/ui-message-feedback/package.json @@ -61,8 +61,6 @@ "@deepseek-ai/dsh-typert-protocol": "workspace:*", "@deepseek-ai/cordis": "workspace:~", "@testing-library/react": "^16.3.3", - "@types/react": "~18.3.1", - "@types/react-dom": "~18.3.0", "react": "^18.2.0", "react-dom": "^18.2.0", "@deepseek-ai/dsh-client-ui-chat": "workspace:*", diff --git a/packages/client/ui-message-feedback/tsdown.config.ts b/packages/client/ui-message-feedback/tsdown.config.ts index 2137857d99..7aa03cebc1 100644 --- a/packages/client/ui-message-feedback/tsdown.config.ts +++ b/packages/client/ui-message-feedback/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-message-feedback', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-model-selection/README.i18n.yaml b/packages/client/ui-model-selection/README.i18n.yaml index 5b631ca046..f9e88d76d0 100644 --- a/packages/client/ui-model-selection/README.i18n.yaml +++ b/packages/client/ui-model-selection/README.i18n.yaml @@ -9,8 +9,8 @@ en: 4cb6feb12e46f3a0 zh: eea39dd76fe1fa8b /deepseek-ai-dsh-client-ui-model-selection/summary: - en: 2fb361ef704528fb - zh: 2537d2afb4d2cb45 + en: 14a351e14b69ca33 + zh: 2f2686efe4832266 /deepseek-ai-dsh-client-ui-model-selection/table-of-contents: en: d152484eb41ac6b4 zh: 09388d293f9be9cb diff --git a/packages/client/ui-model-selection/README.md b/packages/client/ui-model-selection/README.md index ffa4457597..a32460cdb9 100644 --- a/packages/client/ui-model-selection/README.md +++ b/packages/client/ui-model-selection/README.md @@ -11,7 +11,7 @@ Desktop product events use the optional [product analytics service](../product-a ## Summary -The Web GUI lets users switch the model and reasoning effort for an existing session through either the `/model` popup or the composer's model control. Both surfaces present the same provider-grouped choices, and the selected model determines the available effort names and default. A complete selection applies to the next request; a running step keeps the model and effort it started with. If the selected model is unavailable, the composer stays disabled until the user selects an available model or that exact model becomes available again. +The Web GUI lets users switch the model and reasoning effort for an existing session through either the `/model` popup or the composer's model control. Both surfaces present the same provider-grouped choices, and the selected model determines the available effort names and default. A complete selection applies to the next request; a running step keeps the model and effort it started with. ## Table of Contents diff --git a/packages/client/ui-model-selection/README.zh.md b/packages/client/ui-model-selection/README.zh.md index b1ea95f2d7..21b13ec2d5 100644 --- a/packages/client/ui-model-selection/README.zh.md +++ b/packages/client/ui-model-selection/README.zh.md @@ -11,7 +11,7 @@ kind: "package-reference" ## 概述 -Web GUI 允许用户通过 `/model` 弹窗或 composer 模型控件切换既有会话使用的模型与推理(reasoning)强度。两个界面呈现同一组按提供方分组的选择;所选模型决定可用的推理强度名称与默认值。完整选择从下一次请求开始生效;运行中的步骤保留其启动时的模型与推理强度。所选模型不可用时,composer 保持停用,直到用户选择可用模型或同一模型恢复可用。 +Web GUI 允许用户通过 `/model` 弹窗或 composer 模型控件切换既有会话使用的模型与推理(reasoning)强度。两个界面呈现同一组按提供方分组的选择;所选模型决定可用的推理强度名称与默认值。完整选择从下一次请求开始生效;运行中的步骤保留其启动时的模型与推理强度。 ## 目录 diff --git a/packages/client/ui-model-selection/package.json b/packages/client/ui-model-selection/package.json index b932b7de41..0762afbd44 100644 --- a/packages/client/ui-model-selection/package.json +++ b/packages/client/ui-model-selection/package.json @@ -56,11 +56,9 @@ "@deepseek-ai/dsh-client-ui-primitives": "workspace:*", "@deepseek-ai/dsh-client-ui-slots": "workspace:*", "@deepseek-ai/dsh-typert-protocol": "workspace:*", - "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:~", "react": "^18.2.0", "react-dom": "^18.2.0", - "@types/react-dom": "~18.3.0", "@deepseek-ai/dsh-client-store": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-util-values": "workspace:*", diff --git a/packages/client/ui-model-selection/tsdown.config.ts b/packages/client/ui-model-selection/tsdown.config.ts index eba18ff11c..7aa03cebc1 100644 --- a/packages/client/ui-model-selection/tsdown.config.ts +++ b/packages/client/ui-model-selection/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-model-selection', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-open-in-app/package.json b/packages/client/ui-open-in-app/package.json index 3f2544d42f..2dee006328 100644 --- a/packages/client/ui-open-in-app/package.json +++ b/packages/client/ui-open-in-app/package.json @@ -75,7 +75,6 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:*", "@deepseek-ai/dsh-host-open-in-app": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", - "@types/react": "~18.3.1", "react": "^18.2.0", "@deepseek-ai/dsh-client-ui-deliverables": "workspace:*", "@deepseek-ai/dsh-native-command": "workspace:*", diff --git a/packages/client/ui-open-in-app/tsdown.config.ts b/packages/client/ui-open-in-app/tsdown.config.ts index 1153b561f6..7aa03cebc1 100644 --- a/packages/client/ui-open-in-app/tsdown.config.ts +++ b/packages/client/ui-open-in-app/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-open-in-app', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-permission-presets/package.json b/packages/client/ui-permission-presets/package.json index 3628ee6b9e..7f037eab8d 100644 --- a/packages/client/ui-permission-presets/package.json +++ b/packages/client/ui-permission-presets/package.json @@ -64,7 +64,6 @@ "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:*", "@deepseek-ai/dsh-client-ui-slots": "workspace:*", "@deepseek-ai/dsh-permission-presets": "workspace:*", - "@types/react": "~18.3.1", "clsx": "^2.0.0", "react": "^18.2.0", "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", diff --git a/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx b/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx index aec73b8415..160bd49d80 100644 --- a/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx +++ b/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx @@ -3,7 +3,7 @@ import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' -import { bindSnapshotSelector, RemoteError } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, bindSnapshotSelector, RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import type { SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' import { PermissionRow, type PermissionRowProps } from '../src/client/PermissionRow.tsx' import { zh } from '../src/client/locales.ts' @@ -63,6 +63,7 @@ const runtime = { useSessionStatus, usePanelInfo, useSessionRetainInfo: () => undefined, useResource, useWorkspaces: (() => { throw new Error('unused') }) as never, + ...allowAllAccess, } function mount(controller: PermissionPresetSettingsController) { diff --git a/packages/client/ui-permission-presets/tsdown.config.ts b/packages/client/ui-permission-presets/tsdown.config.ts index 3384faddba..7aa03cebc1 100644 --- a/packages/client/ui-permission-presets/tsdown.config.ts +++ b/packages/client/ui-permission-presets/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-permission-presets', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-plan/package.json b/packages/client/ui-plan/package.json index af28609a37..1fbf8eeb4d 100644 --- a/packages/client/ui-plan/package.json +++ b/packages/client/ui-plan/package.json @@ -58,7 +58,6 @@ "@deepseek-ai/dsh-client-ui-primitives": "workspace:*", "@deepseek-ai/dsh-client-ui-slots": "workspace:*", "@deepseek-ai/dsh-plan-mode": "workspace:*", - "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:~", "react": "^18.2.0", "@deepseek-ai/dsh-session": "workspace:*", diff --git a/packages/client/ui-plan/tsdown.config.ts b/packages/client/ui-plan/tsdown.config.ts index 8f8f6ed50e..7aa03cebc1 100644 --- a/packages/client/ui-plan/tsdown.config.ts +++ b/packages/client/ui-plan/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-plan', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-plugin-manager/README.i18n.yaml b/packages/client/ui-plugin-manager/README.i18n.yaml index b5992f5632..d210974ef2 100644 --- a/packages/client/ui-plugin-manager/README.i18n.yaml +++ b/packages/client/ui-plugin-manager/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-client-ui-plugin-manager/use-this-package: - en: 87e0c952a5bfcc41 - zh: 8824e1b588fae213 + en: 91f28554f9ccc921 + zh: 4b60a4bf90a73fcf /deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/installing-a-bundle: en: fcb2e69c8b028c0a zh: d505a075679974eb @@ -24,8 +24,8 @@ en: 59e12480cf0c3e70 zh: f230ea8c752475d5 /deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/switching-one-row-of-a-bundle: - en: 620c6a3ee20848bd - zh: a9429f047a12bb4c + en: d3627d4dba5e6cc2 + zh: e0d0eada4c20331f /deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/configuration-pages: en: e9da90f03691101b zh: 902cda935a4ce3c8 diff --git a/packages/client/ui-plugin-manager/README.md b/packages/client/ui-plugin-manager/README.md index d7640d3de6..77c54ad9b0 100644 --- a/packages/client/ui-plugin-manager/README.md +++ b/packages/client/ui-plugin-manager/README.md @@ -27,11 +27,13 @@ Use the **Plugins** entry in the Web sidebar to manage the profile's installed b ## Use this package -Select **Plugins** in the sidebar. The information button uses an 11px caption-colored icon in a 20px square hover area, with a 4px gap after the subtitle. It explains that this page configures official plugins and installs or manages other plugins, while **Settings → Built-in plugins** shows the built-in inventory and runtime status. Hover or focus shows the shared tooltip; clicking keeps it open until another click, Escape, Tab, or an outside click. The page reads the inventory and the bundles through `api-remotes` when first opened; a Host without a managed profile shows the page as unavailable. **Built-in plugin settings** lists official configuration pages by their configured order: Subagent, web search, agent loop, then shell. **Experimental plugins** shows the primary optional bundles, with **More** beside its heading opening a separate page of the same title. That page lists all offered official bundles, including those on the main list, with Codex and Claude Code subagents last. Its back button returns to the main list, where **Installed plugins** remains below Experimental plugins. Bundle details return to the list they were opened from. Catalog entries keep their position after installation or removal. **Installed plugins** lists other profile bundles. Installation-carried optional bundles have no uninstall action. Experimental packages retain the **Experimental** marker on both lists. Switching a bundle on or off does not change its group or position. A dependency without a bundle patch is not a plugin and is not listed unless the profile selects it, in which case it carries a problem tag. Global configuration remains in the Settings **Built-in plugins** section. +Select **Plugins** in the sidebar. The information button uses an 11px caption-colored icon in a 20px square hover area, with a 4px gap after the subtitle. It explains that this page configures official plugins and installs or manages other plugins, while **Settings → Built-in plugins** shows the built-in inventory and runtime status. Hover or focus shows the shared tooltip; clicking keeps it open until another click, Escape, Tab, or an outside click. The page reads the inventory and the bundles through `api-remotes` when first opened; a Host without a managed profile shows the page as unavailable. **Built-in plugin settings** lists official configuration pages by their configured order: Subagent, web search, agent loop, then shell. **Experimental plugins** shows the primary optional bundles, with **More** beside its heading opening a separate page of the same title. That page lists all offered official bundles, including those on the main list, with Codex and Claude Code subagents last. Its back button returns to the main list, where **Installed plugins** remains below Experimental plugins. Bundle details return to the list they were opened from. Catalog entries keep their position after installation or removal. **Installed plugins** lists other profile bundles. Installation-carried optional bundles have no uninstall action. Experimental packages retain the **Experimental** marker on both lists. Switching on a bundle that page holds back adds it to the main list in the position that page shows it, and switching it off leaves it on that page alone; other bundles keep their place on both. A dependency without a bundle patch is not a plugin and is not listed unless the profile selects it, in which case it carries a problem tag. Global configuration remains in the Settings **Built-in plugins** section. + +The heading moves its complete action group to another line when space is insufficient. Action labels stay on one line, and the information button follows the subtitle text. Installed bundles and their plugin rows display their own title and description in the current UI language on cards and detail pages. Each field falls back from exported locale `meta` to the accessible `package.json` of a package-root plugin; subpath plugins never read one; the final title is the full package or module name, with no package description when both sources omit it. See [Plugin display metadata](../../../docs/cookbook/adding-a-package.md#plugin-display-metadata) for the author format. Bundle cards, details, and component rows display the package root's manifest `icon` or exported `./icon`, or a subpath plugin's exported `/icon`; absent or undecodable images retain the default artwork. Installation previews still use registry or manifest information. -On-demand Official cards are visible offline before installation. Turning one On opens the existing install progress dialog, installs the [target offered by the running Host](../../boot/plugin-manager/README.md#use-this-package), and selects its layer. The same registry fallback, cancellation, and script-approval controls apply. Turning Off keeps the dependency installed; Remove is separate. A different version or development link source offers **Update**; details show current and offered sources and versions. Updates preserve selection and report whether a restart is required. Missing or unreadable packages keep their recorded dependency source alongside the diagnostic. An enabled bundle can still be switched Off when its checkout target is unavailable. +On-demand Official cards are visible offline before installation. Turning one On opens the existing install progress dialog, installs the [target offered by the running Host](../../boot/plugin-manager/README.md#use-this-package), and selects its layer. The same registry fallback, cancellation, and script-approval controls apply. Turning Off keeps the dependency installed; Remove is separate. A different version or development link source offers **Update**; details show current and offered sources and versions. An update removes the installed package and installs the offered one with the same selection, reporting whether a restart is required; when the installation fails after the removal, the package stays uninstalled. Missing or unreadable packages keep their recorded dependency source alongside the diagnostic. An enabled bundle can still be switched Off when its checkout target is unavailable. The first list read shows a short group-heading placeholder and four skeleton cards, using the real cards' row, icon, and text-line spacing, with the action area left blank. They use the same opacity pulse as Session search and remain static with reduced motion. The accessible loading label and read timing are unchanged; refreshes retain the existing list, and installation and component-loading indicators keep their own states. @@ -64,7 +66,7 @@ A bundle's page shows its full package name under the title. Its **Source** sect ### Switching one row of a bundle -A row's switch on the bundle's page calls `pluginManager.setPluginEnabled`, which writes the row's `disabled` override into the profile's `cordis.patch.yml`. The tree recomposes at once on a profile with HMR, so the row's host half unmounts or mounts while the rest of the bundle keeps running, and the page follows the client module graph without reloading. Rows use the shared status marker for their Host fiber phase: pending and disabled are idle, loading and unloading are ongoing, active is done, and failed is error. The switch appears only on a bundle that is on; a row without a live entry, or one the Host will not address through the profile patch, is locked with the Host's reason. A list longer than ten rows gets a filter over localized titles, descriptions, row ids, and module names. +A row's switch on the bundle's page calls `pluginManager.setPluginEnabled`, which records the row's enablement in the profile's `cordis.patch.yml` under the [toggle rules](../../boot/plugin-manager/README.md#use-this-package). The tree recomposes at once on a profile with HMR, so the row's host half unmounts or mounts while the rest of the bundle keeps running, and the page follows the client module graph without reloading. Rows use the shared status marker for their Host fiber phase: pending and disabled are idle, loading and unloading are ongoing, active is done, and failed is error. The switch appears only on a bundle that is on; a row without a live entry, or one the Host will not address through the profile patch, is locked with the Host's reason. A list longer than ten rows gets a filter over localized titles, descriptions, row ids, and module names. ### Configuration pages diff --git a/packages/client/ui-plugin-manager/README.zh.md b/packages/client/ui-plugin-manager/README.zh.md index 88db4ff635..b228f3d99f 100644 --- a/packages/client/ui-plugin-manager/README.zh.md +++ b/packages/client/ui-plugin-manager/README.zh.md @@ -27,11 +27,13 @@ kind: "package-reference" ## 使用本包 -在侧栏选择**插件**。信息按钮使用 11px 的 caption 色图标和 20px 方形悬停区域,与副标题间隔 4px。它说明当前页用于配置官方插件、安装和管理其他插件,**设置 → 内置插件**则展示内置插件列表及运行状态。悬停或聚焦显示共享 tooltip;点击后保持展开,直至再次点击、Escape、Tab 或外部点击。页面首次打开时通过 `api-remotes` 读取清单与组合包;没有受管 profile 的 Host 上页面显示为不可用。**内置插件配置**按配置的顺序列出官方配置页:子智能体、网页搜索、Agent 循环、终端。**实验性插件**展示主要的可选组合包,标题旁的**更多**打开同名的独立页面。该页列出全部官方组合包,也包含主列表已有的条目,Codex 和 Claude Code 子智能体排在最后。返回按钮回到主列表,**已安装插件**保持在实验性插件下方。组合包详情返回打开它的列表。目录条目在安装或移除后保持原位置。**已安装插件**列出其他 profile 组合包。安装随附的可选组合包没有卸载操作。实验性包在两个列表中均保留**实验性**标签。启停组合包不会改变它的分组或位置。没有组合包 patch 的依赖不是插件,除非 profile 选中了它才会带异常标签列出。全局配置仍在设置的**内置插件**分区中编辑。 +在侧栏选择**插件**。信息按钮使用 11px 的 caption 色图标和 20px 方形悬停区域,与副标题间隔 4px。它说明当前页用于配置官方插件、安装和管理其他插件,**设置 → 内置插件**则展示内置插件列表及运行状态。悬停或聚焦显示共享 tooltip;点击后保持展开,直至再次点击、Escape、Tab 或外部点击。页面首次打开时通过 `api-remotes` 读取清单与组合包;没有受管 profile 的 Host 上页面显示为不可用。**内置插件配置**按配置的顺序列出官方配置页:子智能体、网页搜索、Agent 循环、终端。**实验性插件**展示主要的可选组合包,标题旁的**更多**打开同名的独立页面。该页列出全部官方组合包,也包含主列表已有的条目,Codex 和 Claude Code 子智能体排在最后。返回按钮回到主列表,**已安装插件**保持在实验性插件下方。组合包详情返回打开它的列表。目录条目在安装或移除后保持原位置。**已安装插件**列出其他 profile 组合包。安装随附的可选组合包没有卸载操作。实验性包在两个列表中均保留**实验性**标签。开启仅在该页列出的组合包会按该页中的位置把它加入主列表,关闭后它只留在该页;其他组合包在两个列表中均保持原位。没有组合包 patch 的依赖不是插件,除非 profile 选中了它才会带异常标签列出。全局配置仍在设置的**内置插件**分区中编辑。 + +空间不足时,页头将整个操作组移到下一行。操作文案保持单行,信息按钮紧随副标题文字。 已安装的组合包及其插件行在卡片和详情页中,按当前界面语言显示各自的标题与描述。每个字段先读取导出的 locale `meta`,缺失时回退到包根插件可访问的 `package.json`,子路径插件从不读取;标题最终使用完整包名或模块名,两处都没有描述时不提供包描述。作者格式见[插件展示元信息](../../../docs/cookbook/adding-a-package.zh.md#plugin-display-metadata)。组合包卡片、详情和组件行显示包根的清单 `icon` 或导出的 `./icon`,或子路径插件导出的 `<子路径>/icon`;未声明或无法解码时保留默认插画。安装预览仍使用注册表或 manifest 信息。 -官方按需卡片在安装前即可离线显示。开启会打开已有的安装进度对话框,安装[当前 Host 提供的目标](../../boot/plugin-manager/README.zh.md#use-this-package)并选择配置层。安装源回退、取消和脚本审批继续使用同一组控件。关闭保留依赖;移除是独立操作。版本或开发链接来源不同会提供**更新**;详情显示当前及所提供的来源和版本。更新保留选择状态,并报告是否需要重启。文件缺失或不可读的包在诊断旁保留记录的依赖来源。已启用的 bundle 在 checkout 目标不可用时仍可关闭。 +官方按需卡片在安装前即可离线显示。开启会打开已有的安装进度对话框,安装[当前 Host 提供的目标](../../boot/plugin-manager/README.zh.md#use-this-package)并选择配置层。安装源回退、取消和脚本审批继续使用同一组控件。关闭保留依赖;移除是独立操作。版本或开发链接来源不同会提供**更新**;详情显示当前及所提供的来源和版本。更新先删除已安装的包,再以相同的选择状态安装所提供的包,并报告是否需要重启;删除之后安装失败时,该包保持未安装。文件缺失或不可读的包在诊断旁保留记录的依赖来源。已启用的 bundle 在 checkout 目标不可用时仍可关闭。 首次读取列表时,页面显示一条较短的分组标题占位和四行骨架卡片,沿用真实卡片的行、图标和文字间距,操作区域留白。骨架使用与 Session 搜索相同的透明度呼吸动效,在减少动态效果模式下保持静止。无障碍加载文案和读取时机不变;刷新时保留已有列表,安装及组件加载仍使用各自的状态提示。 @@ -64,7 +66,7 @@ Host 将网络失败或超时归因于 GitHub 地址,且提供 npmmirror 时 ### 切换组合包里的一行 -组合包页面上行的开关调用 `pluginManager.setPluginEnabled`,往 profile 的 `cordis.patch.yml` 写入该行的 `disabled` 覆盖。启用了 HMR 的 profile 的树随即重组,该行的宿主半区卸下或挂上,组合包其余部分照常运行,页面无需重载即跟随客户端模块图。行使用共享状态标记表示 Host fiber 阶段:pending 与 disabled 为 idle,loading 与 unloading 为 ongoing,active 为 done,failed 为 error。开关只出现在已打开的组合包上;没有存活条目的行,以及 Host 不通过 profile patch 寻址的行,带着 Host 的原因锁定。超过十行的列表带一个按本地化标题、描述、行 id 和模块名筛选的输入框。 +组合包页面上行的开关调用 `pluginManager.setPluginEnabled`,按[开关规则](../../boot/plugin-manager/README.zh.md#use-this-package)在 profile 的 `cordis.patch.yml` 中记录该行的启用状态。启用了 HMR 的 profile 的树随即重组,该行的宿主半区卸下或挂上,组合包其余部分照常运行,页面无需重载即跟随客户端模块图。行使用共享状态标记表示 Host fiber 阶段:pending 与 disabled 为 idle,loading 与 unloading 为 ongoing,active 为 done,failed 为 error。开关只出现在已打开的组合包上;没有存活条目的行,以及 Host 不通过 profile patch 寻址的行,带着 Host 的原因锁定。超过十行的列表带一个按本地化标题、描述、行 id 和模块名筛选的输入框。 ### 配置页 diff --git a/packages/client/ui-plugin-manager/package.json b/packages/client/ui-plugin-manager/package.json index 32c21c0469..a90b4f0daf 100644 --- a/packages/client/ui-plugin-manager/package.json +++ b/packages/client/ui-plugin-manager/package.json @@ -66,8 +66,6 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:*", "@deepseek-ai/dsh-package-manifest": "workspace:*", "@testing-library/react": "^16.3.3", - "@types/react": "~18.3.1", - "@types/react-dom": "~18.3.0", "clsx": "^2.1.1", "react": "^18.2.0", "react-dom": "^18.2.0", diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css index 3455127437..dda60f3c91 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css @@ -22,6 +22,7 @@ .pageHead { display: flex; + flex-wrap: wrap; align-items: flex-start; justify-content: space-between; gap: 16px; @@ -39,6 +40,11 @@ padding-top: calc(28px + var(--dsh-frame-top-clearance, 0px)); } +.pageHeading { + min-width: 0; + max-width: 100%; +} + .pageTitle { margin: 0; font-size: 20px; @@ -66,10 +72,11 @@ .toolbar { display: flex; - flex-wrap: wrap; + flex: none; align-items: center; justify-content: flex-end; gap: 16px; + white-space: nowrap; } .addMenu { diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx index 8bb9877e4a..bdaa5bae69 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx @@ -29,7 +29,7 @@ import { type InstallInputError, type InstallState, type InstallSubject, type PackageRow, type PackageView, type PluginManagerFace, type RegistryChoice, } from './manager-store.ts' -import { managementText, noticeText, packageText, registryText, rowText, type Translate } from './presentation.ts' +import { managementText, noticeText, packageText, registryText, restartText, rowText, type Translate } from './presentation.ts' import type { PluginPackageRef, PluginRowRef, PluginsSubject } from './slot-contract.ts' import type { ConfigPageForm } from './slot-contract.ts' import css from './PluginManagerPage.module.css' @@ -103,13 +103,14 @@ const BUILTIN_PROFILE_BUNDLES = new Set([ '@deepseek-ai/dsh-sdk-minimal', ]) -/** Display order on the main and full optional plugin lists; new bundles join the main list. */ +/** The optional bundles the main list always holds, in this order; new bundles join the main list. */ const EXTENSION_BUNDLES = [ '@deepseek-ai/dsh-experimental-agent-team-profile', '@deepseek-ai/dsh-experimental-voice-input-bundle', '@deepseek-ai/dsh-experimental-auto-review', '@deepseek-ai/dsh-experimental-inspector-profile', ] +/** The optional bundles the main list holds back until the profile picks them; the More list shows them from the start. */ const MORE_BUNDLES = [ '@deepseek-ai/dsh-experimental-tool-worktree', '@deepseek-ai/dsh-experimental-cot-translation-bundle', @@ -121,13 +122,36 @@ const MORE_BUNDLES = [ '@deepseek-ai/dsh-subagent-codex', '@deepseek-ai/dsh-subagent-claude-code', ] +/** + * Whether the main list shows an official optional bundle: the always-held ones, and any held-back one the profile enabled. + * @param pkg - the official bundle as the Host reports it. + * @returns whether the main list shows it. + */ +function pinnedToMainList(pkg: PackageView): boolean { + return !MORE_BUNDLES.includes(pkg.name) || pkg.enabled +} -function orderPackages(packages: readonly PackageView[], order: readonly string[]): PackageView[] { - const rank = (name: string): number => { - const index = order.indexOf(name) - return index < 0 ? order.length : index - } - return [...packages].sort((a, b) => rank(a.name) - rank(b.name)) +/** + * Where an optional bundle sits on both lists: the always-held ones in their order, then the bundles neither list + * ranks, then the held-back ones in theirs, so Codex and Claude Code stay last on the More list. + * @param name - the bundle's package name. + * @returns the rank; bundles of equal rank keep the order the Host reported. + */ +function optionalBundleRank(name: string): number { + const alwaysHeld = EXTENSION_BUNDLES.indexOf(name) + if (alwaysHeld >= 0) return alwaysHeld + const heldBack = MORE_BUNDLES.indexOf(name) + return heldBack < 0 ? EXTENSION_BUNDLES.length : EXTENSION_BUNDLES.length + 1 + heldBack +} + +/** + * Order the official optional bundles as both lists show them, so a bundle the profile picks from the More list + * keeps its place among the neighbours the main list already holds. + * @param packages - the official bundles the Host offers. + * @returns the bundles in display order. + */ +function orderOptionalBundles(packages: readonly PackageView[]): PackageView[] { + return [...packages].sort((a, b) => optionalBundleRank(a.name) - optionalBundleRank(b.name)) } /** How long a toast holds: long enough to read a failure that names what broke. */ @@ -482,7 +506,8 @@ function DetailTop({ crumbLabel, crumbText, onBack, icon, actions }: { function updateAvailable(pkg: PackageView): boolean { const target = pkg.installTarget - if (!pkg.installed || pkg.availability === 'installation' || target === undefined) return false + // An update removes the installed package first, so a package that cannot be removed offers none. + if (!pkg.installed || !pkg.removable || pkg.availability === 'installation' || target === undefined) return false return pkg.version !== target.version || ((target.spec.startsWith('link:') || pkg.source?.startsWith('link:') === true) && pkg.source !== target.spec) } @@ -1285,7 +1310,7 @@ function InstallDialog({ ?

{t('installDoneNothing')}

: null} {phase === 'done' && install.restartRequired - ?

{t('installDoneRestart')}

+ ?

{restartText('installDoneRestart', install.restartReason, t)}

: null} {phase === 'done' && subject?.kind === 'registry' && subject.name !== undefined && subject.version !== undefined && install.installedVersion !== null && install.installedVersion !== subject.version @@ -1422,8 +1447,13 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode { && (pkg.installed || pkg.official || pkg.error !== undefined)) const mine = listed.filter(pkg => !pkg.official) const official = listed.filter(pkg => pkg.official) - const extensions = orderPackages(official.filter(pkg => !MORE_BUNDLES.includes(pkg.name)), EXTENSION_BUNDLES) - const more = [...extensions, ...orderPackages(official.filter(pkg => MORE_BUNDLES.includes(pkg.name)), MORE_BUNDLES)] + // Both lists read one order over every offered bundle: the main list holds the always-held ones plus the held-back + // ones the profile enabled, and the More list holds all of them, so enabling a bundle from the More list pins it + // to the main list without moving it among its neighbours there. + const ordered = orderOptionalBundles(official) + const pinned = new Set(official.filter(pinnedToMainList).map(pkg => pkg.name)) + const extensions = ordered.filter(pkg => pinned.has(pkg.name)) + const more = ordered const loaded = state.status === 'ready' || state.status === 'error' const refreshing = state.refreshStatus === 'refreshing' const openPkg = view.kind === 'package' || view.kind === 'row' ? listed.find(pkg => pkg.name === view.name) : undefined @@ -1431,7 +1461,7 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode { const openRow = view.kind === 'row' && openPkg !== undefined ? openPkg.rows.find(row => row.rowId === view.rowId) : undefined const showsCards = openPkg === undefined && openItem === undefined const detailFromMore = (view.kind === 'package' || view.kind === 'row') - && (view.from === 'more' || MORE_BUNDLES.includes(view.name)) + && (view.from === 'more' || (MORE_BUNDLES.includes(view.name) && !pinned.has(view.name))) const showsMoreList = showsCards && (view.kind === 'more' || detailFromMore) const showsMainList = showsCards && !showsMoreList const openPkgInMore = openPkg !== undefined && openPkg.official && detailFromMore @@ -1484,7 +1514,7 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode { {showsMainList ? (
-
+

{t('title')}

{t('intro')} diff --git a/packages/client/ui-plugin-manager/src/client/locales.ts b/packages/client/ui-plugin-manager/src/client/locales.ts index b4f3765a62..4b4f14ec22 100644 --- a/packages/client/ui-plugin-manager/src/client/locales.ts +++ b/packages/client/ui-plugin-manager/src/client/locales.ts @@ -23,7 +23,12 @@ export const zh = { chooseAddMethod: '选择添加插件方式', installExisting: '安装第三方插件', installExistingDescription: '支持 npm 包、Git 仓库和本地目录', - restartNotice: '更改将在下次启动生效', + restartNotice: '请重启 DSH 使更改生效。{reason}', + restartReasonNoHmr: '这个 profile 只在启动时应用更改。', + restartReasonPackageMoved: '已安装的插件包解析到了另一个目录,或者更改替换了 DSH 安装自带的包。从其他位置安装前请先卸载。', + restartReasonHostModule: '更改涉及 DSH 自身已加载的模块。', + restartReasonUnmanagedPlugin: '更改涉及一个不受插件列表管理的插件。', + restartReasonModuleComparison: 'DSH 无法将已加载的插件与磁盘上的文件进行比较。', overriddenNotice: '{name} 已保存,但被更高优先级的配置覆盖,当前未生效', bundlesTitle: '已安装插件', basicTitle: '内置插件配置', @@ -182,7 +187,7 @@ export const zh = { terminalSignal: '信号 {signal}', terminalNoExitCode: '未正常退出', installDoneNothing: '安装完成,没有新增依赖。', - installDoneRestart: '已安装,下次启动后加载。', + installDoneRestart: '已安装,请重启 DSH 加载它。{reason}', installDoneApproved: '已允许运行安装脚本:{names}', installDoneOtherVersion: '根据 pnpm 供应链安全策略(minimumReleaseAge),发布时间未满规定时长的版本不会被自动安装,因此本次安装的是 {installed},而非最新版本 {version}。新发布的版本尚未经过该观察期,可能存在未被发现的恶意代码或缺陷。如确需使用 {version},请先卸载当前版本,再安装 {exact} 以指定该版本。', installApprovalTitle: '需要允许安装脚本', @@ -205,6 +210,7 @@ export const zh = { reasonUnaddressable: '当前 profile 的 patch 无法唯一定位这一项', reasonUnknownPlugin: '找不到该插件', reasonInvalidSpec: '请输入有效的包名或地址', + reasonAlreadyInstalled: '这个包已经安装;要安装其他版本,请先卸载它', reasonAmbiguousInstall: '无法从依赖变更中确定安装了哪一个包', reasonNotBundle: '这个包没有声明组合包,不能作为插件管理', reasonNotRemovable: '这个包不属于当前 profile,或者是插件管理所需的组件', @@ -239,7 +245,12 @@ export const en = { chooseAddMethod: 'Choose how to add a plugin', installExisting: 'Install a third-party plugin', installExistingDescription: 'Supports npm packages, Git repositories, and local directories', - restartNotice: 'The change takes effect at the next start', + restartNotice: 'Restart DSH to apply the change. {reason}', + restartReasonNoHmr: 'This profile applies changes only at startup.', + restartReasonPackageMoved: 'An installed package now resolves to another directory, or the change replaces a package the DSH installation provides. Uninstall a package before installing it from another location.', + restartReasonHostModule: 'The change reaches modules DSH itself loaded.', + restartReasonUnmanagedPlugin: 'The change reaches a plugin running outside the plugin list.', + restartReasonModuleComparison: 'DSH could not compare the loaded plugins with their files.', overriddenNotice: '{name} was saved, but a higher-priority configuration overrides it, so it is not in effect', bundlesTitle: 'Installed plugins', basicTitle: 'Built-in plugin settings', @@ -398,7 +409,7 @@ export const en = { terminalSignal: 'signal {signal}', terminalNoExitCode: 'no exit code', installDoneNothing: 'Install finished with no new dependency.', - installDoneRestart: 'Installed; it loads at the next start.', + installDoneRestart: 'Installed; restart DSH to load it. {reason}', installDoneApproved: 'Install scripts allowed for {names}', installDoneOtherVersion: 'Under pnpm\'s supply-chain security policy (minimumReleaseAge), versions published more recently than the required age are not installed automatically, so this installation used {installed} instead of the latest version {version}. A newly published version has not completed this waiting period and may contain undetected malicious code or defects. If you need {version}, uninstall the current version and then install {exact} to request that version.', installApprovalTitle: 'Install scripts need permission', @@ -421,6 +432,7 @@ export const en = { reasonUnaddressable: 'The profile patch cannot address this one uniquely.', reasonUnknownPlugin: 'No such plugin.', reasonInvalidSpec: 'Enter a valid package name or address.', + reasonAlreadyInstalled: 'This package is already installed; uninstall it before installing another version.', reasonAmbiguousInstall: 'Which package was installed cannot be told from the dependency change.', reasonNotBundle: 'This package declares no bundle, so it cannot be managed as a plugin.', reasonNotRemovable: 'This package is not owned by the profile, or plugin management needs it.', diff --git a/packages/client/ui-plugin-manager/src/client/manager-store.ts b/packages/client/ui-plugin-manager/src/client/manager-store.ts index 9dedb29b36..2a997cf5af 100644 --- a/packages/client/ui-plugin-manager/src/client/manager-store.ts +++ b/packages/client/ui-plugin-manager/src/client/manager-store.ts @@ -25,6 +25,7 @@ import type { PluginSpecInspection, ReadOnlyReason, Registry, + RestartReason, } from '@deepseek-ai/dsh-api-remotes/client' import { normalizeRegistry, NPMMIRROR_REGISTRY, OFFICIAL_NPM_REGISTRY, REGISTRY_URL } from '@deepseek-ai/dsh-plugin-manager/registry' import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' @@ -39,7 +40,7 @@ export type FailedAction = 'enable' | 'disable' | 'uninstall' | 'rowEnable' | 'r /** What the last action left to say, shown as a toast; `seq` tells one showing from the next. */ export type ManagerNotice = - | { readonly kind: 'restart'; readonly packageName: string; readonly seq: number } + | { readonly kind: 'restart'; readonly packageName: string; readonly reason: RestartReason | undefined; readonly seq: number } | { readonly kind: 'overridden'; readonly packageName: string; readonly seq: number } | { readonly kind: 'cancelled'; readonly seq: number } | { readonly kind: 'refresh-failed'; readonly seq: number } @@ -224,6 +225,8 @@ export interface InstallState { readonly installedVersion: string | null /** Whether the finished run's bundle waits for the next start to load. */ readonly restartRequired: boolean + /** Why it waits, when the Host says. */ + readonly restartReason?: RestartReason /** * The run's failure, once one settled the dialog: the Host's refusal * `code` with its diagnostic as `reason`, or the transport's words alone; @@ -354,7 +357,7 @@ export interface PluginManagerFace { clearHighlight: () => void /** Put a bundle into, or take it out of, the profile's layer list. */ setEnabled: (packageName: string, enabled: boolean) => void - /** Install the host's catalog version while retaining the current selection. */ + /** Remove an installed package and install the host's catalog version, selected as the package was. */ update: (packageName: string) => void /** Ask before removing a package from the profile. */ uninstall: (packageName: string) => void @@ -661,7 +664,24 @@ export class PluginManagerController { }, update: (packageName) => { const pkg = this.getSnapshot().packages.find(pkg => pkg.name === packageName) - if (pkg?.installTarget !== undefined && pkg.availability !== 'installation') this.openCatalogInstall(pkg, pkg.installTarget, pkg.enabled) + const target = pkg?.installTarget + if (pkg === undefined || target === undefined || pkg.availability === 'installation') return + if (!pkg.installed) { + this.openCatalogInstall(pkg, target, pkg.enabled) + return + } + // An installed package is removed before another version is installed. Without HMR a running bundle stays + // installed, and the installation over it waits for the restart. + void this.run(packageName, { packageName, action: 'uninstall' }, async () => { + const removed = await this.ctx.remote.pluginManager.removeBundle(packageName) + if (removed.ok && removed.value.error?.code === 'stop-profile') { + this.openCatalogInstall(pkg, target, pkg.enabled) + return + } + // A failed removal throws; without HMR a removal waits for the restart, and the installation follows it. + this.applied(removed, packageName) + this.openCatalogInstall(pkg, target, pkg.enabled) + }) }, uninstall: (packageName) => { this.pendingConfirm = () => this.run(packageName, { packageName, action: 'uninstall' }, async () => { @@ -1026,6 +1046,7 @@ export class PluginManagerController { installed: result.bundle ?? null, installedVersion: result.version ?? null, restartRequired: result.application === 'restart-required', + ...result.restartReason === undefined ? {} : { restartReason: result.restartReason }, approvedBuilds: result.approvedBuilds ?? [], ...asked, }) @@ -1197,7 +1218,7 @@ export class PluginManagerController { this.patch({ notice: { kind: 'cancelled', seq: ++this.noticeSeq } }) return case 'restart-required': - this.patch({ notice: { kind: 'restart', packageName, seq: ++this.noticeSeq } }) + this.patch({ notice: { kind: 'restart', packageName, reason: result.restartReason, seq: ++this.noticeSeq } }) return case 'overridden': this.patch({ notice: { kind: 'overridden', packageName, seq: ++this.noticeSeq } }) diff --git a/packages/client/ui-plugin-manager/src/client/presentation.ts b/packages/client/ui-plugin-manager/src/client/presentation.ts index 33998a49ae..29b5d1c204 100644 --- a/packages/client/ui-plugin-manager/src/client/presentation.ts +++ b/packages/client/ui-plugin-manager/src/client/presentation.ts @@ -1,6 +1,6 @@ /** Display labels and toast sentences for global plugin management. */ -import type { IncompatiblePlugin, ManagementError, Registry } from '@deepseek-ai/dsh-api-remotes/client' +import type { IncompatiblePlugin, ManagementError, Registry, RestartReason } from '@deepseek-ai/dsh-api-remotes/client' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { PluginManagerLocaleKey } from './locales.ts' import type { FailedAction, ManagerNotice, PackageRow, PackageView, PluginManagerFace } from './manager-store.ts' @@ -50,6 +50,7 @@ const CODE_KEYS = { 'unaddressable': 'reasonUnaddressable', 'unknown-plugin': 'reasonUnknownPlugin', 'invalid-spec': 'reasonInvalidSpec', + 'already-installed': 'reasonAlreadyInstalled', 'ambiguous-install': 'reasonAmbiguousInstall', 'not-bundle': 'reasonNotBundle', 'not-removable': 'reasonNotRemovable', @@ -138,6 +139,27 @@ export function rowText( } } +/** The sentence naming why a change waits for a restart. */ +const RESTART_REASON_KEYS = { + 'no-hmr': 'restartReasonNoHmr', + 'package-moved': 'restartReasonPackageMoved', + 'host-module': 'restartReasonHostModule', + 'unmanaged-plugin': 'restartReasonUnmanagedPlugin', + 'module-comparison': 'restartReasonModuleComparison', +} satisfies Record + +/** + * A restart prompt with its reason. + * @param key - the prompt's sentence, which takes the reason as `{reason}`. + * @param reason - why the change waits for a restart. + * @param t - the manager's translate seat. + * @returns the prompt. + */ +export function restartText(key: 'restartNotice' | 'installDoneRestart', reason: RestartReason | undefined, t: Translate): string { + // A Host that names no reason still asks for the restart. + return t(key, { reason: reason === undefined ? '' : t(RESTART_REASON_KEYS[reason]) }).trimEnd() +} + /** * The sentence one notice shows. * @param notice - the last action's outcome. @@ -146,7 +168,7 @@ export function rowText( */ export function noticeText(notice: ManagerNotice, t: Translate): string { switch (notice.kind) { - case 'restart': return t('restartNotice') + case 'restart': return restartText('restartNotice', notice.reason, t) case 'overridden': return t('overriddenNotice', { name: notice.packageName }) case 'cancelled': return t('installCancelled') case 'refresh-failed': return t('refreshError') diff --git a/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx b/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx index 8dbe936512..dbce0d098f 100644 --- a/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx +++ b/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx @@ -8,7 +8,7 @@ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { ILayout, PanelInfo } from '@deepseek-ai/dsh-client-ui-layout/client' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' -import { TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' import * as settings from '@deepseek-ai/dsh-client-ui-settings/client' import { apply, inject, NS, PANEL_ID } from '../src/client/index.ts' import { PluginManagerPage } from '../src/client/PluginManagerPage.tsx' @@ -147,7 +147,7 @@ describe('ui-plugin-manager browser plugin', () => { const unread = () => { throw new Error('The sidebar icon must not read application state') } const glyph = render() + useWorkspaces={unread} useResource={unread} {...allowAllAccess} />) expect(glyph.container.querySelector('svg')?.getAttribute('width')).toBe('18') expect(icon.options).toMatchObject({ id: PANEL_ID, order: 0 }) expect(icon.locale).toBe(NS) diff --git a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx index add1b8b8b2..a0055f2a56 100644 --- a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx +++ b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx @@ -5,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from import { Context } from '@deepseek-ai/cordis' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import type { PluginEntryId, PluginInstallRequestId } from '@deepseek-ai/dsh-api-remotes/client' -import { bindSnapshotSelector, stubConfigForm } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, bindSnapshotSelector, stubConfigForm } from '@deepseek-ai/dsh-client-test-runtime' import type { ConfigForm, ConfigFormSnapshot, SettingsMirrorSnapshot } from '@deepseek-ai/dsh-client-ui-settings/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { MenuItemButton } from '@deepseek-ai/dsh-client-ui-primitives' @@ -139,6 +139,7 @@ function renderTab( useSessionStatus: unusedStandardHook, useSessionRetainInfo: unusedStandardHook, useResource: unusedStandardHook, + ...allowAllAccess, } const navigation = createNavigationStore().create() const props: PluginManagerPageProps = { @@ -865,11 +866,11 @@ describe('PluginManagerPage', () => { const team = '@deepseek-ai/dsh-experimental-agent-team-profile' renderTab( { packages: [ - pkg({ name: '@deepseek-ai/dsh-experimental-badge-skill-bundle', official: true }), + pkg({ name: '@deepseek-ai/dsh-experimental-badge-skill-bundle', official: true, enabled: false }), pkg({ name: '@deepseek-ai/dsh-experimental-agent-team-profile', installed: false, optional: true, removable: false, enabled: false }), - pkg({ name: '@deepseek-ai/dsh-subagent-claude-code', official: true, availability: 'missing', installed: false }), - pkg({ name: '@deepseek-ai/dsh-subagent-codex', official: true }), - pkg({ name: '@deepseek-ai/dsh-experimental-tool-worktree', official: true }), + pkg({ name: '@deepseek-ai/dsh-subagent-claude-code', official: true, availability: 'missing', installed: false, enabled: false }), + pkg({ name: '@deepseek-ai/dsh-subagent-codex', official: true, enabled: false }), + pkg({ name: '@deepseek-ai/dsh-experimental-tool-worktree', official: true, enabled: false }), pkg({ name: '@acme/new-official', official: true }), pkg(), ] }, @@ -919,7 +920,7 @@ describe('PluginManagerPage', () => { it('returns from package details to the secondary page and retains it across React remounts', () => { const name = '@deepseek-ai/dsh-experimental-terminal-bundle' - const b = renderTab({ packages: [pkg({ name, official: true })] }) + const b = renderTab({ packages: [pkg({ name, official: true, enabled: false })] }) fireEvent.click(screen.getByRole('button', { name: en.moreAction })) expect(screen.getByRole('heading', { name: en.moreTitle, level: 1 })).toBeTruthy() fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', name) })) @@ -933,19 +934,44 @@ describe('PluginManagerPage', () => { expect(document.querySelector(`[data-plugin-package="${name}"]`)).toBeNull() }) - it('keeps toggled plugins in their group and position', () => { - const names = ['@deepseek-ai/dsh-experimental-cot-translation-bundle', '@deepseek-ai/dsh-experimental-terminal-bundle'] as const - const packages = names.map(name => pkg({ name, official: true, enabled: false })) - const b = renderTab({ packages }) + it('pins a bundle the profile picks onto the main list, in its full-list place', () => { + const team = '@deepseek-ai/dsh-experimental-agent-team-profile' + const first = '@deepseek-ai/dsh-experimental-tool-worktree' + const second = '@deepseek-ai/dsh-experimental-cot-translation-bundle' + const entry = (name: string, enabled: boolean): PackageView => pkg({ name, official: true, enabled }) + const b = renderTab({ packages: [entry(second, true), entry(team, true), entry(first, false)] }) + const cards = (group: 'extensions' | 'more'): (string | null)[] => { + const list = document.querySelector(`[data-plugin-group="${group}"]`) as HTMLElement + return within(list).getAllByRole('listitem').map(card => card.getAttribute('data-plugin-package')) + } + expect(cards('extensions')).toEqual([team, second]) fireEvent.click(screen.getByRole('button', { name: en.moreAction })) - const group = document.querySelector('[data-plugin-group="more"]') as HTMLElement - const cards = () => within(group).getAllByRole('listitem').map(card => card.getAttribute('data-plugin-package')) - expect(cards()).toEqual(names) - fireEvent.click(within(group).getByRole('switch', { name: en.enableToggle.replace('{name}', names[1]) })) - expect(b.actions.setEnabled).toHaveBeenCalledWith(names[1], true) - b.set({ packages: packages.map((entry, index) => ({ ...entry, enabled: index === 1 })) }) - expect(cards()).toEqual(names) + expect(cards('more')).toEqual([team, first, second]) + fireEvent.click(screen.getByRole('switch', { name: en.enableToggle.replace('{name}', first) })) + expect(b.actions.setEnabled).toHaveBeenCalledWith(first, true) + b.set({ packages: [entry(second, true), entry(team, true), entry(first, true)] }) + expect(cards('more')).toEqual([team, first, second]) + fireEvent.click(screen.getByRole('button', { name: en.backToList })) + expect(cards('extensions')).toEqual([team, first, second]) + fireEvent.click(screen.getByRole('switch', { name: en.enableToggle.replace('{name}', first) })) + expect(b.actions.setEnabled).toHaveBeenCalledWith(first, false) + b.set({ packages: [entry(second, true), entry(team, true), entry(first, false)] }) + expect(cards('extensions')).toEqual([team, second]) + fireEvent.click(screen.getByRole('button', { name: en.moreAction })) + expect(cards('more')).toEqual([team, first, second]) + }) + + it('returns a held-back bundle opened outside the lists to the list that shows it', () => { + const name = '@deepseek-ai/dsh-experimental-terminal-bundle' + const b = renderTab({ packages: [pkg({ name, official: true, enabled: false })] }) + act(() => { b.navigation.actions.setView({ kind: 'package', name }) }) + expect(document.querySelector(`[data-plugin-detail="${name}"]`)).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: en.backToPackage.replace('{name}', en.moreTitle) })) expect(screen.getByRole('heading', { name: en.moreTitle, level: 1 })).toBeTruthy() + b.set({ packages: [pkg({ name, official: true, enabled: true })] }) + act(() => { b.navigation.actions.setView({ kind: 'package', name }) }) + fireEvent.click(screen.getByRole('button', { name: en.backToList })) + expect(screen.getByRole('heading', { name: en.title, level: 1 })).toBeTruthy() }) it('starts on the main list when the navigation store is recreated', () => { @@ -1467,9 +1493,9 @@ describe('PluginManagerPage', () => { expect(screen.queryByRole('button', { name: en.installEditAria })).toBeNull() fireEvent.click(screen.getByRole('button', { name: en.installEnableNow })) expect(actions.enableInstalled).toHaveBeenCalledTimes(1) - set({ install: { ...IDLE_INSTALL, open: true, spec: '/plugins/dsh-x', phase: 'done', subject, installed: 'dsh-x', restartRequired: true, enabling: true } }) + set({ install: { ...IDLE_INSTALL, open: true, spec: '/plugins/dsh-x', phase: 'done', subject, installed: 'dsh-x', restartRequired: true, restartReason: 'host-module', enabling: true } }) expect(screen.getByRole('button', { name: en.installEnableNow })).toHaveProperty('disabled', true) - expect(screen.getByText(en.installDoneRestart)).toBeTruthy() + expect(screen.getByText(en.installDoneRestart.replace('{reason}', en.restartReasonHostModule))).toBeTruthy() // A run that named no bundle leaves only Done. set({ install: { ...IDLE_INSTALL, open: true, spec: 'dsh-x', phase: 'done', subject: { spec: 'dsh-x', status: 'accepted', kind: 'registry', name: 'dsh-x', bundle: true, registry: null } } }) @@ -1914,8 +1940,8 @@ describe('PluginManagerPage', () => { Object.defineProperty(Element.prototype, 'scrollIntoView', { value: scrollIntoView, configurable: true }) try { const { actions, set } = renderTab({ packages: [ - pkg({ name, official: true }), - pkg({ name: '@deepseek-ai/dsh-subagent-codex', official: true }), + pkg({ name, official: true, enabled: false }), + pkg({ name: '@deepseek-ai/dsh-subagent-codex', official: true, enabled: false }), ] }) expect(document.querySelector(`[data-plugin-package="${name}"]`)).toBeNull() set({ highlight: name }) @@ -1932,7 +1958,7 @@ describe('PluginManagerPage', () => { expect(screen.getByRole('heading', { name: en.title, level: 1 })).toBeTruthy() expect(document.querySelector(`[data-plugin-package="${name}"]`)).toBeNull() expect(scrollIntoView).toHaveBeenCalledTimes(1) - set({ packages: [pkg({ name, official: true })] }) + set({ packages: [pkg({ name, official: true, enabled: false })] }) act(() => { vi.advanceTimersByTime(799) }) expect(actions.clearHighlight).not.toHaveBeenCalled() act(() => { vi.advanceTimersByTime(1) }) @@ -1995,8 +2021,11 @@ describe('PluginManagerPage', () => { it('words every notice as a toast that dismisses itself', () => { vi.useFakeTimers() try { - const { actions, set } = renderTab({ notice: { kind: 'restart', packageName: 'x', seq: 1 } }) - expect(screen.getByRole('alert').textContent).toContain(en.restartNotice) + const { actions, set } = renderTab({ notice: { kind: 'restart', packageName: 'x', reason: 'no-hmr', seq: 1 } }) + expect(screen.getByRole('alert').textContent).toContain(en.restartNotice.replace('{reason}', en.restartReasonNoHmr)) + // A Host that names no reason still asks for the restart. + set({ notice: { kind: 'restart', packageName: 'x', reason: undefined, seq: 4 } }) + expect(screen.getByRole('alert').textContent).toContain(en.restartNotice.replace(' {reason}', '')) set({ notice: { kind: 'overridden', packageName: 'pkg-1', seq: 2 } }) expect(screen.getByRole('alert').textContent).toContain(en.overriddenNotice.replace('{name}', 'pkg-1')) set({ notice: { kind: 'cancelled', seq: 3 } }) diff --git a/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts b/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts index 73b3c5b778..53e3b1d66c 100644 --- a/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts +++ b/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts @@ -750,9 +750,9 @@ describe('PluginManagerController', () => { controller.installProgress({ requestId, phase: 'applying' }) controller.appendLog({ requestId, jobId: 'old', argv: [], cwd: '/p', stream: 'stdout', text: 'late' }) expect(state().install).toMatchObject({ phase: 'starting', runs: [] }) - second.resolve(ok({ ...APPLIED, application: 'restart-required', bundle: 'slow' })) + second.resolve(ok({ ...APPLIED, application: 'restart-required', restartReason: 'no-hmr', bundle: 'slow' })) await vi.waitFor(() => { expect(state().install.phase).toBe('done') }) - expect(state().install).toMatchObject({ installed: 'slow', restartRequired: true }) + expect(state().install).toMatchObject({ installed: 'slow', restartRequired: true, restartReason: 'no-hmr' }) }) it.each(['too-late', 'not-running', 'offline'] as const)('keeps a stop the Host answered %s apart from a stopped run', async (status) => { @@ -1853,7 +1853,7 @@ it.each(['before', 'after'] as const)('reports one Official toggle when inventor expect(track.mock.calls.filter(([event]) => event === 'plugin_toggle')).toHaveLength(1) }) -it.each([true, false])('updates an Official entry with enabled=%s through the same exact installer', async (enabled) => { +it.each([true, false])('updates an Official entry with enabled=%s by removing it, then using the same exact installer', async (enabled) => { const installed: BundleInfo = { ...CATALOG, availability: 'profile', installed: true, version: '1.0.0', enabled, removable: true } const { controller, plugins, face, state } = bench({ listBundles: vi.fn().mockResolvedValue(ok([installed])), installBundle: vi.fn().mockResolvedValue(ok({ ...APPLIED, bundle: CATALOG.name, application: 'restart-required' })), @@ -1862,12 +1862,44 @@ it.each([true, false])('updates an Official entry with enabled=%s through the sa face.update(CATALOG.name) await vi.waitFor(() => { expect(state().install.phase).toBe('done') }) expect(plugins.inspect).not.toHaveBeenCalled() + expect(plugins.removeBundle).toHaveBeenCalledWith(CATALOG.name) + expect(plugins.removeBundle.mock.invocationCallOrder[0]).toBeLessThan(plugins.installBundle.mock.invocationCallOrder[0]!) expect(plugins.installBundle).toHaveBeenCalledWith(CATALOG.installTarget!.spec, { enabled, saveExact: true, requestId: state().install.requestId, registry: null, }) expect(state().install.restartRequired).toBe(true) }) +it('keeps an installed Official entry and installs nothing when its removal for an update fails', async () => { + const installed: BundleInfo = { ...CATALOG, availability: 'profile', installed: true, version: '1.0.0', enabled: true, removable: true } + const { controller, plugins, face, state } = bench({ listBundles: vi.fn().mockResolvedValue(ok([installed])), + removeBundle: vi.fn().mockResolvedValue(ok({ ...APPLIED, application: 'failed', error: { code: 'bundle-in-use' } })), + }) + await controller.load() + face.update(CATALOG.name) + await vi.waitFor(() => { expect(state().notice).toMatchObject({ kind: 'failed', action: 'uninstall' }) }) + expect(plugins.installBundle).not.toHaveBeenCalled() +}) + +it.each([ + ['an unreachable Host', () => refused('ERR_REMOTE', 'connection lost')], + ['a profile without HMR', () => ok({ ...APPLIED, application: 'failed', error: { code: 'stop-profile' } })], +] as const)('installs an Official update only after the removal, except over a running bundle of %s', async (kind, answer) => { + const installed: BundleInfo = { ...CATALOG, availability: 'profile', installed: true, version: '1.0.0', enabled: true, removable: true } + const { controller, plugins, face, state } = bench({ listBundles: vi.fn().mockResolvedValue(ok([installed])), + removeBundle: vi.fn().mockResolvedValue(answer()), + }) + await controller.load() + face.update(CATALOG.name) + if (kind === 'an unreachable Host') { + await vi.waitFor(() => { expect(state().notice).toMatchObject({ kind: 'failed', action: 'uninstall' }) }) + expect(plugins.installBundle).not.toHaveBeenCalled() + } else { + // Without HMR a running bundle stays installed, and the installation over it waits for the restart. + await vi.waitFor(() => { expect(plugins.installBundle).toHaveBeenCalled() }) + } +}) + it('preserves the Official exact target and selection when script approval retries an installation', async () => { const install = vi.fn().mockResolvedValueOnce(ok({ ...APPLIED, application: 'failed', stage: 'install', error: { code: 'operation-error' }, pendingBuilds: ['native-provider'], @@ -1973,6 +2005,7 @@ it.each([true, false])('replaces a same-version registry package with its source await controller.load() face.update(entry.name) await vi.waitFor(() => { expect(state().install.phase).toBe('done') }) + expect(plugins.removeBundle).toHaveBeenCalledWith(CATALOG.name) expect(plugins.installBundle).toHaveBeenCalledWith(spec, { enabled, requestId: state().install.requestId, registry: null }) expect(state().install.subject).toMatchObject({ kind: 'path', spec, selection: enabled }) expect(state().install.restartRequired).toBe(true) diff --git a/packages/client/ui-plugin-manager/tsdown.config.ts b/packages/client/ui-plugin-manager/tsdown.config.ts index 1169a7be7c..620261533c 100644 --- a/packages/client/ui-plugin-manager/tsdown.config.ts +++ b/packages/client/ui-plugin-manager/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-plugin-manager', ['lib/types/index.js'], { hostPhase: true }) +export default clientBundle(import.meta.dirname, ['lib/types/index.js'], { hostPhase: true }) diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 24924e4f0a..2e59aaf715 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -18,11 +18,11 @@ en: 7bae72c97d0449f5 zh: 4589ecc2b216edc5 /deepseek-ai-dsh-client-ui-primitives/use-this-package/component-catalog: - en: 2afd547980f6f009 - zh: bc0ab804bdf2d296 + en: 6f5457b84fc9db09 + zh: 85993f1c44bfee2c /deepseek-ai-dsh-client-ui-primitives/use-this-package/controls-and-icons: - en: f0e54915b896af5a - zh: 2047520fd321be90 + en: 28ac8f0b1edbcf81 + zh: 5c4c324bf84a282f /deepseek-ai-dsh-client-ui-primitives/use-this-package/rendering-agent-output: en: 12fe9d9b1410edef zh: f2dfdc01170d7cce diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 4c56bdd940..5b29187be1 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -59,7 +59,7 @@ Check this table before writing a control in a feature package. A plugin cannot | `Modal` | Centered dialog over a page mask. A nested dialog can intercept keys with `onKeyDownCapture` before document Escape handlers. The tint and dialog fade in while backdrop blur stays fully applied, honoring reduced motion. Set `backdropBlur={false}` when the caller already blurs the source page. | | `RiskConfirmation` | Sensitive action gated behind an explicit checkbox. | | `Tooltip` | Hover and keyboard-focus text anchored to a cloned child; optional `delayMs` controls hover delay and `focusDelayMs` controls keyboard-focus delay, both defaulting to 0 ms. Optional `portal` rendering escapes clipping containers and ancestor stacking contexts that cap the bubble's z-index. | -| `HoverCard` | Hover preview the pointer can rest on and select from; optional copy button. | +| `HoverCard`, `HoverCardGroup` | Readable, optionally copyable hover preview; a physical list can opt cards into shared first-display dwell and exclusive display. | | `ImageLightbox` | Shared image modal with focus restoration and Escape dismissal. | | `Toast` | Transient top-center banner held for the owner's `holdMs`. | | `SettingsForm`, `SettingsValueField`, `SettingsSecretField` | The frame and the controls of a plugin's settings page: the frame takes its copy as `labels`, saves only on its button, and discards on unmount; a value field shows staged text with the overridden badge and reset; a secret field starts blank, requests no saved-password autofill, and reports only whether a value is configured. | @@ -87,12 +87,14 @@ Writing your own component in your own package is fine when the need is genuinel ### Controls and icons -The catalog above lists what each export is for; this section covers the behavior that props alone do not show. Product icon names omit artboard sizes and end in `Regular` for the supplied one-pixel artwork or `Medium` for the same geometry at a 1.3px stroke; the `size` prop controls rendered dimensions. Named components keep the chosen weight visible at import sites; the library owns stroke geometry instead of consumer CSS overrides. Each product, reference, link, and permission glyph intentionally keeps both weight exports even when the current product uses only one, so callers can choose emphasis without adding another API later; fill-only pairs render identically. `IconWarningOutlineRegular`/`Medium` use a circle; `IconWarningTriangleOutlineRegular`/`Medium` use a rounded triangle. `FishLogo` and `BrandWordmark` fill brand slots. `FileTypeIcon` renders the traditional 28px spreadsheet, folder, HTML, image, Markdown, generic, PDF, PPT, video, and Word glyphs, and uses the imported square technology artwork for the established 48 code and configuration categories. The import replaces artwork only: archive-only categories do not extend `CodeFileType`. `classifyFileType` applies exact filename, prefix, suffix, optional project-context, and extension rules in that order; React names win over TypeScript/JavaScript, Angular suffixes win over their base extension, and a Dart file becomes Flutter only when the supplied project files contain a `pubspec.yaml` with `flutter:`. Markdown and SVG remain traditional Markdown and image files. Spreadsheet mappings include CSV, TSV, Excel workbooks and templates, OpenDocument spreadsheets, and Numbers; KEY maps to slides, while RTF/ODT/Pages map to documents. `fileExtension` exposes the same basename and final-dot parsing for adjacent metadata labels. Traditional glyphs use a solid category-colored sheet with a white mark and translucent white corner; the generic code glyph uses angle brackets and a slash at the supplied 1.35px stroke; the generic file uses a grey sheet and darker grey corner. Callers may override the sheet color through `--dsh-file-type-icon-color`. The full-color technology artwork is the deliberate exception and retains its embedded palette. All glyphs are decorative and carry no label. `LinkIconMedium` is the leading glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, and for a `url` link the mark of a well-known site named by its `href` — the developer sites a transcript usually cites (GitHub, GitLab, npm, PyPI, Stack Overflow, MDN, Wikipedia, Hacker News, YouTube, X, Bilibili, Zhihu, Juejin, CSDN) and mainstream search, video, social, shopping, and reference sites (Google, Baidu, DuckDuckGo, TikTok, Netflix, Spotify, Facebook, Instagram, Reddit, Telegram, WhatsApp, WeChat, QQ, Weibo, Taobao, AliExpress, eBay, Quora, V2EX, Apple) — while `classifyLinkPath` folds the shared file types into that existing six-category vocabulary. `ConnectionIndicator` renders a warning-colored disconnected action whose permanent retry glyph marks the retry action beside the owner-supplied outage label, the shared ongoing loader in the same warning color as its label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Clicking either warning state requests an immediate reconnect; no hover interaction changes the copy. The pill fades in on appearance, fades out for 150ms before unmounting, and sizes to its current label. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor; anchored overlays and portalled `Menu` add 20px to the frame's published top clearance, or keep 20px in native fullscreen, while preserving their own minimum margins in ordinary browsers. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. Its `preview` variant uses the anchor or `widthAnchorRef` element’s width minus 48px, centers the panel with 24px side insets, and places it above or below the row within the viewport. The panel keeps the frame’s top clearance, caps its height at 420px, and follows content and anchor resizing; Escape or pointer and keyboard activation of the anchor dismisses it. The whole preview fades in and out over 100ms; a closing preview stops receiving pointer input and unmounts after the fade. Re-entering the anchor during the fade restores it. Reduced-motion preferences suppress the transition. Copy labels are required only when copying is enabled. A `Tooltip` nested in the anchor hides that preview while its own hover or focus label is visible; releasing, disabling, or unmounting the nested tooltip restores a still-open preview. `Toast` uses the owner's `holdMs` for both the fade delay and its hold-and-fade lifetime. Rerenders with unchanged `holdMs` do not restart that lifetime; completion uses the latest callback, and fully faded actions cannot receive input. A new component key restarts the banner. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order. Portalled `Menu` lists follow their anchor during dragging and CSS transforms, and stop tracking when closed. `Menu.autoFocus` focuses its first enabled item, supports Arrow Up/Down and Home/End navigation, and focuses the first button in the anchor on Escape; it is opt-in for action menus. +The catalog above lists what each export is for; this section covers the behavior that props alone do not show. Product icon names omit artboard sizes and end in `Regular` for the supplied one-pixel artwork or `Medium` for the same geometry at a 1.3px stroke; the `size` prop controls rendered dimensions. Named components keep the chosen weight visible at import sites; the library owns stroke geometry instead of consumer CSS overrides. Each product, reference, link, and permission glyph intentionally keeps both weight exports even when the current product uses only one, so callers can choose emphasis without adding another API later; fill-only pairs render identically. `IconWarningOutlineRegular`/`Medium` use a circle; `IconWarningTriangleOutlineRegular`/`Medium` use a rounded triangle. `FishLogo` and `BrandWordmark` fill brand slots. `FileTypeIcon` renders the traditional 28px spreadsheet, folder, HTML, image, Markdown, generic, PDF, PPT, video, and Word glyphs, and uses the imported square technology artwork for the established 48 code and configuration categories. The import replaces artwork only: archive-only categories do not extend `CodeFileType`. `classifyFileType` applies exact filename, prefix, suffix, optional project-context, and extension rules in that order; React names win over TypeScript/JavaScript, Angular suffixes win over their base extension, and a Dart file becomes Flutter only when the supplied project files contain a `pubspec.yaml` with `flutter:`. Markdown and SVG remain traditional Markdown and image files. Spreadsheet mappings include CSV, TSV, Excel workbooks and templates, OpenDocument spreadsheets, and Numbers; KEY maps to slides, while RTF/ODT/Pages map to documents. `fileExtension` exposes the same basename and final-dot parsing for adjacent metadata labels. Traditional glyphs use a solid category-colored sheet with a white mark and translucent white corner; the generic code glyph uses angle brackets and a slash at the supplied 1.35px stroke; the generic file uses a grey sheet and darker grey corner. Callers may override the sheet color through `--dsh-file-type-icon-color`. The full-color technology artwork is the deliberate exception and retains its embedded palette. All glyphs are decorative and carry no label. `LinkIconMedium` is the leading glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, and for a `url` link the mark of a well-known site named by its `href` — the developer sites a transcript usually cites (GitHub, GitLab, npm, PyPI, Stack Overflow, MDN, Wikipedia, Hacker News, YouTube, X, Bilibili, Zhihu, Juejin, CSDN) and mainstream search, video, social, shopping, and reference sites (Google, Baidu, DuckDuckGo, TikTok, Netflix, Spotify, Facebook, Instagram, Reddit, Telegram, WhatsApp, WeChat, QQ, Weibo, Taobao, AliExpress, eBay, Quora, V2EX, Apple) — while `classifyLinkPath` folds the shared file types into that existing six-category vocabulary. `ConnectionIndicator` renders a warning-colored disconnected action whose permanent retry glyph marks the retry action beside the owner-supplied outage label, the shared ongoing loader in the same warning color as its label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Clicking either warning state requests an immediate reconnect; no hover interaction changes the copy. The pill fades in on appearance, fades out for 150ms before unmounting, and sizes to its current label. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor; anchored overlays and portalled `Menu` add 20px to the frame's published top clearance, or keep 20px in native fullscreen, while preserving their own minimum margins in ordinary browsers. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. Its `preview` variant uses the anchor or `widthAnchorRef` element’s width minus 48px, centers the panel with 24px side insets, and places it above or below the row within the viewport. The panel keeps the frame’s top clearance, caps its height at 420px, and follows content and anchor resizing; Escape or pointer and keyboard activation of the anchor dismisses it. The whole preview fades in and out over 100ms; a closing preview stops receiving pointer input and unmounts after the fade. Re-entering the anchor during the fade restores it. Reduced-motion preferences suppress the transition. Copy labels are required only when copying is enabled. `HoverCard` and nested action tooltips display independently; use `Tooltip.portal` to layer the small bubble above the card. A visible nested `Tooltip` suppresses only its enclosing tooltip, not the hover card. `Toast` uses the owner's `holdMs` for both the fade delay and its hold-and-fade lifetime. Rerenders with unchanged `holdMs` do not restart that lifetime; completion uses the latest callback, and fully faded actions cannot receive input. A new component key restarts the banner. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order. Portalled `Menu` lists follow their anchor during dragging and CSS transforms, and stop tracking when closed. `Menu.autoFocus` focuses its first enabled item, supports Arrow Up/Down and Home/End navigation, and focuses the first button in the anchor on Escape; it is opt-in for action menus. `Tooltip` reads the anchor on hover or keyboard focus and fits its bubble from `ResizeObserver` border-box sizes. The bubble stays hidden until its first fit, slides inside the horizontal viewport margin, and flips above or below only when the opposite side fits. Label-size and viewport changes reuse the anchor coordinates; fitting does not synchronously measure the bubble or trigger a React render. Opt-in `openOnClick` also lets an informational button pin the same bubble for reading and associates it with the anchor description. Another click, Escape, Tab, or an outside pointerdown closes it; ordinary action tooltips still dismiss on click. +`HoverCardGroup` owns private state for one mounted physical list. A `HoverCard` with `grouped` shares the first actually displayed card's dwell: subsequent cards replace the current card immediately during the same pointer interaction. The list and its active portaled card form one pointer interaction: the pointer may enter the card to read or copy, then return to another list item for immediate replacement. After the pointer leaves both, the shared dwell resets when the active card closes through its existing pointer-grace and close lifecycle; the group adds no separate timer. Leaving a list with no active card resets the dwell immediately. Opening an inline card by keyboard focus while the pointer is outside the list does not warm the next pointer visit. Exclusive display includes fading previews: ordinary preview dismissal retains its fade, but a group replacement removes the previous card immediately. Cards without `grouped` remain independent. + ### Rendering agent output The nearest `MarkdownDelegateProvider` supplies optional `openExternalLink` and `openFile` navigation callbacks. Nested providers replace the enclosing capabilities, and callback changes reach already-rendered links without rebuilding Markdown. Its `openFile` makes local Markdown links clickable after settlement. Absolute and workspace-relative paths support percent escapes and `#L24` / `#L24-L30` fragments; ranges open at their first line. Literal `?` and `#` in filenames must be percent-encoded. The tooltip uses the decoded path and supplies the accessible name when the label is empty. The callback receives the decoded path and optional line, while the renderer preserves the label and displays a file icon. Without a callback, local links remain text. URL schemes, queries, unsupported fragments, and malformed destinations never reach the file opener. diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index dcd1626bd0..c1593e273b 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -59,7 +59,7 @@ kind: "package-library" | `Modal` | 页面遮罩之上的居中对话框。嵌套对话框可通过 `onKeyDownCapture` 在文档级 Escape 处理器之前拦截按键。 色层与弹窗淡入,背景模糊始终完整生效,并遵循减少动态效果偏好。调用方已模糊源页面时设置 `backdropBlur={false}`。 | | `RiskConfirmation` | 以显式复选框把关的敏感操作确认。 | | `Tooltip` | 锚定在克隆子元素上的悬停与键盘聚焦提示;可选的 `delayMs` 控制悬停延迟,`focusDelayMs` 控制键盘聚焦延迟,均默认为 0 毫秒。可通过 `portal` 渲染到外层,避免被容器裁剪,或受祖先层叠上下文限制其 z-index。 | -| `HoverCard` | 指针可停留、可选中的悬停预览;可选带复制按钮。 | +| `HoverCard`、`HoverCardGroup` | 可阅读、可选带复制按钮的悬停预览;物理列表可选择让卡片共享首次显示等待,并互斥显示。 | | `ImageLightbox` | 共享图片浮层,支持焦点恢复与 Esc 关闭。 | | `Toast` | 顶部居中的瞬时横幅,保持时长由所有者的 `holdMs` 决定。 | | `SettingsForm`、`SettingsValueField`、`SettingsSecretField` | 插件设置页的框架与控件:框架以 `labels` 接收文案,只在按钮点击时保存,卸载即丢弃;值字段显示暂存文本以及已覆盖标签和重置;密文字段每次为空,请求浏览器不要自动填入已保存的密码,只报告是否已配置。 | @@ -87,12 +87,14 @@ kind: "package-library" ### 控件与图标 -上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。产品图标名称不含画板尺寸,以 `Regular` 表示原始 1px 图形,以 `Medium` 表示同一几何的 1.3px 描边;`size` prop 控制渲染尺寸。具名组件让所选线重在导入处可见;描边几何由组件库拥有,而不是由消费方 CSS 覆盖。每个产品、引用、链接与权限图形都会有意保留两种线重导出,即使当前产品只使用其中一种,也让调用方无需再次扩展 API 就能选择强调程度;仅填充的成对图形外观相同。`IconWarningOutlineRegular`/`Medium` 使用圆形;`IconWarningTriangleOutlineRegular`/`Medium` 使用圆角三角形。`FishLogo` 与 `BrandWordmark` 填充品牌 slot。`FileTypeIcon` 渲染传统的 28px spreadsheet、folder、HTML、image、Markdown、generic、PDF、PPT、video 与 Word 图形,并为现有 48 个代码和配置类别使用导入的方形技术图形。该导入只替换图形:资源包中额外的类别不会扩展 `CodeFileType`。`classifyFileType` 按完整文件名、前缀、后缀、可选项目上下文、扩展名的顺序匹配;React 文件名优先于 TypeScript/JavaScript,Angular 后缀优先于基础扩展名,只有传入的项目文件包含带 `flutter:` 的 `pubspec.yaml` 时 Dart 文件才使用 Flutter。Markdown 与 SVG 仍分别使用传统 Markdown 与图片图形。表格映射包括 CSV、TSV、Excel 工作簿与模板、OpenDocument 表格和 Numbers;KEY 映射为幻灯片,RTF/ODT/Pages 映射为文档。`fileExtension` 为相邻元数据 label 暴露同一套 basename 与最终点号解析。传统图形使用实色分类底板、白色标记和半透明白色折角;通用代码图形使用尖括号与斜线,保留原图 1.35px 描边;通用文件使用灰色底板与较深灰色折角。调用方可通过 `--dsh-file-type-icon-color` 覆盖底板颜色。全彩技术图形是明确例外,会保留其内嵌调色板。所有图形都是装饰性的,不自带 label。`LinkIconMedium` 是可点击产物链接的前置图形——地球、文件夹、代码、图片、文档或纸张,`url` 链接的 `href` 指向已知站点时则改用该站点自己的标记——转写内容常引用的开发者站点(GitHub、GitLab、npm、PyPI、Stack Overflow、MDN、Wikipedia、Hacker News、YouTube、X、Bilibili、知乎、掘金、CSDN),以及主流搜索、视频、社交、购物与参考资料站点(Google、百度、DuckDuckGo、TikTok、Netflix、Spotify、Facebook、Instagram、Reddit、Telegram、WhatsApp、微信、QQ、微博、淘宝、速卖通、eBay、Quora、V2EX、Apple)——`classifyLinkPath` 把共享文件类型折叠进原有六类词汇。`ConnectionIndicator` 可渲染警告色的断联操作(常驻重试图形指明重试动作,断联文案由持有方提供)、与文案使用相同警告色的共享 ongoing loading 加一至三个点以独立于 retry 时序的 500ms 节奏推进的连接中状态,或成功色的恢复状态。点击任一警告状态都会请求立即重连;没有任何悬停交互会改变文案。药丸出现时淡入、卸载前淡出 150ms,宽度随当前 label 自适应。它的持有方提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点;锚定浮层与 portal 模式的 `Menu` 在框架发布的顶部占用量上增加 20px,原生全屏时保留 20px,普通浏览器中沿用自身的最小边距。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。其 `preview` 变体使用 anchor 或 `widthAnchorRef` 元素的宽度减去 48px,左右各内缩 24px,并在视口内放置于行的上方或下方。浮层避开框架顶部保留区,高度最多 420px,会跟随内容及 anchor 尺寸变化;Escape 或通过鼠标和键盘激活锚点可将其关闭。整个浮层的淡入和淡出各持续 100ms;关闭中的浮层停止接收指针输入,淡出后卸载。在淡出期间移回锚点可恢复显示。减少动态效果偏好会禁用过渡动画。只有启用复制时才必须提供复制标签。锚点中嵌套的 `Tooltip` 在显示悬停或焦点标签时隐藏该预览;嵌套提示释放、禁用或卸载后,仍处于打开状态的预览会恢复。`Toast` 使用调用方的 `holdMs` 同时控制淡出延迟与停留加淡出的总时长。`holdMs` 未变时,父组件重渲染不会重启该生命周期;完成时调用最新回调,完全淡出的操作不能接收输入。新的组件 key 会重新开始横幅周期。`rankByName` 是 `/` 菜单命令源与 skill(技能)源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 portal 模式的 `Menu` 列表会在拖动和 CSS 变换期间跟随锚点,并在关闭时停止跟踪。`Menu.autoFocus` 聚焦首个启用项,支持上下方向键与 Home/End 导航,并在 Escape 时聚焦 anchor 内的第一个按钮;操作菜单可显式启用。 +上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。产品图标名称不含画板尺寸,以 `Regular` 表示原始 1px 图形,以 `Medium` 表示同一几何的 1.3px 描边;`size` prop 控制渲染尺寸。具名组件让所选线重在导入处可见;描边几何由组件库拥有,而不是由消费方 CSS 覆盖。每个产品、引用、链接与权限图形都会有意保留两种线重导出,即使当前产品只使用其中一种,也让调用方无需再次扩展 API 就能选择强调程度;仅填充的成对图形外观相同。`IconWarningOutlineRegular`/`Medium` 使用圆形;`IconWarningTriangleOutlineRegular`/`Medium` 使用圆角三角形。`FishLogo` 与 `BrandWordmark` 填充品牌 slot。`FileTypeIcon` 渲染传统的 28px spreadsheet、folder、HTML、image、Markdown、generic、PDF、PPT、video 与 Word 图形,并为现有 48 个代码和配置类别使用导入的方形技术图形。该导入只替换图形:资源包中额外的类别不会扩展 `CodeFileType`。`classifyFileType` 按完整文件名、前缀、后缀、可选项目上下文、扩展名的顺序匹配;React 文件名优先于 TypeScript/JavaScript,Angular 后缀优先于基础扩展名,只有传入的项目文件包含带 `flutter:` 的 `pubspec.yaml` 时 Dart 文件才使用 Flutter。Markdown 与 SVG 仍分别使用传统 Markdown 与图片图形。表格映射包括 CSV、TSV、Excel 工作簿与模板、OpenDocument 表格和 Numbers;KEY 映射为幻灯片,RTF/ODT/Pages 映射为文档。`fileExtension` 为相邻元数据 label 暴露同一套 basename 与最终点号解析。传统图形使用实色分类底板、白色标记和半透明白色折角;通用代码图形使用尖括号与斜线,保留原图 1.35px 描边;通用文件使用灰色底板与较深灰色折角。调用方可通过 `--dsh-file-type-icon-color` 覆盖底板颜色。全彩技术图形是明确例外,会保留其内嵌调色板。所有图形都是装饰性的,不自带 label。`LinkIconMedium` 是可点击产物链接的前置图形——地球、文件夹、代码、图片、文档或纸张,`url` 链接的 `href` 指向已知站点时则改用该站点自己的标记——转写内容常引用的开发者站点(GitHub、GitLab、npm、PyPI、Stack Overflow、MDN、Wikipedia、Hacker News、YouTube、X、Bilibili、知乎、掘金、CSDN),以及主流搜索、视频、社交、购物与参考资料站点(Google、百度、DuckDuckGo、TikTok、Netflix、Spotify、Facebook、Instagram、Reddit、Telegram、WhatsApp、微信、QQ、微博、淘宝、速卖通、eBay、Quora、V2EX、Apple)——`classifyLinkPath` 把共享文件类型折叠进原有六类词汇。`ConnectionIndicator` 可渲染警告色的断联操作(常驻重试图形指明重试动作,断联文案由持有方提供)、与文案使用相同警告色的共享 ongoing loading 加一至三个点以独立于 retry 时序的 500ms 节奏推进的连接中状态,或成功色的恢复状态。点击任一警告状态都会请求立即重连;没有任何悬停交互会改变文案。药丸出现时淡入、卸载前淡出 150ms,宽度随当前 label 自适应。它的持有方提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点;锚定浮层与 portal 模式的 `Menu` 在框架发布的顶部占用量上增加 20px,原生全屏时保留 20px,普通浏览器中沿用自身的最小边距。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。其 `preview` 变体使用 anchor 或 `widthAnchorRef` 元素的宽度减去 48px,左右各内缩 24px,并在视口内放置于行的上方或下方。浮层避开框架顶部保留区,高度最多 420px,会跟随内容及 anchor 尺寸变化;Escape 或通过鼠标和键盘激活锚点可将其关闭。整个浮层的淡入和淡出各持续 100ms;关闭中的浮层停止接收指针输入,淡出后卸载。在淡出期间移回锚点可恢复显示。减少动态效果偏好会禁用过渡动画。只有启用复制时才必须提供复制标签。`HoverCard` 与嵌套的操作提示独立显示;使用 `Tooltip.portal` 将小气泡置于卡片上层。可见的嵌套 `Tooltip` 只隐藏外层 tooltip,不隐藏悬停卡片。`Toast` 使用调用方的 `holdMs` 同时控制淡出延迟与停留加淡出的总时长。`holdMs` 未变时,父组件重渲染不会重启该生命周期;完成时调用最新回调,完全淡出的操作不能接收输入。新的组件 key 会重新开始横幅周期。`rankByName` 是 `/` 菜单命令源与 skill(技能)源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 portal 模式的 `Menu` 列表会在拖动和 CSS 变换期间跟随锚点,并在关闭时停止跟踪。`Menu.autoFocus` 聚焦首个启用项,支持上下方向键与 Home/End 导航,并在 Escape 时聚焦 anchor 内的第一个按钮;操作菜单可显式启用。 `Tooltip` 在悬停或键盘聚焦时读取锚点位置,再根据 `ResizeObserver` 提供的边框盒尺寸调整气泡。首次定位前气泡保持隐藏;横向移入视口留白,仅在另一侧容得下时上下翻转。标签尺寸与视口变化复用锚点坐标;定位不会同步测量气泡,也不会触发 React 渲染。 信息按钮可显式启用 `openOnClick`,点击后保持同一个气泡供阅读,并将内容关联为锚点的无障碍描述;再次点击、Escape、Tab 或外部 pointerdown 会关闭。普通操作按钮的 tooltip 仍在点击时关闭。 +`HoverCardGroup` 为一个已挂载的物理列表持有私有状态。设置 `grouped` 的 `HoverCard` 共享首张实际显示卡片的等待:在同一次指针交互中,后续卡片立即替换当前卡片。列表与其当前活动的 portal 卡片构成一次指针交互:指针可进入卡片阅读或复制,再返回其他列表项并立即替换卡片。指针离开两者后,共享等待在活动卡片通过原有指针宽限及关闭生命周期关闭时重置;分组不另设计时器。列表没有活动卡片时,指针离开便立即重置等待。指针位于列表外时,通过键盘聚焦打开行内卡片不会让下一次指针进入跳过等待。互斥显示也包括正在淡出的预览:普通预览关闭保留淡出,但分组内替换会立即移除前一张卡片。未设置 `grouped` 的卡片保持独立。 + ### 渲染 agent 输出 最近的 `MarkdownDelegateProvider` 提供可选的 `openExternalLink` 和 `openFile` 导航回调。嵌套 Provider 替换外层能力,回调变化无需重新构建 Markdown 即可到达已渲染链接。其 `openFile` 使本地 Markdown 链接在落定后可点击。绝对路径和工作区相对路径支持百分号转义以及 `#L24` / `#L24-L30` 片段;范围定位到起始行。文件名中的字面 `?` 和 `#` 必须百分号编码。悬停提示使用解码后的路径,并在标签为空时提供可访问名称。回调接收解码后的路径和可选行号,渲染器保留标签并显示文件图标。不传回调时,本地链接仍为文本。URL 协议、查询串、不支持的片段及格式错误的目标不会传给文件打开器。 diff --git a/packages/client/ui-primitives/package.json b/packages/client/ui-primitives/package.json index 57d14d2819..be40575d1a 100644 --- a/packages/client/ui-primitives/package.json +++ b/packages/client/ui-primitives/package.json @@ -24,8 +24,6 @@ "license": "MIT", "devDependencies": { "@deepseek-ai/dsh-client-store": "workspace:*", - "@types/react": "~18.3.1", - "@types/react-dom": "~18.3.0", "@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/dsh-util-code-language": "workspace:*", "react": "^18.2.0", diff --git a/packages/client/ui-primitives/src/HoverCard.tsx b/packages/client/ui-primitives/src/HoverCard.tsx index 4aa1b58652..3b3ab47656 100644 --- a/packages/client/ui-primitives/src/HoverCard.tsx +++ b/packages/client/ui-primitives/src/HoverCard.tsx @@ -1,11 +1,11 @@ -import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' +import { useCallback, useContext, useEffect, useLayoutEffect, useRef, useState } from 'react' import type { CSSProperties, ReactNode, RefObject, SyntheticEvent } from 'react' import clsx from 'clsx' import { createPortal } from 'react-dom' import { writeClipboard } from './clipboard.ts' import { usePointerGrace } from './pointer-grace.ts' import { overlayTopMargin } from './overlay-top-margin.ts' -import { TooltipSuppression } from './Tooltip.tsx' +import { HoverCardGroupContext } from './HoverCardGroup.tsx' import css from './HoverCard.module.css' /** Preview opacity transition and retained lifetime during dismissal. */ @@ -17,7 +17,7 @@ const ANCHOR_GAP = 8 const VIEWPORT_MARGIN = 8 /** - * Render an anchor with a hover-triggered preview card, hidden while a tooltip within the anchor is visible. + * Render an anchor with a hover-triggered preview card independently of nested tooltips. * @param props.anchor - the hover target (rendered in place inside a wrapper span). * @param props.content - card content; the pointer may rest on it, so it is * readable and selectable, but it carries no dismissal affordance of its own. @@ -27,6 +27,8 @@ const VIEWPORT_MARGIN = 8 * @param props.widthAnchorRef - optional element whose width and horizontal position size the preview. * @param props.inline - keep the anchor in prose; show a contained preview on hover or keyboard focus. * @param props.disabled - suppress opening; turning true dismisses an open card. + * @param props.grouped - share first-display dwell across the surrounding list and active portal. + * Group replacement removes the previous card immediately, including a fading preview. * @param props.copyText - optional primary value copied by activation and * included in the card's accessible name. * @param props.copyLabel - localized accessible activation-label prefix. @@ -34,7 +36,7 @@ const VIEWPORT_MARGIN = 8 * @returns anchor wrapper with the conditional portaled card. */ export function HoverCard({ - anchor, content, openDelayMs = 500, disabled = false, + anchor, content, openDelayMs = 500, disabled = false, grouped = false, copyText, copyLabel, copiedLabel, variant = 'compact', widthAnchorRef, inline = false, }: { anchor: ReactNode @@ -43,6 +45,7 @@ export function HoverCard({ content: ReactNode openDelayMs?: number disabled?: boolean + grouped?: boolean variant?: 'compact' | 'preview' widthAnchorRef?: RefObject } & ({ copyText?: string | undefined; copyLabel: string; copiedLabel: string } | { @@ -64,7 +67,9 @@ export function HoverCard({ const [pos, setPos] = useState<{ left: number; top: number; width?: number; maxHeight?: number } | null>(null) const positioned = pos !== null const [copied, setCopied] = useState(false) - const [suppressed, setSuppressed] = useState(false) + const parentGroup = useContext(HoverCardGroupContext) + const group = grouped ? parentGroup : null + const [groupOwner] = useState(() => Symbol('hover-card')) const clearCopied = useCallback(() => { if (copyTimerRef.current !== null) { @@ -83,12 +88,28 @@ export function HoverCard({ const { arm: armClose, cancel: cancelClose } = usePointerGrace(close) - const clearTimer = () => { + const clearTimer = useCallback(() => { if (timerRef.current !== null) { clearTimeout(timerRef.current) timerRef.current = null } - } + }, []) + + const dismissImmediately = useCallback(() => { + clearTimer() + cancelClose() + copyEpochRef.current += 1 + clearCopied() + setPhase('closed') + }, [clearTimer, cancelClose, clearCopied]) + // A fading preview still owns the group's visible card until it unmounts or another card replaces it. + const groupVisible = open && positioned + useLayoutEffect(() => { + if (!groupVisible || group === null) return + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- The visible card's ref is attached before layout effects. + group.shown(groupOwner, dismissImmediately, cardRef.current!) + return () => { group.release(groupOwner) } + }, [group, groupOwner, groupVisible, dismissImmediately]) useEffect(() => { if (!closing) return @@ -193,13 +214,13 @@ export function HoverCard({ // card's real height is measurable, correct the bottom-edge clamp. The // correction converges — a clamped top satisfies the guard, so it runs once. useLayoutEffect(() => { - if (!open || pos === null || variant === 'preview' || inline || suppressed) return + if (!open || pos === null || variant === 'preview' || inline) return /* v8 ignore next -- the card is mounted whenever pos is set, so the ref is attached here. */ const h = cardRef.current?.offsetHeight ?? 0 if (pos.top + h > window.innerHeight - VIEWPORT_MARGIN) { setPos({ left: pos.left, top: window.innerHeight - h - VIEWPORT_MARGIN }) } - }, [open, pos, variant, inline, suppressed]) + }, [open, pos, variant, inline]) const copy = async (text: string): Promise => { if (copied || copyingRef.current) return @@ -223,7 +244,7 @@ export function HoverCard({ cancelClose() close() } - const card = open && pos !== null && !suppressed && ( + const card = open && pos !== null && (
{ setPhase('open') }, openDelayMs) + if (group?.isWarm()) setPhase('open') + else timerRef.current = setTimeout(() => { setPhase('open') }, openDelayMs) }} onPointerLeave={() => { clearTimer() @@ -286,8 +308,8 @@ export function HoverCard({ onPointerDownCapture={dismissFromAnchor} onClickCapture={dismissFromAnchor} > - {anchor} - {open && !suppressed && copyable && {copied ? copiedLabel : ''}} + {anchor} + {open && copyable && {copied ? copiedLabel : ''}} {card !== false && createPortal(card, document.body)} ) diff --git a/packages/client/ui-primitives/src/HoverCardGroup.tsx b/packages/client/ui-primitives/src/HoverCardGroup.tsx new file mode 100644 index 0000000000..163202d813 --- /dev/null +++ b/packages/client/ui-primitives/src/HoverCardGroup.tsx @@ -0,0 +1,72 @@ +import { createContext, useState, type ReactNode } from 'react' + +interface GroupState { + isWarm: () => boolean + enter: (overCard: boolean) => void + leave: () => void + containsCard: (target: EventTarget | null) => boolean + shown: (owner: symbol, dismiss: () => void, element: HTMLElement) => void + release: (owner: symbol) => void +} + +/** Private coordination shared only by opted-in HoverCards inside one mounted group. */ +export const HoverCardGroupContext = createContext(null) + +/** + * Share first-display dwell across a list and its active portal, including the card's existing pointer grace. + * @param props.children - list contents, including opted-in HoverCards. + * @param props.className - classes for the physical list container. + * @returns the list container and its private hover coordinator. + */ +export function HoverCardGroup({ children, className }: { children: ReactNode; className?: string }) { + const [group] = useState(() => { + let pointer: 'outside' | 'list' | 'card' = 'outside' + let warm = false + let active: { owner: symbol; dismiss: () => void; element: HTMLElement } | null = null + return { + isWarm: () => warm, + enter: (overCard) => { + pointer = overCard ? 'card' : 'list' + if (overCard) warm = true + }, + leave: () => { + pointer = 'outside' + // A displayed card owns the grace across the gap; its release ends an outside visit. + if (active === null) warm = false + }, + containsCard: target => target instanceof Node && active !== null && active.element.contains(target), + shown: (owner, dismiss, element) => { + // Keyboard focus can replace the portal under the pointer without a pointer-out event. + if (pointer === 'card') { pointer = 'outside'; warm = false } + if (pointer === 'list') warm = true + const previous = active + active = { owner, dismiss, element } + if (previous !== null && previous.owner !== owner) previous.dismiss() + }, + release: (owner) => { + if (active?.owner !== owner) return + active = null + if (pointer !== 'list') { pointer = 'outside'; warm = false } + }, + } + }) + return ( + +
{ + const overCard = group.containsCard(event.target) + if (event.currentTarget.contains(event.target as Node) || overCard) group.enter(overCard) + }} + onPointerOutCapture={(event) => { + const fromGroup = event.currentTarget.contains(event.target as Node) || group.containsCard(event.target) + const intoGroup = (event.relatedTarget instanceof Node && event.currentTarget.contains(event.relatedTarget)) + || group.containsCard(event.relatedTarget) + if (fromGroup && !intoGroup) group.leave() + }} + > + {children} +
+
+ ) +} diff --git a/packages/client/ui-primitives/src/Tooltip.tsx b/packages/client/ui-primitives/src/Tooltip.tsx index 6f32ecab90..a15899be3c 100644 --- a/packages/client/ui-primitives/src/Tooltip.tsx +++ b/packages/client/ui-primitives/src/Tooltip.tsx @@ -14,8 +14,8 @@ import { pointerModality } from './input-modality.ts' export type TooltipSide = 'right' | 'bottom' | 'top' /** - * Suppression channel for enclosing tooltip and hover-card anchors: a visible - * tooltip within an anchor withdraws the enclosing preview while its bubble is shown. + * Suppression channel for nested tooltips: a visible descendant tooltip + * withdraws the enclosing tooltip while its bubble is shown. */ export const TooltipSuppression = createContext<((suppressed: boolean) => void) | null>(null) diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index cb95776fa6..18f9544405 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -33,6 +33,7 @@ export { useAnchoredPosition } from './useAnchoredPosition.ts' export type { AnchoredPositionOptions } from './useAnchoredPosition.ts' export { useDismissOnOutsidePointer } from './useDismissOnOutsidePointer.ts' export { HoverCard } from './HoverCard.tsx' +export { HoverCardGroup } from './HoverCardGroup.tsx' export { ShortcutKeys } from './ShortcutKeys.tsx' export { observeComposition } from './keyboard-composition.ts' export { focusWithoutRing } from './focus.ts' diff --git a/packages/client/ui-primitives/tests/hover-card-group.client.spec.tsx b/packages/client/ui-primitives/tests/hover-card-group.client.spec.tsx new file mode 100644 index 0000000000..da4b665096 --- /dev/null +++ b/packages/client/ui-primitives/tests/hover-card-group.client.spec.tsx @@ -0,0 +1,330 @@ +// @vitest-environment jsdom +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { HoverCard } from '../src/HoverCard.tsx' +import { HoverCardGroup } from '../src/HoverCardGroup.tsx' +import { Tooltip } from '../src/Tooltip.tsx' + +beforeEach(() => { vi.useFakeTimers() }) +afterEach(() => { cleanup(); vi.useRealTimers(); vi.restoreAllMocks() }) + +// jsdom lacks PointerEvent; MouseEvent preserves relatedTarget for physical and React ancestry checks. +function move(from: Element, to: Element): void { + fireEvent(from, new MouseEvent('pointerout', { bubbles: true, relatedTarget: to })) + fireEvent(to, new MouseEvent('pointerover', { bubbles: true, relatedTarget: from })) + fireEvent(from, new MouseEvent('mouseout', { bubbles: true, relatedTarget: to })) + fireEvent(to, new MouseEvent('mouseover', { bubbles: true, relatedTarget: from })) +} +function tick(ms: number): void { act(() => { vi.advanceTimersByTime(ms) }) } +function Card({ name, grouped = true, disabled = false }: { name: string; grouped?: boolean; disabled?: boolean }) { + return {name}} + content={details {name}} copyText={name} copyLabel="Copy" copiedLabel="Copied" /> +} + +describe('HoverCardGroup', () => { + it('does not warm the next pointer visit when keyboard focus displays an inline card outside a pointer visit', () => { + const view = render(<> + + + A} + content={details A} /> + + + ) + const outside = screen.getByRole('button', { name: 'outside' }) + const a = screen.getByRole('button', { name: 'A' }) + act(() => { outside.focus() }) + fireEvent.keyDown(outside, { key: 'Tab' }) + // jsdom does not move focus for Tab, so apply its native focus transition without a pointer event. + act(() => { a.focus() }) + expect(document.activeElement).toBe(a) + expect(screen.getByText('details A')).toBeTruthy() + move(document.body, screen.getByText('B')) + expect(screen.queryByText('details B')).toBeNull() + tick(799) + expect(screen.queryByText('details B')).toBeNull() + tick(1) + expect(screen.getByText('details B')).toBeTruthy() + expect(screen.queryByText('details A')).toBeNull() + view.unmount() + expect(vi.getTimerCount()).toBe(0) + }) + + it('waits for the first actual display, then switches instantly within one physical list', () => { + render(gap) + const a = screen.getByText('A') + const b = screen.getByText('B') + const gap = screen.getByText('gap') + move(document.body, a) + tick(799) + expect(screen.queryByText('details A')).toBeNull() + tick(1) + expect(screen.getByText('details A')).toBeTruthy() + move(a, b) + expect(screen.queryByText('details A')).toBeNull() + expect(screen.getByText('details B')).toBeTruthy() + // Repositioning the old anchor must not let it claim ownership again. + fireEvent(window, new Event('resize')) + tick(300) + expect(screen.queryByText('details A')).toBeNull() + expect(screen.getByText('details B')).toBeTruthy() + move(b, gap) + tick(300) + move(gap, a) + expect(screen.getByText('details A')).toBeTruthy() + move(a, document.body) + tick(300) + move(document.body, b) + expect(screen.queryByText('details B')).toBeNull() + tick(799) + expect(screen.queryByText('details B')).toBeNull() + tick(1) + expect(screen.getByText('details B')).toBeTruthy() + }) + + it.each(['pointer exit', 'disabled'] as const)('replaces a preview fading after %s before displaying the next card', (reason) => { + const markup = (disabled = false) => + A} content={details A} /> + gap + + + const view = render(markup()) + const a = screen.getByText('A') + const gap = screen.getByText('gap') + move(document.body, a) + tick(800) + expect(screen.getByText('details A')).toBeTruthy() + move(a, gap) + // Pointer grace expires after 200ms; disabling closes immediately. Both retain the 100ms fade. + if (reason === 'disabled') view.rerender(markup(true)) + else tick(200) + expect(screen.getByText('details A')).toBeTruthy() + move(gap, screen.getByText('B')) + expect(screen.getByText('details B')).toBeTruthy() + expect(screen.queryByText('details A')).toBeNull() + tick(100) + expect(screen.getByText('details B')).toBeTruthy() + }) + + it('keeps cancelled openings cold and shares displayed cards independently of action tooltips', () => { + render() + const a = screen.getByText('A') + const b = screen.getByText('B') + move(document.body, a) + tick(400) + move(a, b) + tick(799) + expect(screen.queryByText('details A')).toBeNull() + expect(screen.queryByText('details B')).toBeNull() + tick(1) + const card = screen.getByText('details B') + const action = screen.getByRole('button', { name: 'action B' }) + move(b, action) + tick(500) + expect(screen.getByRole('tooltip').textContent).toBe('Archive B') + expect(screen.getByText('details B')).toBe(card) + move(action, a) + expect(screen.queryByRole('tooltip')).toBeNull() + expect(screen.getByText('details A')).toBeTruthy() + expect(screen.queryByText('details B')).toBeNull() + }) + + it('shares warmth through readable, copyable portals until the pointer leaves both surfaces', async () => { + const previous = Object.getOwnPropertyDescriptor(navigator, 'clipboard') + const writeText = vi.fn(async () => {}) + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } }) + try { + render() + const a = screen.getByText('A') + const b = screen.getByText('B') + move(document.body, a) + tick(800) + const portal = screen.getByRole('button', { name: 'Copy: A' }) + move(a, portal) + tick(500) + expect(portal.isConnected).toBe(true) + await act(async () => { fireEvent.click(portal); await Promise.resolve() }) + expect(writeText).toHaveBeenCalledWith('A') + expect(screen.getByRole('status').textContent).toBe('Copied') + move(portal, b) + expect(screen.getByText('details B')).toBeTruthy() + expect(screen.queryByRole('button', { name: 'Copy: A' })).toBeNull() + const secondPortal = screen.getByRole('button', { name: 'Copy: B' }) + move(b, secondPortal) + move(secondPortal, document.body) + tick(300) + expect(screen.queryByText('details B')).toBeNull() + move(document.body, a) + tick(799) + expect(screen.queryByText('details A')).toBeNull() + tick(1) + expect(screen.getByText('details A')).toBeTruthy() + } finally { + if (previous === undefined) Reflect.deleteProperty(navigator, 'clipboard') + else Object.defineProperty(navigator, 'clipboard', previous) + } + }) + + it('bridges the list-to-card gaps with the active card existing pointer grace', () => { + render() + const a = screen.getByText('A') + const b = screen.getByText('B') + move(document.body, a) + tick(800) + const portal = screen.getByRole('button', { name: 'Copy: A' }) + move(a, document.body) + tick(100) + move(document.body, portal) + tick(500) + expect(portal.isConnected).toBe(true) + move(portal, document.body) + tick(100) + move(document.body, b) + expect(screen.getByText('details B')).toBeTruthy() + expect(screen.queryByText('details A')).toBeNull() + move(b, document.body) + tick(300) + move(document.body, a) + tick(799) + expect(screen.queryByText('details A')).toBeNull() + tick(1) + expect(screen.getByText('details A')).toBeTruthy() + }) + + it('warms a keyboard-open card only when the pointer visits that card', () => { + render( + A} + content={details A} /> + + ) + fireEvent.keyDown(document, { key: 'Tab' }) + act(() => { screen.getByRole('button', { name: 'A' }).focus() }) + const portal = screen.getByText('details A') + move(document.body, portal) + move(portal, screen.getByText('B')) + expect(screen.getByText('details B')).toBeTruthy() + expect(screen.queryByText('details A')).toBeNull() + }) + + it('rejects stale clipboard feedback after a group handoff and return', async () => { + const previous = Object.getOwnPropertyDescriptor(navigator, 'clipboard') + let finishWrite: (() => void) | undefined + const writeText = vi.fn(() => new Promise((resolve) => { finishWrite = resolve })) + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } }) + try { + render() + const a = screen.getByText('A') + const b = screen.getByText('B') + move(document.body, a) + tick(800) + fireEvent.keyDown(screen.getByRole('button', { name: 'Copy: A' }), { key: 'Enter' }) + expect(writeText).toHaveBeenCalledWith('A') + move(a, b) + expect(screen.getByText('details B')).toBeTruthy() + move(b, a) + expect(screen.getByText('details A')).toBeTruthy() + await act(async () => { finishWrite?.(); await Promise.resolve() }) + expect(screen.queryByText('Copied')).toBeNull() + expect(screen.getByText('details A')).toBeTruthy() + } finally { + finishWrite?.() + if (previous === undefined) Reflect.deleteProperty(navigator, 'clipboard') + else Object.defineProperty(navigator, 'clipboard', previous) + } + }) + + it('keeps separate groups cold and does not enroll ungrouped cards', () => { + render(<> + ) + const a = screen.getByText('A') + const project = screen.getByText('Project') + const b = screen.getByText('B') + move(document.body, a) + tick(800) + move(a, project) + expect(screen.queryByText('details Project')).toBeNull() + tick(800) + expect(screen.getByText('details Project')).toBeTruthy() + move(project, b) + expect(screen.queryByText('details B')).toBeNull() + tick(800) + expect(screen.getByText('details B')).toBeTruthy() + }) + + it('does not warm a group through a portal belonging to an ungrouped card', () => { + render() + const project = screen.getByText('Project') + const b = screen.getByText('B') + move(document.body, project) + tick(800) + const portal = screen.getByRole('button', { name: 'Copy: Project' }) + move(project, portal) + tick(500) + expect(portal.isConnected).toBe(true) + move(portal, b) + expect(screen.queryByText('details B')).toBeNull() + tick(799) + expect(screen.queryByText('details B')).toBeNull() + tick(1) + expect(screen.getByText('details B')).toBeTruthy() + }) + + it('cools when the active portal is removed from under the pointer', () => { + const markup = (showA: boolean) => + {showA && } + + const view = render(markup(true)) + const a = screen.getByText('A') + move(document.body, a) + tick(800) + const portal = screen.getByRole('button', { name: 'Copy: A' }) + move(a, portal) + view.rerender(markup(false)) + move(portal, screen.getByText('B')) + expect(screen.queryByText('details B')).toBeNull() + tick(800) + expect(screen.getByText('details B')).toBeTruthy() + }) + + it('cools when keyboard focus replaces the portal under the pointer', () => { + render( + A} content={details A} /> + B} content={details B} /> + + ) + const a = screen.getByRole('button', { name: 'A' }) + move(document.body, a) + tick(500) + const portal = screen.getByText('details A') + move(a, portal) + fireEvent.keyDown(document, { key: 'Tab' }) + act(() => { screen.getByRole('button', { name: 'B' }).focus() }) + expect(screen.getByText('details B')).toBeTruthy() + move(portal, screen.getByText('C')) + expect(screen.queryByText('details C')).toBeNull() + tick(800) + expect(screen.getByText('details C')).toBeTruthy() + }) + + it('releases an unmounted active card without cooling its still-mounted list', () => { + const markup = (showA: boolean, groupKey = 'first') => + {showA && } + + const view = render(markup(true)) + const a = screen.getByText('A') + move(document.body, a) + tick(800) + view.rerender(markup(false)) + move(a, screen.getByText('B')) + expect(screen.getByText('details B')).toBeTruthy() + view.rerender(markup(false, 'new')) + move(document.body, screen.getByText('B')) + expect(screen.queryByText('details B')).toBeNull() + tick(800) + expect(screen.getByText('details B')).toBeTruthy() + view.unmount() + expect(vi.getTimerCount()).toBe(0) + }) +}) diff --git a/packages/client/ui-primitives/tests/hover-card.client.spec.tsx b/packages/client/ui-primitives/tests/hover-card.client.spec.tsx index 322cfcb526..e1f76fcd4b 100644 --- a/packages/client/ui-primitives/tests/hover-card.client.spec.tsx +++ b/packages/client/ui-primitives/tests/hover-card.client.spec.tsx @@ -54,7 +54,7 @@ function installClipboard(writeText: (text: string) => Promise): () => voi } describe('HoverCard', () => { - it.each(['disabled', 'unmounted'] as const)('restores its preview after a nested tooltip is %s', (released) => { + it.each(['disabled', 'unmounted'] as const)('keeps its preview mounted while a nested tooltip shows and is then %s', (released) => { const markup = (state: 'active' | 'disabled' | 'unmounted') => ( row{state !== 'unmounted' && ( @@ -70,11 +70,12 @@ describe('HoverCard', () => { act(() => { vi.advanceTimersByTime(500) }) fireEvent.mouseEnter(screen.getByRole('button', { name: 'action' })) expect(screen.getByRole('tooltip').textContent).toBe('Create') - expect(screen.queryByText('card body')).toBeNull() + const card = screen.getByText('card body') + expect(card.isConnected).toBe(true) view.rerender(markup(released)) expect(screen.queryByRole('tooltip')).toBeNull() - expect(screen.getByText('card body')).toBeTruthy() + expect(screen.getByText('card body')).toBe(card) }) it('keeps card content mounted while its own tooltip is visible', () => { diff --git a/packages/client/ui-primitives/tsdown.config.ts b/packages/client/ui-primitives/tsdown.config.ts index 7f6262ae9f..a6264e0b21 100644 --- a/packages/client/ui-primitives/tsdown.config.ts +++ b/packages/client/ui-primitives/tsdown.config.ts @@ -1,6 +1,6 @@ import { staticLinked } from '../tsdown.client.ts' export default staticLinked( - '@deepseek-ai/dsh-client-ui-primitives', + import.meta.dirname, ['lib/types/index.js'], ) diff --git a/packages/client/ui-reference/package.json b/packages/client/ui-reference/package.json index fad641b3fe..10b52588aa 100644 --- a/packages/client/ui-reference/package.json +++ b/packages/client/ui-reference/package.json @@ -56,7 +56,6 @@ "@deepseek-ai/dsh-client-locale": "workspace:*", "@deepseek-ai/dsh-client-test-runtime": "workspace:*", "@deepseek-ai/dsh-client-ui-conversation": "workspace:*", - "@types/react": "~18.3.1", "react": "^18.2.0", "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:*", "@deepseek-ai/dsh-client-ui-primitives": "workspace:*", diff --git a/packages/client/ui-reference/tsdown.config.ts b/packages/client/ui-reference/tsdown.config.ts index f306d4db44..7aa03cebc1 100644 --- a/packages/client/ui-reference/tsdown.config.ts +++ b/packages/client/ui-reference/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-reference', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-renderer/package.json b/packages/client/ui-renderer/package.json index 37afaebac6..12ce753757 100644 --- a/packages/client/ui-renderer/package.json +++ b/packages/client/ui-renderer/package.json @@ -44,8 +44,6 @@ "@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/dsh-client-test-runtime": "workspace:*", "@deepseek-ai/dsh-client-ui-slots": "workspace:*", - "@types/react": "~18.3.1", - "@types/react-dom": "~18.3.0", "@types/use-sync-external-store": "^1.5.0", "react": "^18.2.0", "react-dom": "^18.2.0", diff --git a/packages/client/ui-renderer/src/client/bindings.tsx b/packages/client/ui-renderer/src/client/bindings.tsx index 0e0c0c9527..5ecc314320 100644 --- a/packages/client/ui-renderer/src/client/bindings.tsx +++ b/packages/client/ui-renderer/src/client/bindings.tsx @@ -101,13 +101,43 @@ function useAbsentSnapshot( return undefined } -/** Erased open-key selector Hook synthesized from one keyed source family. */ +/** + * Erased open-key selector Hook synthesized from one keyed source family. + * A string second argument names a member: the Hook reads the source the + * resolver returns for `(key, member)` without a selector. + */ export type KeyedSnapshotHook = ( keyOrSelector: string | ((value: unknown) => unknown), - selectorOrEqual?: ((value: unknown) => unknown) | ((left: unknown, right: unknown) => boolean), + memberOrSelectorOrEqual?: string | ((value: unknown) => unknown) | ((left: unknown, right: unknown) => boolean), equal?: (left: unknown, right: unknown) => boolean, ) => unknown +interface KeyedHookCall { + readonly key: string | undefined + readonly member: string | undefined + readonly selector: ((value: unknown) => unknown) | undefined + readonly equal: ((left: unknown, right: unknown) => boolean) | undefined +} + +/** Read the `(key, member)`, `(key, selector?, equal?)`, or `(selector, equal?)` call forms. */ +function keyedHookCall( + defaultKey: string | undefined, + keyOrSelector: Parameters[0], + second: Parameters[1], + equal: Parameters[2], +): KeyedHookCall { + if (typeof keyOrSelector !== 'string') { + return { + key: defaultKey, + member: undefined, + selector: keyOrSelector, + equal: second as ((left: unknown, right: unknown) => boolean) | undefined, + } + } + if (typeof second === 'string') return { key: keyOrSelector, member: second, selector: undefined, equal: undefined } + return { key: keyOrSelector, member: undefined, selector: second as ((value: unknown) => unknown) | undefined, equal } +} + /** * Bind an open-key source family. * @param source - keyed resolver, or absence for an optional scope. @@ -126,13 +156,13 @@ export function keyedObservableHook( const cacheKey = defaultKey ?? NO_DEFAULT_KEY let hook = hooks.get(cacheKey) if (hook === undefined) { - hook = (keyOrSelector, selectorOrEqual, equal) => { - const keyed = typeof keyOrSelector === 'string' - const key = keyed ? keyOrSelector : defaultKey - const selector = (keyed ? selectorOrEqual : keyOrSelector) as ((value: unknown) => unknown) | undefined - const comparison = (keyed ? equal : selectorOrEqual) as ((left: unknown, right: unknown) => boolean) | undefined - const useValue = observableHook(key === undefined ? absentSource : source(key) ?? absentSource) - return useValue(selector ?? identity, comparison) + hook = (keyOrSelector, second, equal) => { + const call = keyedHookCall(defaultKey, keyOrSelector, second, equal) + const resolved = call.key === undefined + ? undefined + : call.member === undefined ? source(call.key) : source(call.key, call.member) + const useValue = observableHook(resolved ?? absentSource) + return useValue(call.selector ?? identity, call.equal) } hooks.set(cacheKey, hook) } @@ -142,11 +172,9 @@ export function keyedObservableHook( const NO_DEFAULT_KEY = Symbol('no default key') const keyedHookCache = new WeakMap>() const identity = (value: unknown): unknown => value -const absentKeyedHook: KeyedSnapshotHook = (keyOrSelector, selectorOrEqual, equal) => { - const keyed = typeof keyOrSelector === 'string' - const selector = (keyed ? selectorOrEqual : keyOrSelector) as ((value: unknown) => unknown) | undefined - const comparison = (keyed ? equal : selectorOrEqual) as ((left: unknown, right: unknown) => boolean) | undefined - return observableHook(absentSource)(selector ?? identity, comparison) +const absentKeyedHook: KeyedSnapshotHook = (keyOrSelector, second, equal) => { + const call = keyedHookCall(undefined, keyOrSelector, second, equal) + return observableHook(absentSource)(call.selector ?? identity, call.equal) } /** Subscribe the tree to the atomically assembled root standard-source roster. */ diff --git a/packages/client/ui-renderer/tests/keyed-hook-member.client.spec.tsx b/packages/client/ui-renderer/tests/keyed-hook-member.client.spec.tsx new file mode 100644 index 0000000000..fdfaecf3d8 --- /dev/null +++ b/packages/client/ui-renderer/tests/keyed-hook-member.client.spec.tsx @@ -0,0 +1,61 @@ +// @vitest-environment jsdom +import { describe, expect, it, vi } from 'vitest' +import { act, renderHook } from '@testing-library/react' +import type { HostObservable, KeyedStandardSource } from '@deepseek-ai/dsh-client-ui-slots' +import { keyedObservableHook } from '../src/client/bindings.tsx' + +function observable(initial: T): HostObservable & { set(next: T): void } { + let value = initial + const listeners = new Set<() => void>() + return { + getSnapshot: () => value, + subscribe: (listener) => { + listeners.add(listener) + return () => { listeners.delete(listener) } + }, + set: (next) => { + value = next + for (const listener of [...listeners]) listener() + }, + } +} + +describe('keyed standard Hooks', () => { + it('reads the resolver\'s (key, member) source when the second argument is a string', () => { + const fork = observable(true) + const whole = observable({ title: 'one' }) + const resolver = vi.fn((key, member) => + member === undefined ? (key === 's1' ? whole : undefined) : (key === 's1' && member === 'session/fork' ? fork : undefined)) + const useAccess = keyedObservableHook(resolver) + + const member = renderHook(() => useAccess('s1', 'session/fork')) + expect(member.result.current).toBe(true) + act(() => { fork.set(false) }) + expect(member.result.current).toBe(false) + expect(resolver).toHaveBeenCalledWith('s1', 'session/fork') + + expect(renderHook(() => useAccess('s1', 'session/missing')).result.current).toBeUndefined() + expect(renderHook(() => useAccess('s1')).result.current).toEqual({ title: 'one' }) + expect(resolver.mock.calls.some(call => call.length === 1 && call[0] === 's1')).toBe(true) + }) + + it('keeps the selector, equality, and default-key call forms', () => { + const whole = observable({ title: 'one', count: 1 }) + const useValue = keyedObservableHook(key => (key === 'k' ? whole : undefined), 'k') + expect(renderHook(() => useValue('k', (value: unknown) => (value as { title: string }).title)).result.current).toBe('one') + expect(renderHook(() => useValue((value: unknown) => (value as { count: number }).count)).result.current).toBe(1) + const equal = vi.fn(() => true) + renderHook(() => useValue('k', (value: unknown) => value, equal)) + act(() => { whole.set({ title: 'two', count: 2 }) }) + expect(equal).toHaveBeenCalled() + const noDefault = keyedObservableHook(key => (key === 'k' ? whole : undefined)) + expect(renderHook(() => noDefault((value: unknown) => value)).result.current).toBeUndefined() + }) + + it('reads undefined for every call form while the source family is absent', () => { + const absent = keyedObservableHook(undefined) + expect(renderHook(() => absent('s1', 'session/fork')).result.current).toBeUndefined() + expect(renderHook(() => absent('s1', (value: unknown) => value ?? 'fallback')).result.current).toBe('fallback') + expect(renderHook(() => absent((value: unknown) => value ?? 'bare')).result.current).toBe('bare') + }) +}) diff --git a/packages/client/ui-renderer/tsdown.config.ts b/packages/client/ui-renderer/tsdown.config.ts index 2aa78ec480..7aa03cebc1 100644 --- a/packages/client/ui-renderer/tsdown.config.ts +++ b/packages/client/ui-renderer/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-renderer', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-schedule/README.i18n.yaml b/packages/client/ui-schedule/README.i18n.yaml index 50241a25d1..205e4c6b84 100644 --- a/packages/client/ui-schedule/README.i18n.yaml +++ b/packages/client/ui-schedule/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 405b30feaf48c087 /deepseek-ai-dsh-client-ui-schedule/use-this-package: - en: c668e3d223102e47 - zh: efedf3996679fb17 + en: edba0e1573f3ce42 + zh: 77875b469d94cc62 /deepseek-ai-dsh-client-ui-schedule/use-this-package/time-zone-data-source: en: 6bd6882dbbbefcb0 zh: 03aa15f1a65d13dc diff --git a/packages/client/ui-schedule/README.md b/packages/client/ui-schedule/README.md index 2731187fae..220af1e6c7 100644 --- a/packages/client/ui-schedule/README.md +++ b/packages/client/ui-schedule/README.md @@ -27,6 +27,8 @@ Manage active and inactive tasks from the Automation tasks page and the right Si The shipped Web composition mounts `ui-schedule` with the Host Schedule service. The clock context that supplies the model's current time is preset-owned: `standard`, `cordis`, and `ptc` declare it, and `minimal` declares neither it nor the reminder tools. The Automation tasks sidebar entry opens a global page without selecting or activating a Session. The page header's New action starts a new Session, where the reminder is created by asking the model; the page itself has no creation form. Its task list supports text search and one All, Enabled, and Inactive status filter row; search matches the stored task name, the instruction, and the internal Session id, not a resolved Session title, and the empty state keeps the heading's New action beside the notice. Selecting an active or inactive task opens a second detail level with Rules and Records tabs. Every list row shows the task's stored title with its status, frequency, and next run and never the original Session name; the detail's name control shows the stored title. Rules contains the complete reminder, status, next target for active tasks, original recurrence rule, and task id. Daily rule labels show the saved local time and exact stored IANA zone, preserving nonzero seconds and milliseconds rather than substituting the browser's zone. The Linked session entry in the detail tab strip names the linked Session by its catalog title, falling back to the Session id when no title is available. When the strip cannot fit the tab list beside the labelled entry, the entry becomes an icon button with the same button style as More task actions, with the Session chat icon and a Linked session tooltip that also shows while the entry is disabled; the entry keeps focus when it switches layouts; the tab list scrolls without a visible scrollbar. It opens an available, unarchived Session after metadata loads; pending Session or Workspace metadata, an archived Session, missing Session membership, or a Workspace query error keeps it disabled with an explanation. The click rechecks availability; browsing does not unarchive the Session. Only this action requests Session activation. Deletion is available from the More task actions menu in the detail's top strip, separate from navigation. +The page header keeps the title at its natural width and moves New to another line when the two no longer fit together. The action label stays on one line. + Selecting a task from the list opens Rules; refreshing that task preserves the selected tab. Rules edits an active task's name, instruction, and run time; the edits stay in a local draft until saved, and inactive tasks remain read-only. Unmodified Left/Right arrows cycle the tabs, Home/End select the first/last tab, and keyboard focus follows the selection. The tabs do not consume these keys with Alt, Ctrl, Meta, or Shift. The linked-Session control in the detail tab strip stays available on the Rules tab, and confirmed deletion is available from either view. Rules shows the rule in a Run time card whose rows edit a local draft: Repeat is a menu of Weekly, Monday to Friday, Every day, Every N hours, Every N minutes, Every N seconds, Once, and Custom (cron), a weekly rule adds a Weekday row, a fixed-rate choice edits only the interval quantity in the unit it names, a one-shot rule edits separate Date and Time rows, and Custom (cron) edits one Cron expression row holding a five-field expression, interpreted in the selected time zone. The card parses that expression in the browser and, under the row, shows a localized sentence describing it; an expression the Host would reject shows the localized validation message in place of that sentence and blocks Save. A stored weekly rule whose weekday set is exactly Monday through Friday selects the Monday to Friday choice. The Date row is a read-only trigger showing the staged date as `YYYY/MM/DD` and opening a month calendar with localized weekday headings, month navigation, and a marked today; the draft and the submitted text keep the ISO form; the Time row is a read-only trigger showing one 24-hour clock and opening three scrolling columns of hours, minutes, and seconds with the staged value marked and scrolled into view. A calendar or clock pick stages the draft under the same staged save as every other row, arrow keys walk the open column or grid, Enter stages the marked value, Escape closes the panel and returns focus to its row, and a disabled row cannot open one. Clock rows show whole seconds, an untouched row keeps a stored millisecond value for its submit, and editing a time submits whole-second precision. Time zone is a searchable, height-bounded menu. An unlabeled top block keeps the five most recently selected exact zones in browser-local storage; first use seeds the actual system zone and UTC rather than inferring a location from UI language. The system zone is marked, and the remaining inventory is ordered by current UTC offset and canonical IANA id. Each visible label puts `UTC±HH:MM` before the localized zone name; canonical ids stay internal but remain searchable. Zones that ICU renders with the same offset and localized name share one visible row; every grouped id remains searchable, and an already stored id remains selected instead of being rewritten. The inventory comes from `Intl.supportedValuesOf('timeZone')`, and names come from `Intl.DateTimeFormat(..., { timeZoneName: 'longGeneric' })`: both are browser ICU/CLDR data, not a scraped or product-maintained translation table. There is therefore no crawler or generated zone-name resource to refresh. A third-party locale supplies its ordinary `time.locale` value and automatically receives the corresponding ICU names; only the selector's surrounding UI copy needs translation. When the runtime cannot enumerate zones, a short identifier fallback is offered, and a stored zone outside the offered set is kept. Daily, Weekly, and Cron keep their stored rule and IANA zone, and each frequency line names the zone only when it differs from the host's system zone. A one-shot After/At seeds the date and time from the record's stored zone, or this device's zone when the record stores none, so the seeded clock names the stored instant; changing that zone keeps the entered clock values and changes the instant. A kind the card seeds on selection uses the committed occurrence's clock and the choice's zone, which is the record's stored zone or this device's zone, a seeded weekly rule starts from that occurrence's weekday in that zone, and a seeded cron rule states that occurrence's clock as a daily expression. Every accepts at least 60 whole seconds, independent of time zones. One Save submits the complete expected record with the staged change and preserves the id, original Session, latest receipt, and saved history; [Schedule](../../schedule/schedule/README.md#use-this-package) defines future-target selection, reanchoring, and unchanged-rule no-ops. diff --git a/packages/client/ui-schedule/README.zh.md b/packages/client/ui-schedule/README.zh.md index cd027ea2ea..88575c4405 100644 --- a/packages/client/ui-schedule/README.zh.md +++ b/packages/client/ui-schedule/README.zh.md @@ -27,6 +27,8 @@ kind: "package-reference" 随发行版交付的 Web 组合把 `ui-schedule` 与 Host Schedule 服务一起挂载。为模型提供当前时间的时钟上下文归 preset 所有:`standard`、`cordis` 与 `ptc` 声明它,`minimal` 既不声明它,也不声明提醒工具。侧栏的自动化任务入口打开全局页面,不选择或激活会话。页面页头的“新建”操作会打开一个新会话,提醒由模型在该会话中创建;页面本身没有创建表单。列表支持文本搜索和一行“全部/已开启/未运行”状态筛选;搜索匹配任务已存储的名称、指令和内部会话 id,不匹配解析出的会话标题,空状态在提示旁保留页头的“新建”操作。选择活动或未运行任务后,进入含“规则”和“任务运行记录”页签的第二层详情。每条列表行显示任务已存储的标题及其状态、频率和下次运行,不显示原会话名称;详情中的名称输入框显示任务已存储的标题。“规则”显示完整提醒、状态、活动任务的下一目标时间、原始重复规则和任务 id。每天的规则标签显示已保存的本地时间与精确的存储 IANA 时区,保留非零秒与毫秒,而不替换为浏览器时区。详情页签条内的“关联会话”入口按会话标题显示,取不到标题时回退显示会话 id。页签条放不下页签列表和带文字的入口时,入口收为与“更多任务操作”同样式的图标按钮,使用会话对话图标,悬停提示“关联会话”,入口禁用时也会显示;切换形态时保留焦点;页签列表可滚动但不显示滚动条。元数据加载后,它可打开可用且未归档的会话;会话或 Workspace 元数据尚未就绪、会话已归档、会话不在列表中或 Workspace 查询失败时,入口保持禁用并显示原因。点击时会重新检查可用性;浏览不会取消会话归档。仅此操作请求激活会话。删除位于详情顶部条带的“更多任务操作”菜单。 +页头按标题的自然宽度排布;标题和“新建”无法并排容纳时,将按钮移到下一行。操作文案保持单行。 + 从列表选择任务时默认打开“规则”;刷新同一任务会保留当前页签。活动任务的“规则”修改名称、指令和运行时间;这些修改保留在本地草稿中直到保存,未运行任务保持只读。不带修饰键的左右方向键循环切换页签,Home/End 选择第一个/最后一个页签,键盘焦点随选择移动。页签不会拦截这些键与 Alt、Ctrl、Meta 或 Shift 的组合。共用的会话入口位于详情页签条内,在“规则”页签可见;确认删除操作在两种页签下都可用。 “规则”在“运行时间”卡片中以行编辑本地草稿:“重复”是含每周、周一至周五、每天、每 N 小时、每 N 分钟、每 N 秒、仅一次和自定义(cron)的菜单,每周规则增加“星期”行,固定速率选项只修改它所属单位的间隔数量,一次性规则用独立的“日期”和“时间”两行编辑目标,自定义(cron)用一行“Cron 表达式”编辑按所选时区解释的 5 字段表达式。卡片在浏览器中解析该表达式,并在该行下方显示描述它的本地化语句;Host 会拒绝的表达式改为显示本地化校验信息,并在解析通过前阻止保存。已存储的每周规则若星期集合恰好为周一至周五,则选中“周一至周五”选项。“日期”行是只读触发器,以 `YYYY/MM/DD` 显示暂存的日期并展开带本地化星期表头、月份切换和今天标记的月历;草稿与提交文本保持 ISO 形态;“时间”行是只读触发器,显示统一的 24 小时时钟,并展开时、分、秒三列可滚动列表,当前值高亮并在打开时滚动到可见。在月历或时钟上点选即按与其他行相同的分阶段保存写入草稿;方向键在打开的列或网格内移动,Enter 选定当前值,Escape 关闭面板并把焦点还给所在行,被禁用的行无法打开面板。时钟行显示到秒,未改动的行在提交时保留已存储的毫秒值,编辑时间时按整秒精度提交。“时区”使用高度受限的可搜索菜单。顶部无标题区域保留浏览器本地最近选过的 5 个准确时区;首次使用按真实系统时区和 UTC 初始化,而不根据界面语言猜测所在地。系统时区带有标记,其余清单按当前 UTC 偏移、再按规范 IANA 标识排序。可见文案把 `UTC±HH:MM` 放在本地化时区名称之前;IANA 标识仅在内部使用,但可用于搜索。ICU 显示成相同偏移和本地化名称的多个时区只占一行;所有被合并的标识仍可用于搜索,任务已保存的标识也会继续作为选中值,不会被改写。时区清单来自 `Intl.supportedValuesOf('timeZone')`,名称来自 `Intl.DateTimeFormat(..., { timeZoneName: 'longGeneric' })`:两者都是浏览器内置的 ICU/CLDR 数据,不是抓取或产品自维护的翻译表。因此没有需要刷新的爬虫或生成时区资源;第三方语言包只需提供常规的 `time.locale` 值,即可自动获得对应的 ICU 名称,仅需翻译选择器周边的 UI 文案。运行时无法枚举时改用简短的标识候选列表,已存储但不在其中的时区会保留。每天、每周和 cron 保留各自已存储的规则与时区,其频率行仅在时区与宿主系统时区不同时标出时区。一次性 After/At 以记录已存储的时区初始化日期和时间;记录未保存时区时取本设备时区,初始钟表值在该时区下仍表示同一瞬时;更改时区会保留输入的钟表值并改变时点。因选择种类而重新初始化的种类使用已提交发生时点的钟表值和该选项的时区,该时区取记录已存储的时区或本设备时区:每周的星期取自该发生时点在该时区下的星期,cron 则把该发生时点的钟表值写成每天执行的表达式。固定间隔至少为 60 整秒,不受时区影响。一次保存以分阶段的变更提交完整预期记录,并保留 id、原会话、最近一次回执及已保存历史;未来目标选择、重新确定起算时间和未变更规则的无写入语义见 [Schedule](../../schedule/schedule/README.zh.md#use-this-package)。 diff --git a/packages/client/ui-schedule/package.json b/packages/client/ui-schedule/package.json index 2ec4f63955..b8a228585b 100644 --- a/packages/client/ui-schedule/package.json +++ b/packages/client/ui-schedule/package.json @@ -74,8 +74,6 @@ "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-util-values": "workspace:*", "@testing-library/react": "^16.3.3", - "@types/react": "~18.3.1", - "@types/react-dom": "~18.3.0", "clsx": "^2.1.1", "@deepseek-ai/cordis": "workspace:~", "react": "^18.2.0", diff --git a/packages/client/ui-schedule/src/client/TaskManagerPage.module.css b/packages/client/ui-schedule/src/client/TaskManagerPage.module.css index 25b573fcd4..ba3ee2a030 100644 --- a/packages/client/ui-schedule/src/client/TaskManagerPage.module.css +++ b/packages/client/ui-schedule/src/client/TaskManagerPage.module.css @@ -45,9 +45,10 @@ padding: 0 clamp(24px, 4vw, 48px) 48px; } -/* The page name and the creation actions on one line. */ +/* Page title and creation actions. */ .pageHeading { display: flex; + flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 16px; @@ -68,8 +69,9 @@ their titles share one size. The theme carries no size token for this step, which is why every first-level page states the same literal. */ .pageHeading h1 { - flex: 1; + flex: 0 1 auto; min-width: 0; + max-width: 100%; margin: 0; font-size: 20px; line-height: 28px; @@ -82,6 +84,7 @@ flex: none; align-items: center; gap: 16px; + white-space: nowrap; } /* The Plugins toolbar's Add-plugin step: a 32px capsule with the 13px label. */ diff --git a/packages/client/ui-schedule/tests/task-detail.client.spec.tsx b/packages/client/ui-schedule/tests/task-detail.client.spec.tsx index bb6a084780..ff64d41b88 100644 --- a/packages/client/ui-schedule/tests/task-detail.client.spec.tsx +++ b/packages/client/ui-schedule/tests/task-detail.client.spec.tsx @@ -11,7 +11,7 @@ */ import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import type { RemoteResult } from '@deepseek-ai/dsh-api-remotes/client' import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client' @@ -118,6 +118,7 @@ function mount( useSessionStatus: select => select(new Map()), useSessionRetainInfo: () => undefined, useResource: () => { throw new Error('The task manager does not load document resources') }, + ...allowAllAccess, onDelete: vi.fn(async () => 'deleted'), onRetry: vi.fn(async () => {}), onNewTask: vi.fn(), diff --git a/packages/client/ui-schedule/tests/task-manager-page.client.spec.tsx b/packages/client/ui-schedule/tests/task-manager-page.client.spec.tsx index 52158d0ed2..7f29237374 100644 --- a/packages/client/ui-schedule/tests/task-manager-page.client.spec.tsx +++ b/packages/client/ui-schedule/tests/task-manager-page.client.spec.tsx @@ -3,7 +3,7 @@ import { readFileSync } from 'node:fs' import { resolve } from 'node:path' import { act, cleanup, fireEvent, isInaccessible, render, screen, waitFor, within } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { bindSnapshotSelector, makeTranslate, RemoteError } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, bindSnapshotSelector, makeTranslate, RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import type { RemoteResult } from '@deepseek-ai/dsh-api-remotes/client' import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client' @@ -174,6 +174,7 @@ function mount( useSessionStatus: select => select(new Map()), useSessionRetainInfo: () => undefined, useResource: () => { throw new Error('The task manager does not load document resources') }, + ...allowAllAccess, onDelete: vi.fn(async () => 'deleted'), onRetry: vi.fn(async () => {}), onNewTask: vi.fn(), @@ -1598,6 +1599,7 @@ describe('Task manager details', () => { useResource={unread} useSessionStatus={unread} useSessionRetainInfo={unread} + {...allowAllAccess} />) } diff --git a/packages/client/ui-schedule/tsdown.config.ts b/packages/client/ui-schedule/tsdown.config.ts index 5607f49cd6..7aa03cebc1 100644 --- a/packages/client/ui-schedule/tsdown.config.ts +++ b/packages/client/ui-schedule/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-schedule', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-secret-mask/README.i18n.yaml b/packages/client/ui-secret-mask/README.i18n.yaml new file mode 100644 index 0000000000..acb11ab860 --- /dev/null +++ b/packages/client/ui-secret-mask/README.i18n.yaml @@ -0,0 +1,40 @@ +# Bilingual-pair consistency record for README.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/client/ui-secret-mask/README.md +/: + en: 1dc6c25906eed342 + zh: 6d2502e37189d576 +/deepseek-ai-dsh-client-ui-secret-mask: + en: f26ae7ca26826032 + zh: b74b0e6322c0b56c +/deepseek-ai-dsh-client-ui-secret-mask/summary: + en: 2a509a8ec34675b3 + zh: e0afbb185bde0c57 +/deepseek-ai-dsh-client-ui-secret-mask/table-of-contents: + en: d152484eb41ac6b4 + zh: 09388d293f9be9cb +/deepseek-ai-dsh-client-ui-secret-mask/use-this-package: + en: b0381650c2b15970 + zh: a30982f630ea71f1 +/deepseek-ai-dsh-client-ui-secret-mask/use-this-package/what-is-masked: + en: 445a9c788955600d + zh: b36ebee0735f5c48 +/deepseek-ai-dsh-client-ui-secret-mask/understand-the-implementation: + en: 6d51c32ed0187492 + zh: 43cf79bbe45ee90b +/deepseek-ai-dsh-client-ui-secret-mask/further-exploration: + en: 7549b5b4c03132f4 + zh: 50516120cf5d0c25 +/deepseek-ai-dsh-client-ui-secret-mask/model-experience: + en: 9a362b8fa90e0d5a + zh: 65c22dcf8f352782 +/deepseek-ai-dsh-client-ui-secret-mask/model-experience/kv-cache-effect: + en: ca75c51c89c2c9b0 + zh: c2241315dc0c89c9 +/deepseek-ai-dsh-client-ui-secret-mask/known-limitations-and-deferred-work: + en: 14c6e5817a106972 + zh: 13082b68474b5a6f +/deepseek-ai-dsh-client-ui-secret-mask/known-limitations-and-deferred-work/dev-note: + en: e67f633dc6fe7773 + zh: e88aeb617ffb74ea diff --git a/packages/client/ui-secret-mask/README.md b/packages/client/ui-secret-mask/README.md new file mode 100644 index 0000000000..bdd123220d --- /dev/null +++ b/packages/client/ui-secret-mask/README.md @@ -0,0 +1,99 @@ +--- +description: "Web display masking for recognized credentials in sent user text, with a per-value reveal control in interactive messages." +kind: "package-reference" +--- + +# @deepseek-ai/dsh-client-ui-secret-mask + +English | [中文](README.zh.md) + +## Summary + +Use `dsh-client-ui-secret-mask` when Web users paste API keys, tokens, or passwords into messages that others may see on a shared screen or in a screenshot. Sent user text draws each recognized credential masked: a key or token keeps its first eight characters, and each remaining character is drawn as `*`. Masking changes only what is drawn; the message text, the copy action, and the model input keep the original characters. + +## Table of Contents + +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Further Exploration](#further-exploration) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Use this package + +Mount the package beside `dsh-client-ui-conversation`. The Web app bundle mounts it by default; removing its row draws credentials as written. Every caller of the [UserText Factory](../ui-conversation/README.md#sent-user-text) masks: message bubbles, steering messages, pending echoes, queued messages, and `/goal` input bubbles. + +Each masked value is drawn in a monospace font, so revealing it does not move the surrounding text. Interactive text, which is a sent message or an admitted steering message, adds an eye control at the end of each run. The control reveals or hides that one value, and every mount starts masked. Other callers draw the masked value without a control. + + +### What is masked + +| Value | Examples | Display | +| --- | --- | --- | +| Model-service and Google API keys | `sk-…` with at least one digit, `AIza…` | First eight characters kept | +| Code-hosting and package-registry tokens | `ghp_…`, `github_pat_…`, `glpat-…`, `npm_…`, `hf_…` | First eight characters kept | +| Cloud access key IDs | `AKIA…`, `ASIA…`, `LTAI…`, `AKID…` | First eight characters kept | +| Collaboration and payment tokens | `xoxb-…`, `sk_live_…`, `rk_test_…`, `whsec_…` | First eight characters kept | +| JSON Web Tokens | `eyJ….eyJ….…` | First eight characters kept | +| Private key blocks | `-----BEGIN … PRIVATE KEY-----` through its `END` line | `BEGIN` and `END` lines and line breaks kept | +| Address passwords | `postgres://user:password@host/db` | Password masked completely, including a token used as one | + +Named assignments such as `password=…` or `API_KEY=…`, passwords written in a sentence, verification codes, and personal data have no published format and stay as written. + +----- + + +## Understand the implementation + +
+Implementation internals — click to expand + +The plugin registers one UserText matcher, `secret`, and its keyed `conversation.user.text.node` renderer; the `/client` export is the plugin body (`apply`/`inject`) only. The matcher reads no Facts, so Facts updates never query it again. Each query returns the nearest credential at or after the requested offset and allocates nothing before it returns a node. A literal-only alternation finds where a format can begin; where a literal's suffix can begin another literal, the scan resumes at that suffix. Format checks are bounded character comparisons and sticky `[class]*` patterns, which stop at the first character outside the class and leave nothing to backtrack into. The matcher instance that `apply` creates memoizes run ends, the next digit, and the next private-key `END` line per text across queries, so all queries of one text cost linear time together; the instance keeps the last queried text until a query for another text. At an equal start, the private-key, address-password, and prefix formats win in that order, and every overlapping candidate joins the earliest under its mask. When an address's `://` precedes the requested offset, the matcher walks back over the user name only if `:`, a password, and `@` follow the offset, so a query from anywhere inside an address finds the same password. The [user-text benchmark](../../../benchmarks/user-text/user-text.bench.client.ts) bounds evaluation time for text with and without credentials, linear scaling, and the matcher's allocation ([rationale](../../../.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.md)). + +The node payload carries only the masked display text. The renderer draws each run of `*` as centered marks while keeping the characters for copying and assistive technology, because a font's own asterisk sits near the cap height. It switches between that text and the node's original text, and it remembers the revealed original value instead of a flag, so a different credential at the same position starts masked. + +
+ +----- + + +## Further Exploration + +- [ui-conversation sent user text](../ui-conversation/README.md#sent-user-text) — the matcher registry, the scheduler, and the node renderer Slot. +- [User text candidate scheduling](../../../.agents/notes/implemented/architecture/2026-10-10-user-text-json-nodes.md) — the forward-query and cache rules this matcher follows. +- [Credential matching performance](../../../.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.md) — the linear matcher, its per-part complexity and trade-offs, and the time and allocation gates. + +----- + + +## Model Experience + +None, as masking changes only what the browser draws; the message text and the model input keep the original characters. + +#### KV Cache effect + +None; this package neither assembles nor sends a provider request. + +## Known Limitations and Deferred Work + + + +These limits define when a credential is drawn as written; they are current package constraints. + +- **Recognition requires a published format** — a credential without one of the [recognized formats](#what-is-masked) is drawn as written. Named assignments stay plain because the same names also label ordinary values, such as token usage. +- **Only UserText callers mask** — session titles, turn previews, search excerpts, the goal bar, tool cards, assistant replies, and the unsent composer draw credentials as written. +- **An earlier reference draws its own text** — a credential inside a reference that starts before it, such as the file mention `@config/.txt`, is drawn by that reference. + + +### Dev Note + +
+Working context for maintainers — click to expand + +None. + +
diff --git a/packages/client/ui-secret-mask/README.zh.md b/packages/client/ui-secret-mask/README.zh.md new file mode 100644 index 0000000000..3157142215 --- /dev/null +++ b/packages/client/ui-secret-mask/README.zh.md @@ -0,0 +1,99 @@ +--- +description: "Web 端已发送用户文本中可识别凭据的显示遮挡,交互消息中每个值带显示控件。" +kind: "package-reference" +--- + +# @deepseek-ai/dsh-client-ui-secret-mask + +[English](README.md) | 中文 + +## 概述 + +当 Web 用户把 API 密钥、令牌或密码粘贴进消息,而消息可能在共享屏幕或截图中被他人看到时,使用 `dsh-client-ui-secret-mask`。已发送的用户文本会遮挡每个可识别的凭据:密钥或令牌保留前八个字符,其余每个字符显示为 `*`。遮挡只改变显示内容;消息原文、复制操作和模型输入都保留原始字符。 + +## 目录 + +- [使用本包](#use-this-package) +- [理解实现](#understand-the-implementation) +- [进一步探索](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与延期工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用本包 + +把本包与 `dsh-client-ui-conversation` 一起挂载。Web 应用 bundle 默认挂载本包;移除该行后凭据按原样显示。[UserText Factory](../ui-conversation/README.zh.md#sent-user-text) 的每个调用方都会遮挡:消息气泡、插话消息、发送中回显、排队消息以及 `/goal` 输入气泡。 + +每个被遮挡的值以等宽字体显示,因此展开时不会挪动周围文字。交互文本(即已发送消息或已接纳的插话消息)会在每段末尾加一个眼睛控件。该控件显示或隐藏这一个值,每次挂载都从遮挡状态开始。其他调用方只显示遮挡后的值,不带控件。 + + +### 遮挡范围 + +| 值 | 示例 | 显示 | +| --- | --- | --- | +| 模型服务与 Google API 密钥 | 至少含一个数字的 `sk-…`、`AIza…` | 保留前八个字符 | +| 代码托管与包仓库令牌 | `ghp_…`、`github_pat_…`、`glpat-…`、`npm_…`、`hf_…` | 保留前八个字符 | +| 云访问密钥 ID | `AKIA…`、`ASIA…`、`LTAI…`、`AKID…` | 保留前八个字符 | +| 协作与支付令牌 | `xoxb-…`、`sk_live_…`、`rk_test_…`、`whsec_…` | 保留前八个字符 | +| JSON Web Token | `eyJ….eyJ….…` | 保留前八个字符 | +| 私钥块 | 从 `-----BEGIN … PRIVATE KEY-----` 到对应 `END` 行 | 保留 `BEGIN`、`END` 两行及换行 | +| 地址中的密码 | `postgres://user:password@host/db` | 密码全部遮挡,用作密码的令牌也一样 | + +`password=…`、`API_KEY=…` 这类带名字的赋值、写在句子里的密码、验证码以及个人信息都没有公开格式,保持原样。 + +----- + + +## 理解实现 + +
+实现细节——点击展开 + +本插件注册一个 UserText 匹配器 `secret` 及其 keyed `conversation.user.text.node` renderer;`/client` 导出只有插件主体(`apply`/`inject`)。匹配器不读取 Facts,因此 Facts 更新不会再次查询它。每次查询返回请求偏移处或之后最近的凭据,返回节点之前不分配内存。一个只含字面量的交替正则找到格式可能开始的位置;字面量的后缀可能是另一个字面量的开头时,扫描从该后缀处继续。格式检查是有界的字符比较和粘性 `[字符类]*` 模式,这类模式在第一个不属于该类的字符处停止,没有可回溯的部分。`apply` 创建的匹配器实例按文本跨查询记忆字符段结尾、下一个数字和下一行私钥 `END`,因此一段文本的全部查询合计线性;实例保留最近一次查询的文本,直到查询另一段文本。同一起点上,私钥、地址密码和前缀格式依次优先;所有重叠的候选都并入最早的候选,并使用其遮挡方式。地址的 `://` 在请求偏移之前时,只有偏移后紧跟 `:`、密码和 `@` 才回看用户名,因此从地址内任何位置查询都得到同一个密码。[user-text benchmark](../../../benchmarks/user-text/user-text.bench.client.ts) 约束无凭据与含凭据文本的求值耗时、线性增长和匹配器的内存分配([理由](../../../.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.zh.md))。 + +节点 payload 只携带遮挡后的显示文本。renderer 把每段 `*` 绘制为垂直居中的标记,同时保留字符本身供复制与辅助技术读取,因为字体自带的星号靠近大写字母顶部。renderer 在该文本与节点原文之间切换,并记住已显示的原文值而不是一个开关,因此同一位置换成另一个凭据时会从遮挡状态开始。 + +
+ +----- + + +## 进一步探索 + +- [ui-conversation 已发送用户文本](../ui-conversation/README.zh.md#sent-user-text)——匹配器注册表、调度器与节点 renderer Slot。 +- [用户文本候选调度](../../../.agents/notes/implemented/architecture/2026-10-10-user-text-json-nodes.zh.md)——本匹配器遵循的向前查询与缓存规则。 +- [凭据识别的性能](../../../.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.zh.md)——线性匹配器各部分的复杂度与取舍,以及耗时与内存分配门禁。 + +----- + + +## 模型体验 + +无。遮挡只改变浏览器的显示内容;消息原文与模型输入都保留原始字符。 + +#### KV Cache 影响 + +无;本包从不组装或发送提供方请求。 + +## 已知限制与延期工作 + + + +以下限制说明凭据何时按原样显示;它们是本包当前的约束。 + +- **识别依赖公开格式**——不具备[可识别格式](#what-is-masked)的凭据按原样显示。带名字的赋值保持原样,因为同样的名字也用于 token 用量等普通值。 +- **只有 UserText 调用方会遮挡**——会话标题、轮次预览、搜索摘要、目标栏、工具卡片、助手回复以及未发送的输入框都按原样显示凭据。 +- **更早开始的引用显示自身文本**——位于更早开始的引用之内的凭据,例如文件提及 `@config/.txt`,由该引用显示。 + + +### 开发备注 + +
+维护者工作上下文——点击展开 + +无。 + +
diff --git a/packages/client/ui-secret-mask/locale/en.json b/packages/client/ui-secret-mask/locale/en.json new file mode 100644 index 0000000000..07a7beb439 --- /dev/null +++ b/packages/client/ui-secret-mask/locale/en.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "ui-secret-mask", + "description": "Masks recognized keys, tokens, and passwords in sent messages." + } +} diff --git a/packages/client/ui-secret-mask/locale/zh.json b/packages/client/ui-secret-mask/locale/zh.json new file mode 100644 index 0000000000..daee3f10d8 --- /dev/null +++ b/packages/client/ui-secret-mask/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "凭据遮挡", + "description": "遮挡已发送消息中可识别的密钥、令牌与密码。" + } +} diff --git a/packages/client/ui-secret-mask/package.json b/packages/client/ui-secret-mask/package.json new file mode 100644 index 0000000000..3101e5ed7d --- /dev/null +++ b/packages/client/ui-secret-mask/package.json @@ -0,0 +1,62 @@ +{ + "name": "@deepseek-ai/dsh-client-ui-secret-mask", + "description": "Web display masking for recognized credentials in sent user text", + "version": "0.2.1-alpha.2", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/client/ui-secret-mask" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" + }, + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-ui-conversation", + "@deepseek-ai/dsh-client-ui-renderer" + ], + "platform": "web" + } + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:~" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-client-locale": "workspace:*", + "@deepseek-ai/dsh-client-test-runtime": "workspace:*", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:*", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", + "@deepseek-ai/dsh-client-ui-slots": "workspace:*", + "react": "^18.2.0" + }, + "files": [ + "locale/*.json", + "lib/index.js", + "lib/client.js", + "lib/types/**/*.d.ts" + ] +} diff --git a/packages/client/ui-secret-mask/src/client/SecretText.module.css b/packages/client/ui-secret-mask/src/client/SecretText.module.css new file mode 100644 index 0000000000..10d529c107 --- /dev/null +++ b/packages/client/ui-secret-mask/src/client/SecretText.module.css @@ -0,0 +1,62 @@ +/* A monospace box: the value and the control are flex items centered on one + axis, and the box takes the surrounding text's baseline from the value. + Each mask character keeps its original character's advance, so revealing + never reflows the line. */ +.secret { + display: inline-flex; + align-items: center; + box-sizing: border-box; + max-width: 100%; + margin: 0 2px; + font-family: var(--ds-font-family-code); + font-size: 0.875em; +} + +.value { + min-width: 0; + overflow-wrap: anywhere; +} + +/* The characters stay for copying but are not painted; an asterisk tile per + character advance replaces them, because a font's own asterisk sits near + the cap height. */ +.mask { + -webkit-text-fill-color: transparent; + background-color: currentColor; + -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 20'%3E%3Cpath d='M6 5.6v8.8M2.19 7.8l7.62 4.4M2.19 12.2l7.62-4.4' stroke='%23000' stroke-width='1.5' stroke-linecap='round'/%3E%3C/svg%3E") left center / 1ch 100% repeat-x; + mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 20'%3E%3Cpath d='M6 5.6v8.8M2.19 7.8l7.62 4.4M2.19 12.2l7.62-4.4' stroke='%23000' stroke-width='1.5' stroke-linecap='round'/%3E%3C/svg%3E") left center / 1ch 100% repeat-x; +} + +/* The line-box center sits below the middle of the surrounding CJK and Latin + glyphs; both marks and the control rise together without moving layout. */ +.mask, +.toggle { + position: relative; + top: -0.125em; +} + +.toggle { + display: inline-flex; + flex: none; + align-items: center; + padding: 0; + border: none; + background: none; + color: var(--dsw-alias-label-secondary); + cursor: pointer; +} + +.toggle:hover { + color: var(--dsw-alias-label-primary); +} + +.toggle:focus-visible { + outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); + outline-offset: 1px; + border-radius: var(--dsw-radius-xs); +} + +.icon { + width: 1em; + height: 1em; +} diff --git a/packages/client/ui-secret-mask/src/client/SecretText.tsx b/packages/client/ui-secret-mask/src/client/SecretText.tsx new file mode 100644 index 0000000000..2a238d7bf7 --- /dev/null +++ b/packages/client/ui-secret-mask/src/client/SecretText.tsx @@ -0,0 +1,68 @@ +/** Masked credential presentation with a per-value reveal control in interactive text. */ +import { useState } from 'react' +import type { UserTextNodeProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' +import type { NS } from './locales.ts' +import { MASK } from './credential-masks.ts' +import css from './SecretText.module.css' + +const EYE_CONTOUR = 'M1.5 8C2.9 5.25 5.25 3.5 8 3.5C10.75 3.5 13.1 5.25 14.5 8C13.1 10.75 10.75 12.5 8 12.5C5.25 12.5 2.9 10.75 1.5 8Z' +const EYE_PATH = `${EYE_CONTOUR}M10 8C10 9.1 9.1 10 8 10C6.9 10 6 9.1 6 8C6 6.9 6.9 6 8 6C9.1 6 10 6.9 10 8Z` +/** The struck-through glyph drops the pupil so it stays distinct at text size. */ +const EYE_OFF_PATH = `${EYE_CONTOUR}M2.5 2.5L13.5 13.5` +const MASK_RUNS = new RegExp(`(\\${MASK}+)`, 'u') + +function EyeGlyph({ path }: { readonly path: string }) { + return ( + + ) +} + +/** + * Split the masked form into kept text and mask runs. Mask runs keep their + * characters for copying and assistive technology but draw centered marks, + * because a font's own asterisk sits near the cap height. + */ +function MaskedText({ masked }: { readonly masked: string }) { + let offset = 0 + return masked.split(MASK_RUNS).map((part) => { + const key = offset + offset += part.length + if (part === '') return null + return part.startsWith(MASK) + ? {part} + : {part} + }) +} + +type SecretTextProps = UserTextNodeProps<'secret'> & PropsLocale + +/** + * Draw one credential masked; interactive text adds a control that reveals or hides this value. + * Every mount, and every different original value at the same position, starts masked. + * @param props - masked node, original spelling, interaction mode, and locale seat. + * @returns an inline masked run with an optional reveal control. + */ +export function SecretText({ node, raw, interactive, t }: SecretTextProps) { + // Holding the revealed value instead of a flag re-masks a different credential that reuses this element. + const [revealedRaw, setRevealedRaw] = useState() + const revealed = interactive && revealedRaw === raw + return ( + + {revealed ? raw : } + {interactive && ( + + )} + + ) +} diff --git a/packages/client/ui-secret-mask/src/client/credential-formats.ts b/packages/client/ui-secret-mask/src/client/credential-formats.ts new file mode 100644 index 0000000000..fc8b27c950 --- /dev/null +++ b/packages/client/ui-secret-mask/src/client/credential-formats.ts @@ -0,0 +1,100 @@ +/** + * The credential formats the matcher recognizes, as data: the literal each + * format begins with, how its candidate is validated, and the ASCII character + * classes those checks use. [format-tables.ts](./format-tables.ts) derives + * lookup tables from this list on first use. + */ + +// Character classes of one ASCII code unit, as bits. +/** `[A-Za-z0-9_-]` */ +export const KEY = 1 +/** `[A-Za-z0-9]` */ +export const ALNUM = 2 +/** `\w` */ +export const WORD = 4 +/** `[A-Za-z0-9-]` */ +export const ALNUM_DASH = 8 +/** `[A-Z0-9]` */ +export const UPPER_ALNUM = 16 +/** `[A-Z0-9 ]` */ +export const LABEL = 32 +/** `[a-z0-9+.-]` */ +export const SCHEME = 64 +/** `[a-z]` */ +export const LOWER = 128 +/** `[^\s/:@?#]` */ +export const USER = 256 +/** `[^\s/@?#]` */ +export const PASSWORD = 512 + +// Literal kinds. Same-start candidates resolve by ascending kind, so a token used as an address password is masked completely. +/** A private key block from its `-----BEGIN` line. */ +export const KEY_BLOCK = 0 +/** The password of an address that carries credentials. */ +export const ADDRESS = 1 +/** A provider key or token after its fixed prefix. */ +export const PREFIXED = 2 + +// Shapes of a prefixed format's character run after its literal. +/** The whole run, at least `min`. */ +export const RUN = 0 +/** `min` to `max`, then `\b`. */ +export const BOUNDED = 1 +/** At least `min`, with a digit among the first 256. */ +export const SK = 2 +/** Exactly `min`. */ +export const GOOGLE = 3 +/** Three runs joined by `.eyJ` and `.`. */ +export const JWT = 4 + +/** One recognized format. */ +export interface CredentialFormat { + /** ASCII literal the candidate begins with; no literal occurs inside another. */ + readonly literal: string + readonly kind: typeof KEY_BLOCK | typeof ADDRESS | typeof PREFIXED + /** Prefixed formats only: how the run after the literal is validated. */ + readonly shape: number + /** Prefixed formats only: the class bit of the run after the literal. */ + readonly run: number + readonly min: number + readonly max: number +} + +const prefixed = (literal: string, shape: number, run: number, min: number, max = 0): CredentialFormat => + ({ literal, kind: PREFIXED, shape, run, min, max }) + +/** Every prefixed format matches more than the 8 characters a mask leaves readable. */ +export const CREDENTIAL_FORMATS: readonly CredentialFormat[] = [ + { literal: '-----BEGIN ', kind: KEY_BLOCK, shape: RUN, run: 0, min: 0, max: 0 }, + { literal: '://', kind: ADDRESS, shape: RUN, run: 0, min: 0, max: 0 }, + // Model services: `sk-` keys carry a digit, which hyphenated identifiers rarely do. + prefixed('sk-', SK, KEY, 20), + prefixed('AIza', GOOGLE, KEY, 35), + // Code hosting and package registries. + prefixed('ghp_', RUN, ALNUM, 36), + prefixed('gho_', RUN, ALNUM, 36), + prefixed('ghu_', RUN, ALNUM, 36), + prefixed('ghs_', RUN, ALNUM, 36), + prefixed('ghr_', RUN, ALNUM, 36), + prefixed('github_pat_', RUN, WORD, 22), + prefixed('glpat-', RUN, KEY, 20), + prefixed('npm_', RUN, ALNUM, 36), + prefixed('hf_', RUN, ALNUM, 30), + // Cloud access key IDs. + prefixed('AKIA', BOUNDED, UPPER_ALNUM, 16, 16), + prefixed('ASIA', BOUNDED, UPPER_ALNUM, 16, 16), + prefixed('LTAI', BOUNDED, ALNUM, 12, 20), + prefixed('AKID', BOUNDED, ALNUM, 28, 36), + // Collaboration and payment services. + prefixed('xoxa-', RUN, ALNUM_DASH, 10), + prefixed('xoxb-', RUN, ALNUM_DASH, 10), + prefixed('xoxp-', RUN, ALNUM_DASH, 10), + prefixed('xoxr-', RUN, ALNUM_DASH, 10), + prefixed('xoxs-', RUN, ALNUM_DASH, 10), + prefixed('sk_live_', RUN, ALNUM, 16), + prefixed('sk_test_', RUN, ALNUM, 16), + prefixed('rk_live_', RUN, ALNUM, 16), + prefixed('rk_test_', RUN, ALNUM, 16), + prefixed('whsec_', RUN, ALNUM, 24), + prefixed('eyJ', JWT, KEY, 8, 2048), +] diff --git a/packages/client/ui-secret-mask/src/client/credential-masks.ts b/packages/client/ui-secret-mask/src/client/credential-masks.ts new file mode 100644 index 0000000000..eabccb0376 --- /dev/null +++ b/packages/client/ui-secret-mask/src/client/credential-masks.ts @@ -0,0 +1,127 @@ +/** + * Display forms of credential ranges. Every mark comes from a slice of a shared + * string, so a one-line mask is one slice and a multi-line mask one slice per line. + */ +import { ADDRESS, PREFIXED } from './credential-formats.ts' + +/** Character that replaces each hidden credential character in `masked`. */ +export const MASK = '*' +/** Leading characters of a prefixed key left readable: the provider prefix and little else. */ +const VISIBLE_PREFIX = 8 +const SHARED_MARKS = 4096 + +/** Builds masks; owns the shared mark strings and its sticky run pattern. */ +export class CredentialMasks { + private readonly marksOnly = MASK.repeat(SHARED_MARKS) + private readonly marksLf = `${this.marksOnly}\n` + private readonly marksCr = `${this.marksOnly}\r` + /** Code units that `lines` counts without inspection: neither line breaks nor surrogates. Set `lastIndex` before each test. */ + private readonly ordinary = /[^\r\n\ud800-\udfff]*/y + + /** + * The display form of `text[start, end)`, whose earliest candidate has `kind`. + * @param text - the queried text. + * @param kind - the literal kind of the range's earliest candidate. + * @param plain - the range holds only prefixed-format characters: ASCII without line breaks. + * @param start - first offset of the range. + * @param end - offset after the range. + * @returns the mask; line breaks stay in place, one mark per code point. + */ + masked(text: string, kind: number, plain: boolean, start: number, end: number): string { + if (kind === ADDRESS) return this.range(text, start, end) + if (kind === PREFIXED) { + const prefix = text.slice(start, start + VISIBLE_PREFIX) + return prefix + (plain ? this.marks(end - start - VISIBLE_PREFIX) : this.range(text, start + VISIBLE_PREFIX, end)) + } + return this.keyBlock(text, start, end) + } + + /** `count` marks: up to the shared string's length, one slice of it. */ + private marks(count: number): string { + return count <= SHARED_MARKS ? this.marksOnly.slice(0, count) : MASK.repeat(count) + } + + /** `count` marks and then the line break `code`, 10 or 13: one slice where `marks` would be one. */ + private marksThen(count: number, code: number): string { + return count <= SHARED_MARKS + ? (code === 10 ? this.marksLf : this.marksCr).slice(SHARED_MARKS - count) + : MASK.repeat(count) + (code === 10 ? '\n' : '\r') + } + + /** + * Keep the `BEGIN` line, the `END` line when present, and every line break; a block written on + * one line is masked whole. Both searches stay inside the range, so they cost what the mask costs. + */ + private keyBlock(text: string, start: number, end: number): string { + let bodyStart = start + for (let index = start; index < end; index++) { + if (text.charCodeAt(index) === 10) { + bodyStart = index + 1 + break + } + } + let bodyEnd = end + for (let index = end - '\n-----END'.length; index >= bodyStart; index--) { + if (text.charCodeAt(index) === 10 && text.startsWith('-----END', index + 1)) { + bodyEnd = index + break + } + } + // Native runs stop at a line break or the end of the text, so they stay inside a body that ends at one. + const bounded = bodyEnd === text.length || text.charCodeAt(bodyEnd) === 10 || text.charCodeAt(bodyEnd) === 13 + const body = bounded ? this.lines(text, bodyStart, bodyEnd) : this.range(text, bodyStart, bodyEnd) + return text.slice(start, bodyStart) + body + text.slice(bodyEnd, end) + } + + /** + * `text[start, end)` with every character except line breaks replaced; a surrogate pair is one + * character, as `[^\r\n]` with the `u` flag counts it. A character loop: a native run could read far past `end`. + */ + private range(text: string, start: number, end: number): string { + let masked = '' + let count = 0 + for (let index = start; index < end; index++) { + const code = text.charCodeAt(index) + if (code === 10 || code === 13) { + masked += this.marksThen(count, code) + count = 0 + continue + } + if (code >= 0xd800 && code <= 0xdbff && index + 1 < end) { + const next = text.charCodeAt(index + 1) + if (next >= 0xdc00 && next <= 0xdfff) index++ + } + count++ + } + return masked + this.marks(count) + } + + /** `range` for a body that ends at a line break or the end of the text, where native runs stop. */ + private lines(text: string, start: number, end: number): string { + const ordinary = this.ordinary + let masked = '' + let count = 0 + let index = start + while (index < end) { + ordinary.lastIndex = index + ordinary.test(text) + const stop = Math.min(ordinary.lastIndex, end) + count += stop - index + if (stop === end) break + const code = text.charCodeAt(stop) + index = stop + 1 + if (code === 10 || code === 13) { + masked += this.marksThen(count, code) + count = 0 + continue + } + // A high surrogate followed by a low one is one character. + if (code <= 0xdbff && index < end) { + const next = text.charCodeAt(index) + if (next >= 0xdc00 && next <= 0xdfff) index++ + } + count++ + } + return masked + this.marks(count) + } +} diff --git a/packages/client/ui-secret-mask/src/client/credential-scanner.ts b/packages/client/ui-secret-mask/src/client/credential-scanner.ts new file mode 100644 index 0000000000..14bf645708 --- /dev/null +++ b/packages/client/ui-secret-mask/src/client/credential-scanner.ts @@ -0,0 +1,358 @@ +/** + * Prefix-pruned, linear credential scanning that allocates nothing until it + * returns a node. A literal-only alternation finds each place a format can + * begin and resumes inside a literal that can overlap the next one. Format + * checks are bounded character comparisons and sticky `[class]*` runs, which + * end at the first character outside the class and leave nothing to backtrack + * into. Run ends, the next digit, and the next key-block `END` line are + * memoized per text across queries, so each is measured once per text. + */ +import type { UserTextNode } from '@deepseek-ai/dsh-client-ui-conversation/client' +import { + ADDRESS, ALNUM, ALNUM_DASH, BOUNDED, GOOGLE, KEY_BLOCK, LABEL, LOWER, PREFIXED, RUN, SCHEME, SK, UPPER_ALNUM, USER, WORD, +} from './credential-formats.ts' +import { CredentialMasks } from './credential-masks.ts' +import type { FormatTables } from './format-tables.ts' + +// Memo layout: [start, end] pairs. A lookup at `offset` hits when start <= offset <= end; -1 pairs hold nothing. +const KEY_SLOTS = 0 // four pairs: a JSON Web Token measures three runs +const KEY_NEXT = 8 +const ALNUM_SLOT = 9 +const WORD_SLOT = 11 +const ALNUM_DASH_SLOT = 13 +const UPPER_ALNUM_SLOT = 15 +const USER_SLOT = 17 +const PASSWORD_SLOT = 19 +const DIGIT_FROM = 21 +const DIGIT_AT = 22 +const BLOCK_FROM = 23 +const BLOCK_END_START = 24 +const BLOCK_END = 25 +/** Start of the password that the last address check accepted. */ +const CANDIDATE_START = 26 +const MEMO_SIZE = 27 + +/** `\s` above ASCII. */ +function isWideSpace(code: number): boolean { + return code === 0xa0 || code === 0x1680 || (code >= 0x2000 && code <= 0x200a) || code === 0x2028 || code === 0x2029 + || code === 0x202f || code === 0x205f || code === 0x3000 || code === 0xfeff +} + +/** Offset after the run that sticky `pattern` matches at `start`. */ +function runEnd(pattern: RegExp, text: string, start: number): number { + pattern.lastIndex = start + pattern.test(text) + return pattern.lastIndex +} + +/** + * One matcher's scanning state: the tables, its own patterns, and a memo of the + * last queried text, which it keeps until a query for another text. Queries are + * synchronous and never re-enter, so the patterns' cursors are never shared. + */ +export class CredentialScanner { + private readonly classes: Uint16Array + private readonly literals: readonly string[] + private readonly literalLength: Uint8Array + private readonly literalKind: Uint8Array + private readonly literalShape: Uint8Array + private readonly literalMin: Int32Array + private readonly literalMax: Int32Array + private readonly literalOverlap: Uint8Array + private readonly ending: Int8Array + private readonly sharedEndings: readonly (readonly number[] | undefined)[] + /** Memo slot of each prefixed format's run class; 0 for `[A-Za-z0-9_-]`, which has four. */ + private readonly literalSlot: Uint8Array + private readonly literalPattern: readonly RegExp[] + private readonly prefixes: RegExp + // Sticky run patterns: nothing follows the starred class, so nothing backtracks, and `test` allocates nothing. + private readonly keyRun = /[A-Za-z0-9_-]*/y + private readonly userRun = /[^\s/:@?#]*/y + private readonly passwordRun = /[^\s/@?#]*/y + private readonly nonDigits = /\D*/y + private readonly masks = new CredentialMasks() + private readonly memo = new Int32Array(MEMO_SIZE).fill(-1) + private text = '' + + constructor(tables: FormatTables) { + this.classes = tables.classes + this.literals = tables.literals + this.literalLength = tables.literalLength + this.literalKind = tables.literalKind + this.literalShape = tables.literalShape + this.literalMin = tables.literalMin + this.literalMax = tables.literalMax + this.literalOverlap = tables.literalOverlap + this.ending = tables.ending + this.sharedEndings = tables.sharedEndings + this.prefixes = new RegExp(tables.prefixSource, 'g') + const patterns = new Map([ + [ALNUM, [ALNUM_SLOT, /[A-Za-z0-9]*/y]], + [WORD, [WORD_SLOT, /\w*/y]], + [ALNUM_DASH, [ALNUM_DASH_SLOT, /[A-Za-z0-9-]*/y]], + [UPPER_ALNUM, [UPPER_ALNUM_SLOT, /[A-Z0-9]*/y]], + ]) + this.literalSlot = Uint8Array.from(tables.literalRun, run => patterns.get(run)?.[0] ?? 0) + this.literalPattern = Array.from(tables.literalRun, run => patterns.get(run)?.[1] ?? this.keyRun) + } + + /** + * The nearest credential at or after `from`. Overlapping candidates of any format join the earliest + * one and take its mask, so no credential is drawn half masked. Candidates arrive in start order; + * an address password waits until the scan passes its start, and no other `://` can occur before + * then, because a user name and a password exclude `/`. A password that starts inside a range also + * ends inside it: the range holds the `:` before the password, so it is a key block, whose `END` + * line has spaces, or one that runs to the end of the text. + * @param text - the complete original text. + * @param from - the query offset. + * @returns the `secret` node, or undefined when no credential starts at or after `from`. + */ + match(text: string, from: number): UserTextNode<'secret'> | undefined { + if (text !== this.text) { + this.memo.fill(-1) + this.text = text + } + const prefixes = this.prefixes + const length = text.length + let start = -1 + let end = -1 + let kind = PREFIXED + let plain = true + let deferredEnd = this.addressBefore(from) + let deferredStart = deferredEnd < 0 ? -1 : this.slot(CANDIDATE_START) + prefixes.lastIndex = from + while (prefixes.test(text)) { + const literalEnd = prefixes.lastIndex + const literal = this.literalEndingAt(literalEnd) + const at = literalEnd - (this.literalLength[literal] as number) + prefixes.lastIndex = literalEnd - (this.literalOverlap[literal] as number) + if (deferredStart >= 0 && deferredStart <= at) { + if (start < 0) { + start = deferredStart + end = deferredEnd + kind = ADDRESS + } else if (deferredStart >= end) { + break + } + deferredStart = -1 + } + // A candidate starts at or after its literal, so none past the range reaches it; nothing extends a range to the end. + if (start >= 0 && (at >= end || end === length)) break + const literalKind = this.literalKind[literal] as number + if (literalKind === ADDRESS) { + deferredEnd = this.addressPassword(at) + deferredStart = deferredEnd < 0 ? -1 : this.slot(CANDIDATE_START) + continue + } + const candidateEnd = literalKind === KEY_BLOCK ? this.keyBlockEnd(at) : this.prefixedEnd(literal, at) + if (candidateEnd < 0) continue + if (start < 0) { + start = at + end = candidateEnd + kind = literalKind + plain = literalKind === PREFIXED + } else if (candidateEnd > end) { + end = candidateEnd + if (literalKind !== PREFIXED) plain = false + } + } + if (start < 0 && deferredStart >= 0) { + start = deferredStart + end = deferredEnd + kind = ADDRESS + } + if (start < 0) return undefined + return { id: 'secret', start, end, data: { masked: this.masks.masked(text, kind, plain, start, end) } } + } + + /** Memo entry at `index`; the layout keeps every index inside the array. */ + private slot(index: number): number { + return this.memo[index] as number + } + + /** `\w` at `index`; out of range is not a word character. */ + private isWordAt(index: number): boolean { + const code = this.text.charCodeAt(index) + return code < 128 && ((this.classes[code] as number) & WORD) !== 0 + } + + /** `[^\s/:@?#]` for one UTF-16 code unit; NaN, read out of range, is not one. */ + private isUserCode(code: number): boolean { + return code < 128 ? ((this.classes[code] as number) & USER) !== 0 : code === code && !isWideSpace(code) + } + + /** Index of the literal the prefix scan matched ending at `end`; one literal sharing the ending matches, so the last needs no check. */ + private literalEndingAt(end: number): number { + const text = this.text + const key = text.charCodeAt(end - 2) * 128 + text.charCodeAt(end - 1) + const literal = this.ending[key] as number + if (literal > 0) return literal - 1 + const shared = this.sharedEndings[key] as readonly number[] + let index = 0 + for (; index < shared.length - 1; index++) { + const candidate = shared[index] as number + if (text.startsWith(this.literals[candidate] as string, end - (this.literalLength[candidate] as number))) break + } + return shared[index] as number + } + + /** End of the `[A-Za-z0-9_-]*` run at `start`, through four memo pairs replaced in turn. */ + private keyRunEnd(start: number): number { + for (let slot = KEY_SLOTS; slot < KEY_NEXT; slot += 2) { + if (this.slot(slot) <= start && start <= this.slot(slot + 1)) return this.slot(slot + 1) + } + const end = runEnd(this.keyRun, this.text, start) + const next = (this.slot(KEY_NEXT) + 1) & 3 + this.memo[KEY_NEXT] = next + this.memo[KEY_SLOTS + 2 * next] = start + this.memo[KEY_SLOTS + 2 * next + 1] = end + return end + } + + /** End of the run `pattern` matches at `start`, through the memo pair at `slot`. */ + private memoRunEnd(pattern: RegExp, start: number, slot: number): number { + if (this.slot(slot) <= start && start <= this.slot(slot + 1)) return this.slot(slot + 1) + const end = runEnd(pattern, this.text, start) + this.memo[slot] = start + this.memo[slot + 1] = end + return end + } + + /** Offset of the first digit at or after `start`, or the text length. */ + private nextDigit(start: number): number { + if (this.slot(DIGIT_FROM) <= start && start <= this.slot(DIGIT_AT)) return this.slot(DIGIT_AT) + this.memo[DIGIT_FROM] = start + this.memo[DIGIT_AT] = runEnd(this.nonDigits, this.text, start) + return this.slot(DIGIT_AT) + } + + /** Offset after `[A-Z0-9 ]{0,40}PRIVATE KEY-----` at `start`, or -1; reads at most 52 characters. */ + private labelEnd(start: number): number { + const text = this.text + const limit = Math.min(text.length, start + 52) + let index = start + while (index < limit) { + const code = text.charCodeAt(index) + if (code > 127 || ((this.classes[code] as number) & LABEL) === 0) break + index++ + } + // "PRIVATE KEY" is label characters and the `-` after it is not, so the run ends right after it. + const tail = index - 'PRIVATE KEY'.length + return index < start + 52 && tail >= start && text.startsWith('PRIVATE KEY-----', tail) ? tail + 'PRIVATE KEY-----'.length : -1 + } + + /** + * End of the `-----BEGIN [A-Z0-9 ]{0,40}PRIVATE KEY-----` block at `at`: after its first complete + * `END` line, or the end of the text; -1 for an invalid header. + */ + private keyBlockEnd(at: number): number { + const header = this.labelEnd(at + '-----BEGIN '.length) + if (header < 0) return -1 + if (this.slot(BLOCK_FROM) <= header && header <= this.slot(BLOCK_END_START)) return this.slot(BLOCK_END) + const text = this.text + this.memo[BLOCK_FROM] = header + this.memo[BLOCK_END_START] = text.length + this.memo[BLOCK_END] = text.length + for (let line = text.indexOf('-----END ', header); line >= 0; line = text.indexOf('-----END ', line + 1)) { + const end = this.labelEnd(line + '-----END '.length) + if (end < 0) continue + this.memo[BLOCK_END_START] = line + this.memo[BLOCK_END] = end + break + } + return this.slot(BLOCK_END) + } + + /** Whether `\b[a-z][a-z0-9+.-]{1,15}` ends at `at`; reads at most 17 characters back. */ + private schemeBefore(at: number): boolean { + const text = this.text + for (let start = at - 1; start >= 0 && start >= at - 16; start--) { + const code = text.charCodeAt(start) + const bits = code < 128 ? (this.classes[code] as number) : 0 + if ((bits & SCHEME) === 0) return false + if ((bits & LOWER) !== 0 && at - start >= 2 && !this.isWordAt(start - 1)) return true + } + return false + } + + /** + * The password in `\b[a-z][a-z0-9+.-]{1,15}://[^\s/:@?#]{0,256}:[^\s/@?#]+@` for the `://` at + * `at`; neither part crosses `?` or `#`, where the authority ends. Callers pass a `://` at or after + * the query offset, or one whose user name reaches it, so the password never starts before it. + * @returns the password end with its start in the memo, or -1. + */ + private addressPassword(at: number): number { + if (!this.schemeBefore(at)) return -1 + const text = this.text + const userStart = at + '://'.length + const userEnd = this.memoRunEnd(this.userRun, userStart, USER_SLOT) + if (userEnd - userStart > 256 || text.charCodeAt(userEnd) !== 58) return -1 + const start = userEnd + 1 + const end = this.memoRunEnd(this.passwordRun, start, PASSWORD_SLOT) + if (end === start || text.charCodeAt(end) !== 64) return -1 + this.memo[CANDIDATE_START] = start + return end + } + + /** + * The address password at or after `from` whose `://` starts before `from`, as `addressPassword` + * returns it. The walk back over the user name runs only when a `:`, a password, and `@` follow. + */ + private addressBefore(from: number): number { + const text = this.text + if (from < 3) return -1 + // `from` inside the `://`. + if (text.startsWith('://', from - 1)) return this.addressPassword(from - 1) + if (text.startsWith('://', from - 2)) return this.addressPassword(from - 2) + let index = from - 1 + const before = text.charCodeAt(index) + let passwordStart: number + if (before === 58) { + // The password starts at `from`, right after the user name's `:`. + passwordStart = from + index-- + } else if (this.isUserCode(before)) { + const userEnd = this.memoRunEnd(this.userRun, from, USER_SLOT) + if (text.charCodeAt(userEnd) !== 58) return -1 + passwordStart = userEnd + 1 + } else { + return text.startsWith('://', from - 3) ? this.addressPassword(from - 3) : -1 + } + const passwordEnd = this.memoRunEnd(this.passwordRun, passwordStart, PASSWORD_SLOT) + if (passwordEnd === passwordStart || text.charCodeAt(passwordEnd) !== 64) return -1 + // A user name is at most 256 characters, followed by `:`. + const lowest = Math.max(0, passwordStart - 258) + while (index >= lowest && this.isUserCode(text.charCodeAt(index))) index-- + return index >= 2 && text.startsWith('://', index - 2) ? this.addressPassword(index - 2) : -1 + } + + /** End of the prefixed credential whose literal starts at `at`, or -1. */ + private prefixedEnd(literal: number, at: number): number { + if (this.isWordAt(at - 1)) return -1 + const text = this.text + const start = at + (this.literalLength[literal] as number) + const shape = this.literalShape[literal] as number + const min = this.literalMin[literal] as number + if (shape === SK) { + // `(?=[A-Za-z0-9_-]{0,255}\d)[A-Za-z0-9_-]{20,}`: the memoized next digit rejects a digit-free `sk-` before any run is measured. + const digit = this.nextDigit(start) + if (digit - start > 255) return -1 + const end = this.keyRunEnd(start) + return end - start >= min && digit < end ? end : -1 + } + const slot = this.literalSlot[literal] as number + const end = slot === 0 ? this.keyRunEnd(start) : this.memoRunEnd(this.literalPattern[literal] as RegExp, start, slot) + const length = end - start + if (length < min) return -1 + if (shape === RUN) return end + if (shape === GOOGLE) return start + min + if (shape === BOUNDED) return length <= (this.literalMax[literal] as number) && !this.isWordAt(end) ? end : -1 + // `[A-Za-z0-9_-]{8,2048}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}` after `eyJ`. + if (length > (this.literalMax[literal] as number) || !text.startsWith('.eyJ', end)) return -1 + const payloadStart = end + '.eyJ'.length + const payloadEnd = this.keyRunEnd(payloadStart) + if (payloadEnd - payloadStart < min || text.charCodeAt(payloadEnd) !== 46) return -1 + const signatureEnd = this.keyRunEnd(payloadEnd + 1) + return signatureEnd - (payloadEnd + 1) >= min ? signatureEnd : -1 + } +} diff --git a/packages/client/ui-secret-mask/src/client/format-tables.ts b/packages/client/ui-secret-mask/src/client/format-tables.ts new file mode 100644 index 0000000000..07bf976e82 --- /dev/null +++ b/packages/client/ui-secret-mask/src/client/format-tables.ts @@ -0,0 +1,113 @@ +/** Lookup tables derived from the credential formats, built once on first use. */ +import { + ALNUM, ALNUM_DASH, CREDENTIAL_FORMATS, KEY, LABEL, LOWER, PASSWORD, SCHEME, UPPER_ALNUM, USER, WORD, +} from './credential-formats.ts' + +/** Tables indexed by format position in `CREDENTIAL_FORMATS`, by ASCII code unit, or by a literal's last two code units. */ +export interface FormatTables { + /** Class bits per ASCII code unit. */ + readonly classes: Uint16Array + readonly literals: readonly string[] + readonly literalLength: Uint8Array + readonly literalKind: Uint8Array + readonly literalShape: Uint8Array + readonly literalRun: Uint16Array + readonly literalMin: Int32Array + readonly literalMax: Int32Array + /** + * Length of the longest proper suffix of each literal that begins a literal. The + * scan resumes there, so a key block whose first dash ends `sk-` is still seen. + */ + readonly literalOverlap: Uint8Array + /** Literal-only alternation of every literal, grouped by shared leading characters; no quantifier. */ + readonly prefixSource: string + /** Per key `code[-2] * 128 + code[-1]`: the literal with that ending plus one, or -1 where several share it. */ + readonly ending: Int8Array + /** Literals sharing an ending, by the same key. */ + readonly sharedEndings: readonly (readonly number[] | undefined)[] +} + +let tables: FormatTables | undefined + +/** + * The tables for `CREDENTIAL_FORMATS`; the first call builds them. + * @returns the shared, read-only tables. + */ +export function formatTables(): FormatTables { + tables ??= buildTables() + return tables +} + +function buildTables(): FormatTables { + const formats = CREDENTIAL_FORMATS + const literals = formats.map(f => f.literal) + const { ending, sharedEndings } = endingTables(literals) + return { + classes: charClasses(), + literals, + literalLength: Uint8Array.from(literals, literal => literal.length), + literalKind: Uint8Array.from(formats, f => f.kind), + literalShape: Uint8Array.from(formats, f => f.shape), + literalRun: Uint16Array.from(formats, f => f.run), + literalMin: Int32Array.from(formats, f => f.min), + literalMax: Int32Array.from(formats, f => f.max), + literalOverlap: Uint8Array.from(literals, literal => overlap(literal, literals)), + prefixSource: alternation(literals), + ending, + sharedEndings, + } +} + +function charClasses(): Uint16Array { + const classes = new Uint16Array(128) + for (let code = 0; code < 128; code++) { + const upper = code >= 65 && code <= 90 + const lower = code >= 97 && code <= 122 + const digit = code >= 48 && code <= 57 + const alnum = upper || lower || digit + const authority = !((code >= 9 && code <= 13) || code === 32 || code === 47 || code === 64 || code === 63 || code === 35) + classes[code] = (alnum || code === 95 || code === 45 ? KEY : 0) | (alnum ? ALNUM : 0) | (alnum || code === 95 ? WORD : 0) + | (alnum || code === 45 ? ALNUM_DASH : 0) | (upper || digit ? UPPER_ALNUM : 0) | (upper || digit || code === 32 ? LABEL : 0) + | (lower || digit || code === 43 || code === 46 || code === 45 ? SCHEME : 0) | (lower ? LOWER : 0) + | (authority && code !== 58 ? USER : 0) | (authority ? PASSWORD : 0) + } + return classes +} + +function overlap(literal: string, literals: readonly string[]): number { + for (let size = literal.length - 1; size > 0; size--) { + const suffix = literal.slice(-size) + if (literals.some(other => other.startsWith(suffix))) return size + } + return 0 +} + +function endingTables(literals: readonly string[]): Pick { + const ending = new Int8Array(128 * 128) + const sharedEndings: number[][] = [] + literals.forEach((literal, index) => { + const key = literal.charCodeAt(literal.length - 2) * 128 + literal.charCodeAt(literal.length - 1) + const existing = ending[key] as number + if (existing === 0) { + ending[key] = index + 1 + return + } + if (existing > 0) { + sharedEndings[key] = [existing - 1] + ending[key] = -1 + } + (sharedEndings[key] as number[]).push(index) + }) + return { ending, sharedEndings } +} + +const escape = (literal: string): string => literal.replace(/[.*+?^${}()|[\]\\/]/gu, '\\$&') + +/** Literal alternatives grouped by shared leading characters; groups only factor literals and add no quantifier. */ +function alternation(literals: readonly string[]): string { + const groups = new Map() + for (const literal of literals) groups.set(literal.charAt(0), [...groups.get(literal.charAt(0)) ?? [], literal.slice(1)]) + return [...groups].map(([head, tails]) => tails.length === 1 + ? escape(head + tails.join('')) + : `${escape(head)}(?:${alternation(tails)})`).join('|') +} diff --git a/packages/client/ui-secret-mask/src/client/index.ts b/packages/client/ui-secret-mask/src/client/index.ts new file mode 100644 index 0000000000..0250d4a569 --- /dev/null +++ b/packages/client/ui-secret-mask/src/client/index.ts @@ -0,0 +1,33 @@ +/** + * Credential display masking for sent Web user text: one UserText matcher and + * its keyed `secret` node renderer. Masking changes only the drawn text; the + * message text, copy actions, and model input keep the original characters. + * + * @module @deepseek-ai/dsh-client-ui-secret-mask/client + */ +import type { Context as ClientContext } from '@deepseek-ai/cordis' +// Type-only: pulls the locale plugin's Context merge (ctx.locale). +import type {} from '@deepseek-ai/dsh-client-locale/client' +// Type-only: pulls the SlotRegistry service merge (ctx.slots). +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +// Type-only: pulls the UiConversation service merge (ctx.uiConversation). +import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +import { SecretText } from './SecretText.tsx' +import { createSecretRule } from './secret-rule.ts' +export type {} from './secret-rule.ts' +import { en, NS, zh } from './locales.ts' + +/** Required services: the copy, the node renderer registry, and the UserText rule registry. */ +export const inject = ['locale', 'slots', 'uiConversation'] + +/** + * Register the dictionaries, the credential matcher, and its node renderer. + * @param ctx - client root context. + */ +export function apply(ctx: ClientContext): void { + ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-secret-mask: dictionaries') + ctx.uiConversation.userText.register(createSecretRule()) + ctx.slots.inject('conversation.user.text.node', () => ctx.slots.register({ + name: 'conversation.user.text.node', key: 'secret', priority: 100, locale: NS, + }, SecretText)) +} diff --git a/packages/client/ui-secret-mask/src/client/locales.ts b/packages/client/ui-secret-mask/src/client/locales.ts new file mode 100644 index 0000000000..5a42d5a8e2 --- /dev/null +++ b/packages/client/ui-secret-mask/src/client/locales.ts @@ -0,0 +1,28 @@ +/** `secretMask` dictionaries for the per-credential reveal control. */ + +import type {} from '@deepseek-ai/dsh-client-ui-slots' + +/** Dictionary namespace owned by this plugin. */ +export const NS = 'secretMask' + +/** Simplified Chinese dictionary (the key-set source of truth). */ +export const zh = { + 'secret.reveal': '显示密钥', + 'secret.hide': '隐藏密钥', +} satisfies Record + +/** The secretMask namespace key union. */ +export type SecretMaskKey = keyof typeof zh + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** Accessible names of the control that reveals one masked credential. */ + secretMask: SecretMaskKey + } +} + +/** English dictionary, checked complete against the zh key set. */ +export const en = { + 'secret.reveal': 'Show secret', + 'secret.hide': 'Hide secret', +} satisfies Record diff --git a/packages/client/ui-secret-mask/src/client/secret-rule.ts b/packages/client/ui-secret-mask/src/client/secret-rule.ts new file mode 100644 index 0000000000..ff6ead7eab --- /dev/null +++ b/packages/client/ui-secret-mask/src/client/secret-rule.ts @@ -0,0 +1,36 @@ +/** + * The `secret` UserText rule. A value is recognized by its published format + * alone: provider key and token prefixes, JSON Web Tokens, private key blocks, + * and the password of an address that carries credentials. Free-form passwords + * and `name=value` assignments have no such format and stay plain. The formats + * live in [credential-formats.ts](./credential-formats.ts); scanning lives in + * [credential-scanner.ts](./credential-scanner.ts). + */ +import type { UserTextRule } from '@deepseek-ai/dsh-client-ui-conversation/client' +import { CredentialScanner } from './credential-scanner.ts' +import { formatTables } from './format-tables.ts' + +declare module '@deepseek-ai/dsh-client-ui-conversation/client' { + interface UserTextDataMap { + /** One recognized credential range. */ + secret: { + /** Display form of the original range; line breaks stay in place. */ + readonly masked: string + } + } +} + +/** + * Create the credential matcher. Each instance owns a scanner whose memo holds + * the last queried text until a query for another text. + * @returns the `secret` rule; it reads no Facts, so Facts changes never invalidate its queries. + */ +export function createSecretRule(): UserTextRule<'secret'> { + const scanner = new CredentialScanner(formatTables()) + return { + id: 'secret', + // Same-start ties with other rules draw the credential masked. + order: 0, + match: (text, from) => scanner.match(text, from), + } +} diff --git a/packages/client/ui-secret-mask/src/css-modules.d.ts b/packages/client/ui-secret-mask/src/css-modules.d.ts new file mode 100644 index 0000000000..bc5e482353 --- /dev/null +++ b/packages/client/ui-secret-mask/src/css-modules.d.ts @@ -0,0 +1,6 @@ +declare module '*.module.css' { + const classes: Record + export default classes +} + +declare module '*.css' diff --git a/packages/client/ui-secret-mask/src/index.ts b/packages/client/ui-secret-mask/src/index.ts new file mode 100644 index 0000000000..bb2ed8f04e --- /dev/null +++ b/packages/client/ui-secret-mask/src/index.ts @@ -0,0 +1,9 @@ +/** + * Credential display masking plugin, node half. Pure UI plugin: the empty + * apply exists so the plugin appears in the host cordis.yml / Loader; the + * browser half ships via exports["./client"], discovered through the + * package.json `dsh.client` declaration. + */ + +/** Host plugin body — no host-side behavior for this display plugin. */ +export function apply(): void {} diff --git a/packages/client/ui-secret-mask/tests/browser-plugin.client.spec.ts b/packages/client/ui-secret-mask/tests/browser-plugin.client.spec.ts new file mode 100644 index 0000000000..f6bbe35e62 --- /dev/null +++ b/packages/client/ui-secret-mask/tests/browser-plugin.client.spec.ts @@ -0,0 +1,50 @@ +/** Plugin registration: the credential matcher and its keyed node renderer leave with the plugin fiber. */ +import { Context } from '@deepseek-ai/cordis' +import { describe, expect, it, onTestFinished } from 'vitest' +import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' +import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' +import { TestSessions } from '@deepseek-ai/dsh-client-test-runtime' +import { UiConversation } from '@deepseek-ai/dsh-client-ui-conversation/src/client/conversation/assembly.ts' +import { SecretText } from '../src/client/SecretText.tsx' +import { NS } from '../src/client/locales.ts' +import { apply, inject } from '../src/client/index.ts' +import { apply as nodeApply } from '../src/index.ts' + +function TextSlotDeclaration(_props: PropsRenderSlots<'conversation.user.text.node'>): null { + return null +} + +describe('apply', () => { + it('declares its services and releases the matcher and renderer on disposal', async () => { + expect(inject).toEqual(['locale', 'slots', 'uiConversation']) + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + ctx.provide('locale', new LocaleRuntime(ctx)) + const sessions = new TestSessions(async (action) => { await action() }, ctx) + onTestFinished(() => sessions.disposeScopes()) + ctx.provide('sessions', sessions) + new UiConversation(ctx, sessions) + await ctx.plugin(SlotRegistry).await() + ctx.slots.register({ + name: 'root', + children: { 'conversation.user.text.node': { kind: 'keyed', scope: 'session' } }, + }, TextSlotDeclaration) + + const fiber = ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + expect(ctx.uiConversation.userText.entries().map(rule => rule.id)).toEqual(['secret']) + expect(ctx.slots.entries('conversation.user.text.node')).toMatchObject([ + { options: { key: 'secret' }, component: SecretText }, + ]) + expect(ctx.locale.bind(NS)('secret.reveal')).toBe('Show secret') + + await fiber.dispose() + expect(ctx.uiConversation.userText.entries()).toHaveLength(0) + expect(ctx.slots.entries('conversation.user.text.node')).toHaveLength(0) + }) + + it('the node half applies without host-side behavior', () => { + expect(() => { nodeApply() }).not.toThrow() + }) +}) diff --git a/packages/client/ui-secret-mask/tests/regex-oracle.client.ts b/packages/client/ui-secret-mask/tests/regex-oracle.client.ts new file mode 100644 index 0000000000..8a68bc6e0b --- /dev/null +++ b/packages/client/ui-secret-mask/tests/regex-oracle.client.ts @@ -0,0 +1,119 @@ +/** + * The backtracking regular-expression formats this package replaced, kept as + * an equivalence oracle for the prefix-pruned matcher. Not product code. It + * joins every overlapping match, including later matches of the same format. + */ +import type { UserTextRule } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type {} from '../src/client/secret-rule.ts' + +const MASK = '*' +/** Leading characters of a prefixed key left readable: the provider prefix and little else. */ +const VISIBLE_PREFIX = 8 + +const maskRun = (value: string): string => value.replace(/[^\r\n]/gu, MASK) +const maskAfterPrefix = (value: string): string => + value.slice(0, VISIBLE_PREFIX) + maskRun(value.slice(VISIBLE_PREFIX)) +/** + * Keeps the `BEGIN` line, the `END` line when present, and every line break. + * A block written on one line has no line to keep and is masked whole. + */ +const maskKeyBlock = (value: string): string => { + const bodyStart = value.indexOf('\n') + 1 + const end = value.lastIndexOf('\n-----END') + const bodyEnd = end < bodyStart ? value.length : end + return value.slice(0, bodyStart) + maskRun(value.slice(bodyStart, bodyEnd)) + value.slice(bodyEnd) +} + +interface Format { + /** Global pattern whose whole match is the credential. */ + readonly pattern: RegExp + readonly mask: (value: string) => string +} + +/** + * Credentials whose published format starts with a fixed prefix. Every pattern + * matches more than `VISIBLE_PREFIX` characters, which `maskAfterPrefix` relies on. + */ +const PREFIXED: readonly RegExp[] = [ + // Model services: `sk-` keys carry at least one digit, which hyphenated identifiers rarely do. + // The bounded lookahead keeps a long run of digit-free `sk-` repeats linear. + /\bsk-(?=[A-Za-z0-9_-]{0,255}\d)[A-Za-z0-9_-]{20,}/gu, + /\bAIza[A-Za-z0-9_-]{35}/gu, + // Code hosting and package registries. + /\bgh[pousr]_[A-Za-z0-9]{36,}/gu, + /\bgithub_pat_[A-Za-z0-9_]{22,}/gu, + /\bglpat-[A-Za-z0-9_-]{20,}/gu, + /\bnpm_[A-Za-z0-9]{36,}/gu, + /\bhf_[A-Za-z0-9]{30,}/gu, + // Cloud access key IDs. + /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/gu, + /\bLTAI[A-Za-z0-9]{12,20}\b/gu, + /\bAKID[A-Za-z0-9]{28,36}\b/gu, + // Collaboration and payment services. + /\bxox[abprs]-[A-Za-z0-9-]{10,}/gu, + /\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{16,}/gu, + /\bwhsec_[A-Za-z0-9]{24,}/gu, + // JSON Web Tokens. The header bound keeps a long run of `eyJ` repeats linear. + /\beyJ[A-Za-z0-9_-]{8,2048}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/gu, +] + +/** Same-start matches resolve in this order, so a token used as an address password is masked completely. */ +const FORMATS: readonly Format[] = [ + { + pattern: /-----BEGIN [A-Z0-9 ]{0,40}PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z0-9 ]{0,40}PRIVATE KEY-----|$)/gu, + mask: maskKeyBlock, + }, + // The lookbehind starts the match at the password, so a query from inside the address finds the same candidate. + // The user name may be empty, and neither part crosses `?` or `#`, where the authority ends. + { pattern: /(?<=\b[a-z][a-z0-9+.-]{1,15}:\/\/[^\s/:@?#]{0,256}:)[^\s/@?#]+(?=@)/gu, mask: maskRun }, + ...PREFIXED.map(pattern => ({ pattern, mask: maskAfterPrefix })), +] + +interface Hit { + readonly start: number + readonly end: number +} + +function find(regex: RegExp, text: string, from: number): Hit | undefined { + regex.lastIndex = from + const match = regex.exec(text) + return match === null ? undefined : { start: match.index, end: match.index + match[0].length } +} + +/** + * Return the nearest credential at or after `from`. Overlapping matches of any + * format join the earliest one and take its mask, so no credential is drawn half masked. + * Reads no Facts, so Facts changes never invalidate its queries. + */ +export const regexSecretRule: UserTextRule<'secret'> = { + id: 'secret', + order: 0, + match(text, from) { + const scans = FORMATS.map((format) => { + const regex = new RegExp(format.pattern) + return { format, regex, hit: find(regex, text, from) } + }) + let first: { readonly format: Format; readonly hit: Hit } | undefined + for (const { format, hit } of scans) { + // The strict comparison keeps the earlier format at an equal start. + if (hit !== undefined && (first === undefined || hit.start < first.hit.start)) first = { format, hit } + } + if (first === undefined) return undefined + const { start } = first.hit + let end = first.hit.end + for (let grown = true; grown;) { + grown = false + for (const scan of scans) { + while (scan.hit !== undefined && scan.hit.start < end) { + if (scan.hit.end > end) { + end = scan.hit.end + grown = true + } + // Overlapping matches of one format join as well, so a key that starts inside another is masked whole. + scan.hit = find(scan.regex, text, scan.hit.start + 1) + } + } + } + return { id: 'secret', start, end, data: { masked: first.format.mask(text.slice(start, end)) } } + }, +} diff --git a/packages/client/ui-secret-mask/tests/secret-rule-equivalence.client.spec.ts b/packages/client/ui-secret-mask/tests/secret-rule-equivalence.client.spec.ts new file mode 100644 index 0000000000..f3e51ab6ca --- /dev/null +++ b/packages/client/ui-secret-mask/tests/secret-rule-equivalence.client.spec.ts @@ -0,0 +1,102 @@ +/** + * The prefix-pruned matcher produces the same nodes as the regular-expression + * matcher it replaced, through the production evaluator, on fixed and generated inputs. + */ +import { Context } from '@deepseek-ai/cordis' +import { describe, expect, it, onTestFinished } from 'vitest' +import type { UserTextRule, UserTextTransformContext } from '@deepseek-ai/dsh-client-ui-conversation/client' +import { UserTextRegistry } from '@deepseek-ai/dsh-client-ui-conversation/src/client/conversation/user-text-registry.ts' +import { collapseWhitespaceRule, trimWhitespaceRule } from '@deepseek-ai/dsh-client-ui-conversation/src/client/user-text/whitespace-rules.ts' +import { createSecretRule } from '../src/client/secret-rule.ts' +import { regexSecretRule } from './regex-oracle.client.ts' + +function evaluator(rule: UserTextRule) { + const ctx = new Context() + onTestFinished(async () => { await ctx.fiber.dispose() }) + const registry = new UserTextRegistry(ctx) + for (const each of [rule, collapseWhitespaceRule, trimWhitespaceRule]) registry.register(each) + return registry.createEvaluator() +} + +const join = (...parts: string[]): string => parts.join('') +/** Fragments that begin, continue, break, or end each recognized format. */ +const FRAGMENTS = [ + join('s', 'k-'), join('s', 'k_live_'), join('r', 'k_test_'), join('AI', 'za'), join('gh', 'p_'), join('gh', 's_'), + join('github', '_pat_'), join('gl', 'pat-'), join('np', 'm_'), join('h', 'f_'), join('AK', 'IA'), join('AS', 'IA'), + join('LT', 'AI'), join('AK', 'ID'), join('xo', 'xb-'), join('wh', 'sec_'), join('ey', 'J'), '.', join('.ey', 'J'), + join('-----BEGIN', ' '), join('-----END', ' '), ' PRIVATE KEY-----', 'PRIVATE KEY-----', 'RSA ', + '://', 'https', 'postgres', ':', '@', '/', '?', '#', ' ', '\n', '\r\n', '\t', '-', '_', '+', + 'a', 'Z', '0', '7', 'abcdefgh', 'ABCDEFGH', '01234567', 'a1B2c3D4e5F6g7H8i9J0', '用', ':', '🙂', +] as const + +/** Deterministic generator so every run checks the same inputs. */ +function generator(seed: number): () => number { + let state = seed + return () => { + state = (state + 0x6d2b79f5) | 0 + let mixed = Math.imul(state ^ (state >>> 15), 1 | state) + mixed = (mixed + Math.imul(mixed ^ (mixed >>> 7), 61 | mixed)) ^ mixed + return ((mixed ^ (mixed >>> 14)) >>> 0) / 4_294_967_296 + } +} + +function generated(count: number, seed: number, maxFragments = 60): string[] { + const random = generator(seed) + return Array.from({ length: count }, () => { + const length = 1 + Math.floor(random() * maxFragments) + return Array.from({ length }, () => { + const fragment = FRAGMENTS[Math.floor(random() * FRAGMENTS.length)]! + // Repeat short fragments so length thresholds are crossed on both sides. + return fragment.length <= 2 ? fragment.repeat(1 + Math.floor(random() * 40)) : fragment + }).join('') + }) +} + +const fill = (unit: string, characters: number): string => unit.repeat(Math.ceil(characters / unit.length)).slice(0, characters) +const FIXED = [ + '', + join('用 ', 'sk', '-', '0123456789abcdef0123456789abcdef', ' 试试'), + join('postgres://admin:', 'p4ss-W0rd', '@db.internal:5432/app'), + join('git clone https://x-access-token:', 'gh', 'p_', fill('a1B2c3', 36), '@github.com/o/r.git'), + join('redis://:p4ss@cache https://example.com:8080/a@b https://example.com:8443?e=a@b'), + join('https://', 'sk', '-', '0123456789abcdef0123456789abcdef', ':p4ss-W0rd@api.example.com/v1'), + [join('-----BEGIN', ' OPENSSH PRIVATE KEY-----'), 'b3BlbnNzaC1rZXk', join('-----END', ' OPENSSH PRIVATE KEY-----')].join('\n'), + [join('-----BEGIN', ' PRIVATE KEY-----'), 'MIIEvQIBADANBgkq', join('-----END', ' PRIVATE KEY-----')].join('\r\n'), + join('-----BEGIN', ' PRIVATE KEY-----', 'MIIE', '-----END', ' PRIVATE KEY-----'), + join('ey', 'J', fill('a1B2', 17), '.', 'ey', 'J', fill('c3D4', 30), '.', fill('e5F6', 43)), + join('xo', 'xb-1234567890-', 'sk', '-abc_', fill('a1B2', 20)), + join('AK', 'IA', 'ABCDEFGHIJ234567 ', 'AK', 'IA', 'ABCDEFGHIJ2345678 ', 'LT', 'AI', fill('a1', 20), ' ', 'AK', 'ID', fill('a1', 40)), + join('x', 's', 'k-----BEGIN', ' RSA PRIVATE KEY-----\nabc\n'), + join('gl', 'pat-----BEGIN', ' RSA PRIVATE KEY-----\nabc\n-----END', ' RSA PRIVATE KEY-----'), + join('LT', 'AIza', 'a1B2c3D4e5F6 '), + fill(join('-', 's', 'k-', 'gh', 'p_', 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJ'), 2_000), + fill(join('s', 'k-'), 2_000), + fill(join('ey', 'Jabcdefgh-'), 2_000), + fill(join('-----BEGIN', ' RSA PRIVATE KEY-----\nabc\n'), 2_000), + fill('a://u:', 2_000), + fill(`abcdefghijklmnop://${'u'.repeat(250)}:`, 2_000), + fill(join('gh', 'p_', fill('a1B2c3', 35), ' '), 2_000), +] + +const context: UserTextTransformContext = { read: () => undefined } + +describe('prefix-pruned credential matching', () => { + it.each([['fixed', FIXED], ['generated', generated(5_000, 5824)]] as const)('matches the regular-expression nodes on %s inputs', (_name, texts) => { + const pruned = evaluator(createSecretRule()) + const oracle = evaluator(regexSecretRule) + for (const text of texts) { + for (const facts of [{}, { collapseWhitespace: true, trimWhitespace: true }]) { + expect(pruned({ text, facts }), JSON.stringify(text)).toEqual(oracle({ text, facts })) + } + } + }) + + it('matches the regular-expression candidate from every query offset', () => { + const rule = createSecretRule() + for (const text of [...FIXED.filter(text => text.length <= 600), ...generated(400, 77, 25)]) { + for (let from = 0; from <= text.length; from++) { + expect(rule.match(text, from, context), `${JSON.stringify(text)} from ${String(from)}`).toEqual(regexSecretRule.match(text, from, context)) + } + } + }) +}) diff --git a/packages/client/ui-secret-mask/tests/secret-rule.client.spec.ts b/packages/client/ui-secret-mask/tests/secret-rule.client.spec.ts new file mode 100644 index 0000000000..8bc69500bb --- /dev/null +++ b/packages/client/ui-secret-mask/tests/secret-rule.client.spec.ts @@ -0,0 +1,245 @@ +/** + * Credential recognition, masking forms, and forward-query behavior through the + * production UserText evaluator. + */ +import { Context } from '@deepseek-ai/cordis' +import { describe, expect, it, onTestFinished, vi } from 'vitest' +import type { UserTextNode, UserTextRule, UserTextTransformContext } from '@deepseek-ai/dsh-client-ui-conversation/client' +import { UserTextRegistry } from '@deepseek-ai/dsh-client-ui-conversation/src/client/conversation/user-text-registry.ts' +import { collapseWhitespaceRule, trimWhitespaceRule } from '@deepseek-ai/dsh-client-ui-conversation/src/client/user-text/whitespace-rules.ts' +import { formatTables } from '../src/client/format-tables.ts' +import { createSecretRule } from '../src/client/secret-rule.ts' + +// Samples are assembled at run time so the repository holds no literal in a credential format. +const hex = (length: number): string => '0123456789abcdef'.repeat(8).slice(0, length) +const mixed = (length: number): string => 'a1B2c3D4e5F6g7H8i9J0'.repeat(8).slice(0, length) +const stars = (length: number): string => '*'.repeat(length) +const join = (...parts: string[]): string => parts.join('') + +function setup(rules: readonly UserTextRule[] = [createSecretRule()]) { + const ctx = new Context() + onTestFinished(async () => { await ctx.fiber.dispose() }) + const registry = new UserTextRegistry(ctx) + for (const rule of rules) registry.register(rule) + return registry.createEvaluator() +} + +const context: UserTextTransformContext = { read: () => undefined } +const isSecret = (node: UserTextNode): node is UserTextNode<'secret'> => node.id === 'secret' +const secrets = (text: string): UserTextNode<'secret'>[] => setup()({ text, facts: {} }).filter(isSecret) +const found = (text: string): string[] => secrets(text).map(node => text.slice(node.start, node.end)) +const masks = (text: string): string[] => secrets(text).map(node => node.data.masked) + +describe('secretRule recognition', () => { + it.each([ + ['a model-service key', join('sk', '-', hex(32))], + ['a project-scoped model-service key', join('sk', '-proj-', mixed(48))], + ['a Google API key', join('AI', 'za', mixed(35))], + ['a GitHub classic token', join('gh', 'p_', mixed(36))], + ['a GitHub fine-grained token', join('github', '_pat_', mixed(22), '_', mixed(59))], + ['a GitLab token', join('gl', 'pat-', mixed(20))], + ['an npm token', join('np', 'm_', mixed(36))], + ['a Hugging Face token', join('h', 'f_', mixed(34))], + ['an AWS access key ID', join('AK', 'IA', 'ABCDEFGHIJ234567')], + ['a temporary AWS access key ID', join('AS', 'IA', 'ABCDEFGHIJ234567')], + ['an Alibaba Cloud AccessKey ID', join('LT', 'AI', mixed(20))], + ['a Tencent Cloud SecretId', join('AK', 'ID', mixed(32))], + ['a Slack token', join('xo', 'xb-', '1234567890-', mixed(24))], + ['a Stripe secret key', join('sk', '_live_', mixed(24))], + ['a Stripe restricted key', join('rk', '_test_', mixed(24))], + ['a Stripe webhook secret', join('wh', 'sec_', mixed(32))], + ['a JSON Web Token', join('ey', 'J', mixed(17), '.', 'ey', 'J', mixed(30), '.', mixed(43))], + ])('recognizes %s inside a sentence', (_name, sample) => { + expect(found(`用 ${sample} 试试`)).toEqual([sample]) + }) + + it.each([ + ['ordinary prose', '把密码改一下,token 用量也看看,password=hunter2'], + ['a hyphenated identifier without digits', 'sk-button-primary-large-rounded'], + ['a key prefix that is too short', 'sk-12345'], + ['a key prefix inside a longer word', join('task', '-', hex(32))], + ['a commit hash', hex(40)], + ['a UUID', '123e4567-e89b-12d3-a456-426614174000'], + ])('leaves %s alone', (_name, text) => { + expect(found(text)).toEqual([]) + }) + + it('leaves addresses without a password alone', () => { + expect(found('https://example.com:8080/a@b https://user@example.com/x git@github.com:o/r.git')).toEqual([]) + expect(found('https://example.com:8443?email=alice@example.org https://example.com:8443#alice@example.org')).toEqual([]) + }) +}) + +describe('secretRule masking', () => { + it('keeps the first eight characters of a key and masks every other character', () => { + const key = join('sk', '-', hex(32)) + expect(masks(`${key}用这个`)).toEqual([key.slice(0, 8) + stars(27)]) + }) + + it('masks only the password of an address that carries credentials', () => { + const text = join('postgres://admin:', 'p4ss-W0rd', '@db.internal:5432/app') + expect(found(text)).toEqual(['p4ss-W0rd']) + expect(masks(text)).toEqual([stars(9)]) + }) + + it('masks a key used as an address user name and the password after it separately', () => { + const key = join('sk', '-', hex(32)) + const text = `https://${key}:p4ss-W0rd@api.example.com/v1` + expect(found(text)).toEqual([key, 'p4ss-W0rd']) + expect(masks(text)).toEqual([key.slice(0, 8) + stars(27), stars(9)]) + }) + + it('masks one character per code point, so an emoji or a lone surrogate in a password is one mark', () => { + expect(masks('postgres://u:p\u{1F642}\ud83dx@db')).toEqual([stars(4)]) + }) + + it('ends a key in an address user name before the password, which a later query finds', () => { + const key = join('sk', '-', hex(32)) + const token = join('gh', 'p_', mixed(36)) + const text = `https://${key}:p4ss-W0rd@api.example.com ${token}` + expect(found(text)).toEqual([key, 'p4ss-W0rd', token]) + }) + + it('masks an address password that has no user name', () => { + expect(found('redis://:p4ss-W0rd@cache:6379/0')).toEqual(['p4ss-W0rd']) + }) + + it('masks a token used as an address password completely', () => { + const token = join('gh', 'p_', mixed(36)) + const text = `git clone https://x-access-token:${token}@github.com/o/r.git` + expect(found(text)).toEqual([token]) + expect(masks(text)).toEqual([stars(token.length)]) + }) + + it('joins an overlapping later match to the earliest one and keeps its mask', () => { + const slack = join('xo', 'xb-', '1234567890-') + const key = join('sk', '-abc_', mixed(20)) + const text = `${slack}${key} 完` + expect(found(text)).toEqual([slack + key]) + expect(masks(text)).toEqual([(slack + key).slice(0, 8) + stars(slack.length + key.length - 8)]) + }) + + it('masks the body of a private key block and keeps its first and last lines', () => { + const begin = join('-----BEGIN', ' OPENSSH PRIVATE KEY-----') + const end = join('-----END', ' OPENSSH PRIVATE KEY-----') + const block = [begin, 'b3BlbnNzaC1rZXk', 'AAAAB3NzaC1yc2E=', end].join('\n') + const text = `私钥在这\n${block}\n帮我配一下` + expect(found(text)).toEqual([block]) + expect(masks(text)).toEqual([[begin, stars(15), stars(16), end].join('\n')]) + }) + + it('keeps carriage returns of a private key block with CRLF line endings', () => { + const begin = join('-----BEGIN', ' PRIVATE KEY-----') + const end = join('-----END', ' PRIVATE KEY-----') + const text = [begin, 'MIIEvQIBADANBgkq', end].join('\r\n') + expect(masks(text)).toEqual([[begin, stars(16), end].join('\r\n')]) + }) + + it('masks a private key block that is cut off before its last line', () => { + const begin = join('-----BEGIN', ' RSA PRIVATE KEY-----') + const text = `${begin}\nMIIEowIBAAKCAQEA` + expect(found(text)).toEqual([text]) + expect(masks(text)).toEqual([`${begin}\n${stars(16)}`]) + }) + + it.each([join('s', 'k-'), join('gl', 'pat-'), join('xo', 'xb-')])('finds a private key block whose first dash ends the key prefix %s', (prefix) => { + const block = [join('-----BEGIN', ' RSA PRIVATE KEY-----'), 'MIIEowIBAAKCAQEA', join('-----END', ' RSA PRIVATE KEY-----')].join('\n') + const text = `x${prefix.slice(0, -1)}${block}` + expect(found(text)).toEqual([block]) + }) + + it('masks a key longer than four thousand characters one mark per character', () => { + const key = join('sk', '-', hex(16), 'a'.repeat(5_000)) + expect(masks(`${key} 完`)).toEqual([key.slice(0, 8) + stars(key.length - 8)]) + }) + + it('joins a private key block that starts inside a key and keeps the line breaks of both', () => { + const key = join('sk', '-', hex(20)) + const block = [join('-----BEGIN', ' RSA PRIVATE KEY-----'), 'MIIE', join('-----END', ' RSA PRIVATE KEY-----')].join('\r\n') + const text = `${key}${block}` + expect(found(text)).toEqual([text]) + const tail = text.slice(8).split('\r\n').map(line => stars(line.length)).join('\r\n') + expect(masks(text)).toEqual([text.slice(0, 8) + tail]) + }) + + it('masks a private key body one mark per code point, including lone and cut surrogates', () => { + const begin = join('-----BEGIN', ' PRIVATE KEY-----') + const end = join('-----END', ' PRIVATE KEY-----') + const text = [begin, 'a\u{1F642}b\udc00c\ud83dx\ud83d', end].join('\n') + expect(masks(text)).toEqual([[begin, stars(8), end].join('\n')]) + }) + + it('masks private key body lines longer than four thousand characters, before either line break', () => { + const begin = join('-----BEGIN', ' PRIVATE KEY-----') + const end = join('-----END', ' PRIVATE KEY-----') + const text = `${begin}\n${'A'.repeat(5_000)}\r\n${'B'.repeat(5_000)}\nC\n${end}` + expect(masks(text)).toEqual([`${begin}\n${stars(5_000)}\r\n${stars(5_000)}\n${stars(1)}\n${end}`]) + }) + + it('masks an address inside a private key block as part of the block', () => { + const begin = join('-----BEGIN', ' PRIVATE KEY-----') + const end = join('-----END', ' PRIVATE KEY-----') + const key = join('sk', '-', hex(32)) + const block = [begin, 'https://u:p4ss@host', key, end].join('\n') + expect(found(`${block}\n用这个`)).toEqual([block]) + }) + + it('masks each of many one-line private key blocks whole, with text after the last', () => { + const block = join('-----BEGIN', ' PRIVATE KEY-----', 'abcd', '-----END', ' PRIVATE KEY-----') + expect(masks(`${block} ${block} 完`)).toEqual([stars(block.length), stars(block.length)]) + }) + + it('masks a private key block written on one line whole', () => { + const text = join('-----BEGIN', ' PRIVATE KEY-----', 'MIIEvQIBADANBgkq', '-----END', ' PRIVATE KEY-----') + expect(masks(text)).toEqual([stars(text.length)]) + }) +}) + +describe('secretRule scheduling', () => { + it('finds format starts through grouped literals only, so scanning cannot backtrack', () => { + const literals = formatTables().prefixSource.replace(/\\./gu, '').replaceAll('(?:', '').replaceAll(')', '') + expect(literals.split('|').length).toBeGreaterThan(20) + expect(literals).not.toMatch(/[*+?{}()[\].^$]/u) + }) + + it('reads no Facts, queries forward once per credential, and reuses the result across Facts updates', () => { + const read = vi.fn() + const rule = createSecretRule() + const match = vi.fn['match']>((text, from, context) => rule.match(text, from, { + read: (key) => { read(key); return context.read(key) }, + })) + const evaluate = setup([{ ...rule, match }]) + const first = join('gh', 'p_', mixed(36)) + const second = join('sk', '-', hex(32)) + const text = `${first} 和 ${second}` + const before = evaluate({ text, facts: {} }) + expect(before.filter(isSecret).map(node => text.slice(node.start, node.end))).toEqual([first, second]) + expect(before.map(node => [node.start, node.end])).toEqual([ + [0, first.length], [first.length, first.length + 3], [first.length + 3, text.length], + ]) + expect(match.mock.calls.map(([, from]) => from)).toEqual([0, first.length, text.length]) + expect(evaluate({ text, facts: { collapseWhitespace: true } })).toBe(before) + expect(match).toHaveBeenCalledTimes(3) + expect(read).not.toHaveBeenCalled() + }) + + it.each(['postgres://admin:p4ss@db', 'redis://:p4ss@cache'])('finds the same address password in %s from any cursor before it', (text) => { + const start = text.indexOf('p4ss') + for (let from = 0; from <= start; from++) { + // A fresh instance per cursor: no query earlier in the text warms its memo. + expect(createSecretRule().match(text, from, context), `from ${String(from)}`).toEqual({ id: 'secret', start, end: start + 4, data: { masked: stars(4) } }) + } + expect(createSecretRule().match(text, start + 1, context)).toBeUndefined() + }) + + it('keeps line breaks of a private key block when queue whitespace transforms run beside it', () => { + const begin = join('-----BEGIN', ' PRIVATE KEY-----') + const end = join('-----END', ' PRIVATE KEY-----') + const block = [begin, 'MIIEvQIBADANBgkq', end].join('\n') + const evaluate = setup([createSecretRule(), collapseWhitespaceRule, trimWhitespaceRule]) + const nodes = evaluate({ text: ` 私钥:\n${block}\n `, facts: { collapseWhitespace: true, trimWhitespace: true } }) + const display = (node: UserTextNode): string => + isSecret(node) ? node.data.masked : node.id === 'text' ? node.data.display ?? node.data.text : '' + expect(nodes.map(display).join('')).toBe(`私钥: ${[begin, stars(16), end].join('\n')}`) + }) +}) diff --git a/packages/client/ui-secret-mask/tests/secret-text.client.spec.tsx b/packages/client/ui-secret-mask/tests/secret-text.client.spec.tsx new file mode 100644 index 0000000000..c075d9de30 --- /dev/null +++ b/packages/client/ui-secret-mask/tests/secret-text.client.spec.tsx @@ -0,0 +1,59 @@ +// @vitest-environment jsdom +/** The masked credential renderer: reveal control, accessible names, and reset to masked. */ +import { cleanup, fireEvent, render } from '@testing-library/react' +import type { ComponentProps } from 'react' +import { afterEach, describe, expect, it } from 'vitest' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { SecretText } from '../src/client/SecretText.tsx' +import { en, zh } from '../src/client/locales.ts' + +afterEach(cleanup) + +type Props = ComponentProps + +// Assembled at run time so the repository holds no literal in a credential format. +const KEY = ['sk', '-', '0123456789abcdef0123456789abcdef'].join('') +const OTHER = ['sk', '-', 'fedcba9876543210fedcba9876543210'].join('') +const masked = (value: string): string => value.slice(0, 8) + '*'.repeat(value.length - 8) + +function props(raw: string, interactive: boolean, dictionary: Record = zh): Props { + // The component reads only these owner and locale seats. + return { + node: { id: 'secret', start: 0, end: raw.length, data: { masked: masked(raw) } }, + raw, interactive, t: makeTranslate(dictionary), + } as Props +} + +describe('SecretText', () => { + it('draws only the masked form without a control in non-interactive text', () => { + const view = render(
) + const run = view.container.querySelector('[data-secret]')! + expect(run.textContent).toBe(masked(KEY)) + expect(view.container.querySelector('button')).toBeNull() + }) + + it('reveals and hides the value from its own control', () => { + const view = render() + const run = view.container.querySelector('[data-secret]')! + const reveal = view.getByRole('button', { name: '显示密钥' }) + expect(run.textContent).toBe(masked(KEY)) + expect(reveal.getAttribute('aria-pressed')).toBe('false') + expect(reveal.querySelector('svg')).not.toBeNull() + + fireEvent.click(reveal) + expect(run.textContent).toBe(KEY) + const hide = view.getByRole('button', { name: '隐藏密钥' }) + expect(hide.getAttribute('aria-pressed')).toBe('true') + + fireEvent.click(hide) + expect(run.textContent).toBe(masked(KEY)) + }) + + it('starts masked again when a different credential reuses the same element', () => { + const view = render() + fireEvent.click(view.getByRole('button', { name: 'Show secret' })) + view.rerender() + expect(view.container.querySelector('[data-secret]')!.textContent).toBe(masked(OTHER)) + expect(view.getByRole('button', { name: 'Show secret' }).getAttribute('aria-pressed')).toBe('false') + }) +}) diff --git a/packages/client/ui-secret-mask/tsconfig.json b/packages/client/ui-secret-mask/tsconfig.json new file mode 100644 index 0000000000..a783ae90ea --- /dev/null +++ b/packages/client/ui-secret-mask/tsconfig.json @@ -0,0 +1,27 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../locale/tsconfig.client.json" + }, + { + "path": "../ui-conversation/tsconfig.client.json" + }, + { + "path": "../ui-renderer" + }, + { + "path": "../ui-slots" + } + ] +} diff --git a/packages/client/ui-secret-mask/tsdown.config.ts b/packages/client/ui-secret-mask/tsdown.config.ts new file mode 100644 index 0000000000..7aa03cebc1 --- /dev/null +++ b/packages/client/ui-secret-mask/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-session/README.i18n.yaml b/packages/client/ui-session/README.i18n.yaml index 02db9d6ecb..447f8f489b 100644 --- a/packages/client/ui-session/README.i18n.yaml +++ b/packages/client/ui-session/README.i18n.yaml @@ -12,8 +12,11 @@ en: 75428e0474a073a4 zh: 18f1db4b4c5302e5 /deepseek-ai-dsh-client-ui-session/table-of-contents: - en: b4683d2a53256fda - zh: 4e65ff63dc8c48fe + en: 12e1ffc25a2d6695 + zh: 321156dea1356e55 +/deepseek-ai-dsh-client-ui-session/access-hooks: + en: 619ba442c3e40fb0 + zh: af0206a774ac9bc1 /deepseek-ai-dsh-client-ui-session/model-experience: en: cb2d4451e8bf8520 zh: 6d3adbb930d90c59 diff --git a/packages/client/ui-session/README.md b/packages/client/ui-session/README.md index d10b62ffbc..79ac9f0e73 100644 --- a/packages/client/ui-session/README.md +++ b/packages/client/ui-session/README.md @@ -14,12 +14,18 @@ Running status comes from Host list baselines or status events. Subagent catalog ## Table of Contents +- [Access Hooks](#access-hooks) - [Model Experience](#model-experience) - [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) - [Dev Note](#dev-note) ----- + +## Access Hooks + +The package injects the Client `remoteAccess` service and publishes Session access Hooks over its `source()`: the root `useSessionAccess(sessionId, action)` answers on one Session resource, and the Session-scope `useAccess(action)` answers on the bound Session's resource; the `global` resource's answer is a different Hook, `useRemoteAccess(action)`, so neither shadows the other. The SessionMaybe-scope `useAccess(action)` returns `undefined` while no Session is bound, which callers treat as not allowed; a decision on the `global` resource reads `useRemoteAccess` instead. Snapshot semantics belong to [`dsh-api-remote-access`](../../api/remote-access/README.md#client-face). + ## Model Experience diff --git a/packages/client/ui-session/README.zh.md b/packages/client/ui-session/README.zh.md index 95944f55f3..157cc79a7f 100644 --- a/packages/client/ui-session/README.zh.md +++ b/packages/client/ui-session/README.zh.md @@ -14,12 +14,18 @@ kind: "package-reference" ## 目录 +- [访问 Hook](#access-hooks) - [模型体验](#model-experience) - [已知限制与暂缓事项](#known-limitations-and-deferred-work) - [开发备注](#dev-note) ----- + +## 访问 Hook + +本包注入 Client `remoteAccess` 服务,并在其 `source()` 之上发布会话访问 Hook:根作用域的 `useSessionAccess(sessionId, action)` 回答某一个 Session 资源上的访问,Session 作用域的 `useAccess(action)` 回答所绑定 Session 资源上的访问;`global` 资源上的答案是另一个 Hook `useRemoteAccess(action)`,两者互不遮蔽。SessionMaybe 作用域的 `useAccess(action)` 在未绑定 Session 时返回 `undefined`,调用方将其视为不允许;需要 `global` 资源上的判断时读 `useRemoteAccess`。快照语义归 [`dsh-api-remote-access`](../../api/remote-access/README.zh.md#client-face)。 + ## 模型体验 diff --git a/packages/client/ui-session/package.json b/packages/client/ui-session/package.json index a55f45f712..1518437c49 100644 --- a/packages/client/ui-session/package.json +++ b/packages/client/ui-session/package.json @@ -29,6 +29,7 @@ "dsh": { "client": { "inject": [ + "@deepseek-ai/dsh-api-remote-access", "@deepseek-ai/dsh-api-remotes", "@deepseek-ai/dsh-api-session-controller", "@deepseek-ai/dsh-client-ui-renderer" @@ -46,6 +47,7 @@ }, "devDependencies": { "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-api-remote-access": "workspace:*", "@deepseek-ai/dsh-api-remotes": "workspace:*", "@deepseek-ai/dsh-api-session-controller": "workspace:*", "@deepseek-ai/dsh-client-store": "workspace:*", @@ -55,7 +57,6 @@ "@deepseek-ai/dsh-remote-mock": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-util-values": "workspace:*", - "@types/react": "~18.3.1", "react": "^18.2.0" }, "files": [ diff --git a/packages/client/ui-session/src/client/index.ts b/packages/client/ui-session/src/client/index.ts index 5017904d86..29aeebce04 100644 --- a/packages/client/ui-session/src/client/index.ts +++ b/packages/client/ui-session/src/client/index.ts @@ -12,6 +12,7 @@ import type { } from '@deepseek-ai/dsh-api-session-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type {} from '@deepseek-ai/dsh-api-remotes/client' +import type { RemoteEndpoint } from '@deepseek-ai/dsh-api-remote-access/client' import { notifySubscribers } from '@deepseek-ai/dsh-client-store' import { WeakMapWithValues } from '@deepseek-ai/dsh-util-values' import { standardHookPropName } from '@deepseek-ai/dsh-client-ui-slots' @@ -158,6 +159,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { useSessions: UseSessions useSessionStatus: UseSessionStatus useSessionRetainInfo: UseSessionRetainInfo + /** Whether the current Peer may call `action` on one Session resource. */ + useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean } interface SessionStandardProps { @@ -167,6 +170,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { sessionId: SessionId /** Host-computed projection values addressed by projection key. */ useProjection: UseProjection + /** Whether the current Peer may call `action` on this Session resource. */ + useAccess: (action: RemoteEndpoint) => boolean } interface SessionMaybeStandardProps { @@ -176,6 +181,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { sessionId: SessionId | undefined /** Host-computed projection values; every key is absent without a Session. */ useProjection: UseProjection + /** The bound Session's access answer; `undefined` without a Session, which callers treat as not allowed. */ + useAccess: (action: RemoteEndpoint) => boolean | undefined } } @@ -660,8 +667,8 @@ function claimStandardProp(kind: StandardMemberKind, name: string, finalProps: S finalProps.add(propName) } -/** Required Controller and renderer services. */ -export const inject = ['sessions', 'slots', 'remote'] +/** Required Controller, renderer, Remote, and access services. */ +export const inject = ['sessions', 'slots', 'remote', 'remoteAccess'] /** * Install the Session root source and scoped adapter. @@ -676,9 +683,18 @@ export function apply(ctx: Context): void { }, keyedHooks: { sessionRetainInfo: key => ctx.sessions.retainInfo(key as SessionId), + sessionAccess: (sessionId, action) => action === undefined + ? undefined + : ctx.remoteAccess.source('session', sessionId, action), }, } satisfies RootStandardSourceContribution) ctx.slots.installScope('session', service.adapter) + service.provide({ + keyedHooks: ['access'], + resolve: binding => ({ + keyedHooks: { access: action => ctx.remoteAccess.source('session', binding.sessionId, action) }, + }), + }) } function sameSessionStatus(left: SessionStatusSnapshot, right: SessionStatusSnapshot): boolean { diff --git a/packages/client/ui-session/tests/ui-session.client.spec.ts b/packages/client/ui-session/tests/ui-session.client.spec.ts index a91f0e2b9c..ae105e7b8e 100644 --- a/packages/client/ui-session/tests/ui-session.client.spec.ts +++ b/packages/client/ui-session/tests/ui-session.client.spec.ts @@ -715,25 +715,49 @@ describe('ui-session apply', () => { const slots = { provideRoot: vi.fn(), installScope: vi.fn(), + bindStoreScope: vi.fn(), + } + const accessSources = new Map>() + const remoteAccess = { + source: vi.fn((kind: string, id: string, action: string): HostObservable => { + const key = `${kind}:${id}:${action}` + let source = accessSources.get(key) + if (source === undefined) { + source = createSnapshotStore(false) + accessSources.set(key, source) + } + return source + }), } ctx.provide('sessions', bench.sessions) ctx.provide('slots', slots as never) + ctx.provide('remoteAccess', remoteAccess) apply(ctx) expect(ctx.uiSession).toBeInstanceOf(UiSession) const retainInfoMatcher: unknown = expect.any(Function) + const sessionAccessMatcher: unknown = expect.any(Function) expect(slots.provideRoot).toHaveBeenCalledWith({ hooks: { sessions: bench.sessions.list, sessionStatus: ctx.uiSession.sessionStatus, }, - keyedHooks: { sessionRetainInfo: retainInfoMatcher }, + keyedHooks: { sessionRetainInfo: retainInfoMatcher, sessionAccess: sessionAccessMatcher }, }) expect(slots.installScope).toHaveBeenCalledWith('session', ctx.uiSession.adapter) const root = slots.provideRoot.mock.calls[0]![0] as RootStandardSourceContribution expect(root.keyedHooks?.sessionRetainInfo?.(sessionId('s1'))) .toBe(bench.retainInfo(sessionId('s1'))) + // `useSessionAccess(sessionId, action)` reads the Session resource's answer; a call without an action names no source. + expect(root.keyedHooks?.sessionAccess?.(sessionId('s1'), 'session/fork')) + .toBe(remoteAccess.source('session', sessionId('s1'), 'session/fork')) + expect(root.keyedHooks?.sessionAccess?.(sessionId('s1'))).toBeUndefined() + + // The Session scope's `useAccess(action)` reads the bound Session's answer from the same source. + const binding = bench.binding(sessionId('s2')) + const scoped = ctx.uiSession.bindingSource(bench.reference(binding)).getSnapshot() + expect(scoped.keyedHooks.access?.('session/fork')).toBe(remoteAccess.source('session', sessionId('s2'), 'session/fork')) }) it('keeps the Host loader half inert', () => { diff --git a/packages/client/ui-session/tsconfig.json b/packages/client/ui-session/tsconfig.json index de3f6472ee..e38563a19b 100644 --- a/packages/client/ui-session/tsconfig.json +++ b/packages/client/ui-session/tsconfig.json @@ -14,6 +14,9 @@ { "path": "../../api/remotes/tsconfig.client.json" }, + { + "path": "../../api/remote-access/tsconfig.client.json" + }, { "path": "../../api/session-controller/tsconfig.client.json" }, diff --git a/packages/client/ui-session/tsdown.config.ts b/packages/client/ui-session/tsdown.config.ts index ca879a1232..7aa03cebc1 100644 --- a/packages/client/ui-session/tsdown.config.ts +++ b/packages/client/ui-session/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-session', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-settings-account/README.i18n.yaml b/packages/client/ui-settings-account/README.i18n.yaml index 8c9126dd3c..a0ed274b05 100644 --- a/packages/client/ui-settings-account/README.i18n.yaml +++ b/packages/client/ui-settings-account/README.i18n.yaml @@ -3,8 +3,8 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-settings-account/README.md /: - en: 1fcba8f273b661fb - zh: 14e1236f84ae39d7 + en: 13d891a226092817 + zh: 90bf6ff56dd3c096 /deepseek-ai-dsh-client-ui-settings-account: en: 9ff7ac2d70f70b59 zh: ded522c4cfd6e040 @@ -15,8 +15,8 @@ en: c26f81e8adf0128b zh: e11ab371f51f66bc /deepseek-ai-dsh-client-ui-settings-account/use-this-package: - en: fe1db216d397749f - zh: f823c2cd43152e66 + en: f73e8f3448239b51 + zh: 88bee1c1f465b906 /deepseek-ai-dsh-client-ui-settings-account/use-this-package/desktop-onboarding: en: c1c4db71fd09ca13 zh: a40c0a01e9951945 @@ -24,8 +24,8 @@ en: 149d25b949a24036 zh: a432400e245bf48b /deepseek-ai-dsh-client-ui-settings-account/further-exploration: - en: a28acdef21b1643a - zh: 5e2e357a6b716274 + en: f5ca95916ef486dc + zh: 1e238d8299e0d838 /deepseek-ai-dsh-client-ui-settings-account/model-experience: en: ee1df7ca3467ee3f zh: 84904a5148da3862 @@ -33,8 +33,8 @@ en: ca3015e252db0114 zh: ebca9253cf15e8e9 /deepseek-ai-dsh-client-ui-settings-account/known-limitations-and-deferred-work: - en: d5c9f1ec3d81715b - zh: 93f66ba21737c50c + en: 7afbd3f7afc20bd8 + zh: f54c57e3d6738c7c /deepseek-ai-dsh-client-ui-settings-account/known-limitations-and-deferred-work/dev-note: en: 2facb0fc2e6c1a9c zh: bd04803f5c9e132c diff --git a/packages/client/ui-settings-account/README.md b/packages/client/ui-settings-account/README.md index b2893573d9..913eddfa4b 100644 --- a/packages/client/ui-settings-account/README.md +++ b/packages/client/ui-settings-account/README.md @@ -1,5 +1,5 @@ --- -description: "Desktop Account settings display DeepSeek login state and offer browser authorization and cancellation; the sidebar account menu provides Platform sign-out." +description: "Account settings display DeepSeek login state and offer browser authorization and cancellation; the sidebar account menu provides Platform sign-out." kind: "package-reference" --- @@ -28,7 +28,7 @@ A new account-model sign-in-required event displays “Model unavailable. Please ## Use this package -The client activates only inside Desktop, identified by its preload bridge. Plain Web clients keep the standard Settings launcher and API-key onboarding without account login, account settings, or an account-state subscription. +Desktop and plain Web clients both mount the sidebar account launcher, Account settings, the sign-in step of model onboarding, and the account-state subscription. Desktop main opens the authorization page in the system browser. A plain Web client, identified by the absent Desktop preload bridge, sends `login_source` web with its own origin as the callback origin, preopens a tab during the sign-in click, navigates it to the authorization URL with the same `theme` parameter once its attempt receives one, and closes it on failure, expiry, or cancellation; a blocked popup leaves the copy-link route. Embedded Platform pages, Desktop onboarding, and the native device reader stay Desktop-only, so Web Platform links open new tabs. The sidebar and Account settings display circular profile images with diameters of 24px and 32px respectively, with the account icon as fallback when the URL is absent or the image fails to load. The collapsed sidebar centers the avatar in a 36 × 36 px button. Before sign-in the sidebar launcher shows a More row instead, leading with the ellipsis glyph. @@ -42,7 +42,7 @@ Desktop usage, top-up, and onboarding recharge actions open one isolated native Chat offers each live quota failure from a bound Session to the frame-wide `shell.quota-notice` chain from its host in `shell.overlay`, so the notice outlives the Chat panel that reported it. The account entry claims `ACCOUNT_QUOTA`: while an account credential is stored and the shared host entry exists, the Modal offers Cancel and Top up. While a page requested through the shared request channel is showing, the native view covers this document, so the entry paints neither the Modal nor the Toast. A quota failure that arrives while a Settings-opened page is already showing is not retained: it stays subject to newest-notice replacement, and the Modal appears after the viewer returns while that notice is still current. That Modal reports the failure of the original request, not the account's current balance: the top-up re-read is a separate read, and returning neither proves a successful payment nor dismisses the notice on its own. The notice's own Top up is the retained case: it calls `keepOpen()` and requests the `top-up` page through the shared request channel, keeping that notice and its page mounted through later quota failures. With the shared host entry the entry waits for the first account snapshot instead of rendering a Toast that its own timer could discard; a snapshot that reports no stored credential, or a failed account stream with no snapshot, then renders the same neutral warning Toast itself, and without that host entry the entry renders that Toast immediately, instead of relying on the chat host fallback. The entry owns the hold and its page request and releases both when it unmounts, so a later quota failure publishes again without replaying the dropped ones; dismissal or sign-out clears the hold and the page request. Close, Cancel, a sign-out, or leaving the notice's own embedded page takes the notice down without retrying the request. -The account menu's Feedback entry opens the Feishu questionnaire in the system browser. Every click samples its context then: the account UID when the profile is ready and carries an id, the build version as harness_version, and device_info. The device_info value includes the physical screen resolution as screen_resolution=widthxheight, after rounding the screen dimensions multiplied by devicePixelRatio; unavailable screen dimensions omit this entry. Each context field also carries hide_*=1, stale prefill values for these fields are dropped from the configured form URL first, including uid, device_info and harness_version regardless of the configured URL. The URL carries no os_version, device_brand, app_locale or separate screen_resolution parameters; configured prefill and hide parameters for these fields are removed. Tokens and masked contact details stay out, and a signed-out, still-loading, or failed profile leaves prefill_uid out. When the Desktop bridge has no deviceInfo reader, the form opens with navigator.userAgent. Desktop exposes the optional dshDesktop.deviceInfo reader, whose field format the [Desktop README](../../../apps/desktop/README.md) owns; its answer replaces the user agent, a failed read still opens the form with any available screen resolution in device_info, and device_info describes the reported environment rather than a hardware model. Configure contactFormUrl on the ui-settings-account plugin to select another HTTPS form; contactSource defaults to empty until the questionnaire supports a Harness source option. +The account menu's Feedback entry opens the Feishu questionnaire in the system browser. Every click samples its context then: the account UID when the profile is ready and carries an id, the build version as harness_version, and device_info. The device_info value includes the physical screen resolution as screen_resolution=widthxheight, after rounding the screen dimensions multiplied by devicePixelRatio; unavailable screen dimensions omit this entry. Each context field also carries hide_*=1, stale prefill values for these fields are dropped from the configured form URL first, including uid, device_info and harness_version regardless of the configured URL. The URL carries no os_version, device_brand, app_locale or separate screen_resolution parameters; configured prefill and hide parameters for these fields are removed. Tokens and masked contact details stay out, and a signed-out, still-loading, or failed profile leaves prefill_uid out. In a plain browser, or when the Desktop bridge has no deviceInfo reader, the form opens with navigator.userAgent. Desktop exposes the optional dshDesktop.deviceInfo reader, whose field format the [Desktop README](../../../apps/desktop/README.md) owns; its answer replaces the user agent, a failed read still opens the form with any available screen resolution in device_info, and device_info describes the reported environment rather than a hardware model. Configure contactFormUrl on the ui-settings-account plugin to select another HTTPS form; contactSource defaults to empty until the questionnaire supports a Harness source option. Account profile and balance cards share the [settings card material and radius](../../../docs/web-styling.md#corner-radii-and-settings-cards). Usage and top-up links match standard Button geometry; authorization actions use the shared Button. @@ -75,7 +75,7 @@ The desktop controller writes progress to `ui-settings-account` through Host set The [credentials subsystem](../../../docs/subsystems/credentials.md) owns storage APIs; the [architecture](../../../docs/architecture.md) explains application composition. -Account login uses a dismissible dialog before the model onboarding credential editor. The dialog uses a compact, right-aligned action row with the primary action last. The sidebar displays the profile name, falling back to the server-masked phone number or email when the name is absent; it stays blank while the profile is loading. Dialog copy and the sidebar account label are not text-selectable; the copy-link button remains available and restores its label two seconds after each successful copy. Clipboard failures show Copy failed on the link for two seconds without interrupting sign-in. Waiting shows a copyable authorization link and a loading indicator; timeout and failure require an explicit retry. The dialog's Copy sign-in link and the Settings Open link append the resolved Desktop palette as `theme=light` or `theme=dark` from the live theme snapshot, so both routes reach the Platform page in the current application theme without changing the theme preference Platform has saved. Sign-in failures render in that dialog; a failed sign-out keeps the menu open so the user can try again, and the launcher shows no failure of its own. Closing a waiting dialog cancels its Host attempt. The sidebar can reopen the same API-key editor through the settings coordinator. +Account login uses a dismissible dialog before the model onboarding credential editor. The dialog uses a compact, right-aligned action row with the primary action last. The sidebar displays the profile name, falling back to the server-masked phone number or email when the name is absent; it stays blank while the profile is loading. Dialog copy and the sidebar account label are not text-selectable; the copy-link button remains available and restores its label two seconds after each successful copy. Clipboard failures show Copy failed on the link for two seconds without interrupting sign-in. Waiting shows a copyable authorization link and a loading indicator; timeout and failure require an explicit retry. The dialog's Copy sign-in link and the Settings Open link append the resolved application palette as `theme=light` or `theme=dark` from the live theme snapshot, so both routes reach the Platform page in the current application theme without changing the theme preference Platform has saved. Sign-in failures render in that dialog; a failed sign-out keeps the menu open so the user can try again, and the launcher shows no failure of its own. Closing a waiting dialog cancels its Host attempt. The sidebar can reopen the same API-key editor through the settings coordinator. A terminal account-state stream failure appears in the sign-in dialog or Account settings. Plugin unload suppresses late failure reports. @@ -103,6 +103,8 @@ No model request prefix changes. - The notice copy is server-localized for the UI language in effect when the client reads it. Switching language does not itself re-read the unnotified bonus, so a notice already on screen keeps the copy the server sent for the earlier language until the user refreshes or the account lifecycle restarts. +- Web sign-in needs a loopback HTTP page origin (`localhost`, `127.0.0.1`, or `[::1]`), because the Host accepts only those callback origins. A Web UI reached through `--trusted-host` or an HTTPS reverse proxy still shows the entry, but starting sign-in fails with the generic retry state; such deployments add an API key instead. + - The Platform view’s dialog marks every other document child inert, including the Desktop-owned caption menu host, so the Application and Edit menu stays visible but cannot be operated until the view closes. Exempting that host needs an inertness contract owned by the Desktop. diff --git a/packages/client/ui-settings-account/README.zh.md b/packages/client/ui-settings-account/README.zh.md index e8fbf2895d..0015b1ffe5 100644 --- a/packages/client/ui-settings-account/README.zh.md +++ b/packages/client/ui-settings-account/README.zh.md @@ -1,5 +1,5 @@ --- -description: "Desktop 设置中的账号页面显示 DeepSeek 登录状态,并提供浏览器授权和取消;侧边栏账号菜单提供 Platform 退出登录。" +description: "设置中的账号页面显示 DeepSeek 登录状态,并提供浏览器授权和取消;侧边栏账号菜单提供 Platform 退出登录。" kind: "package-reference" --- @@ -28,7 +28,7 @@ kind: "package-reference" ## 使用此包 -客户端仅在具有 preload 桥接的 Desktop 内启用。普通 Web 客户端保留标准设置入口和 API Key 引导,不显示账号登录和账号设置,也不订阅账号状态。 +Desktop 与普通 Web 客户端都挂载侧边栏账号菜单、账号设置、模型引导中的登录步骤,并订阅账号状态。Desktop 主进程在系统浏览器中打开授权页面。普通 Web 客户端(以缺少 Desktop preload 桥接识别)以 `login_source` web 发起登录,并以自身 origin 作为回调 origin;它在点击登录时预先打开一个标签页,本次尝试获得授权 URL 后带上相同的 `theme` 参数导航该标签页,并在失败、过期或取消时关闭它;弹窗被拦截时仍可使用复制链接。内嵌 Platform 页面、Desktop 引导和原生设备信息读取器仅在 Desktop 中提供,因此 Web 中的 Platform 链接在新标签页打开。 侧边栏和账号设置以正圆图片显示账号头像,直径分别为 24px 和 32px;头像 URL 缺失或图片加载失败时使用账号图标兜底。折叠侧边栏将头像居中放置在 36 × 36 px 的按钮内。登录前侧边栏入口改为「更多」行,以三点图标开头。 @@ -42,7 +42,7 @@ Desktop 的用量、充值与引导充值操作在 48px 返回栏下方打开同 Chat 通过 `shell.overlay` 中的宿主把已绑定 Session 的实时欠费交给全局 `shell.quota-notice` 链,提示的生命周期因此长于上报它的 Chat 面板。账号条目认领 `ACCOUNT_QUOTA`:已存储账号凭证且存在共享宿主条目时,Modal 提供取消和去充值。只要通过该共享请求通道请求的页面正在显示,原生视图就会盖住本页,因此该条目既不绘制 Modal 也不绘制 Toast。在 Account 设置页已打开页面的情况下新到达的欠费不会被保持:它仍受最新提示替换规则约束,返回后若该提示仍是最新一条就显示 Modal。该 Modal 报告的是原始请求的失败,而不是账号当前余额:返回时的重读是一次独立读取,返回本身既不证明支付成功,也不会自动撤下该提示。提示自身的去充值属于被保持的情形:它调用 `keepOpen()` 并通过共享请求通道申请 `top-up` 页面,使该提示与其页面在后续欠费期间保持挂载。存在共享宿主条目时,该条目会等待首个账号快照,而不是渲染一个可能被自身计时器丢弃的 Toast;快照报告未存储凭证,或账号流失败且无快照时,才由该条目自行渲染同一段中立警告 Toast,缺少该宿主条目时则立即渲染该 Toast,而不依赖 Chat 宿主回退。该条目持有保持与页面请求,并在自身卸载时释放二者,因此后续欠费会重新发布而不重放已丢弃的提示;关闭与退出登录也会清除保持和页面请求。关闭、取消、退出登录或离开该提示自己的内嵌页面都会撤下提示,且不会重试请求。 -账号菜单的“意见反馈”入口在系统浏览器中打开飞书问卷。每次点击都按当时的上下文取值:profile ready 且带 id 时的账号 UID、以 harness_version 表示的构建版本,以及 device_info。device_info 以 screen_resolution=宽x高 包含屏幕物理分辨率,尺寸乘以 devicePixelRatio 后四舍五入;屏幕尺寸不可用时省略此项。所有上下文字段同时设置 hide_*=1,配置的表单 URL 中这些字段的 stale prefill 参数先被清除,uid、device_info 与 harness_version 的隐藏参数不依赖配置 URL 的取值。URL 不携带 os_version、device_brand、app_locale 或独立的 screen_resolution 参数;配置 URL 中这些字段的 prefill 与 hide 参数会被移除。不传 token 或脱敏联系方式;未登录、profile 仍在加载或读取失败时都不填写 prefill_uid。Desktop 桥接没有 deviceInfo 读取器时,打开的表单以 navigator.userAgent 作为 device_info。Desktop 端暴露可选的 dshDesktop.deviceInfo 读取器,其字段格式由 [Desktop README](../../../apps/desktop/README.zh.md) 负责;该读取器的结果取代 user-agent,读取失败时仍打开表单,device_info 保留可获取的屏幕分辨率,而 device_info 描述的是上报的环境而非硬件型号。可在 ui-settings-account 插件配置 contactFormUrl,切换到另一个 HTTPS 问卷;问卷支持 Harness 来源选项前,contactSource 默认为空。 +账号菜单的“意见反馈”入口在系统浏览器中打开飞书问卷。每次点击都按当时的上下文取值:profile ready 且带 id 时的账号 UID、以 harness_version 表示的构建版本,以及 device_info。device_info 以 screen_resolution=宽x高 包含屏幕物理分辨率,尺寸乘以 devicePixelRatio 后四舍五入;屏幕尺寸不可用时省略此项。所有上下文字段同时设置 hide_*=1,配置的表单 URL 中这些字段的 stale prefill 参数先被清除,uid、device_info 与 harness_version 的隐藏参数不依赖配置 URL 的取值。URL 不携带 os_version、device_brand、app_locale 或独立的 screen_resolution 参数;配置 URL 中这些字段的 prefill 与 hide 参数会被移除。不传 token 或脱敏联系方式;未登录、profile 仍在加载或读取失败时都不填写 prefill_uid。普通浏览器中,或 Desktop 桥接没有 deviceInfo 读取器时,打开的表单以 navigator.userAgent 作为 device_info。Desktop 端暴露可选的 dshDesktop.deviceInfo 读取器,其字段格式由 [Desktop README](../../../apps/desktop/README.zh.md) 负责;该读取器的结果取代 user-agent,读取失败时仍打开表单,device_info 保留可获取的屏幕分辨率,而 device_info 描述的是上报的环境而非硬件型号。可在 ui-settings-account 插件配置 contactFormUrl,切换到另一个 HTTPS 问卷;问卷支持 Harness 来源选项前,contactSource 默认为空。 账号资料与余额卡片共用[设置卡片材质与圆角](../../../docs/web-styling.zh.md#corner-radii-and-settings-cards)。用量与充值链接与标准 Button 尺寸一致;授权操作使用公共 Button。 @@ -75,7 +75,7 @@ Desktop preload 标记在账号凭证已存储后启用引导。进度属于本 [凭证子系统](../../../docs/subsystems/credentials.zh.md)定义存储接口;[架构](../../../docs/architecture.zh.md)说明应用组合。 -账号登录在模型引导的凭证编辑器之前显示可关闭的弹窗。弹窗底部采用紧凑的右对齐横排操作,主操作位于末尾。侧边栏优先显示昵称,缺少昵称时显示服务端返回的脱敏手机号或邮箱;资料加载期间保持留空。 弹窗文字和侧边栏账号状态文字不可选中,复制登录链接按钮始终可用,并在每次复制成功两秒后恢复原文案。剪贴板失败时,链接原位显示“复制失败”两秒,不中断登录。等待状态提供可复制的授权链接和加载图标;超时和失败均须用户手动重试。弹窗的“复制登录链接”和设置页的“打开”链接会从当前主题快照读取 Desktop 生效配色,追加 `theme=light` 或 `theme=dark`,使两条路径都以当前应用主题打开 Platform 页面,且不改变 Platform 已保存的主题偏好。登录失败在该弹窗内呈现;退出登录失败时菜单保持展开以便重试,入口本身不展示失败提示。关闭等待弹窗会取消对应 Host 登录流程。侧边栏通过设置协调器重新打开同一个 API Key 编辑器。 +账号登录在模型引导的凭证编辑器之前显示可关闭的弹窗。弹窗底部采用紧凑的右对齐横排操作,主操作位于末尾。侧边栏优先显示昵称,缺少昵称时显示服务端返回的脱敏手机号或邮箱;资料加载期间保持留空。 弹窗文字和侧边栏账号状态文字不可选中,复制登录链接按钮始终可用,并在每次复制成功两秒后恢复原文案。剪贴板失败时,链接原位显示“复制失败”两秒,不中断登录。等待状态提供可复制的授权链接和加载图标;超时和失败均须用户手动重试。弹窗的“复制登录链接”和设置页的“打开”链接会从当前主题快照读取应用生效配色,追加 `theme=light` 或 `theme=dark`,使两条路径都以当前应用主题打开 Platform 页面,且不改变 Platform 已保存的主题偏好。登录失败在该弹窗内呈现;退出登录失败时菜单保持展开以便重试,入口本身不展示失败提示。关闭等待弹窗会取消对应 Host 登录流程。侧边栏通过设置协调器重新打开同一个 API Key 编辑器。 账户状态流的终止错误会显示在登录对话框或账户设置中。插件卸载后不再报告延迟到达的错误。 @@ -103,6 +103,8 @@ Desktop preload 标记在账号凭证已存储后启用引导。进度属于本 - 通知文案按客户端读取时的界面语言由服务端本地化。切换语言本身不会重新读取未通知赠金,因此已在屏幕上的通知会沿用服务端先前语言下发的文案,直到用户手动刷新或登录生命周期重新开始。 +- Web 登录要求页面 origin 为回环 HTTP(`localhost`、`127.0.0.1` 或 `[::1]`),因为 Host 只接受这些回调 origin。经 `--trusted-host` 或 HTTPS 反向代理访问的 Web UI 仍显示登录入口,但发起登录会进入通用的失败重试状态;这类部署应改为添加 API Key。 + - Platform 视图的弹窗会把文档内其它子节点设为 inert,其中包括 Desktop 拥有的顶栏菜单 host,因此 Application 和 Edit 菜单保持可见,但在视图关闭前无法操作。放行该 host 需要 Desktop 侧提供 inert 契约。 diff --git a/packages/client/ui-settings-account/package.json b/packages/client/ui-settings-account/package.json index 4465baf5cd..6f9784dfbe 100644 --- a/packages/client/ui-settings-account/package.json +++ b/packages/client/ui-settings-account/package.json @@ -64,11 +64,9 @@ "@deepseek-ai/dsh-settings": "workspace:*", "@testing-library/react": "^16.3.3", "@types/big.js": "^6.2.2", - "@types/react": "~18.3.1", "react": "^18.2.0", "react-dom": "^18.2.0", "@deepseek-ai/dsh-client-ui-settings-models": "workspace:*", - "@types/react-dom": "~18.3.0", "@deepseek-ai/dsh-host-webserver": "workspace:*", "big.js": "^7.0.1", "@deepseek-ai/dsh-client-product-analytics": "workspace:*" diff --git a/packages/client/ui-settings-account/src/client/SignInDialog.tsx b/packages/client/ui-settings-account/src/client/SignInDialog.tsx index 6fae239b3c..77441cae58 100644 --- a/packages/client/ui-settings-account/src/client/SignInDialog.tsx +++ b/packages/client/ui-settings-account/src/client/SignInDialog.tsx @@ -10,7 +10,7 @@ import css from './SignInDialog.module.css' /** @param props - safe account state, localized copy, and user actions. @returns login dialog. */ export function SignInDialog({ account, colorScheme, start, cancel, close, useApiKey, t }: { account: AccountSnapshot - /** Resolved scheme of the active Desktop theme; the copied link carries it. */ + /** Resolved scheme of the active application theme; the copied link carries it. */ colorScheme: 'light' | 'dark' start: () => Promise cancel: (id: SignInAttemptId) => Promise diff --git a/packages/client/ui-settings-account/src/client/authorize-url.ts b/packages/client/ui-settings-account/src/client/authorize-url.ts index eba9e19094..3e701b9de9 100644 --- a/packages/client/ui-settings-account/src/client/authorize-url.ts +++ b/packages/client/ui-settings-account/src/client/authorize-url.ts @@ -1,10 +1,10 @@ -/** Platform authorization links that carry the Desktop palette into the login page. */ +/** Platform authorization links that carry the active application palette into the login page. */ /** - * Add the resolved Desktop palette to a Platform authorization link without + * Add the resolved application palette to a Platform authorization link without * dropping any parameter the Host already put on it. * @param authorizeUrl - authorization URL of the current account attempt. - * @param colorScheme - resolved scheme of the active Desktop theme. + * @param colorScheme - resolved scheme of the active application theme. * @returns the authorization URL carrying `theme=light` or `theme=dark`. */ export function authorizeUrlWithTheme(authorizeUrl: string, colorScheme: 'light' | 'dark'): string { diff --git a/packages/client/ui-settings-account/src/client/browser-login.ts b/packages/client/ui-settings-account/src/client/browser-login.ts new file mode 100644 index 0000000000..3d03d1a9a8 --- /dev/null +++ b/packages/client/ui-settings-account/src/client/browser-login.ts @@ -0,0 +1,76 @@ +/** Browser tab ownership for one UI-initiated authorization attempt. */ +import type { AccountView, SignInAttemptId } from '@deepseek-ai/dsh-deepseek-account/types' + +/** Keeps a preopened browser tab separate from Host authorization state. */ +export class BrowserLogin { + private popup: Window | null = null + private navigated = false + private attempt: SignInAttemptId | undefined + + /** @param destination - maps the Host authorization URL to the page the tab opens, read when the tab navigates. */ + constructor(private readonly destination: (authorizeUrl: string) => string) {} + + /** + * Preopen a tab and bind it to the attempt returned by the Host. + * @param request - authenticated Host start request. + * @param latest - most recent streamed state, including frames received before the response. + * @returns after this UI is following the new attempt. + */ + async start(request: () => Promise, latest: () => AccountView | undefined): Promise { + this.begin() + try { + this.follow(await request()) + this.update(latest()) + } catch (error) { + this.dispose() + throw error + } + } + + /** Preopen a tab during the user's click; blocked popups need the copy-link route. */ + begin(): void { + this.dispose() + this.popup = window.open('about:blank', '_blank') + if (this.popup) this.popup.opener = null + } + + /** + * Bind the preopened tab to its Host attempt. + * @param view - initial RPC response identifying this UI's attempt. + */ + follow(view: AccountView): void { + this.attempt = view.attempt?.id + this.update(view) + } + + /** + * Navigate or close the pending tab when its attempt changes. + * @param view - latest Host state. + */ + update(view: AccountView | undefined): void { + const attempt = view?.attempt + if (attempt?.id !== this.attempt) return + if (attempt && ['failed', 'expired', 'cancelled'].includes(attempt.phase)) { + this.dispose() + return + } + if (attempt?.phase === 'succeeded') { + // A tab that never reached the authorization page stays blank; the Host finished without it. + if (!this.navigated) this.popup?.close() + this.popup = null + this.attempt = undefined + return + } + if (!attempt?.authorizeUrl || this.navigated) return + this.navigated = true + if (this.popup && !this.popup.closed) this.popup.location.replace(this.destination(attempt.authorizeUrl)) + } + + /** Close the owned authorization tab and stop following its attempt. */ + dispose(): void { + this.popup?.close() + this.popup = null + this.attempt = undefined + this.navigated = false + } +} diff --git a/packages/client/ui-settings-account/src/client/index.ts b/packages/client/ui-settings-account/src/client/index.ts index 974626fa04..b28b3e7747 100644 --- a/packages/client/ui-settings-account/src/client/index.ts +++ b/packages/client/ui-settings-account/src/client/index.ts @@ -1,4 +1,4 @@ -/** Desktop account settings registration and reconnecting Remote subscription. */ +/** Account settings registration and reconnecting Remote subscription. */ import type {} from '@deepseek-ai/dsh-client-product-analytics/client' import type { TranscriptViewMode } from '@deepseek-ai/dsh-client-ui-chat/client' import type { Context } from '@deepseek-ai/cordis' @@ -14,6 +14,8 @@ import type { OnboardingChange } from './onboarding-contract.ts' import type { PlatformBridge } from './PlatformOverlay.tsx' import { ContactConfig, CONTACT_CONFIG_GLOBAL } from '../contact-config.ts' import { contactUrl } from './contact-url.ts' +import { authorizeUrlWithTheme } from './authorize-url.ts' +import { BrowserLogin } from './browser-login.ts' import { AccountOnboarding } from './AccountOnboarding.tsx' import { AccountPlatformHost, type AccountPlatformHostInjected } from './AccountPlatformHost.tsx' import { AccountMenu } from './AccountMenu.tsx' @@ -39,14 +41,17 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { /** Services required by account settings. */ export const inject = ['slots', 'locale', 'remote', 'remote.account', 'remote.session', 'theme', 'configForms'] -/** Register account UI only in the Desktop renderer. @param ctx - client plugin context. */ +/** Register account UI in Desktop and plain Web clients. @param ctx - client plugin context. */ export function apply(ctx: Context): void { - if (!('dshDesktop' in globalThis)) return + const desktop = 'dshDesktop' in globalThis ctx.effect(() => ctx.locale.register('settings.account', { en, zh }), 'account: dictionaries') const t = ctx.locale.bind('settings.account') const page = globalThis as Partial> const config = ContactConfig(page[CONTACT_CONFIG_GLOBAL] ?? {}) let snapshot: AccountSnapshot = { view: undefined, details: undefined, failed: false, loginVisible: false } + // Desktop main opens the authorization page natively; a plain browser opens it in a tab it owns. + const browser = new BrowserLogin(url => authorizeUrlWithTheme(url, ctx.theme.getTheme().active.colorScheme)) + ctx.effect(() => () => { browser.dispose() }, 'account: pending browser window') const listeners = new Set<() => void>() const publish = (value: AccountSnapshot) => { snapshot = value; for (const listener of listeners) listener() } /** @returns the client identity for one account call, read at call time so it carries the language and zone in effect then. */ @@ -119,6 +124,7 @@ export function apply(ctx: Context): void { const { notice, ...previous } = snapshot publish({ ...previous, view: frame.value, details: undefined, failed: false, ...(frame.value.status === 'credential-stored' && notice ? { notice } : {}) }) + browser.update(frame.value) frame.accept() if (frame.value.status === 'credential-stored') notices.begin() else notices.end() @@ -176,7 +182,7 @@ export function apply(ctx: Context): void { dshDesktop?: { deviceInfo?: () => Promise } }).dshDesktop?.deviceInfo if (readDeviceInfo === undefined) { - // A Desktop bridge without the optional reader reports the renderer user agent. + // A plain browser, or a Desktop bridge without the optional reader, reports the renderer user agent. openForm(navigator.userAgent) return } @@ -208,11 +214,16 @@ export function apply(ctx: Context): void { const transport = (globalThis as typeof globalThis & { __DSH_TRANSPORT__?: { streamBaseUrl?: string } }).__DSH_TRANSPORT__ - try { + const request = async () => { const result = await ctx.remote.account.startSignIn(client(), - transport?.streamBaseUrl !== undefined ? new URL(transport.streamBaseUrl).origin : window.location.origin, - 'desktop') + desktop && transport?.streamBaseUrl !== undefined ? new URL(transport.streamBaseUrl).origin : window.location.origin, + desktop ? 'desktop' : 'web') if (!result.ok) throw new Error('account start failed') + return result.value + } + try { + if (desktop) await request() + else await browser.start(request, () => snapshot.view) } catch (error) { publish({ ...snapshot, loginFailed: true }) throw error @@ -230,7 +241,7 @@ export function apply(ctx: Context): void { throw result.error }, } - if ('dshDesktop' in globalThis) { + if (desktop) { const controller = new DesktopOnboardingController( ctx.configForms.get(DESKTOP_ONBOARDING_NAMESPACE), ctx.configForms.get<{ transcriptView?: TranscriptViewMode | null; performanceUsage: 'compact' | 'detailed' }>('ui-chat'), diff --git a/packages/client/ui-settings-account/tests/account.client.spec.tsx b/packages/client/ui-settings-account/tests/account.client.spec.tsx index a1c4b9960f..9ed6bac4fc 100644 --- a/packages/client/ui-settings-account/tests/account.client.spec.tsx +++ b/packages/client/ui-settings-account/tests/account.client.spec.tsx @@ -505,7 +505,7 @@ it.each(['initializing', 'waiting-browser', 'exchanging'] as const)('shows %s an expect(operations.cancel).toHaveBeenCalledExactlyOnceWith('attempt') }) -it('opens the authorization link with the active Desktop palette and follows later switches', () => { +it('opens the authorization link with the active application palette and follows later switches', () => { const authorizeUrl = 'https://platform.deepseek.com/dsh/authorize?state=example' const operations = operationsOf({ status: 'signed-out', attempt: { id: 'attempt' as SignInAttemptId, phase: 'waiting-browser', authorizeUrl } }) const element = (theme: ThemeSnapshot) => { }) afterEach(() => { vi.unstubAllGlobals(); vi.unstubAllEnvs(); vi.restoreAllMocks() }) -it('keeps account UI and account RPC inactive in a plain browser, including after reload', async ({ start, mock }) => { +it('registers account sign-in in a plain browser without the Desktop-only surfaces, including after reload', async ({ start }) => { const c = await start() for (const reload of [false, true]) { if (reload) await c.reload(SELF) - await c.flush() - expect(c.ctx.slots.entries('settings.launcher')).toHaveLength(0) - expect(c.ctx.slots.entries('settings.models.sign-in')).toHaveLength(0) - expect(c.ctx.slots.entries('settings.section').some(entry => entry.options.id === 'account')).toBe(false) - expect(quotaNoticeEntry(c)).toBeUndefined() + await c.mock.streams.opened('account/watch', reload ? 2 : 1) + expect(c.ctx.slots.entries('settings.launcher')).toHaveLength(1) + expect(c.ctx.slots.entries('settings.models.sign-in')).toHaveLength(1) + expect(quotaNoticeEntry(c)).toBeDefined() expect(platformHostEntry(c)).toBeUndefined() - expect(mock.log.calls().filter(call => call.endpoint.startsWith('account/'))).toEqual([]) - expect(mock.log.streams().filter(stream => stream.endpoint.startsWith('account/'))).toEqual([]) + expect(c.ctx.slots.entries('shell.overlay').some(entry => entry.options.id === 'desktop-onboarding')).toBe(false) } }, 60_000) @@ -241,15 +239,17 @@ it('coalesces refreshes, publishes independent failures, and rejects stale respo expect(actions.hooks.account.getSnapshot().details?.profile).toEqual({ status: 'failed' }) }, 60_000) -it('uses the Desktop login carrier and exposes operation errors', async ({ start, mock }) => { - vi.spyOn(window, 'open').mockReturnValue(null) - vi.stubGlobal('dshDesktop', {}) +for (const desktop of [false, true]) it(`uses the browser or Desktop login carrier (Desktop: ${desktop}) and exposes operation errors`, async ({ start, mock }) => { + const open = vi.spyOn(window, 'open').mockReturnValue(null) + if (desktop) vi.stubGlobal('dshDesktop', {}) const c = await start() const actions = operations(c) mock.remote.account.startSignIn.mockResolvedValue(ok(view)) await actions.start() expect(mock.remote.account.startSignIn).toHaveBeenCalledWith(expect.objectContaining({ locale: 'en', version: '0.0.0-test' }), - window.location.origin, 'desktop') + window.location.origin, desktop ? 'desktop' : 'web') + // Only a plain browser preopens the authorization tab during the click. + expect(open).toHaveBeenCalledTimes(desktop ? 0 : 1) const failure = { ok: false as const, error: new RemoteError('gateway/internal', 'offline', {}) } mock.remote.account.startSignIn.mockResolvedValueOnce(failure) await expect(actions.start()).rejects.toThrow('account start failed') @@ -264,6 +264,21 @@ it('uses the Desktop login carrier and exposes operation errors', async ({ start await expect(actions.signOut()).rejects.toBe(failure.error) }, 60_000) +it('navigates the preopened browser tab to the themed authorization page', async ({ start, mock }) => { + const replace = vi.fn() + const popup: Pick & { location: Pick } = { closed: false, close: vi.fn(), opener: window, location: { replace } } + // jsdom opens no real tab; the UI only touches these members of the preopened window. + vi.spyOn(window, 'open').mockReturnValue(popup as Window) + const c = await start() + const actions = operations(c) + const attempt = { id: 'attempt' as SignInAttemptId, phase: 'waiting-browser' as const, authorizeUrl: 'https://platform.deepseek.com/dsh/authorize' } + mock.remote.account.startSignIn.mockResolvedValue(ok({ ...view, attempt: { id: attempt.id, phase: 'initializing' } })) + await actions.start() + c.mock.streams.push('account/watch', { ...view, attempt }) + const theme = (c.ctx.get('theme') as ThemeRuntime).getTheme().active.colorScheme + await vi.waitFor(() => { expect(replace).toHaveBeenCalledExactlyOnceWith(`${attempt.authorizeUrl}?theme=${theme}`) }) +}, 60_000) + it('uses the Desktop stream origin and exposes the native platform bridge', async ({ start, mock }) => { vi.stubGlobal('dshDesktop', {}) const c = await start() diff --git a/packages/client/ui-settings-account/tests/browser-login.client.spec.ts b/packages/client/ui-settings-account/tests/browser-login.client.spec.ts new file mode 100644 index 0000000000..7d27c3d33c --- /dev/null +++ b/packages/client/ui-settings-account/tests/browser-login.client.spec.ts @@ -0,0 +1,99 @@ +// @vitest-environment jsdom +/** Browser handoff tracks the initiating attempt and cleans up unused tabs. */ +import { afterEach, expect, it, vi } from 'vitest' +import type { AccountView, SignInAttemptId } from '@deepseek-ai/dsh-deepseek-account/types' +import { BrowserLogin } from '../src/client/browser-login.ts' + +afterEach(() => { vi.restoreAllMocks() }) +function view(phase: NonNullable['phase'], authorizeUrl?: string): AccountView { + return { status: 'signed-out', links: { usageUrl: '', topUpUrl: '' }, + attempt: { id: 'attempt' as SignInAttemptId, phase, ...(authorizeUrl ? { authorizeUrl } : {}) } } +} +function setup() { + const close = vi.fn() + const replace = vi.fn() + const popup: { closed: boolean; opener: unknown; close: typeof close; location: { replace: typeof replace } } = { + closed: false, opener: window, close, location: { replace }, + } + vi.spyOn(window, 'open').mockReturnValue(new Proxy(window, { + get(target, property, receiver): unknown { + if (property === 'closed') return popup.closed + if (property === 'opener') return popup.opener + if (property === 'close') return popup.close + if (property === 'location') return popup.location + return Reflect.get(target, property, receiver) + }, + set(target, property, value: unknown, receiver) { + if (property === 'opener') { popup.opener = value; return true } + return Reflect.set(target, property, value, receiver) + }, + })) + const browser = new BrowserLogin(url => url) + browser.begin() + return { browser, popup, close, replace } +} +it('navigates once when waiting-browser arrives after the initial RPC response', () => { + const { browser, popup, replace, close } = setup() + expect(popup.opener).toBeNull() + browser.follow(view('initializing')) + expect(replace).not.toHaveBeenCalled() + browser.update(view('waiting-browser', 'https://platform.deepseek.com/dsh/authorize')) + browser.update(view('waiting-browser', 'https://platform.deepseek.com/dsh/authorize')) + expect(replace).toHaveBeenCalledExactlyOnceWith('https://platform.deepseek.com/dsh/authorize') + browser.dispose() + expect(close).toHaveBeenCalledOnce() +}) +it('closes the blank tab on request failure without later navigating it', () => { + const { browser, close, replace } = setup() + browser.dispose() + browser.update(view('waiting-browser', 'https://platform.deepseek.com/dsh/authorize')) + expect(close).toHaveBeenCalledOnce() + expect(replace).not.toHaveBeenCalled() +}) +it('retains the manual link route when popup opening is blocked', () => { + vi.spyOn(window, 'open').mockReturnValue(null) + const browser = new BrowserLogin(url => url) + browser.begin() + expect(() => { browser.follow(view('waiting-browser', 'https://platform.deepseek.com/dsh/authorize')) }).not.toThrow() +}) + +it('closes the pending tab when the authenticated start request rejects', async () => { + const { browser, close, replace } = setup() + close.mockClear() + await expect(browser.start(() => Promise.reject(new Error('disconnected')), () => undefined)).rejects.toThrow('disconnected') + expect(close).toHaveBeenCalledTimes(2) + expect(replace).not.toHaveBeenCalled() +}) +it('uses a waiting frame received before the initial response', async () => { + const { browser, replace } = setup() + await browser.start(async () => view('initializing'), () => view('waiting-browser', 'https://platform.deepseek.com/dsh/authorize')) + expect(replace).toHaveBeenCalledExactlyOnceWith('https://platform.deepseek.com/dsh/authorize') +}) + +it.each(['failed', 'expired', 'cancelled'] as const)('closes an already navigated tab on %s', (phase) => { + const { browser, close } = setup() + browser.follow(view('waiting-browser', 'https://platform.deepseek.com/dsh/authorize')) + browser.update(view(phase)) + expect(close).toHaveBeenCalledOnce() +}) +it('leaves success completion to the Platform page', () => { + const { browser, close } = setup() + browser.follow(view('waiting-browser', 'https://platform.deepseek.com/dsh/authorize')) + browser.update(view('succeeded')) + browser.dispose() + expect(close).not.toHaveBeenCalled() +}) +it('closes a blank tab when the Host finishes before it navigates', () => { + const { browser, close } = setup() + browser.follow(view('exchanging')) + browser.update(view('succeeded')) + browser.dispose() + expect(close).toHaveBeenCalledOnce() +}) +it('opens the destination mapped from the authorization URL', () => { + const { replace } = setup() + const browser = new BrowserLogin(url => `${url}?theme=dark`) + browser.begin() + browser.follow(view('waiting-browser', 'https://platform.deepseek.com/dsh/authorize')) + expect(replace).toHaveBeenCalledExactlyOnceWith('https://platform.deepseek.com/dsh/authorize?theme=dark') +}) diff --git a/packages/client/ui-settings-account/tests/onboarding.client.spec.tsx b/packages/client/ui-settings-account/tests/onboarding.client.spec.tsx index 9d5915fcb5..007aa59644 100644 --- a/packages/client/ui-settings-account/tests/onboarding.client.spec.tsx +++ b/packages/client/ui-settings-account/tests/onboarding.client.spec.tsx @@ -6,7 +6,7 @@ import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import type { ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client' import { AccountOnboarding } from '../src/client/AccountOnboarding.tsx' import type { AccountSnapshot } from '../src/client/AccountSection.tsx' -import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { en } from '../src/client/locales.ts' afterEach(cleanup) @@ -22,6 +22,7 @@ it('claims onboarding while loading, offers API key fallback, and completes afte const globals: GlobalStandardProps = { usePanelInfo: unused, useSessions: unused, useSessionStatus: unused, useSessionRetainInfo: unused, useResource: unused, useWorkspaces: unused, + ...allowAllAccess, } const props = { ...globals, diff --git a/packages/client/ui-settings-account/tsdown.config.ts b/packages/client/ui-settings-account/tsdown.config.ts index 08f459e8f7..45af1505d1 100644 --- a/packages/client/ui-settings-account/tsdown.config.ts +++ b/packages/client/ui-settings-account/tsdown.config.ts @@ -3,7 +3,7 @@ import { readFile } from 'node:fs/promises' import { basename } from 'node:path' import { fileURLToPath } from 'node:url' -const bundle = clientBundle('@deepseek-ai/dsh-client-ui-settings-account', ['lib/types/index.js']) +const bundle = clientBundle(import.meta.dirname, ['lib/types/index.js']) export default ((options) => bundle(options).map(config => ({ ...config, diff --git a/packages/client/ui-settings-agent-loop/package.json b/packages/client/ui-settings-agent-loop/package.json index 744a50bbec..16e111f91f 100644 --- a/packages/client/ui-settings-agent-loop/package.json +++ b/packages/client/ui-settings-agent-loop/package.json @@ -55,7 +55,6 @@ "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", "@deepseek-ai/dsh-client-ui-settings": "workspace:*", "@deepseek-ai/dsh-client-ui-slots": "workspace:*", - "@types/react": "~18.3.1", "react": "^18.2.0" }, "files": [ diff --git a/packages/client/ui-settings-agent-loop/tsdown.config.ts b/packages/client/ui-settings-agent-loop/tsdown.config.ts index 7ae5a8134a..7aa03cebc1 100644 --- a/packages/client/ui-settings-agent-loop/tsdown.config.ts +++ b/packages/client/ui-settings-agent-loop/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-settings-agent-loop', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-settings-general/package.json b/packages/client/ui-settings-general/package.json index 01d3748a6f..ba5dcebee3 100644 --- a/packages/client/ui-settings-general/package.json +++ b/packages/client/ui-settings-general/package.json @@ -67,8 +67,6 @@ "@deepseek-ai/dsh-client-ui-sidebar": "workspace:*", "@deepseek-ai/dsh-client-ui-slots": "workspace:*", "@deepseek-ai/dsh-remote-mock": "workspace:*", - "@types/react": "~18.3.1", - "@types/react-dom": "~18.3.0", "clsx": "^2.0.0", "react": "^18.2.0", "@deepseek-ai/dsh-settings": "workspace:*", diff --git a/packages/client/ui-settings-general/tests/components.client.spec.tsx b/packages/client/ui-settings-general/tests/components.client.spec.tsx index ac2e7f000b..ab8a19976d 100644 --- a/packages/client/ui-settings-general/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-general/tests/components.client.spec.tsx @@ -2,7 +2,7 @@ import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' -import { bindSnapshotSelector, RemoteError } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, bindSnapshotSelector, RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import type { GeneralSectionComponentProps } from '../src/client/GeneralSection.tsx' import { GeneralSection } from '../src/client/GeneralSection.tsx' import { CloseLabel, HeaderContent, TriggerContent } from '../src/client/chrome.tsx' @@ -41,6 +41,7 @@ const useSessionStatus: TriggerContentProps['useSessionStatus'] = selector => se const kit = { useSessions: unusedHook, useSessionStatus, usePanelInfo, useSessionRetainInfo: () => undefined, useResource, useWorkspaces: unusedHook, + ...allowAllAccess, } describe('Desktop collapsed update badge', () => { diff --git a/packages/client/ui-settings-general/tests/settings-root.client.spec.tsx b/packages/client/ui-settings-general/tests/settings-root.client.spec.tsx index dcf874bcf7..2f46ad2057 100644 --- a/packages/client/ui-settings-general/tests/settings-root.client.spec.tsx +++ b/packages/client/ui-settings-general/tests/settings-root.client.spec.tsx @@ -5,7 +5,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { useEffect, useState } from 'react' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { createSettingsShellStore } from '../src/client/shell-store.ts' -import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' import { SessionId } from '@deepseek-ai/dsh-session/types' import type { SettingsRootComponentProps } from '../src/client/shell-contract.ts' @@ -104,6 +104,7 @@ function mount({ useSessionStatus, usePanelInfo, useSessionRetainInfo: () => undefined, useResource, useWorkspaces: unusedHook, + ...allowAllAccess, wide, reconnect, openDesktopUpdate: () => {}, diff --git a/packages/client/ui-settings-general/tests/shell.client.spec.ts b/packages/client/ui-settings-general/tests/shell.client.spec.ts index ee90396ccd..1305c915d1 100644 --- a/packages/client/ui-settings-general/tests/shell.client.spec.ts +++ b/packages/client/ui-settings-general/tests/shell.client.spec.ts @@ -109,9 +109,7 @@ describe('ui-settings-general shell', () => { off() }) - it('shows Account first in Desktop while signed in and removes it on sign-out', async ({ start }) => { - vi.stubGlobal('dshDesktop', {}) - onTestFinished(() => { vi.unstubAllGlobals() }) + it('shows Account first only while signed in and removes it on sign-out', async ({ start }) => { const c = await start() const { sections } = injectedOf(c).hooks await c.mock.streams.opened('account/watch', 1) diff --git a/packages/client/ui-settings-general/tsdown.config.ts b/packages/client/ui-settings-general/tsdown.config.ts index 45665e4054..7aa03cebc1 100644 --- a/packages/client/ui-settings-general/tsdown.config.ts +++ b/packages/client/ui-settings-general/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-settings-general', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-settings-models/README.i18n.yaml b/packages/client/ui-settings-models/README.i18n.yaml index 8cd95f4015..cef103488f 100644 --- a/packages/client/ui-settings-models/README.i18n.yaml +++ b/packages/client/ui-settings-models/README.i18n.yaml @@ -18,11 +18,11 @@ en: db5aaf7f702e1f0c zh: 409a78c14fb2b6e3 /deepseek-ai-dsh-client-ui-settings-models/use-this-package/api-keys: - en: c2f2a5c06ef20d5b - zh: 49a7eb0b66b4ed53 + en: b39c74c9a874f5d4 + zh: db0dfcc2de9cab53 /deepseek-ai-dsh-client-ui-settings-models/use-this-package/editing-a-provider: - en: ec0ba62a4e7185b4 - zh: 37d1e1e7a3817d12 + en: 0786af5e4ed58c8a + zh: af94474af0749101 /deepseek-ai-dsh-client-ui-settings-models/use-this-package/adding-and-deleting-providers: en: 9d533c65c82cd4a4 zh: 0a5da334e127c035 @@ -30,8 +30,8 @@ en: 589397a35f82244e zh: 342fbceee2c173a2 /deepseek-ai-dsh-client-ui-settings-models/use-this-package/extension-slots: - en: 65e9d8822b67b6c2 - zh: 7e83be9defda28ee + en: 79f8f80bdaba73cd + zh: f4d7496b293fbf81 /deepseek-ai-dsh-client-ui-settings-models/understand-the-implementation: en: a87afbec5e339b36 zh: 6348c81af7e650e1 diff --git a/packages/client/ui-settings-models/README.md b/packages/client/ui-settings-models/README.md index a13f58ed78..6c9548829a 100644 --- a/packages/client/ui-settings-models/README.md +++ b/packages/client/ui-settings-models/README.md @@ -43,11 +43,13 @@ Host configuration `credentialOnboarding` defaults to `true`. The Electron prelo API-key inputs start empty and use `autocomplete="new-password"` to ask browsers not to autofill saved login passwords. -The primary field on an editor card is a single **API key** input — the page never asks for an environment-variable name. A typed key stores write-only through `credentials.set` under the profile's reference, deriving `_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `cordis.patch.yml` never carries a key value. Leaving a new pi-ai provider's key blank saves a reference-free profile and preserves provider-native authentication (for example the Bedrock credential chain or Vertex ADC). A row labels API-key state with a green solid dot only when a referenced credential is confirmed configured, and with a red solid dot only when a named reference is confirmed missing. A successful Apply emits a local accessible status message without echoing secret material. +The primary field on an editor card is a single **API key** input — the page never asks for an environment-variable name. A typed key stores write-only through `credentials.set` under the profile's reference, deriving `_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `cordis.patch.yml` never carries a key value. Leaving a new pi-ai provider's key blank saves a reference-free profile and preserves provider-native authentication (for example the Bedrock credential chain or Vertex ADC). The field's placeholder and help tooltip use the profile's `apiKeyEnv` when it has one; otherwise they use the environment variables of the route's `nativeAuth`, in lookup order, as the configurable-provider directory reports them. These display hints omit references containing `oauth` (case-insensitively), and describe saved credentials followed by the displayed environment fallbacks. This presentation filter does not change the [credential resolution order](../../../docs/subsystems/credentials.md#resolution), the adapter's complete environment list, or an explicit reference. A filtered explicit reference still has a usable key input with the plain placeholder and no tooltip. A native route that has no key authentication omits the key field and authentication help entirely; an explicit reference still enables the field. A profile without a reference on a route without `nativeAuth`, such as a hand-declared route, shows the plain key placeholder and no tooltip. A row labels API-key state with a green solid dot only when a referenced credential is confirmed configured, and with a red solid dot only when a named reference is confirmed missing. A successful Apply emits a local accessible status message without echoing secret material. + +For a profile with a named credential reference, the editor also shows **API key source** from the page's existing redacted `CredentialInfo` snapshot: saved credentials (`file`), launch environment (`env`), `$cwd/.env` (`project-env`), or `$DSH_HOME/.env` (`user-env`). Refreshing that snapshot updates an open editor without a separate editor probe. Unconfigured references, unknown source ids, and reference-free native authentication show no source label: a derived reference cannot identify the native provider's active credential. The label never receives a credential value or changes field writability. ### Editing a provider -The collapsed 自定义设置 fold carries the curated extras: `baseURL` for both families (the deepseek placeholder shows the public endpoint), each adapter's model catalog, and the **display name** and **API protocol** of a pi-ai route the adapter does not ship. Profile `headers` remain deployment configuration in `cordis.patch.yml` or Cordis config and have no Models-page editor. The Provider ID stays fixed: it is the settings key, the name every other namespace and every logged session references, and the stem of a credential reference the page cannot read back to move. Reasoning effort is deliberately not among the editable fields: it is a per-model capability, so a provider-scoped control could only be set to a value some models reject. Each model row edits `id`, optional display `name`, optional `contextWindow`/`maxTokens`, and input types; unrelated model fields survive edits. +The collapsed 自定义设置 fold carries the curated extras: `baseURL` for both families (the placeholder shows the endpoint the profile resolves to, the public endpoint for deepseek, else the `defaultBaseURL` the configurable-provider directory reports for a pi-ai catalog route), each adapter's model catalog, and the **display name** and **API protocol** of a pi-ai route the adapter does not ship. Profile `headers` remain deployment configuration in `cordis.patch.yml` or Cordis config and have no Models-page editor. The Provider ID stays fixed: it is the settings key, the name every other namespace and every logged session references, and the stem of a credential reference the page cannot read back to move. Reasoning effort is deliberately not among the editable fields: it is a per-model capability, so a provider-scoped control could only be set to a value some models reject. Each model row edits `id`, optional display `name`, optional `contextWindow`/`maxTokens`, and input types; unrelated model fields survive edits. The DeepSeek card edits the `llm-deepseek` endpoint, credentials, and model catalog. It uses Messages, with the default endpoint placeholder `https://api.deepseek.com/anthropic`. @@ -65,7 +67,7 @@ After the versioned notice step completes, the DeepSeek step projects first-run The section declares two seats for plugins distributed outside this repository, typed in [`src/client/slot-contract.ts`](src/client/slot-contract.ts) and exported from `./client`. `settings.models.provider-card` (keyed) renders inside every card that shows a directory row — a saved row's card, its first-run setup posture, and the add-provider draft — dispatched with `entryKey = settingsNs` and owner props carrying the row's `ConfigurableProviderView`, its configured state, and its confirmed api-key credential state, so one registration under an adapter family's namespace receives every card of that family, hand-declared routes included; the hand-declared draft card has no directory row yet and dispatches nothing until saved. `settings.models.footer` (list) renders after the rows and the add controls. A registrant activates through `ctx.slots.inject` with a type-only import of this package's `/client` entry; without registrants both seats render nothing. -The Models page includes **DeepSeek Account** (`deepseek-account`, **DeepSeek 账号** in Chinese). Its editor exposes the shared DeepSeek model catalog without API-key or base-URL inputs, and saves that catalog under the account route's own settings section (`llm-deepseek-account` by default), so an account edit never rewrites the `llm-deepseek` section the official route reads. The account row is hidden when its available model catalog is empty, including before sign-in and after sign-out; it returns when account models become available. +The Models page includes **DeepSeek Account** (`deepseek-account`, **DeepSeek 账号** in Chinese). The row shows a **Signed in** status and offers no editor: the account listing supplies its models, and [model overrides](../../llm/llm-deepseek-account/README.md#use-this-package) live in `cordis.patch.yml`. The account row is hidden when its available model catalog is empty, including before sign-in and after sign-out; it returns when account models become available. ----- diff --git a/packages/client/ui-settings-models/README.zh.md b/packages/client/ui-settings-models/README.zh.md index 5509ac386b..c5e80da5c5 100644 --- a/packages/client/ui-settings-models/README.zh.md +++ b/packages/client/ui-settings-models/README.zh.md @@ -43,11 +43,13 @@ Host 配置 `credentialOnboarding` 默认为 `true`。Electron preload 标记会 API 密钥输入框初始为空,并通过 `autocomplete="new-password"` 请求浏览器不要自动填入已保存的登录密码。 -编辑卡片上的主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名。键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `cordis.patch.yml` 从不携带密钥值。为新的 pi-ai 提供商留空密钥会保存一个不带引用的 profile,从而保留提供商原生认证(例如 Bedrock 凭据链或 Vertex ADC)。只有确认引用的凭据已配置时,行才会以绿色实心点标示 API 密钥状态;只有确认具名引用缺失时,才会以红色实心点标示。「应用」成功后会发出本地无障碍状态消息,且绝不回显任何机密内容。 +编辑卡片上的主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名。键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `cordis.patch.yml` 从不携带密钥值。为新的 pi-ai 提供商留空密钥会保存一个不带引用的 profile,从而保留提供商原生认证(例如 Bedrock 凭据链或 Vertex ADC)。profile 有 `apiKeyEnv` 时,字段的占位文字与帮助提示采用该引用;否则按查找顺序采用可配置提供商目录为该路由报告的 `nativeAuth` 环境变量。这些显示提示会忽略包含 `oauth` 的引用(不区分大小写),并说明优先使用已保存凭据,其后为显示的环境后备。这项显示过滤不改变[凭据解析顺序](../../../docs/subsystems/credentials.zh.md#resolution)、适配器的完整环境变量列表或显式引用。被过滤的显式引用仍有可用的密钥输入框,使用普通占位文字且不显示帮助提示。没有密钥认证的原生路由不显示密钥字段及认证帮助;显式引用仍会启用该字段。路由没有 `nativeAuth`(例如手动声明的路由)且 profile 没有引用时,显示普通的密钥占位文字,且不显示帮助提示。只有确认引用的凭据已配置时,行才会以绿色实心点标示 API 密钥状态;只有确认具名引用缺失时,才会以红色实心点标示。「应用」成功后会发出本地无障碍状态消息,且绝不回显任何机密内容。 + +profile 有具名凭据引用时,编辑器还会根据页面已有的脱敏 `CredentialInfo` 快照显示 **API 密钥来源**:已保存凭据(`file`)、启动环境(`env`)、`$cwd/.env`(`project-env`)或 `$DSH_HOME/.env`(`user-env`)。快照刷新会更新打开的编辑器,无需单独探测。未配置的引用、未知来源 id 和无引用的原生认证不显示来源标签:派生引用不能说明原生提供商当前采用的凭据。标签从不接收凭据值,也不改变字段的可写性。 ### 编辑提供商 -收起的「自定义设置」折叠区承载精选的额外字段:两个家族都有 `baseURL`(deepseek 的占位符显示公共端点)、各适配器自己的模型目录,以及适配器未提供的 pi-ai 路由的**显示名称**与 **API 协议**。Profile `headers` 仍是 `cordis.patch.yml` 或 Cordis 配置中的部署配置,Models 页面不提供编辑器。Provider ID 保持固定:它是 settings 的键、其他每个 namespace 与每一条已记录会话引用的名字,也是页面读不回、因而搬不走的凭据引用词干。推理等级刻意不在可编辑字段之列:它是按模型的能力,提供商级的控件只可能被设成某些模型会拒绝的值。每个模型行可编辑 `id`、可选显示 `name`、可选 `contextWindow`/`maxTokens` 和输入类型;无关的模型字段在编辑后仍会保留。 +收起的「自定义设置」折叠区承载精选的额外字段:两个家族都有 `baseURL`(占位符显示 profile 解析出的端点,deepseek 即公共端点;没有时显示可配置提供商目录为 pi-ai 内置路由报告的 `defaultBaseURL`)、各适配器自己的模型目录,以及适配器未提供的 pi-ai 路由的**显示名称**与 **API 协议**。Profile `headers` 仍是 `cordis.patch.yml` 或 Cordis 配置中的部署配置,Models 页面不提供编辑器。Provider ID 保持固定:它是 settings 的键、其他每个 namespace 与每一条已记录会话引用的名字,也是页面读不回、因而搬不走的凭据引用词干。推理等级刻意不在可编辑字段之列:它是按模型的能力,提供商级的控件只可能被设成某些模型会拒绝的值。每个模型行可编辑 `id`、可选显示 `name`、可选 `contextWindow`/`maxTokens` 和输入类型;无关的模型字段在编辑后仍会保留。 `llm-deepseek` 的 DeepSeek 卡片编辑端点、凭据和模型目录。它使用 Messages,默认端点占位符为 `https://api.deepseek.com/anthropic`。 @@ -65,7 +67,7 @@ API 密钥输入框初始为空,并通过 `autocomplete="new-password"` 请求 本分区为仓库外分发的插件声明两个席位,类型定义在 [`src/client/slot-contract.ts`](src/client/slot-contract.ts) 并从 `./client` 导出。`settings.models.provider-card`(keyed)渲染在每张展示目录行的卡片内部——已保存行的卡片、其首次运行 setup 形态、以及「添加提供商」草稿卡——以 `entryKey = settingsNs` 分发,owner props 携带该行的 `ConfigurableProviderView`、其 configured 状态与已确认的 api-key 凭据状态,因此以某适配器家族的 namespace 注册一次即可收到该家族的全部卡片,含手工声明的路由;手工声明的草稿卡尚无目录行,保存之前不分发。`settings.models.footer`(list)渲染在行列表与新增控件之后。注册方通过 `ctx.slots.inject` 激活,并以 type-only import 引入本包 `/client` 入口;没有注册方时两个席位均不渲染任何内容。 -Models 页面包含 **DeepSeek 账号**(`deepseek-account`,英文为 **DeepSeek Account**)。其编辑器展示共享的 DeepSeek 模型目录,不提供 API Key 或 Base URL 输入框;目录保存到账号路由自己的设置段(默认为 `llm-deepseek-account`),因此账号侧的编辑不会改写 official 路由读取的 `llm-deepseek` 段。账号可用模型目录为空时隐藏账号行,包括登录前和退登后;账号模型恢复可用时重新显示。 +Models 页面包含 **DeepSeek 账号**(`deepseek-account`,英文为 **DeepSeek Account**)。该行显示 **已登录** 状态,不提供编辑器:模型来自账号的模型列表,[模型覆盖](../../llm/llm-deepseek-account/README.zh.md#use-this-package)写在 `cordis.patch.yml` 中。账号可用模型目录为空时隐藏账号行,包括登录前和退登后;账号模型恢复可用时重新显示。 ----- diff --git a/packages/client/ui-settings-models/package.json b/packages/client/ui-settings-models/package.json index a6eb4914dc..4884bc67a0 100644 --- a/packages/client/ui-settings-models/package.json +++ b/packages/client/ui-settings-models/package.json @@ -53,7 +53,6 @@ "@deepseek-ai/dsh-client-ui-primitives": "workspace:*", "@deepseek-ai/dsh-client-ui-settings": "workspace:*", "@deepseek-ai/dsh-client-ui-slots": "workspace:*", - "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:~", "react": "^18.2.0", "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", diff --git a/packages/client/ui-settings-models/src/client/ModelsSection.module.css b/packages/client/ui-settings-models/src/client/ModelsSection.module.css index 2074fc6a07..91f5c2e9c0 100644 --- a/packages/client/ui-settings-models/src/client/ModelsSection.module.css +++ b/packages/client/ui-settings-models/src/client/ModelsSection.module.css @@ -113,6 +113,16 @@ background: var(--dsw-alias-state-error-primary); } +.accountStatus { + display: inline-flex; + flex: none; + align-items: center; + gap: 6px; + font-size: 13px; + line-height: 20px; + color: var(--dsw-alias-state-success-primary); +} + .rowActions { display: inline-flex; align-items: center; @@ -265,6 +275,28 @@ color: var(--dsw-alias-label-secondary); } +.helpButton { + width: 16px; + height: 16px; + padding: 0; + border: 0.5px solid var(--dsw-alias-border-l3); + border-radius: 50%; + corner-shape: round; + background: transparent; + color: var(--dsw-alias-label-tertiary); + font: inherit; + font-size: 11px; + line-height: 15px; + text-align: center; + cursor: help; +} + +.helpButton:hover, +.helpButton:focus-visible { + color: var(--dsw-alias-label-primary); + border-color: var(--dsw-alias-label-tertiary); +} + .linkButton { box-sizing: border-box; display: inline-flex; diff --git a/packages/client/ui-settings-models/src/client/ModelsSection.tsx b/packages/client/ui-settings-models/src/client/ModelsSection.tsx index cb5dc86b08..87727ffff1 100644 --- a/packages/client/ui-settings-models/src/client/ModelsSection.tsx +++ b/packages/client/ui-settings-models/src/client/ModelsSection.tsx @@ -25,7 +25,7 @@ import { useId, useState } from 'react' import type { ReactNode } from 'react' import { Button, IconPlusOutlineRegular, Modal, SegmentedControl } from '@deepseek-ai/dsh-client-ui-primitives' import type { InjectFace, PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' -import type { SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' +import type { LlmNativeAuth, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' // Type-only: pulls this package's SlotMap merge (the two Models child slots). import type {} from './slot-contract.ts' import { CustomProviderCard } from './CustomProviderCard.tsx' @@ -92,6 +92,10 @@ interface EditorTarget extends ProviderIdentity { credentialRef?: string /** The adapter reports this route as one it does not ship (see {@link ProviderEditorProps.declared}). */ declared?: boolean + /** The route's catalog endpoint (see {@link ProviderEditorProps.defaultBaseURL}). */ + defaultBaseURL?: string + /** The route's native authentication (see {@link ProviderEditorProps.nativeAuth}). */ + nativeAuth?: LlmNativeAuth } /** A dormant directory row the add card can adopt, with its registered namespace. */ @@ -109,7 +113,7 @@ interface CatalogDraft { /** Values that vary around the shared provider-editor rendering. */ interface ProviderEditorRenderProps extends Pick< ProviderEditorProps, - 'namespace' | 'schema' | 'operations' | 't' | 'readOnly' | 'onClose' + 'namespace' | 'schema' | 'operations' | 't' | 'readOnly' | 'onClose' | 'credential' > { target: EditorTarget } @@ -122,6 +126,8 @@ function renderProviderEditor({ target, ...props }: ProviderEditorRenderProps): displayName={target.displayName} settingsPath={target.settingsPath} {...target.declared === true ? { declared: true } : {}} + {...target.defaultBaseURL === undefined ? {} : { defaultBaseURL: target.defaultBaseURL }} + {...target.nativeAuth === undefined ? {} : { nativeAuth: target.nativeAuth }} {...props} /> ) @@ -200,6 +206,8 @@ function targetOf(row: ProviderRow): EditorTarget { ...credentialRef === undefined ? {} : { credentialRef }, // Only declared routes may expose route-owned fields. ...row.entry.declared === true ? { declared: true } : {}, + ...row.entry.defaultBaseURL === undefined ? {} : { defaultBaseURL: row.entry.defaultBaseURL }, + ...row.entry.nativeAuth === undefined ? {} : { nativeAuth: row.entry.nativeAuth }, } } @@ -424,6 +432,7 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS {renderProviderEditor({ target, namespace, + ...row.credential === undefined ? {} : { credential: row.credential }, schema, operations, t, @@ -454,6 +463,15 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS {row.entry.declared === true ? {t('customTag')} : null} + {/* The store keeps the account row only while the account lists models. */} + {row.entry.provider === 'deepseek-account' + ? ( + + + ) + : null} {credentialConfigured ? ( - + {/* The account catalog comes from the account listing; its model overrides live in cordis.patch.yml. */} + {row.entry.provider === 'deepseek-account' + ? null + : ( + + )} {row.removable ? ( + + ) + // A provider with no native key authentication has no key for this field to store. + const keylessNativeAuth = nativeAuth?.env.length === 0 && nativeAuth.oauth + const keyPlaceholder = keyHint === undefined + ? t('keyPlaceholder') + : t('keyPlaceholderFallback').replace('{hint}', () => keyHint) /** What both family editors take: the rows, whose layer owns them, and the two writes. */ const catalogProps = { models, @@ -373,28 +386,31 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { }, onReset: () => { setDraft(current => schema.deletePath(current, ['models'])) }, } - if (accountProvider) return return ( <> -
- {t('keyInput')} - { setKeyDraft(event.target.value) }} - /> - {shownKeyFailure === undefined ? null :

{t(shownKeyFailure)}

} -
+ {keylessNativeAuth + ? null + :
+ + {t('keyInput')} + {authHelp} + + { setKeyDraft(event.target.value) }} + /> + {keySource === undefined ? null : {keySource}} + {shownKeyFailure === undefined ? null :

{t(shownKeyFailure)}

} +
} {props.credentialOnly === true ? null :
{t('customized')}
@@ -433,7 +449,10 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { value={stringAt(draft, 'baseURL') ?? ''} placeholder={family === 'deepseek' ? t('deepSeekBaseUrl') - : stringAt(fallback, 'baseURL') ?? t('baseUrlDefault')} + : (() => { + const baseURL = stringAt(fallback, 'baseURL') ?? props.defaultBaseURL + return baseURL === undefined ? t('baseUrlDefault') : t('baseUrlPlaceholder').replace('{url}', () => baseURL) + })()} aria-describedby={family === 'deepseek' ? `${props.provider}-endpoint-hint` : undefined} aria-label={t('baseUrl')} disabled={disabled} diff --git a/packages/client/ui-settings-models/src/client/locales.ts b/packages/client/ui-settings-models/src/client/locales.ts index 0ece7ada5e..034c1e7b94 100644 --- a/packages/client/ui-settings-models/src/client/locales.ts +++ b/packages/client/ui-settings-models/src/client/locales.ts @@ -31,19 +31,25 @@ export const en = { savedProvider: 'Saved {provider}.', credentialConfigured: 'API key configured', credentialMissing: 'API key missing', + accountSignedIn: 'Signed in', readOnly: 'The settings document is read-only in this deployment.', loadFailed: 'Loading the provider directory failed', conflict: 'Someone else changed these settings while this card was open. Close it and reopen to edit the current values.', retry: 'Retry', keyInput: 'API key', keyPlaceholder: 'Enter your API key', - keyPlaceholderNative: 'Enter an API key, or leave blank to use environment authentication', - keyStored: 'Configured — enter a new value to replace', - keyEnvLocked: 'Provided by the launch environment (read-only)', + keyPlaceholderFallback: 'Leave blank to keep existing authentication ({hint})', + keyHelp: 'Saved credentials are used first. Environment fallbacks shown here, in order: {env}. Sources are launch environment, $cwd/.env, then $DSH_HOME/.env.', + authHelp: 'Authentication details', + keySourceFile: 'API key source: saved credentials', + keySourceEnv: 'API key source: launch environment', + keySourceProjectEnv: 'API key source: $cwd/.env', + keySourceUserEnv: 'API key source: $DSH_HOME/.env', customized: 'Customized settings', baseUrl: 'Base URL', - baseUrlDefault: 'Provider default', - deepSeekBaseUrl: 'https://api.deepseek.com/anthropic', + baseUrlDefault: 'No default endpoint', + baseUrlPlaceholder: '{url}', + deepSeekBaseUrl: 'https://api.deepseek.com/anthropic (Default to use DEEPSEEK_BASE_URL)', deepSeekEndpointHint: 'Use an API endpoint compatible with Anthropic Messages.', models: 'Models', modelsInherited: 'Using the adapter defaults', @@ -151,19 +157,25 @@ export const zh: { [Key in keyof typeof en]: string } = { savedProvider: '已保存 {provider}。', credentialConfigured: 'API 密钥已配置', credentialMissing: 'API 密钥缺失', + accountSignedIn: '已登录', readOnly: '当前部署的设置文档为只读。', loadFailed: '加载提供商目录失败', conflict: '这张卡片打开期间,这些设置已被其他地方改动。请关闭后重新打开,在当前值上编辑。', retry: '重试', keyInput: 'API 密钥', keyPlaceholder: '输入 API 密钥', - keyPlaceholderNative: '输入 API 密钥,或留空使用环境认证', - keyStored: '已配置——输入新值可替换', - keyEnvLocked: '由启动环境提供(只读)', + keyPlaceholderFallback: '留空时保留现有认证({hint})', + keyHelp: '优先使用已保存的凭据。此处显示的环境后备按以下顺序排列:{env}。来源依次为启动环境、$cwd/.env、$DSH_HOME/.env。', + authHelp: '认证说明', + keySourceFile: 'API 密钥来源:已保存凭据', + keySourceEnv: 'API 密钥来源:启动环境', + keySourceProjectEnv: 'API 密钥来源:$cwd/.env', + keySourceUserEnv: 'API 密钥来源:$DSH_HOME/.env', customized: '自定义设置', baseUrl: 'API 地址', - baseUrlDefault: '提供商默认', - deepSeekBaseUrl: 'https://api.deepseek.com/anthropic', + baseUrlDefault: '没有默认地址', + baseUrlPlaceholder: '{url}', + deepSeekBaseUrl: 'https://api.deepseek.com/anthropic(默认使用环境 DEEPSEEK_BASE_URL)', deepSeekEndpointHint: '请填写兼容 Anthropic Messages 协议的 API 地址。', models: '模型目录', modelsInherited: '正在使用适配器默认模型', diff --git a/packages/client/ui-settings-models/src/client/operations.ts b/packages/client/ui-settings-models/src/client/operations.ts index 8ca6beb798..d8c5d0ac99 100644 --- a/packages/client/ui-settings-models/src/client/operations.ts +++ b/packages/client/ui-settings-models/src/client/operations.ts @@ -7,7 +7,7 @@ import type { Context as ClientContext } from '@deepseek-ai/cordis' import type { - CredentialInfo, LlmDiscoveredModel, LlmModelDiscoveryRequest, + LlmDiscoveredModel, LlmModelDiscoveryRequest, SettingsNamespaceView, SettingsPathOpView, } from '@deepseek-ai/dsh-api-remotes/client' @@ -32,12 +32,6 @@ export type ModelDiscoveryOutcome = /** The Host operations the Models page and its cards invoke. */ export interface ModelsOperations { - /** - * Read one credential reference's state. - * @param ref - credential reference name. - * @returns the state, or undefined when the reference is unknown or the read was refused. - */ - describeCredential(ref: string): Promise /** * Store one credential literal under its reference. * @param ref - credential reference name. @@ -81,10 +75,6 @@ export interface ModelsOperations { */ export function createModelsOperations(ctx: ClientContext): ModelsOperations { return { - describeCredential: async (ref) => { - const response = await ctx.remote.credentials.describe([ref]) - return response.ok ? response.value[ref] : undefined - }, storeCredential: async (ref, value) => { const response = await ctx.remote.credentials.set(ref, value) return response.ok ? undefined : response.error.message diff --git a/packages/client/ui-settings-models/src/client/store.ts b/packages/client/ui-settings-models/src/client/store.ts index 0b4d53fb8d..4283890eac 100644 --- a/packages/client/ui-settings-models/src/client/store.ts +++ b/packages/client/ui-settings-models/src/client/store.ts @@ -9,7 +9,7 @@ import type { Context as ClientContext } from '@deepseek-ai/cordis' import type { - CredentialInfo, LlmConfigurableProvider, LlmProviderInfo, SettingsNamespaceView, + CredentialInfo, LlmConfigurableProvider, LlmNativeAuth, LlmProviderInfo, SettingsNamespaceView, } from '@deepseek-ai/dsh-api-remotes/client' import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' @@ -30,6 +30,8 @@ export interface ProviderDirectoryEntry { readonly settingsPath: readonly string[] readonly active: boolean readonly declared?: boolean + readonly defaultBaseURL?: string + readonly nativeAuth?: LlmNativeAuth readonly error?: string } @@ -52,6 +54,8 @@ export function joinProviderDirectory( settingsPath: [...entry.settingsPath], active: active.has(entry.provider), ...entry.declared === undefined ? {} : { declared: entry.declared }, + ...entry.defaultBaseURL === undefined ? {} : { defaultBaseURL: entry.defaultBaseURL }, + ...entry.nativeAuth === undefined ? {} : { nativeAuth: entry.nativeAuth }, ...entry.error === undefined ? {} : { error: entry.error }, })) for (const provider of registered) { diff --git a/packages/client/ui-settings-models/tests/components.client.spec.tsx b/packages/client/ui-settings-models/tests/components.client.spec.tsx index 588d0eda2d..0b12855e64 100644 --- a/packages/client/ui-settings-models/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/components.client.spec.tsx @@ -14,6 +14,7 @@ import { } from '../src/client/ModelsSection.tsx' import type { ModelsSectionInjected, ModelsSectionProps } from '../src/client/ModelsSection.tsx' import { ProviderEditor, pathOps } from '../src/client/ProviderEditor.tsx' +import type { ProviderEditorProps } from '../src/client/ProviderEditor.tsx' import { DeepSeekModelsEditor, formatCapacity, modelDrafts, parseCapacity, validateDeepSeekModels, } from '../src/client/DeepSeekModelsEditor.tsx' @@ -23,7 +24,7 @@ import { deriveKeyRef, ModelsSettingsStore } from '../src/client/store.ts' import { createModelsOperations } from '../src/client/operations.ts' import type { ModelsOperations } from '../src/client/operations.ts' import type { ProviderRow } from '../src/client/store.ts' -import { en, zh } from '../src/client/locales.ts' +import { en } from '../src/client/locales.ts' import { settingsSchema } from './settings-schema.client.ts' afterEach(cleanup) @@ -155,11 +156,6 @@ function wireNamespaces(): SettingsNamespaceView[] { ] } -/** The account provider's settings namespace, under a composition's entry id. */ -function accountNamespace(ns = 'llm-deepseek-account'): SettingsNamespaceView { - return { ...wireNamespaces().find(view => view.ns === 'llm-deepseek-account')!, ns } -} - /** Credentials answers over the Remote carrier, which has no envelope. */ function remoteOk(value: T) { return { ok: true as const, value } @@ -198,8 +194,15 @@ function scriptedFace(overrides: { ]))), listConfigurableProviders: vi.fn(() => Promise.resolve(remoteOk([ { provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [], active: true }, - { provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'], active: true }, - { provider: 'anthropic', displayName: 'anthropic', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'anthropic'], active: false }, + { + provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'], active: true, + defaultBaseURL: 'https://api.openai.com/v1', nativeAuth: { env: ['OPENAI_API_KEY'], oauth: false }, + }, + { + provider: 'anthropic', displayName: 'anthropic', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'anthropic'], active: false, + defaultBaseURL: 'https://api.anthropic.com', + nativeAuth: { env: ['ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_OAUTH_TOKEN', 'ANTHROPIC_API_KEY'], oauth: true }, + }, { provider: 'zombie', displayName: 'zombie', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'zombie'], active: false }, { provider: 'broken', displayName: 'broken', settingsNs: 'llm-pi-ai', settingsPath: ['nope', 'x'], active: false }, { provider: 'plain', displayName: 'plain', settingsNs: 'llm-plain', settingsPath: ['profiles', 'plain'], active: false }, @@ -261,6 +264,32 @@ function operationsWith(face: object): ModelsOperations { return bound } +/** The pi-ai namespace with one more route profile stored in the user layer. */ +function piAiNamespaceWith(provider: string, profile: { [key: string]: JsonValue }): SettingsNamespaceView { + const namespace = wireNamespaces().find(view => view.ns === 'llm-pi-ai')! + const providers = { ...(namespace.value as { providers: object }).providers, [provider]: profile } + return { ...namespace, value: { providers }, user: { providers } } +} + +/** Render one pi-ai route's editor with the directory and credential facts the page already holds. */ +function renderPiAiEditor( + provider: string, + namespace: SettingsNamespaceView, + facts: Pick = {}, +): void { + render() +} + +/** Hover the key label's help button and read its tooltip; null when the label has no button. */ +function authHelpText(): string | null { + const button = screen.queryByRole('button', { name: en.authHelp }) + if (button === null) return null + fireEvent.mouseEnter(button) + return screen.getByRole('tooltip').textContent +} + /** One recorded child-slot dispatch: seat name, owner share, kind options. */ type RenderSlotCall = [name: string, owner: Record, opts?: { entryKey?: string }] @@ -871,7 +900,7 @@ describe('ModelsSection', () => { const baseURL = screen.getByLabelText(en.baseUrl) // The deepseek placeholder is pinned to the public endpoint, not the // effective value (which may reflect a launch-environment override). - expect(baseURL.placeholder).toBe('https://api.deepseek.com/anthropic') + expect(baseURL.placeholder).toBe('https://api.deepseek.com/anthropic (Default to use DEEPSEEK_BASE_URL)') fireEvent.change(baseURL, { target: { value: 'https://next2' } }) fireEvent.click(screen.getByText(en.apply)) await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) }) @@ -943,7 +972,7 @@ describe('ModelsSection', () => { />) fireEvent.click(screen.getByText(en.customized)) expect(screen.getByLabelText(en.baseUrl).placeholder) - .toBe('https://api.deepseek.com/anthropic') + .toBe('https://api.deepseek.com/anthropic (Default to use DEEPSEEK_BASE_URL)') expect(screen.queryByLabelText(en.customApi)).toBeNull() expect(screen.getByText(en.deepSeekEndpointHint)).toBeTruthy() fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'sk-messages-test' } }) @@ -1355,7 +1384,7 @@ describe('ModelsSection', () => { />) fireEvent.click(screen.getByText(en.customized)) const baseURL = screen.getByLabelText(en.baseUrl) - expect(baseURL.placeholder).toBe('https://api.deepseek.com/anthropic') + expect(baseURL.placeholder).toBe('https://api.deepseek.com/anthropic (Default to use DEEPSEEK_BASE_URL)') fireEvent.change(baseURL, { target: { value: 'https://x' } }) expect(baseURL.value).toBe('https://x') fireEvent.change(baseURL, { target: { value: '' } }) @@ -1384,14 +1413,15 @@ describe('ModelsSection', () => { it('edits a pi-ai profile with the curated fields only', async () => { const { mutate } = await mountSection() fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.editProvider) })) - // The configured credential shows as the stored placeholder. + // The field names the profile's reference; a stored value stays hidden. const editorKey = await screen.findByLabelText(en.keyInput) - await waitFor(() => { expect(editorKey.placeholder).toBe(en.keyStored) }) + expect(editorKey.placeholder).toBe(en.keyPlaceholderFallback.replace('{hint}', 'OPENAI_API_KEY')) // pi-ai carries Base URL too: the stored override shows as the value and - // the effective profile endpoint as its placeholder source. + // the effective profile endpoint, ahead of the catalog's, as its placeholder. fireEvent.click(screen.getByText(en.customized)) const url = screen.getByLabelText(en.baseUrl) expect(url.value).toBe('https://proxy') + expect(url.placeholder).toBe('https://proxy') fireEvent.change(url, { target: { value: 'https://proxy/v2' } }) fireEvent.click(screen.getByText(en.apply)) await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) }) @@ -1410,12 +1440,12 @@ describe('ModelsSection', () => { const pick = await screen.findByLabelText(en.provider) expect([...pick.options].map(option => option.value)).toEqual(['anthropic', 'broken', 'plain']) expect(pick.value).toBe('anthropic') - // A dormant profile has no endpoint anywhere: the pi-ai placeholder - // falls back to the provider-default wording. + // A dormant route shows its catalog endpoint and the first variable its + // provider reads; a typed key still stores under the derived reference. fireEvent.click(screen.getByText(en.customized)) - expect(screen.getByLabelText(en.baseUrl).placeholder).toBe(en.baseUrlDefault) + expect(screen.getByLabelText(en.baseUrl).placeholder).toBe('https://api.anthropic.com') const addKey = screen.getByLabelText(en.keyInput) - expect(addKey.placeholder).toBe(en.keyPlaceholderNative) + expect(addKey.placeholder).toBe(en.keyPlaceholderFallback.replace('{hint}', 'ANTHROPIC_AUTH_TOKEN')) fireEvent.change(addKey, { target: { value: 'sk-ant' } }) fireEvent.click(screen.getByText(en.apply)) await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) }) @@ -1525,7 +1555,7 @@ describe('ModelsSection', () => { renderSlot={() => null} />) const key = await screen.findByLabelText(en.keyInput) - expect(key.placeholder).toBe(en.keyPlaceholder) + expect(key.placeholder).toBe(en.keyPlaceholderFallback.replace('{hint}', 'DEEPSEEK_API_KEY')) }) it('tells the user to reopen when another writer moved the namespace first', async () => { @@ -1553,36 +1583,25 @@ describe('ModelsSection', () => { expect(screen.getByText(en.apply)).toBeTruthy() }) - it('surfaces a shadowed credential write on the card', async () => { + it('surfaces a refused credential write on the card', async () => { await mountFirstRun({ - set: vi.fn(() => Promise.resolve(remoteFail('credentials: DEEPSEEK_API_KEY is shadowed by the read-only environment'))), + set: vi.fn(() => Promise.resolve(remoteFail( + 'credentials-local: invalid document at /home/user/.dsh/.credentials.yaml: BAD_INDENT at line 3, column 1', + ))), }) const key = screen.getByLabelText(en.keyInput) fireEvent.change(key, { target: { value: 'sk-live' } }) fireEvent.click(screen.getByText(en.apply)) - await screen.findByText(/shadowed by the read-only environment/) + await screen.findByText(/invalid document at \/home\/user\/\.dsh\/\.credentials\.yaml/) expect(screen.queryByRole('status')).toBeNull() }) - it('locks the key input when the launch environment provides the credential', async () => { - const { face } = await mountSection() - face.credentials.describe.mockImplementation((refs: string[]) => Promise.resolve(remoteOk( - Object.fromEntries(refs.map(ref => [ref, { - configured: ref === 'OPENAI_API_KEY', source: 'env', writable: false, - }])), - ))) - fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.editProvider) })) - const editorKey = await screen.findByLabelText(en.keyInput) - await waitFor(() => { expect(editorKey.placeholder).toBe(en.keyEnvLocked) }) - expect(editorKey.disabled).toBe(true) - }) - it('keeps a failed credential describe silent and the input usable', async () => { const { face, set } = await mountSection() face.credentials.describe.mockImplementation(() => Promise.resolve(remoteFail('down', 'gateway/internal'))) fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.editProvider) })) const editorKey = await screen.findByLabelText(en.keyInput) - expect(editorKey.placeholder).toBe(en.keyPlaceholderNative) + expect(editorKey.placeholder).toBe(en.keyPlaceholderFallback.replace('{hint}', 'OPENAI_API_KEY')) fireEvent.change(editorKey, { target: { value: 'sk-live' } }) fireEvent.click(screen.getByText(en.apply)) await waitFor(() => { expect(set).toHaveBeenCalledTimes(1) }) @@ -2131,118 +2150,131 @@ describe('apiKeyFailure', () => { }) }) -it.each([en, zh])('edits the account model catalog without credential or endpoint fields', async (copy) => { - const mutate = vi.fn(() => Promise.resolve(remoteOk(accountNamespace()))) - const scripted = scriptedFace({ mutate }) - const ops = operationsWith(scripted.face) - const describe = vi.spyOn(ops, 'describeCredential') - const namespace = accountNamespace() - const onClose = vi.fn() - render( copy[key]} readOnly={false} onClose={onClose} />) - expect(screen.queryByLabelText(copy.keyInput)).toBeNull() - expect(screen.queryByLabelText(copy.baseUrl)).toBeNull() - expect(describe).not.toHaveBeenCalled() - expect(screen.getByDisplayValue('deepseek-v4-flash')).toBeTruthy() - expect(screen.queryByText(content => content.includes(copy.advancedHint))).toBeNull() - await expect(`${document.body.textContent}\n`) - .toMatchFileSnapshot(`./expected/deepseek-account-${copy === en ? 'en' : 'zh'}.txt`) - const set = vi.spyOn(ops, 'storeCredential') - fireEvent.change(screen.getAllByLabelText(new RegExp(copy.modelId))[0]!, { target: { value: 'deepseek-v4-mini' } }) - fireEvent.click(screen.getByText(copy.apply)) - await waitFor(() => { expect(onClose).toHaveBeenCalledWith(true) }) - expect(mutate.mock.calls[0]).toEqual([ - 'llm-deepseek-account', - [{ - op: 'set', - path: ['models'], - value: [ - { - id: 'deepseek-v4-mini', - name: 'DeepSeek-V4-Flash', - description: 'Preserved hidden detail', - contextWindow: 1_000_000, - }, - { id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro', contextWindow: 1_000_000 }, - ], - }], - 0, - ]) - expect(set).not.toHaveBeenCalled() +it.each([ + ['file', 'keySourceFile'], ['env', 'keySourceEnv'], + ['project-env', 'keySourceProjectEnv'], ['user-env', 'keySourceUserEnv'], +] as const)('shows the named credential source %s without reading its value', (source, key) => { + renderPiAiEditor('openai', piAiNamespaceWith('openai', { apiKeyEnv: 'OPENAI_API_KEY' }), { + credential: { configured: true, source, writable: true }, + }) + expect(screen.getByText(en[key])).toBeTruthy() + expect(screen.getByLabelText(en.keyInput).value).toBe('') }) -it('keeps the DeepSeek editor for an account route under a renamed settings entry', async () => { - const namespace = accountNamespace('team-account-entry') - const mutate = vi.fn(() => Promise.resolve(remoteOk(namespace))) - const ops = operationsWith(scriptedFace({ mutate }).face) - const set = vi.spyOn(ops, 'storeCredential') - const onClose = vi.fn() - render() - expect(screen.queryByText(content => content.includes(en.advancedHint))).toBeNull() - expect(screen.getByDisplayValue('deepseek-v4-flash')).toBeTruthy() - fireEvent.change(screen.getAllByLabelText(new RegExp(en.modelId))[0]!, { target: { value: 'deepseek-v4-mini' } }) - fireEvent.click(screen.getByText(en.apply)) - await waitFor(() => { expect(onClose).toHaveBeenCalledWith(true) }) - expect(mutate).toHaveBeenCalledWith('team-account-entry', expect.any(Array), 0) - expect(set).not.toHaveBeenCalled() +it.each([ + ['unconfigured', { apiKeyEnv: 'OPENAI_API_KEY' }, { configured: false, source: 'env', writable: true }], + ['unknown source', { apiKeyEnv: 'OPENAI_API_KEY' }, { configured: true, source: 'oauth', writable: true }], + ['native authentication', {}, { configured: true, source: 'file', writable: true }], +] as const)('does not guess a source for %s', (_label, profile, credential) => { + renderPiAiEditor('openai', piAiNamespaceWith('openai', profile), { credential }) + expect(screen.queryByText(/^API key source:/)).toBeNull() + expect(document.body.textContent).not.toMatch(/oauth/i) }) -it('opens the account row from the section and saves to its own namespace', async () => { - const namespace = accountNamespace() - const mutate = vi.fn(() => Promise.resolve(remoteOk(namespace))) - const { controller, set } = await mountSection({ mutate }) - const row = controller.store.getSnapshot().rows[0]! - await act(async () => { controller.store.update((state) => { - state.rows = [{ - ...row, - accountAvailable: true, - entry: { - provider: 'deepseek-account', displayName: en.deepSeekAccount, settingsNs: namespace.ns, settingsPath: [], active: true, - }, - }, ...state.rows] - }) }) - fireEvent.click(screen.getByRole('button', { - name: providerCopy(en.editProvider, { provider: 'deepseek-account', displayName: en.deepSeekAccount }), - })) - expect(screen.getByDisplayValue('deepseek-v4-flash')).toBeTruthy() - fireEvent.change(screen.getAllByLabelText(new RegExp(en.modelId))[0]!, { target: { value: 'deepseek-v4-mini' } }) - fireEvent.click(screen.getByText(en.apply)) - await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) }) - expect(mutate.mock.calls[0]).toEqual([ - 'llm-deepseek-account', - [{ - op: 'set', - path: ['models'], - value: [ - { - id: 'deepseek-v4-mini', - name: 'DeepSeek-V4-Flash', - description: 'Preserved hidden detail', - contextWindow: 1_000_000, - }, - { id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro', contextWindow: 1_000_000 }, - ], - }], - 0, - ]) - expect(set).not.toHaveBeenCalled() +it('refreshes the open editor source from the page snapshot without an editor probe', async () => { + const { face, controller } = await mountSection() + const reads = face.credentials.describe.mock.calls.length + fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.editProvider) })) + expect(screen.getByText(en.keySourceFile)).toBeTruthy() + expect(face.credentials.describe).toHaveBeenCalledTimes(reads) + face.credentials.describe.mockImplementation(refs => Promise.resolve(remoteOk( + Object.fromEntries(refs.map(ref => [ref, { configured: true, source: 'env', writable: true }])), + ))) + await act(() => controller.load()) + expect(screen.getByText(en.keySourceEnv)).toBeTruthy() + expect(screen.queryByText(en.keySourceFile)).toBeNull() + expect(face.credentials.describe).toHaveBeenCalledTimes(reads + 1) }) -it('renders the localized account row and supports catalogs without capacity defaults', async () => { +it('keeps an OAuth-only provider on its native authentication path', () => { + renderPiAiEditor('openai-codex', wireNamespaces().find(view => view.ns === 'llm-pi-ai')!, { + nativeAuth: { env: [], oauth: true }, + }) + expect(screen.queryByLabelText(en.keyInput)).toBeNull() + expect(authHelpText()).toBeNull() + expect(document.body.textContent).not.toMatch(/oauth/i) +}) + +it.each([ + ['anthropic', ['ANTHROPIC_API_KEY'], en.keyHelp.replace('{env}', 'ANTHROPIC_API_KEY')], + ['openai', ['OPENAI_API_KEY'], en.keyHelp.replace('{env}', 'OPENAI_API_KEY')], +] as const)('opens authentication help for %s on click and dismisses it with Escape', (provider, env, text) => { + renderPiAiEditor(provider, piAiNamespaceWith(provider, {}), { + nativeAuth: { env: [...env], oauth: true }, + }) + const help = screen.getByLabelText(en.authHelp) + fireEvent.click(help) + const tooltip = screen.getByRole('tooltip') + expect(tooltip.textContent).toBe(text) + expect(help.getAttribute('aria-describedby')).toBe(tooltip.id) + fireEvent.keyDown(document, { key: 'Escape' }) + expect(screen.queryByRole('tooltip')).toBeNull() +}) + +it.each([ + ['an OAuth-only provider', 'openai-codex', 'CODEX_TOKEN', { env: [], oauth: true }], + [ + 'a provider with catalog variables and an OAuth sign-in', 'anthropic', 'TEAM_ANTHROPIC_KEY', + { env: ['ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_OAUTH_TOKEN', 'ANTHROPIC_API_KEY'], oauth: true }, + ], +] as const)('names the profile reference of %s instead of its native authentication', (_label, provider, ref, nativeAuth) => { + renderPiAiEditor(provider, piAiNamespaceWith(provider, { apiKeyEnv: ref }), { nativeAuth }) + expect(screen.getByLabelText(en.keyInput).placeholder) + .toBe(en.keyPlaceholderFallback.replace('{hint}', ref)) + expect(authHelpText()).toBe(en.keyHelp.replace('{env}', ref)) + expect(document.body.textContent).not.toMatch(/oauth/i) +}) + +it.each([ + [ + 'with an OAuth sign-in', 'anthropic', + { env: ['ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_OAUTH_TOKEN', 'ANTHROPIC_API_KEY'], oauth: true }, + 'ANTHROPIC_AUTH_TOKEN / ANTHROPIC_API_KEY', + ], + ['without an OAuth sign-in', 'minimax-cn', { env: ['MINIMAX_CN_API_KEY'], oauth: false }, 'MINIMAX_CN_API_KEY'], +] as const)('lists method-neutral fallbacks for a catalog route %s', (_label, provider, nativeAuth, refs) => { + renderPiAiEditor(provider, piAiNamespaceWith(provider, {}), { nativeAuth }) + expect(screen.getByLabelText(en.keyInput).placeholder) + .toBe(en.keyPlaceholderFallback.replace('{hint}', nativeAuth.env[0])) + expect(authHelpText()).toBe(en.keyHelp.replace('{env}', refs)) + expect(document.body.textContent).not.toMatch(/oauth/i) +}) + +it('shows the next method-neutral fallback without modifying native authentication metadata', () => { + const nativeAuth = { env: ['ANTHROPIC_OAUTH_TOKEN', 'ANTHROPIC_API_KEY'], oauth: true } + renderPiAiEditor('anthropic', piAiNamespaceWith('anthropic', {}), { nativeAuth }) + expect(screen.getByLabelText(en.keyInput).placeholder) + .toBe(en.keyPlaceholderFallback.replace('{hint}', 'ANTHROPIC_API_KEY')) + expect(authHelpText()).toBe(en.keyHelp.replace('{env}', 'ANTHROPIC_API_KEY')) + expect(nativeAuth.env).toEqual(['ANTHROPIC_OAUTH_TOKEN', 'ANTHROPIC_API_KEY']) + expect(document.body.textContent).not.toMatch(/oauth/i) +}) + +it('keeps a method-specific profile reference usable without displaying its name', () => { + renderPiAiEditor('anthropic', piAiNamespaceWith('anthropic', { apiKeyEnv: 'TEAM_OAUTH_TOKEN' }), { + nativeAuth: { env: ['ANTHROPIC_API_KEY'], oauth: true }, + }) + expect(screen.getByLabelText(en.keyInput).placeholder).toBe(en.keyPlaceholder) + expect(authHelpText()).toBeNull() + expect(document.body.textContent).not.toMatch(/oauth/i) +}) + +it('offers a plain key field to a route the adapter reports no authentication or endpoint for', () => { + renderPiAiEditor('zombie', wireNamespaces().find(view => view.ns === 'llm-pi-ai')!) + expect(screen.getByLabelText(en.keyInput).placeholder).toBe(en.keyPlaceholder) + expect(authHelpText()).toBeNull() + fireEvent.click(screen.getByText(en.customized)) + expect(screen.getByLabelText(en.baseUrl).placeholder).toBe(en.baseUrlDefault) +}) + +it('renders the localized account row as signed in without an edit action', async () => { const scripted = scriptedFace({}) - const { controller, view } = await mountFace(scripted) + const { controller } = await mountFace(scripted) const row = controller.store.getSnapshot().rows[0]! await act(async () => { controller.store.update((state) => { state.rows = [{ ...row, accountAvailable: true, entry: { ...row.entry, provider: 'deepseek-account' } }] }) }) - expect(screen.getByText(en.deepSeekAccount)).toBeTruthy() - view.unmount() - const namespace = accountNamespace() - render( {}} />) - expect(screen.queryByLabelText(en.keyInput)).toBeNull() + const item = screen.getByText(en.deepSeekAccount).closest('li')! + expect(within(item).getByText(en.accountSignedIn)).toBeTruthy() + expect(within(item).queryByText(en.edit)).toBeNull() }) diff --git a/packages/client/ui-settings-models/tests/expected/deepseek-account-en.txt b/packages/client/ui-settings-models/tests/expected/deepseek-account-en.txt deleted file mode 100644 index c9841889a6..0000000000 --- a/packages/client/ui-settings-models/tests/expected/deepseek-account-en.txt +++ /dev/null @@ -1 +0,0 @@ -DeepSeek Accountdeepseek-accountModelsUsing the adapter defaultsAdd modelCancelApply diff --git a/packages/client/ui-settings-models/tests/expected/deepseek-account-zh.txt b/packages/client/ui-settings-models/tests/expected/deepseek-account-zh.txt deleted file mode 100644 index e11c2e1969..0000000000 --- a/packages/client/ui-settings-models/tests/expected/deepseek-account-zh.txt +++ /dev/null @@ -1 +0,0 @@ -DeepSeek 账号deepseek-account模型目录正在使用适配器默认模型添加模型取消保存 diff --git a/packages/client/ui-settings-models/tests/model-list-catalog.client.spec.tsx b/packages/client/ui-settings-models/tests/model-list-catalog.client.spec.tsx index a0034f2f7c..1e40a70f1e 100644 --- a/packages/client/ui-settings-models/tests/model-list-catalog.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/model-list-catalog.client.spec.tsx @@ -11,7 +11,6 @@ afterEach(cleanup) function operations(discoverModels: ModelsOperations['discoverModels']): ModelsOperations { return { discoverModels, - describeCredential: vi.fn(), storeCredential: vi.fn(), removeCredential: vi.fn(), writeSettings: vi.fn(), diff --git a/packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx b/packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx index f04b8853b4..a60aff9f8c 100644 --- a/packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx @@ -6,7 +6,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import Schema from '@deepseek-ai/schemastery' import type { SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' import type { JsonValue } from '@deepseek-ai/dsh-util-values' -import { bindSnapshotSelector, RemoteError } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, bindSnapshotSelector, RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import { DeepSeekOnboardingDialog } from '../src/client/DeepSeekOnboardingDialog.tsx' import type { DeepSeekOnboardingDialogProps } from '../src/client/DeepSeekOnboardingDialog.tsx' import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts' @@ -152,6 +152,7 @@ function harness(options: { useSessionStatus, usePanelInfo, useSessionRetainInfo: () => undefined, useResource, useWorkspaces: unusedHook, + ...allowAllAccess, controller, useModels: bindSnapshotSelector(controller.store), operations, diff --git a/packages/client/ui-settings-models/tests/store.client.spec.ts b/packages/client/ui-settings-models/tests/store.client.spec.ts index 50b3064979..0c327d45fe 100644 --- a/packages/client/ui-settings-models/tests/store.client.spec.ts +++ b/packages/client/ui-settings-models/tests/store.client.spec.ts @@ -4,7 +4,28 @@ import type { RpcResponse } from '@deepseek-ai/dsh-api-remotes/client' import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts' import { settingsSchema } from './settings-schema.client.ts' -import { joinProviderDirectory, ModelsSettingsStore, providerUsable } from '../src/client/store.ts' +import { deriveKeyRef, joinProviderDirectory, ModelsSettingsStore, providerUsable } from '../src/client/store.ts' + +it.each([ + ['anthropic', 'ANTHROPIC_API_KEY'], + ['moonshotai', 'MOONSHOTAI_API_KEY'], + ['moonshotai-cn', 'MOONSHOTAI_CN_API_KEY'], + ['google-vertex', 'GOOGLE_VERTEX_API_KEY'], + ['constructor', 'CONSTRUCTOR_API_KEY'], +])('stores a typed key for route %s under a reference derived from the route id', (provider, ref) => { + expect(deriveKeyRef(provider)).toBe(ref) +}) + +it('carries the endpoint and native authentication the adapter reports onto the row', () => { + const nativeAuth = { env: ['ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_OAUTH_TOKEN', 'ANTHROPIC_API_KEY'], oauth: true } + expect(joinProviderDirectory([], [{ + provider: 'anthropic', displayName: 'anthropic', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'anthropic'], + defaultBaseURL: 'https://api.anthropic.com', nativeAuth, + }])).toEqual([{ + provider: 'anthropic', displayName: 'anthropic', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'anthropic'], + active: false, defaultBaseURL: 'https://api.anthropic.com', nativeAuth, + }]) +}) it.each([false, true])('retains configuration diagnostics when the route is active: %s', (active) => { expect(joinProviderDirectory(active ? [{ id: 'openai', name: 'openai' }] : [], [{ diff --git a/packages/client/ui-settings-models/tests/welcome-notice.client.spec.tsx b/packages/client/ui-settings-models/tests/welcome-notice.client.spec.tsx index 09cb3bb49d..107266f082 100644 --- a/packages/client/ui-settings-models/tests/welcome-notice.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/welcome-notice.client.spec.tsx @@ -2,7 +2,7 @@ import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' -import { bindSnapshotSelector, RemoteError } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, bindSnapshotSelector, RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import { Context } from '@deepseek-ai/cordis' import { SettingsSchemaService } from '@deepseek-ai/dsh-client-ui-settings/src/client/schema.ts' import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts' @@ -95,6 +95,7 @@ function mount( useSessionStatus, usePanelInfo, useSessionRetainInfo: () => undefined, useResource, useWorkspaces: unusedHook, + ...allowAllAccess, controller, useWelcome: bindSnapshotSelector(controller.store), t: key => zh[key], diff --git a/packages/client/ui-settings-models/tsdown.config.ts b/packages/client/ui-settings-models/tsdown.config.ts index 758f6681f8..7aa03cebc1 100644 --- a/packages/client/ui-settings-models/tsdown.config.ts +++ b/packages/client/ui-settings-models/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-settings-models', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-settings-plugin-inventory/package.json b/packages/client/ui-settings-plugin-inventory/package.json index 2ad924fadd..260faa8169 100644 --- a/packages/client/ui-settings-plugin-inventory/package.json +++ b/packages/client/ui-settings-plugin-inventory/package.json @@ -56,7 +56,6 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:*", "@deepseek-ai/dsh-package-manifest": "workspace:*", "@testing-library/react": "^16.3.3", - "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:~", "react": "^18.2.0", "react-dom": "^18.2.0", diff --git a/packages/client/ui-settings-plugin-inventory/tsdown.config.ts b/packages/client/ui-settings-plugin-inventory/tsdown.config.ts index 205aaf47d4..7aa03cebc1 100644 --- a/packages/client/ui-settings-plugin-inventory/tsdown.config.ts +++ b/packages/client/ui-settings-plugin-inventory/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-settings-plugin-inventory', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-settings-plugins/package.json b/packages/client/ui-settings-plugins/package.json index a2a4492ad6..6a3275581a 100644 --- a/packages/client/ui-settings-plugins/package.json +++ b/packages/client/ui-settings-plugins/package.json @@ -51,7 +51,6 @@ "@deepseek-ai/dsh-client-ui-settings": "workspace:*", "@deepseek-ai/dsh-client-ui-slots": "workspace:*", "@deepseek-ai/dsh-util-values": "workspace:*", - "@types/react": "~18.3.1", "react": "^18.2.0" }, "files": [ diff --git a/packages/client/ui-settings-plugins/tsdown.config.ts b/packages/client/ui-settings-plugins/tsdown.config.ts index f9bc4e134b..7aa03cebc1 100644 --- a/packages/client/ui-settings-plugins/tsdown.config.ts +++ b/packages/client/ui-settings-plugins/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-settings-plugins', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-settings-session-log/package.json b/packages/client/ui-settings-session-log/package.json index d3fe0aede2..d1f0a7a55e 100644 --- a/packages/client/ui-settings-session-log/package.json +++ b/packages/client/ui-settings-session-log/package.json @@ -54,11 +54,9 @@ "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", "@deepseek-ai/dsh-client-ui-settings": "workspace:*", "@deepseek-ai/dsh-client-ui-slots": "workspace:*", - "@types/react": "~18.3.1", "react": "^18.2.0", "@deepseek-ai/dsh-client-ui-layout": "workspace:*", - "react-dom": "^18.2.0", - "@types/react-dom": "~18.3.0" + "react-dom": "^18.2.0" }, "files": [ "locale/*.json", diff --git a/packages/client/ui-settings-session-log/tsdown.config.ts b/packages/client/ui-settings-session-log/tsdown.config.ts index 701ce5e140..7aa03cebc1 100644 --- a/packages/client/ui-settings-session-log/tsdown.config.ts +++ b/packages/client/ui-settings-session-log/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-settings-session-log', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-settings-shell/package.json b/packages/client/ui-settings-shell/package.json index f2d48c2aa4..2afa80d649 100644 --- a/packages/client/ui-settings-shell/package.json +++ b/packages/client/ui-settings-shell/package.json @@ -55,7 +55,6 @@ "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", "@deepseek-ai/dsh-client-ui-settings": "workspace:*", "@deepseek-ai/dsh-client-ui-slots": "workspace:*", - "@types/react": "~18.3.1", "react": "^18.2.0" }, "files": [ diff --git a/packages/client/ui-settings-shell/tsdown.config.ts b/packages/client/ui-settings-shell/tsdown.config.ts index 4e48f8dff3..7aa03cebc1 100644 --- a/packages/client/ui-settings-shell/tsdown.config.ts +++ b/packages/client/ui-settings-shell/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-settings-shell', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-settings-subagent/package.json b/packages/client/ui-settings-subagent/package.json index fa5d97b680..a07ea3707e 100644 --- a/packages/client/ui-settings-subagent/package.json +++ b/packages/client/ui-settings-subagent/package.json @@ -56,7 +56,6 @@ "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", "@deepseek-ai/dsh-client-ui-settings": "workspace:*", "@deepseek-ai/dsh-client-ui-slots": "workspace:*", - "@types/react": "~18.3.1", "react": "^18.2.0" }, "files": [ diff --git a/packages/client/ui-settings-subagent/tsdown.config.ts b/packages/client/ui-settings-subagent/tsdown.config.ts index f97175aba2..7aa03cebc1 100644 --- a/packages/client/ui-settings-subagent/tsdown.config.ts +++ b/packages/client/ui-settings-subagent/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-settings-subagent', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-settings-web-search/package.json b/packages/client/ui-settings-web-search/package.json index a858c70745..9604d1c6cf 100644 --- a/packages/client/ui-settings-web-search/package.json +++ b/packages/client/ui-settings-web-search/package.json @@ -56,7 +56,6 @@ "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", "@deepseek-ai/dsh-client-ui-settings": "workspace:*", "@deepseek-ai/dsh-client-ui-slots": "workspace:*", - "@types/react": "~18.3.1", "react": "^18.2.0" }, "files": [ diff --git a/packages/client/ui-settings-web-search/tsdown.config.ts b/packages/client/ui-settings-web-search/tsdown.config.ts index 112d2e7f96..7aa03cebc1 100644 --- a/packages/client/ui-settings-web-search/tsdown.config.ts +++ b/packages/client/ui-settings-web-search/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-settings-web-search', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-settings/package.json b/packages/client/ui-settings/package.json index 772529a9eb..d01f49a4f2 100644 --- a/packages/client/ui-settings/package.json +++ b/packages/client/ui-settings/package.json @@ -49,7 +49,6 @@ "@deepseek-ai/dsh-client-test-runtime": "workspace:*", "@deepseek-ai/dsh-client-ui-slots": "workspace:*", "@deepseek-ai/dsh-settings": "workspace:*", - "@types/react": "~18.3.1", "react": "^18.2.0", "@deepseek-ai/dsh-util-values": "workspace:*" }, diff --git a/packages/client/ui-settings/tsdown.config.ts b/packages/client/ui-settings/tsdown.config.ts index 53ee2302d2..7aa03cebc1 100644 --- a/packages/client/ui-settings/tsdown.config.ts +++ b/packages/client/ui-settings/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-settings', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-shortcuts/README.i18n.yaml b/packages/client/ui-shortcuts/README.i18n.yaml index c19532d5f5..48f267d1ad 100644 --- a/packages/client/ui-shortcuts/README.i18n.yaml +++ b/packages/client/ui-shortcuts/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 0cd57b7b8d8c9b59 /deepseek-ai-dsh-client-ui-shortcuts/use-this-package: - en: 045d38d5efdf1ff0 - zh: 86a700339f5abc49 + en: 84a109505e902f0a + zh: 87053c34cf4da672 /deepseek-ai-dsh-client-ui-shortcuts/understand-the-implementation: en: f56f2610e7258825 zh: 249d7cdd55c57166 diff --git a/packages/client/ui-shortcuts/README.md b/packages/client/ui-shortcuts/README.md index 8c874fb2bc..d6bf0cf06b 100644 --- a/packages/client/ui-shortcuts/README.md +++ b/packages/client/ui-shortcuts/README.md @@ -29,7 +29,7 @@ The Web app bundle mounts this package together with `shortcuts`. The reference `Mod+/` opens the reference when no modal is open or Settings is in front, and closes it when the reference is in front. While the reference is in front, the Settings command cannot open or close Settings. Held-key repeats are ignored. Recording, pending writes, and a nested confirmation prevent the reference shortcut from closing the dialog. -Core actions follow a fixed product order, independent of plugin registration, unloading, or remounting. Stopping the current response ends the Message input group and remains read-only. Extension commands outside that order follow the core actions in stable command ID order; other fixed actions retain their groups. +Core actions follow a fixed product order, independent of plugin registration, unloading, or remounting. Desktop's font-size actions follow Open settings, with Increase before Decrease. Stopping the current response ends the Message input group and remains read-only. Extension commands outside that order follow the core actions in stable command ID order; other fixed actions retain their groups. Search matches an ordered subsequence within one label, English alias, or key combination, ignoring case. Each matching command appears once. Search relevance takes precedence; equal matches retain the display order. Application and fixed-action rows remain 42 CSS pixels high during normal display and recording; long command names are truncated. Application rows show command names and binding controls without availability subtitles or reason tooltips, including conflicting and invalid bindings. Commands that cannot execute remain silent. Both platforms show each editable row’s complete key combination in one settings-control grey block with secondary ink. Fixed actions use unboxed tertiary text, cannot enter recording, and do not highlight on hover. Group titles align with command labels. diff --git a/packages/client/ui-shortcuts/README.zh.md b/packages/client/ui-shortcuts/README.zh.md index 4ba2688c9f..a8f61d7466 100644 --- a/packages/client/ui-shortcuts/README.zh.md +++ b/packages/client/ui-shortcuts/README.zh.md @@ -29,7 +29,7 @@ Web 应用 bundle 将此包与 `shortcuts` 一同挂载。速查仅展示已注 没有模态弹窗或设置位于最前方时,`Mod+/` 打开速查;速查位于最前方时,该快捷键关闭速查。速查位于前方时,设置命令不能打开或关闭设置。长按产生的重复按键不执行操作。录键期间、写入尚未完成或嵌套确认框打开时,速查快捷键不会关闭弹窗。 -核心操作采用固定产品顺序,不受插件注册、卸载或重新挂载影响。停止当前回复位于“消息输入”分组末尾,仍为只读项。未列入该顺序的扩展命令在核心操作之后按命令 ID 稳定排列;其余固定操作保留原有分组。 +核心操作采用固定产品顺序,不受插件注册、卸载或重新挂载影响。Desktop 的字号操作位于“打开设置”之后,“增大字号”在“减小字号”之前。停止当前回复位于“消息输入”分组末尾,仍为只读项。未列入该顺序的扩展命令在核心操作之后按命令 ID 稳定排列;其余固定操作保留原有分组。 搜索在单个操作名称、英文别名或按键组合内按字符顺序模糊匹配,忽略大小写。每个命中命令只出现一次。搜索时优先按相关性排序,同分项保留展示顺序。正常显示和录制时,应用操作行和固定操作行均保持 42 CSS 像素高;过长的命令名称会被截断。应用命令行只显示名称和改键控件,不显示可用性副标题或原因悬浮提示,冲突和无效绑定也不例外。无法执行的命令静默结束。两个平台均将可编辑行的完整快捷键放在同一个设置控件灰色色块中,使用次级文字色。固定操作使用无背景的三级文字色,不能进入录键,悬停时不高亮。分组标题与命令名称左对齐。 diff --git a/packages/client/ui-shortcuts/package.json b/packages/client/ui-shortcuts/package.json index b014f63904..f50a08ccdf 100644 --- a/packages/client/ui-shortcuts/package.json +++ b/packages/client/ui-shortcuts/package.json @@ -56,7 +56,6 @@ "@deepseek-ai/dsh-client-ui-layout": "workspace:*", "@deepseek-ai/dsh-client-ui-settings": "workspace:*", "@deepseek-ai/dsh-brand": "workspace:*", - "@types/react": "~18.3.1", "clsx": "^2.1.1", "react": "^18.2.0", "@deepseek-ai/dsh-client-test-runtime": "workspace:*" diff --git a/packages/client/ui-shortcuts/src/client/Reference.tsx b/packages/client/ui-shortcuts/src/client/Reference.tsx index d5dd35efd6..1235b27631 100644 --- a/packages/client/ui-shortcuts/src/client/Reference.tsx +++ b/packages/client/ui-shortcuts/src/client/Reference.tsx @@ -33,6 +33,8 @@ const coreActionOrder = new Map([ 'session.fork', 'session.archive', 'settings.open', + 'fontSize.increase', + 'fontSize.decrease', 'workspace.openLocal', 'sidebar.right.toggle', 'workspace.files', diff --git a/packages/client/ui-shortcuts/tests/reference.client.spec.tsx b/packages/client/ui-shortcuts/tests/reference.client.spec.tsx index d2ffd27699..2e79e2fb88 100644 --- a/packages/client/ui-shortcuts/tests/reference.client.spec.tsx +++ b/packages/client/ui-shortcuts/tests/reference.client.spec.tsx @@ -173,7 +173,7 @@ function referenceFixture({ runtime = 'web', dictionary = en }: { runtime?: 'web } it('keeps core actions in product order across registration, remount and label changes', () => { - const { catalog, fixedCatalog, store } = referenceFixture() + const { catalog, fixedCatalog, store } = referenceFixture({ runtime: 'desktop' }) const template = catalog.getSnapshot()[0]! const core = [ ['shortcuts.open', 'Keyboard shortcuts'], ['session.new', 'New session'], @@ -197,11 +197,14 @@ it('keeps core actions in product order across registration, remount and label c { ...stop, id: 'fixed.select' as ShortcutCommandId, label: 'Select', keys: ['Enter'], bindings: [{ code: 'Enter', modifiers: [] }], group: 'menus' }, { ...stop, id: 'fixed.dismiss' as ShortcutCommandId, label: 'Dismiss', group: 'menus' }, { ...stop, id: 'approval.accept' as ShortcutCommandId, label: 'Approve', keys: ['Enter'], bindings: [{ code: 'Enter', modifiers: [] }], group: 'approval' }, + { ...stop, id: 'fontSize.increase' as ShortcutCommandId, label: 'Increase font size', keys: ['⌘', '='], bindings: [{ code: 'Equal', modifiers: ['meta'] }], group: 'application' }, + { ...stop, id: 'fontSize.decrease' as ShortcutCommandId, label: 'Decrease font size', keys: ['⌘', '-'], bindings: [{ code: 'Minus', modifiers: ['meta'] }], group: 'application' }, stop, ] const labels = (group: string) => within(screen.getByRole('region', { name: group })).getAllByRole('listitem') .map(row => row.querySelector(':scope > span')?.textContent) - const expected = [...core.map(([, label]) => label), 'Zulu extension', 'Alpha extension'] + const expected = [...core.flatMap(([id, label]) => id === 'settings.open' + ? [label, 'Increase font size', 'Decrease font size'] : [label]), 'Zulu extension', 'Alpha extension'] for (const reversed of [true, false]) { act(() => { catalog.set(reversed ? [...extensions, ...commands].reverse() : [...extensions, ...commands]) @@ -218,7 +221,7 @@ it('keeps core actions in product order across registration, remount and label c expect(screen.queryByRole('button', { name: 'Edit shortcut for Stop reply' })).toBeNull() expect(screen.queryByRole('group', { name: 'Stop reply' })).toBeNull() act(() => { catalog.set(extensions); fixedCatalog.set(fixed.filter(row => row.id !== stop.id)) }) - expect(labels(en.application)).toEqual(['Zulu extension', 'Alpha extension']) + expect(labels(en.application)).toEqual(['Increase font size', 'Decrease font size', 'Zulu extension', 'Alpha extension']) act(() => { catalog.set([...extensions, ...[...commands].reverse()]); fixedCatalog.set(fixed) }) expect(labels(en.application)).toEqual(expected) act(() => { diff --git a/packages/client/ui-shortcuts/tsdown.config.ts b/packages/client/ui-shortcuts/tsdown.config.ts index 5d5228cbaf..7aa03cebc1 100644 --- a/packages/client/ui-shortcuts/tsdown.config.ts +++ b/packages/client/ui-shortcuts/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-shortcuts', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-sidebar-browser/package.json b/packages/client/ui-sidebar-browser/package.json index 7306f7a40d..ecd80c90a2 100644 --- a/packages/client/ui-sidebar-browser/package.json +++ b/packages/client/ui-sidebar-browser/package.json @@ -61,8 +61,6 @@ "@deepseek-ai/dsh-client-ui-sidebar-right": "workspace:*", "@deepseek-ai/dsh-client-ui-slots": "workspace:*", "@testing-library/react": "^16.3.3", - "@types/react": "~18.3.1", - "@types/react-dom": "~18.3.0", "react": "^18.2.0", "react-dom": "^18.2.0" }, diff --git a/packages/client/ui-sidebar-browser/tsdown.config.ts b/packages/client/ui-sidebar-browser/tsdown.config.ts index 229343b169..7aa03cebc1 100644 --- a/packages/client/ui-sidebar-browser/tsdown.config.ts +++ b/packages/client/ui-sidebar-browser/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-sidebar-browser', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-sidebar-documentpreview/package.json b/packages/client/ui-sidebar-documentpreview/package.json index 96a17e69c8..793a88c1b1 100644 --- a/packages/client/ui-sidebar-documentpreview/package.json +++ b/packages/client/ui-sidebar-documentpreview/package.json @@ -74,8 +74,6 @@ "@fortune-sheet/react": "1.0.4", "@testing-library/react": "^16.3.3", "@types/papaparse": "^5.3.16", - "@types/react": "~18.3.1", - "@types/react-dom": "~18.3.0", "clsx": "^2.0.0", "dompurify": "^3.4.16", "exceljs": "4.4.0", diff --git a/packages/client/ui-sidebar-documentpreview/tsdown.config.ts b/packages/client/ui-sidebar-documentpreview/tsdown.config.ts index b664119dfd..9db8b5b107 100644 --- a/packages/client/ui-sidebar-documentpreview/tsdown.config.ts +++ b/packages/client/ui-sidebar-documentpreview/tsdown.config.ts @@ -4,7 +4,7 @@ import { dirname, join } from 'node:path' import { Rolldown, type UserConfig } from 'tsdown' import { clientBundle } from '../tsdown.client.ts' -const bundle = clientBundle('@deepseek-ai/dsh-client-ui-sidebar-documentpreview', ['lib/types/index.js'], { +const bundle = clientBundle(import.meta.dirname, ['lib/types/index.js'], { clientBanner: fileName => fileName.endsWith('client.pdf.js') ? pdfLicenseBanner() : fileName.endsWith('client.excel.js') ? excelLicenseBanner() : undefined, }) diff --git a/packages/client/ui-sidebar-files/package.json b/packages/client/ui-sidebar-files/package.json index 1e1a929d11..4039019176 100644 --- a/packages/client/ui-sidebar-files/package.json +++ b/packages/client/ui-sidebar-files/package.json @@ -63,8 +63,6 @@ "@deepseek-ai/dsh-typert-protocol": "workspace:*", "@deepseek-ai/dsh-util-workspace-path": "workspace:*", "@testing-library/react": "^16.3.3", - "@types/react": "~18.3.1", - "@types/react-dom": "~18.3.0", "react": "^18.2.0", "clsx": "^2.0.0", "react-dom": "^18.2.0", diff --git a/packages/client/ui-sidebar-files/tsdown.config.ts b/packages/client/ui-sidebar-files/tsdown.config.ts index 15e759c557..7aa03cebc1 100644 --- a/packages/client/ui-sidebar-files/tsdown.config.ts +++ b/packages/client/ui-sidebar-files/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-sidebar-files', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-sidebar-right/package.json b/packages/client/ui-sidebar-right/package.json index cfac61654c..6367d31c9f 100644 --- a/packages/client/ui-sidebar-right/package.json +++ b/packages/client/ui-sidebar-right/package.json @@ -64,8 +64,6 @@ "@deepseek-ai/dsh-session": "workspace:*", "@testing-library/react": "^16.3.3", "@types/picomatch": "^4.0.3", - "@types/react": "~18.3.1", - "@types/react-dom": "~18.3.0", "react": "^18.2.0", "react-dom": "^18.2.0", "picomatch": "^4.0.7", diff --git a/packages/client/ui-sidebar-right/tests/expand-button.client.spec.tsx b/packages/client/ui-sidebar-right/tests/expand-button.client.spec.tsx index 9061f68145..a379b7010f 100644 --- a/packages/client/ui-sidebar-right/tests/expand-button.client.spec.tsx +++ b/packages/client/ui-sidebar-right/tests/expand-button.client.spec.tsx @@ -10,6 +10,7 @@ import { useSyncExternalStore } from 'react' import type { GlobalStandardProps, SessionStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import type { ShortcutCatalogEntry } from '@deepseek-ai/dsh-client-shortcuts/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { allowAllAccess } from '@deepseek-ai/dsh-client-test-runtime' import { ExpandButton } from '../src/client/shell/ExpandButton.tsx' import type { ExpandButtonProps } from '../src/client/shell/ExpandButton.tsx' import { createSidebarRightStore } from '../src/client/stores.ts' @@ -21,6 +22,7 @@ const standard: GlobalStandardProps & SessionStandardProps = { useInput: unused, useChat: unused, useTrajectory: unused, usePanelInfo: unused, useSessions: unused, useSessionStatus: unused, useSessionRetainInfo: unused, useResource: unused, useWorkspaces: unused, + ...allowAllAccess, inputActions: { captureInsertion: unused, insertText: unused, setDraft: unused, persistDraft: unused, addAttachments: unused, removeAttachment: unused, pruneAttachments: unused, submit: unused }, } diff --git a/packages/client/ui-sidebar-right/tests/guide-body.client.spec.tsx b/packages/client/ui-sidebar-right/tests/guide-body.client.spec.tsx index a4015b079e..445b4dae68 100644 --- a/packages/client/ui-sidebar-right/tests/guide-body.client.spec.tsx +++ b/packages/client/ui-sidebar-right/tests/guide-body.client.spec.tsx @@ -19,7 +19,7 @@ import type { IconProps } from '@deepseek-ai/dsh-client-ui-primitives' import { GuideBody } from '../src/client/tabs/guide/GuideBody.tsx' import type { GuideBodyProps } from '../src/client/tabs/guide/GuideBody.tsx' import type { SidebarRightGuideBox } from '../src/client/tab-registry.ts' -import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' import css from '../src/client/tabs/guide/GuideBody.module.css' afterEach(cleanup) @@ -31,6 +31,7 @@ const standard: GlobalStandardProps & SessionStandardProps = { useInput: unused, useChat: unused, useTrajectory: unused, usePanelInfo: unused, useSessions: unused, useSessionStatus: unused, useSessionRetainInfo: unused, useResource: unused, useWorkspaces: unused, + ...allowAllAccess, inputActions: { captureInsertion: unused, insertText: unused, setDraft: unused, persistDraft: unused, addAttachments: unused, removeAttachment: unused, pruneAttachments: unused, submit: unused }, } diff --git a/packages/client/ui-sidebar-right/tsdown.config.ts b/packages/client/ui-sidebar-right/tsdown.config.ts index 559865cdcd..7aa03cebc1 100644 --- a/packages/client/ui-sidebar-right/tsdown.config.ts +++ b/packages/client/ui-sidebar-right/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-sidebar-right', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-sidebar-terminal/package.json b/packages/client/ui-sidebar-terminal/package.json index e872e94014..0de48c9ac3 100644 --- a/packages/client/ui-sidebar-terminal/package.json +++ b/packages/client/ui-sidebar-terminal/package.json @@ -60,8 +60,6 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", "@testing-library/react": "^16.3.3", - "@types/react": "~18.3.1", - "@types/react-dom": "~18.3.0", "react": "^18.2.0", "clsx": "^2.0.0", "react-dom": "^18.2.0", diff --git a/packages/client/ui-sidebar-terminal/tests/cleanup.client.spec.tsx b/packages/client/ui-sidebar-terminal/tests/cleanup.client.spec.tsx index ec7bcd46ca..390fecc4da 100644 --- a/packages/client/ui-sidebar-terminal/tests/cleanup.client.spec.tsx +++ b/packages/client/ui-sidebar-terminal/tests/cleanup.client.spec.tsx @@ -2,7 +2,7 @@ /** Cleanup errors remain reachable after the terminal tab has disappeared. */ import { afterEach, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render, within } from '@testing-library/react' -import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import type { TerminalCloseFailure } from '@deepseek-ai/dsh-api-terminal-controller/client' import type { WebTerminalId } from '@deepseek-ai/dsh-api-terminal-controller/types' import { TerminalCleanup } from '../src/client/TerminalCleanup.tsx' @@ -15,6 +15,7 @@ const unusedHook = (): never => { throw new Error('Terminal cleanup does not con const standard: Omit = { useSessions: unusedHook, useSessionStatus: unusedHook, useSessionRetainInfo: unusedHook, usePanelInfo: unusedHook, useWorkspaces: unusedHook, useResource: unusedHook, + ...allowAllAccess, } it('stays absent for ordinary closes and offers a retry only after cleanup fails', () => { diff --git a/packages/client/ui-sidebar-terminal/tests/terminal-body.client.spec.tsx b/packages/client/ui-sidebar-terminal/tests/terminal-body.client.spec.tsx index 1c75616db3..28f3895e4b 100644 --- a/packages/client/ui-sidebar-terminal/tests/terminal-body.client.spec.tsx +++ b/packages/client/ui-sidebar-terminal/tests/terminal-body.client.spec.tsx @@ -57,7 +57,7 @@ beforeEach(() => { afterEach(() => { cleanup(); vi.restoreAllMocks(); vi.unstubAllGlobals(); disconnect.mockClear(); measure = undefined }) const info = { id: 'terminal' as WebTerminalId, title: 'bash', shell: { path: '/bin/bash', name: 'bash', args: ['-i'] }, cwd: '/workspace', cols: 80, rows: 24, state: 'running' as const, exitCode: null } -const environment = { cwd: '/workspace', maxCols: 200, maxRows: 100, scrollback: 1000, maxInputBytes: 1000 } +const environment = { cwd: '/workspace', maxCols: 200, maxRows: 100, scrollback: 1000, maxInputBytes: 1000, maxBufferedBytes: 100_000 } const idle: TerminalViewState = { phase: 'idle', writable: false, environment } const titleSurfaces = [ { @@ -140,7 +140,7 @@ it('shows only the terminal screen while connected and detaches on unmount', () }) it('keeps one emulator across rename and locale updates, applies snapshots and output once, and releases listeners', () => { - const snapshot: TerminalViewState = { ...idle, info, phase: 'connected', writable: true, render: { revision: 1, frame: { type: 'snapshot', sequence: 0, info, screen: 'restored' } } } + const snapshot: TerminalViewState = { ...idle, info, phase: 'connected', writable: true, render: { revision: 1, frame: { type: 'snapshot', writable: true, sequence: 0, info, screen: 'restored' } } } const h = mount(snapshot) const terminal = fake.terminals[0]! expect(terminal.reset).toHaveBeenCalledOnce() diff --git a/packages/client/ui-sidebar-terminal/tests/terminal-guide.client.spec.tsx b/packages/client/ui-sidebar-terminal/tests/terminal-guide.client.spec.tsx index a50f7ca835..bdcd5fb2b1 100644 --- a/packages/client/ui-sidebar-terminal/tests/terminal-guide.client.spec.tsx +++ b/packages/client/ui-sidebar-terminal/tests/terminal-guide.client.spec.tsx @@ -2,7 +2,7 @@ /** Guide shell discovery, direct launch and cancellation stay within the entry lifetime. */ import { act, cleanup, fireEvent, render } from '@testing-library/react' import { afterEach, expect, it, vi } from 'vitest' -import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import type { TerminalLaunchShells } from '@deepseek-ai/dsh-api-terminal-controller/client' import type { GlobalStandardProps, SessionStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import type { SessionId } from '@deepseek-ai/dsh-session/types' @@ -19,6 +19,7 @@ const standard: GlobalStandardProps & SessionStandardProps = { useInput: unused, useChat: unused, useTrajectory: unused, usePanelInfo: unused, useSessions: unused, useSessionStatus: unused, useSessionRetainInfo: unused, useResource: unused, useWorkspaces: unused, + ...allowAllAccess, inputActions: { captureInsertion: unused, insertText: unused, setDraft: unused, persistDraft: unused, addAttachments: unused, removeAttachment: unused, pruneAttachments: unused, submit: unused }, } diff --git a/packages/client/ui-sidebar-terminal/tsdown.config.ts b/packages/client/ui-sidebar-terminal/tsdown.config.ts index 016643c5fb..7aa03cebc1 100644 --- a/packages/client/ui-sidebar-terminal/tsdown.config.ts +++ b/packages/client/ui-sidebar-terminal/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-sidebar-terminal', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-sidebar/package.json b/packages/client/ui-sidebar/package.json index d3a6116c6f..e671c81756 100644 --- a/packages/client/ui-sidebar/package.json +++ b/packages/client/ui-sidebar/package.json @@ -59,7 +59,6 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:*", "@deepseek-ai/dsh-client-ui-session": "workspace:*", "@deepseek-ai/dsh-client-ui-workspace": "workspace:*", - "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:~", "react": "^18.2.0", "clsx": "^2.0.0", diff --git a/packages/client/ui-sidebar/tests/pointer-scrollbars.client.spec.tsx b/packages/client/ui-sidebar/tests/pointer-scrollbars.client.spec.tsx index 1522597dae..200cfe6f74 100644 --- a/packages/client/ui-sidebar/tests/pointer-scrollbars.client.spec.tsx +++ b/packages/client/ui-sidebar/tests/pointer-scrollbars.client.spec.tsx @@ -8,6 +8,7 @@ import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render } from '@testing-library/react' +import { allowAllAccess } from '@deepseek-ai/dsh-client-test-runtime' import type { SidebarRootComponentProps, SidebarSectionOwnerProps } from '../src/client/contract/slots.ts' import { SidebarRoot } from '../src/client/SidebarRoot.tsx' import { en } from '../src/client/locales.ts' @@ -43,6 +44,7 @@ function mountColumn(): { column: HTMLElement; quiet: () => boolean } { useSessions={neverHook} useSessionStatus={useSessionStatus} useSessionRetainInfo={neverHook} usePanelInfo={usePanelInfo} selectPanel={() => {}} usePanels={selector => selector([])} useShortcuts={selector => selector([])} useResource={useResource} useWorkspaces={neverHook} + {...allowAllAccess} startSession={vi.fn()} toggleSidebar={vi.fn()} t={t} renderSlot={((_key: string, owner: SidebarSectionOwnerProps) =>
) as SidebarRootComponentProps['renderSlot']} diff --git a/packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx b/packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx index f2407246f8..c0aee40acf 100644 --- a/packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx +++ b/packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx @@ -4,7 +4,7 @@ import type { ShortcutCatalogEntry, ShortcutCommandId } from '@deepseek-ai/dsh-c import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' -import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import type { ReactNode } from 'react' import type { SidebarFooterActionOwnerProps, SidebarRootComponentProps, SidebarSectionOwnerProps, @@ -54,6 +54,7 @@ function mountShell({ collapsed = false, width = 300, shortcuts = [] }: { useSessions={neverHook} useSessionStatus={useSessionStatus} useSessionRetainInfo={neverHook} usePanelInfo={usePanelInfo} selectPanel={() => {}} usePanels={selector => selector([])} useShortcuts={selector => selector(shortcuts)} useResource={useResource} useWorkspaces={neverHook} + {...allowAllAccess} startSession={startSession} toggleSidebar={toggleSidebar} t={t} renderSlot={(( key: string, @@ -115,6 +116,7 @@ describe('SidebarRoot shell', () => { render( select([])} useShortcuts={select => select([shortcut])} />) const button = screen.getByRole('button', { name: 'New session' }) expect(button.getAttribute('aria-keyshortcuts')).toBe('Control+N') @@ -143,6 +145,7 @@ describe('SidebarRoot shell', () => { useSessions={neverHook} useSessionStatus={useSessionStatus} useSessionRetainInfo={neverHook} usePanelInfo={usePanelInfo} selectPanel={() => {}} usePanels={selector => selector([])} useShortcuts={selector => selector([])} useResource={useResource} useWorkspaces={neverHook} + {...allowAllAccess} startSession={vi.fn()} toggleSidebar={vi.fn()} t={t} renderSlot={((_key: string, _owner: unknown, options?: { fallback?: ReactNode }) => options?.fallback ?? null) as SidebarRootComponentProps['renderSlot']} @@ -163,6 +166,7 @@ describe('SidebarRoot shell', () => { useSessions={neverHook} useSessionStatus={useSessionStatus} useSessionRetainInfo={neverHook} usePanelInfo={usePanelInfo} selectPanel={() => {}} usePanels={selector => selector([])} useShortcuts={selector => selector([])} useResource={useResource} useWorkspaces={neverHook} + {...allowAllAccess} startSession={vi.fn()} toggleSidebar={vi.fn()} t={t} renderSlot={((_key: string, _owner: unknown, options?: { fallback?: ReactNode }) => options?.fallback ?? null) as SidebarRootComponentProps['renderSlot']} @@ -178,6 +182,7 @@ describe('SidebarRoot shell', () => { useSessions={neverHook} useSessionStatus={useSessionStatus} useSessionRetainInfo={neverHook} usePanelInfo={usePanelInfo} selectPanel={() => {}} usePanels={selector => selector([])} useShortcuts={selector => selector([])} useResource={useResource} useWorkspaces={neverHook} + {...allowAllAccess} startSession={vi.fn()} toggleSidebar={vi.fn()} t={t} renderSlot={((_key: string, _owner: unknown, options?: { fallback?: ReactNode }) => options?.fallback ?? null) as SidebarRootComponentProps['renderSlot']} @@ -225,6 +230,7 @@ describe('SidebarRoot shell', () => { useSessions={neverHook} useSessionStatus={useSessionStatus} useSessionRetainInfo={neverHook} usePanelInfo={usePanelInfo} selectPanel={() => {}} usePanels={selector => selector([])} useShortcuts={selector => selector([])} useResource={useResource} useWorkspaces={neverHook} + {...allowAllAccess} startSession={vi.fn()} toggleSidebar={vi.fn()} t={t} renderSlot={((key: string) => key === 'sidebar.toggle.badge' ? diff --git a/packages/client/ui-sidebar/tsdown.config.ts b/packages/client/ui-sidebar/tsdown.config.ts index 15da8a116a..7aa03cebc1 100644 --- a/packages/client/ui-sidebar/tsdown.config.ts +++ b/packages/client/ui-sidebar/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-sidebar', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-skill/package.json b/packages/client/ui-skill/package.json index 78a34d32ad..da3f022efc 100644 --- a/packages/client/ui-skill/package.json +++ b/packages/client/ui-skill/package.json @@ -63,7 +63,6 @@ "@deepseek-ai/dsh-client-ui-tool": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", "@testing-library/react": "^16.3.3", - "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:~", "react": "^18.2.0", "react-dom": "^18.2.0", diff --git a/packages/client/ui-skill/tsdown.config.ts b/packages/client/ui-skill/tsdown.config.ts index 4c5539a6b4..7aa03cebc1 100644 --- a/packages/client/ui-skill/tsdown.config.ts +++ b/packages/client/ui-skill/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-skill', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-slots/README.i18n.yaml b/packages/client/ui-slots/README.i18n.yaml index af812bc333..453ea30bac 100644 --- a/packages/client/ui-slots/README.i18n.yaml +++ b/packages/client/ui-slots/README.i18n.yaml @@ -21,8 +21,8 @@ en: 71cbe7aa26f3ff80 zh: 0e35265d4d95cc36 /deepseek-ai-dsh-client-ui-slots/use-this-package/the-five-framework-props-shares: - en: 45e2933a5e084956 - zh: 938deedaf489f892 + en: b69ffb6ca503e781 + zh: fe5887cbfc7fb588 /deepseek-ai-dsh-client-ui-slots/use-this-package/store-seats: en: 0d1c2948112841ec zh: fc5a998cea485024 diff --git a/packages/client/ui-slots/README.md b/packages/client/ui-slots/README.md index fc4bfaf6ec..0698714bff 100644 --- a/packages/client/ui-slots/README.md +++ b/packages/client/ui-slots/README.md @@ -37,6 +37,8 @@ Factory `children` remain ordinary global Slots and must match `SlotMap`, while Every registered component receives props composed from five framework shares: the runtime share (`owner` from the parent's render call site, plus the session standard kit and global seat), the child-render share (`renderSlot` statically narrowed to declared children), the Factory-render share (`renderFactorySlot`), the store share (the declared handle's selector hook and draft-stripped actions), and the business share (inferred from `inject`). Components reference the derived props aliases; they never re-type a share locally. +An open-key standard Hook, called as `useName(key, selector?, equal?)`, also accepts `useName(key, member)`: a string second argument reads the `KeyedStandardSource` resolver's `(key, member)` source without a selector. The resolver returns the same source object for the same arguments while that source is live. + ### Store seats A register call may declare a store seat with `store: defineStore(...)`: `init` infers the state schema and `actions` is the complete draft-transform write set. Components read through the selector hook and write through the baked callbacks; the engine implementation of `defineStore` lives in the runtime package and satisfies the `DefineStore` contract exported here. diff --git a/packages/client/ui-slots/README.zh.md b/packages/client/ui-slots/README.zh.md index b08b278328..365db5b428 100644 --- a/packages/client/ui-slots/README.zh.md +++ b/packages/client/ui-slots/README.zh.md @@ -37,6 +37,8 @@ Factory `children` 仍是普通全局 Slots 且必须与 `SlotMap` 匹配,而 每个已注册组件都会收到由五个框架 share 组合而成的 props:运行时 share(父级 render 调用点的 `owner`,加上会话标准工具包与全局席位)、child render share(静态缩窄到已声明 children 的 `renderSlot`)、Factory render share(`renderFactorySlot`)、store share(已声明 handle 的 selector 钩子与移除 draft 的 actions),以及业务 share(从 `inject` 推导)。组件引用推导出的 props 别名;它们绝不在本地重新定义任何 share 的类型。 +以 `useName(key, selector?, equal?)` 调用的开放键标准钩子也接受 `useName(key, member)`:第二个参数为字符串时,钩子不经 selector,直接读取 `KeyedStandardSource` 解析器为 `(key, member)` 返回的 source。在该 source 存活期间,解析器对相同参数返回同一个 source 对象。 + ### Store 席位 register 调用可以用 `store: defineStore(...)` 声明 store 席位:`init` 推断状态 schema,`actions` 是完整的 draft-transform 写入集合。组件经 selector 钩子读取、经烘焙回调写入;`defineStore` 的引擎实现位于运行时包,并满足这里导出的 `DefineStore` 约定。 diff --git a/packages/client/ui-slots/package.json b/packages/client/ui-slots/package.json index 96aa83737a..5155bb9d17 100644 --- a/packages/client/ui-slots/package.json +++ b/packages/client/ui-slots/package.json @@ -24,7 +24,7 @@ "license": "MIT", "devDependencies": { "@deepseek-ai/dsh-client-store": "workspace:*", - "@types/react": "~18.3.1", + "react": "^18.2.0", "@deepseek-ai/cordis": "workspace:~" }, "files": [ diff --git a/packages/client/ui-slots/src/renderer.ts b/packages/client/ui-slots/src/renderer.ts index 2ea326cea4..e6ed27a9ef 100644 --- a/packages/client/ui-slots/src/renderer.ts +++ b/packages/client/ui-slots/src/renderer.ts @@ -61,8 +61,12 @@ export interface StoreInstanceLike { readonly actions: Record void> } -/** Resolve one member of an open-key standard hook family. */ -export type KeyedStandardSource = (key: string) => HostObservable | undefined +/** + * Resolve one member of an open-key standard hook family. `member` is present + * only when the Hook is called as `useName(key, member)`; the resolver returns + * the same source object for the same arguments while that source is live. + */ +export type KeyedStandardSource = (key: string, member?: string) => HostObservable | undefined /** * Framework-neutral inputs from which the renderer materializes standard @@ -74,7 +78,11 @@ export interface StandardSourceBinding { readonly key: string | undefined /** Fixed-name sources; each `name` becomes a `useName` selector Hook. */ readonly hooks: Readonly | undefined>> - /** Open-key source families; each `name` becomes a `useName(key, selector)` Hook. */ + /** + * Open-key source families; each `name` becomes a `useName(key, selector?, equal?)` + * Hook, which also accepts `useName(key, member)` to read the resolver's + * `(key, member)` source without a selector. + */ readonly keyedHooks: Readonly> /** Stable plain values copied into standard props. */ readonly props: Readonly> diff --git a/packages/client/ui-slots/tsdown.config.ts b/packages/client/ui-slots/tsdown.config.ts index 09a4a4dc0e..a6264e0b21 100644 --- a/packages/client/ui-slots/tsdown.config.ts +++ b/packages/client/ui-slots/tsdown.config.ts @@ -1,6 +1,6 @@ import { staticLinked } from '../tsdown.client.ts' export default staticLinked( - '@deepseek-ai/dsh-client-ui-slots', + import.meta.dirname, ['lib/types/index.js'], ) diff --git a/packages/client/ui-subagent/README.i18n.yaml b/packages/client/ui-subagent/README.i18n.yaml index 7cd80f156c..b12b090972 100644 --- a/packages/client/ui-subagent/README.i18n.yaml +++ b/packages/client/ui-subagent/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-client-ui-subagent/use-this-package: - en: 9a72f6fbd3828d1f - zh: e8b23941cbd16036 + en: 9aafcfbd6a33173c + zh: 6dbd48672f58cd2d /deepseek-ai-dsh-client-ui-subagent/use-this-package/browsing-the-tree: en: 3508631091820123 zh: ec7da9e5bedb22ba diff --git a/packages/client/ui-subagent/README.md b/packages/client/ui-subagent/README.md index 76e0106c68..ad56362a7a 100644 --- a/packages/client/ui-subagent/README.md +++ b/packages/client/ui-subagent/README.md @@ -25,7 +25,7 @@ Browse local subagent conversations and external tasks from the parent header. L ## Use this package -The session header keeps the current session title as the lineage breadcrumb; when the session's direct catalog has entries or a read has failed, the descendant-count trigger renders at the start of the header actions band, with no breadcrumb separator. An absent catalog, an empty loading catalog, or a successfully loaded empty catalog hides the count trigger. The trigger opens that direct catalog, reports its total and running counts, and loads nested catalogs only when their rows expand. Select a local child at any depth to open that child's conversation with its exact `{parentSessionId, childSessionId, mode}` address, or use the row's trailing arrow to open the same address in the right Sidebar, preferring a separate pane when room permits. +The session header keeps the current session title as the lineage breadcrumb; when the session's direct catalog has entries or a read has failed, the descendant-count trigger renders after the preset label in the header actions band, with no breadcrumb separator. An absent catalog, an empty loading catalog, or a successfully loaded empty catalog hides the count trigger. The trigger opens that direct catalog, reports its total and running counts, and loads nested catalogs only when their rows expand. Select a local child at any depth to open that child's conversation with its exact `{parentSessionId, childSessionId, mode}` address, or use the row's trailing arrow to open the same address in the right Sidebar, preferring a separate pane when room permits. This package registers the `dsh-resource://subagentchat/session/?parent=&mode=` resource and builtin Sidebar tab type. The resource retains the child `SessionReference` directly from its address without refreshing the parent catalog, and releases the reference when the tab record closes. The tab renders the shared `conversation.content` Factory through `sidebar.chat.conversation`, fixes the local View to Chat, and omits the main Conversation header and width controls. diff --git a/packages/client/ui-subagent/README.zh.md b/packages/client/ui-subagent/README.zh.md index 6bb19ef56c..1c2048920b 100644 --- a/packages/client/ui-subagent/README.zh.md +++ b/packages/client/ui-subagent/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -会话页头把当前会话标题作为谱系面包屑;当会话的直接目录有子项或读取失败时,后代数量触发器渲染在页头操作区的最前方,不带任何面包屑分隔符。目录缺席、空目录加载中或成功加载为空时,均隐藏数量触发器。触发器打开该直接目录,报告总数与运行数,并且只在行展开时加载嵌套目录。选择任意深度,即可用该子会话的确切 `{parentSessionId, childSessionId, mode}` 地址打开其对话;也可以使用行尾箭头在右侧 Sidebar 打开同一地址,并在空间允许时优先使用独立分栏。 +会话页头把当前会话标题作为谱系面包屑;当会话的直接目录有子项或读取失败时,后代数量触发器渲染在页头操作区中预设标签之后,不带任何面包屑分隔符。目录缺席、空目录加载中或成功加载为空时,均隐藏数量触发器。触发器打开该直接目录,报告总数与运行数,并且只在行展开时加载嵌套目录。选择任意深度,即可用该子会话的确切 `{parentSessionId, childSessionId, mode}` 地址打开其对话;也可以使用行尾箭头在右侧 Sidebar 打开同一地址,并在空间允许时优先使用独立分栏。 本包注册 `dsh-resource://subagentchat/session/?parent=&mode=` 资源与 builtin Sidebar tab 类型。资源直接根据地址保留 child 的 `SessionReference`,不刷新 parent 目录,并在 tab 记录关闭时释放 reference。tab 通过 `sidebar.chat.conversation` 渲染共享 `conversation.content` Factory,把局部 View 固定为 Chat,并省略主 Conversation 的 Header 与宽度控制。 diff --git a/packages/client/ui-subagent/package.json b/packages/client/ui-subagent/package.json index caf8ee1949..a0d55f7c57 100644 --- a/packages/client/ui-subagent/package.json +++ b/packages/client/ui-subagent/package.json @@ -61,8 +61,6 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:*", "@deepseek-ai/dsh-subagent": "workspace:*", "@deepseek-ai/dsh-token-meter": "workspace:*", - "@types/react": "~18.3.1", - "@types/react-dom": "~18.3.0", "@deepseek-ai/cordis": "workspace:~", "react": "^18.2.0", "react-dom": "^18.2.0", diff --git a/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx b/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx index 611557c923..fa020060fa 100644 --- a/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx +++ b/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx @@ -751,7 +751,7 @@ export type SubagentCatalogActionProps = /** * Session-header catalog action for root sessions: the descendant count and - * its dropdown at the start of the header actions band. Child sessions render nothing + * its dropdown after the preset label in the header actions band. Child sessions render nothing * here — their breadcrumb switcher in the lineage slot owns the same * navigation. * @param props - Session standard props plus the catalog actions and translator. diff --git a/packages/client/ui-subagent/src/client/index.ts b/packages/client/ui-subagent/src/client/index.ts index e24490966d..6d4220ef9c 100644 --- a/packages/client/ui-subagent/src/client/index.ts +++ b/packages/client/ui-subagent/src/client/index.ts @@ -84,10 +84,9 @@ export function apply(ctx: ClientContext): void { () => ctx.slots.register({ name: 'conversation.session.header.actions', id: 'subagent-catalog', - // Leads the band, directly after the title crumbs: subagent lineage is - // the title's own continuation, ahead of Team navigation (-20) and the - // preset label (-10). - order: -30, + // Between the preset label (-10) and background tasks (20), after Team + // navigation (-20). + order: 10, locale: NS, inject: catalogActions, }, SubagentCatalogAction), diff --git a/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts b/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts index 23aa61b015..d75c9d8639 100644 --- a/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts @@ -134,10 +134,11 @@ describe('apply', () => { ]) // The root-session catalog seat registers in the actions band with the - // same business face, leading the band directly after the title crumbs. + // same business face, between the preset label (-10) and background + // tasks (20). const actionEntry = ctx.slots.entries('conversation.session.header.actions') .find(entry => entry.options.id === 'subagent-catalog')! - expect(actionEntry.options.order).toBe(-30) + expect(actionEntry.options.order).toBe(10) expect(actionEntry.inject).toBe(catalogEntry.inject) const composerEntry = ctx.slots.entries('conversation.composer') diff --git a/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx b/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx index dac2d9e053..ed5b4043fa 100644 --- a/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx +++ b/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react' -import { makeTranslate, RemoteError, sessionSnapshot } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, makeTranslate, RemoteError, sessionSnapshot } from '@deepseek-ai/dsh-client-test-runtime' import type { SessionListState, SessionSummary, SessionSnapshot, } from '@deepseek-ai/dsh-api-session-controller/client' @@ -94,6 +94,7 @@ function props( useSessionRetainInfo: unused, useWorkspaces: unused, useResource: unused, + ...allowAllAccess, useProjection: unused, useConversation: unused, useInput: unused, diff --git a/packages/client/ui-subagent/tsdown.config.ts b/packages/client/ui-subagent/tsdown.config.ts index 159de0398f..7aa03cebc1 100644 --- a/packages/client/ui-subagent/tsdown.config.ts +++ b/packages/client/ui-subagent/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-subagent', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-theme/README.i18n.yaml b/packages/client/ui-theme/README.i18n.yaml index d828a43c5a..b208594041 100644 --- a/packages/client/ui-theme/README.i18n.yaml +++ b/packages/client/ui-theme/README.i18n.yaml @@ -18,8 +18,8 @@ en: 5a279269e31713c1 zh: dcd5c31fbdfd63e2 /deepseek-ai-dsh-client-ui-theme/use-this-package/appearance-and-font-size: - en: ebf89ea31593f49c - zh: 06c98ba30ec3bb6b + en: cbfc02f8b3b02e0d + zh: 5a8f4d9d688d113c /deepseek-ai-dsh-client-ui-theme/use-this-package/fonts: en: b45d5b48ca799c3a zh: af65514b3759204f diff --git a/packages/client/ui-theme/README.md b/packages/client/ui-theme/README.md index 1ed77be835..8904a359b2 100644 --- a/packages/client/ui-theme/README.md +++ b/packages/client/ui-theme/README.md @@ -31,6 +31,8 @@ Users switch the color scheme, font sizes, and fonts from rows in Settings (Gene The plugin registers Appearance preference cubes and the text font-size stepper in the General section. A More font settings button after the stepper expands a region below it, collapsed on every settings open, with a font row for each of text, code, and the terminal, plus the code and terminal font-size steppers. The text stepper accepts integer values from 10 through 22 px and defaults to 14 px. It changes conversation headings and base text by the same increment, including the user bubble and composer draft; flow-row titles, summaries, and tables follow one step under the body size, while small text keeps a fixed size. The code stepper (10–16 px, default 11) sets the code-block size; inline code stays 1 px larger, and the small tool-output variant uses the same size. The terminal stepper (10–20 px, default 13) sets the sidebar terminal cell font size; changing it keeps the shell and output and refits the grid. Each accepted change writes through the Host settings API. Rapid changes serialize in gesture order with namespace revisions, and a rejected latest write reloads the durable values. +Desktop's fixed `Cmd`/`Ctrl` + `+` (or `=`) and `Cmd`/`Ctrl` + `-` shortcuts change the text, code, and terminal font sizes together by 1 px, each within its own range. Each physical key press saves the changed fields in one settings mutation; holding a key does not repeat the adjustment. The actions appear in the shortcut reference and work in the main document, including dialogs and text inputs, unless a local control consumes the input; terminal input retains its own key handling. Web retains the browser's zoom shortcuts. + ### Fonts Three rows set the text font (interface and conversation text), the code font (code blocks, inline code, and other `--ds-font-family-code` text), and the sidebar terminal font independently. Each field takes comma-separated family names and commits on blur or Enter; an empty field restores the built-in stack. The service normalizes each list by dropping quotes, backslashes, angle brackets, and control characters and double-quoting every name except CSS generic families, then stores it in `textFontFamily`, `codeFontFamily`, or `terminalFontFamily`. The user list precedes the role's built-in stack, so a font missing on the machine falls back to the default. The field accepts any installed font name; it does not enumerate local fonts. diff --git a/packages/client/ui-theme/README.zh.md b/packages/client/ui-theme/README.zh.md index bac4c7f3a3..a6c660c0db 100644 --- a/packages/client/ui-theme/README.zh.md +++ b/packages/client/ui-theme/README.zh.md @@ -31,6 +31,8 @@ kind: "package-reference" 插件在「通用」分区注册外观偏好方块与正文字号步进器。步进器后的「更多字体设置」按钮在其下方展开一个区域,每次打开设置时均为折叠状态,其中包含正文、代码与终端各自的字体行,以及代码与终端的字号步进器。正文步进器接受 10 至 22 px 的整数,默认值为 14 px。它以相同增量调整会话标题与基础文本,包括用户气泡与 composer 草稿;流内行的标题、摘要与表格跟随比正文低一档的字号,小号文本保持固定字号。代码步进器(10–16 px,默认 11)设置代码块字号;行内代码保持大 1 px,工具输出的小号变体使用相同字号。终端步进器(10–20 px,默认 13)设置侧栏终端的单元格字号;修改后保留 shell 与输出,并重新适配网格。每次通过的变更都经 Host settings API 写入。连续快速变更按操作顺序携带命名空间 revision 串行写入,最新写入被拒时重新加载持久值。 +Desktop 的固定快捷键 `Cmd`/`Ctrl` + `+`(或 `=`)和 `Cmd`/`Ctrl` + `-` 同时将正文、代码与终端字号增减 1 px,各自沿用其取值范围。每次实际按下按键,通过一次设置操作保存变化字段;长按不会连续调整字号。这些操作显示在快捷键参考中,可在主文档的对话框和文本输入框内使用,但会让已消费输入的本地控件先处理;终端输入保留自身的按键处理。Web 保留浏览器的缩放快捷键。 + ### 字体 三行分别独立设置正文字体(界面与会话正文)、代码字体(代码块、行内代码及其他使用 `--ds-font-family-code` 的文本)与侧栏终端字体。每个输入框接受逗号分隔的字体名,失焦或按 Enter 时提交;清空输入框即恢复内置字体栈。服务会规范化每个列表:去掉引号、反斜杠、尖括号与控制字符,并给 CSS 通用字体族以外的名称加双引号,然后存入 `textFontFamily`、`codeFontFamily` 或 `terminalFontFamily`。用户列表排在该类内置字体栈之前,因此本机缺少的字体会回退到默认字体。输入框接受任何已安装字体的名称,但不枚举本地字体。 diff --git a/packages/client/ui-theme/package.json b/packages/client/ui-theme/package.json index d66ddbf105..669685f892 100644 --- a/packages/client/ui-theme/package.json +++ b/packages/client/ui-theme/package.json @@ -32,6 +32,7 @@ "inject": [ "@deepseek-ai/dsh-client-connection", "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-shortcuts", "@deepseek-ai/dsh-client-ui-renderer", "@deepseek-ai/dsh-client-ui-settings", "@deepseek-ai/dsh-api-remotes" @@ -49,6 +50,7 @@ "@deepseek-ai/dsh-api-remotes": "workspace:*", "@deepseek-ai/dsh-client-connection": "workspace:*", "@deepseek-ai/dsh-client-locale": "workspace:*", + "@deepseek-ai/dsh-client-shortcuts": "workspace:*", "@deepseek-ai/dsh-client-store": "workspace:*", "@deepseek-ai/dsh-client-test-runtime": "workspace:*", "@deepseek-ai/dsh-client-ui-primitives": "workspace:*", @@ -56,7 +58,6 @@ "@deepseek-ai/dsh-client-ui-settings": "workspace:*", "@deepseek-ai/dsh-client-ui-slots": "workspace:*", "@deepseek-ai/dsh-host-webserver": "workspace:*", - "@types/react": "~18.3.1", "react": "^18.2.0", "clsx": "^2.0.0", "@deepseek-ai/dsh-settings": "workspace:*" diff --git a/packages/client/ui-theme/src/client/index.ts b/packages/client/ui-theme/src/client/index.ts index e2f47eb3bd..f37bb34f4b 100644 --- a/packages/client/ui-theme/src/client/index.ts +++ b/packages/client/ui-theme/src/client/index.ts @@ -6,10 +6,12 @@ * settings scope loads and stores the preference in the user-settings * document. The plugin also registers the Appearance and font settings rows * into the settings General section — the theme feature owns its own settings - * surface. + * surface. Desktop shortcuts adjust text, code, and terminal font sizes together. */ import type { Context as ClientContext } from '@deepseek-ai/cordis' import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots' +import type { ShortcutCommandId } from '@deepseek-ai/dsh-client-shortcuts/client' +import type { NormalizedBinding } from '@deepseek-ai/dsh-client-shortcuts/protocol' // Type-only: the ctx.configForms Context merge. Cross-plugin collaboration // goes through the service, never a value import (client bundle purity gate). import type { ConfigForm } from '@deepseek-ai/dsh-client-ui-settings/client' @@ -201,6 +203,7 @@ export class ThemeRuntime { } ctx.effect(() => host.subscribe(() => { this.adopt() }), 'ui-theme: settings scope adoption') this.adopt() + installFontSizeShortcuts(ctx, (delta) => { this.adjustFontSizes(delta) }) } /** @@ -242,12 +245,12 @@ export class ThemeRuntime { } if (this.preference === id) return this.preference = id as ThemePreference - if (isThemePreference(id)) this.write(THEME_PREFERENCE_FIELD, id) + if (isThemePreference(id)) this.write(() => this.host.set(THEME_PREFERENCE_FIELD, id)) this.publish() } /** - * Change one role's font size — the only font-size write entry. Accepted + * Change one role's font size. Accepted * values are written through the settings scope and emit `theme/change`. * @param role - font role. * @param px - integer px within the role's `FONT_SIZE_SPECS` range; out-of-range or fractional values throw. @@ -257,7 +260,29 @@ export class ThemeRuntime { if (!isFontSize(role, px)) throw new Error(`${role} font size ${px} is outside ${spec.min}..${spec.max}`) if (this.fontSizes[role] === px) return this.fontSizes = Object.freeze({ ...this.fontSizes, [role]: px }) - this.write(spec.field, px) + this.write(() => this.host.set(spec.field, px)) + this.publish() + } + + /** + * Step all font sizes within their own ranges and save changed fields in one mutation. + * Emits one `theme/change`; no write or event occurs when every role is at its limit. + * @param delta - one pixel to add or remove from each role's current size. + */ + private adjustFontSizes(delta: 1 | -1): void { + const fontSizes = { ...this.fontSizes } + const fields: Record = {} + for (const role of FONT_ROLES) { + const { field, min, max } = FONT_SIZE_SPECS[role] + const px = Math.max(min, Math.min(max, fontSizes[role] + delta)) + if (px === fontSizes[role]) continue + fontSizes[role] = px + fields[field] = px + } + const ops = Object.entries(fields).map(([field, value]) => ({ op: 'set' as const, path: [field], value })) + if (ops.length === 0) return + this.fontSizes = Object.freeze(fontSizes) + this.write(() => this.host.mutate(ops)) this.publish() } @@ -272,14 +297,14 @@ export class ThemeRuntime { const list = normalizeFontFamily(value) if (this.fontFamilies[role] === list) return this.fontFamilies = Object.freeze({ ...this.fontFamilies, [role]: list }) - this.write(FONT_FAMILY_FIELDS[role], list) + this.write(() => this.host.set(FONT_FAMILY_FIELDS[role], list)) this.publish() } - /** Write one field through the settings scope, then adopt the durable section once no own write remains. */ - private write(field: string, value: string | number): void { + /** Queue one settings write, then adopt the durable section once no own write remains. */ + private write(operation: () => Promise): void { this.pendingWrites += 1 - void this.host.set(field, value).finally(() => { + void operation().finally(() => { this.pendingWrites -= 1 this.adopt() }) @@ -470,6 +495,39 @@ function dynamicToken(name: string): ThemeTokenInspection { } } +/** Register desktop font-size keys when keyboard and locale services are available. */ +function installFontSizeShortcuts(ctx: ClientContext, adjust: (delta: 1 | -1) => void): void { + ctx.inject(['shortcuts', 'locale'], (scope) => { + if (scope.shortcuts.runtime !== 'desktop') return + const t = scope.locale.bind(SETTINGS_NS) + const combinations: { binding: NormalizedBinding; delta: 1 | -1 }[] = [] + // Fixed bindings drive both reservation and matching. + for (const [direction, code, delta] of [['increase', 'Equal', 1], ['decrease', 'Minus', -1]] as const) { + const binding = { code, modifiers: ['primary'] as const } + const bindings = direction === 'increase' + ? [binding, { code, modifiers: ['primary', 'shift'] as const }] as const : [binding] as const + for (const candidate of bindings) { + combinations.push({ binding: scope.shortcuts.describeBinding(candidate).binding as NormalizedBinding, delta }) + } + scope.effect(() => scope.shortcuts.registerFixed({ + id: `fontSize.${direction}` as ShortcutCommandId, label: () => t(`fontSize.text.${direction}`), + keys: scope.shortcuts.describeBinding(binding).keys, + bindings, group: 'application', + }), `ui-theme: ${direction} font size reference`) + } + scope.effect(() => scope.shortcuts.observeFixedInput((input) => { + if (input.type === 'reset') return + const { gesture, context } = input + if (gesture.composing || gesture.defaultPrevented || gesture.repeat || context.region === 'terminal') return + const matched = combinations.find(({ binding }) => binding.code === gesture.code + && (['control', 'alt', 'shift', 'meta'] as const).every(modifier => gesture[modifier] === binding.modifiers.includes(modifier))) + if (matched === undefined) return + input.consume() + adjust(matched.delta) + }), 'ui-theme: font size input') + }) +} + /** * Required services: settings transport plus slots/locale for the Appearance * row. `remote` carries the forwarded settings invalidation that @@ -480,17 +538,17 @@ export const inject = ['slots', 'locale', 'remote', 'configForms'] /** * Client plugin body: provide the theme service and register the * feature-owned Appearance and font settings rows into the General section's item - * slot (a feature owns its settings surface). + * slot (a feature owns its settings surface). Desktop font-size shortcuts share + * all three font-size settings when the keyboard service is available. * @param ctx - client cordis context. */ export function apply(ctx: ClientContext): void { installThemeStyles(ctx) + ctx.effect(() => ctx.locale.register(SETTINGS_NS, { zh, en }), 'ui-theme: settings row dictionaries') const host = ctx.configForms.get(THEME_SETTINGS_NAMESPACE) const theme = new ThemeRuntime(ctx, host) ctx.provide('theme', theme) - ctx.effect(() => ctx.locale.register(SETTINGS_NS, { zh, en }), 'ui-theme: settings row dictionaries') - const store = createAppearanceRowStore() let bound: BoundActions | undefined const fontStore = createFontRowStore() diff --git a/packages/client/ui-theme/tests/appearance-row.client.spec.tsx b/packages/client/ui-theme/tests/appearance-row.client.spec.tsx index 75fcdcd0d2..d7d2afa0d2 100644 --- a/packages/client/ui-theme/tests/appearance-row.client.spec.tsx +++ b/packages/client/ui-theme/tests/appearance-row.client.spec.tsx @@ -5,7 +5,7 @@ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' -import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' import { AppearanceRow } from '../src/client/AppearanceRow.tsx' import type { AppearanceRowComponentProps } from '../src/client/AppearanceRow.tsx' import { createAppearanceRowStore } from '../src/client/settings-store.ts' @@ -50,6 +50,7 @@ function mount(preference: ThemePreference = 'system') { useSessionStatus, usePanelInfo, useSessionRetainInfo: () => undefined, useResource, useWorkspaces: emptyWorkspaces(), + ...allowAllAccess, useStore: bindSnapshotSelector(store), actions: store.actions, t: (key: string) => COPY[key] ?? key, diff --git a/packages/client/ui-theme/tests/apply.client.spec.ts b/packages/client/ui-theme/tests/apply.client.spec.ts index 9bb2ef9db0..877ce818c3 100644 --- a/packages/client/ui-theme/tests/apply.client.spec.ts +++ b/packages/client/ui-theme/tests/apply.client.spec.ts @@ -2,17 +2,20 @@ * locale service, declaration-aware Appearance row registration, snapshot * projection into the row store, and HMR collapse recovery. */ import { Context } from '@deepseek-ai/cordis' -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it, onTestFinished, vi } from 'vitest' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' -import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' +import { TestRemote, stubConfigForm } from '@deepseek-ai/dsh-client-test-runtime' import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client' -import { apply, inject, SETTINGS_NS } from '@deepseek-ai/dsh-client-ui-theme/client' -import type { AppearanceRowInjected, FontSettingsInjected, ThemeRuntime } from '@deepseek-ai/dsh-client-ui-theme/client' -import { THEME_SETTINGS_NAMESPACE, ThemeSettingsSchema } from '../src/theme-settings.ts' +import { apply, inject, SETTINGS_NS, ThemeRuntime } from '@deepseek-ai/dsh-client-ui-theme/client' +import type { AppearanceRowInjected, FontSettingsInjected, ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client' +import { THEME_SETTINGS_NAMESPACE, ThemeSettingsSchema, type ThemeSettings } from '../src/theme-settings.ts' import { AppearanceRow } from '../src/client/AppearanceRow.tsx' import type { createAppearanceRowStore, createFontRowStore } from '../src/client/settings-store.ts' import { FontSettingsGroup } from '../src/client/FontSettingsGroup.tsx' +import type { Shortcuts, ShortcutContext, ShortcutFixedInput, ShortcutGesture, ShortcutPlatform } from '@deepseek-ai/dsh-client-shortcuts/client' +import { bindingKey, normalizeBinding, presentBinding } from '@deepseek-ai/dsh-client-shortcuts/protocol' +import { ShortcutRegistry } from '../../shortcuts/src/client/registry.ts' // These specs assert the shipped Chinese copy. The lane has no jsdom `window`, // so browser-language detection never runs and a fresh LocaleRuntime opens on @@ -46,8 +49,7 @@ async function bench(isLoopback = true) { value: { writable: true, hasDocument: true, namespaces: [namespace()] }, })) const mutate = vi.fn((_ns: string, ops: { path: string[]; value: unknown }[]) => { - const op = ops[0]! - section[op.path[0]!] = op.value + for (const op of ops) section[op.path[0]!] = op.value return Promise.resolve({ ok: true as const, value: namespace() }) }) const events = new TestRemote(ctx, { settings: { describe, mutate } }) @@ -85,7 +87,213 @@ function fontSettingsFaceOf(slots: SlotRegistry) { return { entry, instance, face: face as FontSettingsInjected } } +/** Deliver the keyboard service's arbitrated input while keeping its real catalog and bindings. */ +function fontKeyboard(ctx: Context, runtime: Shortcuts['runtime'] = 'desktop', platform: ShortcutPlatform = 'macos') { + onTestFinished(() => ctx.fiber.dispose()) + const registry = new ShortcutRegistry(runtime, platform) + const listeners = new Set<(input: ShortcutFixedInput) => void>() + const shortcuts: Pick = { + runtime, platform, + registerFixed: command => registry.registerFixed(command), + describeBinding: (candidate) => { + const binding = candidate === null ? null : normalizeBinding(candidate, platform) + return { binding, keys: presentBinding(binding, platform).keys, issue: null, conflicts: [] } + }, + observeFixedInput: (listener) => { + listeners.add(listener) + return () => { listeners.delete(listener) } + }, + } + ctx.provide('shortcuts', shortcuts) + return { + registry, + press: (gesture: Partial = {}, context: Partial = {}) => { + const consume = vi.fn() + for (const listener of listeners) listener({ + type: 'keydown', consume, + gesture: { code: 'Equal', meta: platform === 'macos', control: platform !== 'macos', alt: false, + shift: false, repeat: false, composing: false, defaultPrevented: false, ...gesture }, + context: { region: 'page', modal: null, target: null, ...context }, + }) + return consume + }, + reset: () => { for (const listener of listeners) listener({ type: 'reset' }) }, + } +} + +/** Isolate theme write outcomes behind the same registered keyboard input. */ +async function keyboardTheme(host = stubConfigForm()) { + const ctx = new Context() + ctx.provide('locale', new LocaleRuntime(ctx)) + const keyboard = fontKeyboard(ctx) + const events: ThemeSnapshot[] = [] + ctx.on('theme/change', (snapshot) => { events.push(snapshot) }) + await ctx.plugin({ apply: (scope) => { + scope.provide('theme', new ThemeRuntime(scope, host.scope)) + } }).await() + return { theme: ctx.get('theme') as ThemeRuntime, keyboard, events, host } +} + describe('ui-theme apply', () => { + it('steps all font sizes in one settings mutation and one snapshot', async () => { + const { theme, keyboard, events, host } = await keyboardTheme() + keyboard.press() + expect(theme.getTheme().fontSizes).toEqual({ text: 15, code: 12, terminal: 14 }) + expect(host.mutate).toHaveBeenCalledExactlyOnceWith([ + { op: 'set', path: ['fontSize'], value: 15 }, + { op: 'set', path: ['codeFontSize'], value: 12 }, + { op: 'set', path: ['terminalFontSize'], value: 14 }, + ]) + expect(host.set).not.toHaveBeenCalled() + expect(events).toHaveLength(1) + expect(events[0]?.fontSizes).toEqual({ text: 15, code: 12, terminal: 14 }) + }) + + it('continues stepping other font roles when one has reached its own limit', async () => { + const { theme, keyboard, host } = await keyboardTheme() + theme.setFontSize('text', 22) + theme.setFontSize('code', 10) + theme.setFontSize('terminal', 20) + keyboard.press() + expect(theme.getTheme().fontSizes).toEqual({ text: 22, code: 11, terminal: 20 }) + expect(host.mutate).toHaveBeenCalledExactlyOnceWith([ + { op: 'set', path: ['codeFontSize'], value: 11 }, + ]) + keyboard.press({ code: 'Minus' }) + expect(theme.getTheme().fontSizes).toEqual({ text: 21, code: 10, terminal: 19 }) + keyboard.press({ code: 'Minus' }) + expect(theme.getTheme().fontSizes).toEqual({ text: 20, code: 10, terminal: 18 }) + expect(host.mutate).toHaveBeenLastCalledWith([ + { op: 'set', path: ['fontSize'], value: 20 }, + { op: 'set', path: ['terminalFontSize'], value: 18 }, + ]) + }) + + it('adopts all durable font sizes together after a refused batch settles', async () => { + const host = stubConfigForm() + const section: ThemeSettings = { preference: 'system', fontSize: 14, codeFontSize: 11, terminalFontSize: 13, + textFontFamily: '', codeFontFamily: '', terminalFontFamily: '' } + host.publish({ status: 'ready', value: section, revision: 1, writable: true }) + const { theme, keyboard } = await keyboardTheme(host) + const write = Promise.withResolvers() + host.mutate.mockReturnValue(write.promise) + keyboard.press() + host.publish({ value: section, revision: 2 }) + expect(theme.getTheme().fontSizes).toEqual({ text: 15, code: 12, terminal: 14 }) + write.resolve(false) + await vi.waitFor(() => { expect(theme.getTheme().fontSizes).toEqual({ text: 14, code: 11, terminal: 13 }) }) + }) + + it.each(['macos', 'windows', 'linux'] as const)('shares %s font shortcuts with the settings control and durable values', async (platform) => { + const b = await bench() + const keyboard = fontKeyboard(b.ctx, 'desktop', platform) + declareItems(b.slots) + const fiber = b.ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + const theme = b.ctx.get('theme') as ThemeRuntime + const { instance, face } = fontSettingsFaceOf(b.slots) + + expect(keyboard.press()).toHaveBeenCalledOnce() + expect(theme.getTheme().fontSizes).toEqual({ text: 15, code: 12, terminal: 14 }) + keyboard.press({ shift: true }, { region: 'editable' }) + expect(instance.getSnapshot().fontSizes).toEqual({ text: 16, code: 13, terminal: 15 }) + keyboard.press({ code: 'Minus' }) + expect(instance.getSnapshot().fontSizes).toEqual({ text: 15, code: 12, terminal: 14 }) + face.setFontSize('text', 20) + keyboard.press() + expect(theme.getTheme().fontSizes).toEqual({ text: 21, code: 13, terminal: 15 }) + await vi.waitFor(() => { expect(b.mutate).toHaveBeenCalledTimes(5) }) + expect(b.mutate.mock.calls.at(-1)?.[1]).toEqual([ + { op: 'set', path: ['fontSize'], value: 21 }, + { op: 'set', path: ['codeFontSize'], value: 13 }, + { op: 'set', path: ['terminalFontSize'], value: 15 }, + ]) + expect(theme.getTheme().fontSizes).toEqual({ text: 21, code: 13, terminal: 15 }) + + await fiber.dispose() + expect(keyboard.registry.fixedCatalog.getSnapshot()).toEqual([]) + expect(keyboard.press()).not.toHaveBeenCalled() + expect(theme.getTheme().fontSizes.text).toBe(21) + await b.ctx.fiber.dispose() + }) + + it('presents both desktop font actions with their accepted physical combinations', async () => { + const b = await bench() + const keyboard = fontKeyboard(b.ctx) + await b.ctx.plugin({ inject: [...inject], apply }).await() + expect(keyboard.registry.fixedCatalog.getSnapshot().map(row => ({ + label: row.label, keys: row.keys, bindings: row.bindings.map(bindingKey), + }))).toMatchInlineSnapshot(` + [ + { + "bindings": [ + "meta+Equal", + "shift+meta+Equal", + ], + "keys": [ + "⌘", + "=", + ], + "label": "增大字号", + }, + { + "bindings": [ + "meta+Minus", + ], + "keys": [ + "⌘", + "-", + ], + "label": "减小字号", + }, + ] + `) + await b.ctx.fiber.dispose() + }) + + it('consumes font shortcuts at either size limit without persisting another write', async () => { + const b = await bench() + const keyboard = fontKeyboard(b.ctx) + await b.ctx.plugin({ inject: [...inject], apply }).await() + const theme = b.ctx.get('theme') as ThemeRuntime + theme.setFontSize('text', 22) + theme.setFontSize('code', 16) + theme.setFontSize('terminal', 20) + await vi.waitFor(() => { expect(b.mutate).toHaveBeenCalledTimes(3) }) + expect(keyboard.press()).toHaveBeenCalledOnce() + expect(keyboard.press({ shift: true })).toHaveBeenCalledOnce() + theme.setFontSize('text', 10) + theme.setFontSize('code', 10) + theme.setFontSize('terminal', 10) + await vi.waitFor(() => { expect(b.mutate).toHaveBeenCalledTimes(6) }) + expect(keyboard.press({ code: 'Minus' })).toHaveBeenCalledOnce() + expect(theme.getTheme().fontSizes).toEqual({ text: 10, code: 10, terminal: 10 }) + expect(b.mutate).toHaveBeenCalledTimes(6) + await b.ctx.fiber.dispose() + }) + + it('leaves browser zoom and locally owned input unchanged', async () => { + const browser = await bench() + const web = fontKeyboard(browser.ctx, 'web') + await browser.ctx.plugin({ inject: [...inject], apply }).await() + expect(web.registry.fixedCatalog.getSnapshot()).toEqual([]) + expect(web.press()).not.toHaveBeenCalled() + await browser.ctx.fiber.dispose() + + const b = await bench() + const keyboard = fontKeyboard(b.ctx) + await b.ctx.plugin({ inject: [...inject], apply }).await() + for (const gesture of [ + { composing: true }, { defaultPrevented: true }, { repeat: true }, { alt: true }, + { meta: false }, { control: true }, { code: 'KeyA' }, { code: 'Minus', shift: true }, + ]) expect(keyboard.press(gesture)).not.toHaveBeenCalled() + expect(keyboard.press({}, { region: 'terminal' })).not.toHaveBeenCalled() + keyboard.reset() + expect((b.ctx.get('theme') as ThemeRuntime).getTheme().fontSizes.text).toBe(14) + expect(b.mutate).not.toHaveBeenCalled() + await b.ctx.fiber.dispose() + }) + it('declares the slot and locale services', () => { expect(inject).toEqual(['slots', 'locale', 'remote', 'configForms']) }) diff --git a/packages/client/ui-theme/tests/font-family-row.client.spec.tsx b/packages/client/ui-theme/tests/font-family-row.client.spec.tsx index 1fc4d77a32..a2ca325a7e 100644 --- a/packages/client/ui-theme/tests/font-family-row.client.spec.tsx +++ b/packages/client/ui-theme/tests/font-family-row.client.spec.tsx @@ -8,7 +8,7 @@ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' -import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' import { FontFamilyRow } from '../src/client/FontFamilyRow.tsx' import type { FontFamilyRowComponentProps } from '../src/client/FontFamilyRow.tsx' import { createFontRowStore } from '../src/client/settings-store.ts' @@ -52,6 +52,7 @@ function mount(kind: FontRole = 'code', code = '') { useSessionStatus, usePanelInfo, useSessionRetainInfo: () => undefined, useResource, useWorkspaces: emptyWorkspaces(), + ...allowAllAccess, useStore: bindSnapshotSelector(store), actions: store.actions, t: (key: string) => COPY[key] ?? key, diff --git a/packages/client/ui-theme/tests/font-settings-group.client.spec.tsx b/packages/client/ui-theme/tests/font-settings-group.client.spec.tsx index f9c823be8d..947c099e20 100644 --- a/packages/client/ui-theme/tests/font-settings-group.client.spec.tsx +++ b/packages/client/ui-theme/tests/font-settings-group.client.spec.tsx @@ -8,7 +8,7 @@ import { cleanup, fireEvent, render, screen } from '@testing-library/react' import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' -import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' import { FontSettingsGroup } from '../src/client/FontSettingsGroup.tsx' import type { FontSettingsGroupProps } from '../src/client/FontSettingsGroup.tsx' import { createFontRowStore } from '../src/client/settings-store.ts' @@ -55,6 +55,7 @@ function mount() { useSessionStatus, usePanelInfo, useSessionRetainInfo: () => undefined, useResource, useWorkspaces: emptyWorkspaces(), + ...allowAllAccess, useStore: bindSnapshotSelector(store), actions: store.actions, t: (key: string) => COPY[key] ?? key, diff --git a/packages/client/ui-theme/tests/font-size-row.client.spec.tsx b/packages/client/ui-theme/tests/font-size-row.client.spec.tsx index 0081edfea6..35b67f6205 100644 --- a/packages/client/ui-theme/tests/font-size-row.client.spec.tsx +++ b/packages/client/ui-theme/tests/font-size-row.client.spec.tsx @@ -7,7 +7,7 @@ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' -import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' import { FontSizeRow } from '../src/client/FontSizeRow.tsx' import type { FontSizeRowComponentProps } from '../src/client/FontSizeRow.tsx' import { createFontRowStore } from '../src/client/settings-store.ts' @@ -55,6 +55,7 @@ function mount(fontSize = 14, role: FontRole = 'text') { useSessionStatus, usePanelInfo, useSessionRetainInfo: () => undefined, useResource, useWorkspaces: emptyWorkspaces(), + ...allowAllAccess, useStore: bindSnapshotSelector(store), actions: store.actions, t: (key: string) => COPY[key] ?? key, diff --git a/packages/client/ui-theme/tsconfig.client.json b/packages/client/ui-theme/tsconfig.client.json index 1e68a788f4..5e56d362dc 100644 --- a/packages/client/ui-theme/tsconfig.client.json +++ b/packages/client/ui-theme/tsconfig.client.json @@ -15,6 +15,9 @@ { "path": "../locale/tsconfig.client.json" }, + { + "path": "../shortcuts/tsconfig.client.json" + }, { "path": "../store" }, diff --git a/packages/client/ui-theme/tsdown.config.ts b/packages/client/ui-theme/tsdown.config.ts index d0be379b73..9bf50e3060 100644 --- a/packages/client/ui-theme/tsdown.config.ts +++ b/packages/client/ui-theme/tsdown.config.ts @@ -1,7 +1,7 @@ import { clientBundle } from '../tsdown.client.ts' export default clientBundle( - '@deepseek-ai/dsh-client-ui-theme', + import.meta.dirname, ['lib/types/index.js'], { lib: { diff --git a/packages/client/ui-tool/README.i18n.yaml b/packages/client/ui-tool/README.i18n.yaml index 7facde8709..01eb372ca9 100644 --- a/packages/client/ui-tool/README.i18n.yaml +++ b/packages/client/ui-tool/README.i18n.yaml @@ -30,8 +30,8 @@ en: 672e63b2f92e689b zh: ba950ecb480325a6 /deepseek-ai-dsh-client-ui-tool/understand-the-implementation/cards: - en: 679c7bb0f49d7929 - zh: 93bba9a6da95d303 + en: d3cfe0abdce4718d + zh: c2b5708e8a028226 /deepseek-ai-dsh-client-ui-tool/understand-the-implementation/declared-optional-capabilities: en: c1926deb4e5f3734 zh: c5845f465b94a459 diff --git a/packages/client/ui-tool/README.md b/packages/client/ui-tool/README.md index cf1d9d1d78..d3c98dbeeb 100644 --- a/packages/client/ui-tool/README.md +++ b/packages/client/ui-tool/README.md @@ -79,7 +79,7 @@ Tool Definitions own the [lazy argument view](../../util/values/README.md). Rows ### Cards -Every card is read in place in the call tree; there is no second, full-height presentation of a selected call. Row renderers share one pure card model for each terminal, read, diff, search, and web card, and the image card's gallery renders through the tool-owned `tool.call.images` slot. These models validate raw call arguments, result content, failure state, persisted metadata, PTC dispatch `parentCallId`, and Session path facts. Unsupported or malformed inputs use flattened Tool result text. A file-path summary opens the file through the owner's `openFile`, which the chat view routes to the right Sidebar's text preview; `inspect` opens the trajectory view and is absent when that View is unavailable; cards then omit Inspect. Card-specific limits and fallback rules for the terminal, diff, read, search, and web cards remain in [the ui-primitives README](../ui-primitives/README.md); the image card's model in this package carries its own fallback rules. +Every card is read in place in the call tree; there is no second, full-height presentation of a selected call. Row renderers share one pure card model for each terminal, read, diff, search, and web card, and the image card's gallery renders through the tool-owned `tool.call.images` slot, followed by the full recorded path and each attached image's pixel size (with the original size when downscaled) and byte count. These models validate raw call arguments, result content, failure state, persisted metadata, PTC dispatch `parentCallId`, and Session path facts. Unsupported or malformed inputs use flattened Tool result text. A file-path summary opens the file through the owner's `openFile`, which the chat view routes to the right Sidebar's text preview; `inspect` opens the trajectory view and is absent when that View is unavailable; cards then omit Inspect. Card-specific limits and fallback rules for the terminal, diff, read, search, and web cards remain in [the ui-primitives README](../ui-primitives/README.md); the image card's model in this package carries its own fallback rules. Chat diff cards keep nine rows before folding, enough for a file header, one removed/added pair, and three context lines on either side. The tool row shows the primitive's exact or coarse-replacement counts; the expanded card contains the diff body without a totals footer. Hovering the tool header colors its added count green and removed count red; both retain those colors while the card is expanded. diff --git a/packages/client/ui-tool/README.zh.md b/packages/client/ui-tool/README.zh.md index 607fae545a..f898a9eeb1 100644 --- a/packages/client/ui-tool/README.zh.md +++ b/packages/client/ui-tool/README.zh.md @@ -79,7 +79,7 @@ Tool Definition 拥有[懒计算参数视图](../../util/values/README.zh.md)。 ### 卡片 -每张卡片都直接在调用树中查看;选中调用后不会再显示第二个全高视图。行 renderer 为 terminal、read、diff、search 和 web 卡片各复用同一个纯 card model,image 卡片的图库经由工具自有 `tool.call.images` slot 渲染。这些 model 校验原始调用参数、结果内容、失败状态、持久 metadata、PTC dispatch 的 `parentCallId` 与会话路径信息。不受支持或格式错误的输入使用压平的工具结果文本。文件路径摘要经属主的 `openFile` 打开文件,chat 视图把它路由到右侧 Sidebar 的文本预览;`inspect` 打开轨迹视图;该视图不可用时不提供此回调,卡片随之隐藏 Inspect。terminal、diff、read、search 与 web 卡片的上限与 fallback 规则仍由 [ui-primitives README](../ui-primitives/README.zh.md) 负责;image 卡片的 fallback 规则由本包内的 card model 自行承载。 +每张卡片都直接在调用树中查看;选中调用后不会再显示第二个全高视图。行 renderer 为 terminal、read、diff、search 和 web 卡片各复用同一个纯 card model,image 卡片的图库经由工具自有 `tool.call.images` slot 渲染,其下显示完整的记录路径以及每张附件图片的像素尺寸(缩小时附原图尺寸)和字节数。这些 model 校验原始调用参数、结果内容、失败状态、持久 metadata、PTC dispatch 的 `parentCallId` 与会话路径信息。不受支持或格式错误的输入使用压平的工具结果文本。文件路径摘要经属主的 `openFile` 打开文件,chat 视图把它路由到右侧 Sidebar 的文本预览;`inspect` 打开轨迹视图;该视图不可用时不提供此回调,卡片随之隐藏 Inspect。terminal、diff、read、search 与 web 卡片的上限与 fallback 规则仍由 [ui-primitives README](../ui-primitives/README.zh.md) 负责;image 卡片的 fallback 规则由本包内的 card model 自行承载。 Chat diff 卡片在折叠前保留九行,足以容纳文件标题、一对删除与新增行及两侧各三行上下文。工具行显示原语提供的精确或粗粒度替换统计;展开卡片包含差异正文,不显示底部统计。悬停工具标题行时,新增数量显示绿色、删除数量显示红色;卡片展开期间保留这两种颜色。 diff --git a/packages/client/ui-tool/package.json b/packages/client/ui-tool/package.json index 28eaf9342b..2d0e0f2001 100644 --- a/packages/client/ui-tool/package.json +++ b/packages/client/ui-tool/package.json @@ -65,7 +65,6 @@ "@deepseek-ai/dsh-client-ui-primitives": "workspace:*", "@deepseek-ai/dsh-client-ui-slots": "workspace:*", "@testing-library/react": "^16.3.3", - "@types/react": "~18.3.1", "react": "^18.2.0", "react-dom": "^18.2.0", "@deepseek-ai/dsh-api-workspace-controller": "workspace:*", diff --git a/packages/client/ui-tool/src/client/tool/components/ToolRow.module.css b/packages/client/ui-tool/src/client/tool/components/ToolRow.module.css index acf675a13e..10432d7041 100644 --- a/packages/client/ui-tool/src/client/tool/components/ToolRow.module.css +++ b/packages/client/ui-tool/src/client/tool/components/ToolRow.module.css @@ -275,22 +275,23 @@ color: var(--dsw-alias-label-tertiary); } -/* The image card's in-card label: the view's replacement title, else the - shortened path — the same role ReadBlock's own label plays, so the presentation - contract's replacement-title rule is honoured here too. */ -.imageLabel { - margin-bottom: 4px; +/* The image card's full path and size lines under the gallery, in the small + code font of the expanded IN/OUT card. When the attachment slot renders + nothing, these lines are the only evidence an image was returned. pre-wrap + keeps repeated whitespace in a path. The enclosing .imageBody already carries + the row indent. */ +.imageMeta { + margin-top: 4px; + white-space: pre-wrap; overflow-wrap: anywhere; - font: var(--dsw-font-sm-13); - color: var(--dsw-alias-label-secondary); + font: var(--dsw-font-markdown-code-block-small); + color: var(--dsw-alias-label-tertiary); } -/* The image card's own result text (media type, dimensions, byte size) under the - gallery, in the same muted tone the search recovery footer uses. Always - rendered: the attachment presentation slot is optional, so when nothing - occupies it the gallery is empty and this line is the only evidence an image - was returned. The enclosing .imageBody already carries the row indent. */ -.imageMeta { +/* Text blocks other than the envelope (for example, appended by a post-execute + hook), in the muted tone the search recovery footer uses. */ +.imageExtra { + margin-top: 4px; white-space: pre-wrap; overflow-wrap: anywhere; font: var(--dsw-font-xs-13); diff --git a/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx b/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx index b62d581d8b..4b4674d452 100644 --- a/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx +++ b/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx @@ -270,22 +270,33 @@ export const ToolRow = memo(function ToolRow({ ? : imageBody !== null ? ( - /* Label, gallery, then the result's OWN envelope text. The text - comes from the image card model (which reads the result's text - block), never from the row's flattened output: an image read's - content is [text envelope, image block] and flattening - JSON.stringifies the image block, printing the raw attachment - object under the picture. It is not redundant either — the - attachment slot can render nothing, and then this line is the - only evidence an image was returned. */ + /* Gallery, then the full path and each attached image's size. + These come from the image card model, never from the row's + flattened output, which JSON.stringifies the image block. They + stay visible when the attachment slot renders nothing. */
-
{imageBody.label}
{renderSlot !== undefined && loadImage !== undefined && renderSlot('tool.call.images', { images: imageBody.images, loadImage, align: 'start', })} -
{imageBody.text}
+
+
{imageBody.path}
+ {imageBody.images.map(({ attachment }, index) => ( +
+ {attachment.originalDimensions === undefined + ? t('row.imageSize', { width: attachment.width, height: attachment.height, bytes: attachment.bytes }) + : t('row.imageSizeDownscaled', { + width: attachment.width, + height: attachment.height, + originalWidth: attachment.originalDimensions.width, + originalHeight: attachment.originalDimensions.height, + bytes: attachment.bytes, + })} +
+ ))} +
+ {imageBody.extraText !== null &&
{imageBody.extraText}
}
) : searchBody !== null diff --git a/packages/client/ui-tool/src/client/tool/models/image-card-model.ts b/packages/client/ui-tool/src/client/tool/models/image-card-model.ts index 085451b35b..b2d0f36073 100644 --- a/packages/client/ui-tool/src/client/tool/models/image-card-model.ts +++ b/packages/client/ui-tool/src/client/tool/models/image-card-model.ts @@ -1,31 +1,28 @@ /** Pure image-card derivation from raw result content and metadata. @module */ import type { AttachmentId, ImageAttachmentRef, ImageMediaType } from '@deepseek-ai/dsh-attachment' -import { abbreviateHomePath, relativizeToCwd } from '@deepseek-ai/dsh-util-workspace-path' import type { ToolCallBlock } from './tool-call-model.ts' import { parsedToolCall } from './raw-tool-call.ts' /** - * The image-card material one settled call contributes: the display label plus - * the durable references the attachment slot renders as a gallery. + * The image-card material one settled call contributes: the read path plus the + * durable references the attachment slot renders as a gallery. * * The bytes are not here. `attachmentId` is opaque and provider-owned, so a UI * resolves it to a session-authorized URL at render time; this model never parses * it nor derives a path from it. */ export interface ImageCardModel { - /** Card label: the read path, shortened the way every other card's is. */ - label: string + /** The resolved path as recorded, unshortened; the row summary already shows the short form. */ + path: string /** The durable images this result returned, in result order. */ images: readonly { readonly attachment: ImageAttachmentRef }[] /** - * The model-facing envelope text, for the line under the gallery. - * - * Taken from the result's own text block rather than the row's flattened - * result text: an image read's content is `[text envelope, image block]`, and - * flattening JSON.stringifies the image block, which would print the raw - * attachment object under the picture — the symptom this card exists to remove. + * Text blocks other than the model-facing envelope (for example, text a + * post-execute hook appended), joined in order; null when there are none. + * The envelope itself is not repeated: its path and image facts are already + * `path` and `images`. */ - text: string + extraText: string | null } /** @@ -35,7 +32,7 @@ export interface ImageCardModel { * * Root calls persist it; a nested call (a read_image dispatched from inside * run_code) settles without `meta`, so the card falls back to the call's own - * `file_path` argument for the label. + * `file_path` argument for the path. * * The attachment reference deliberately does NOT come from here. The settled * `content` already carries the image block with its complete reference, so @@ -150,24 +147,17 @@ function imageReferences(content: readonly unknown[]): ImageAttachmentRef[] | nu } /** - * Read the text of every text block of a settled image result, joined in order. - * - * The envelope is one of them; a post-execute hook that appends further text - * blocks keeps them visible under the gallery instead of being dropped. The - * envelope shape is still the recognition gate: a result without it is not a - * well-formed image read and declines. - * @param content - the settled result's content blocks. - * @returns the joined text, or null when no envelope-shaped block is present. + * Join the text blocks other than the image envelope, in order. A post-execute + * hook that appends further text blocks keeps them visible under the gallery + * instead of being dropped. + * @param content - the settled result's text blocks. + * @returns the joined text, or null when there is none. */ -function imageTexts(content: readonly { type: string; text?: string }[]): string | null { - const parts: string[] = [] - let sawEnvelope = false - for (const part of content) { - if (part.type !== 'text' || typeof part.text !== 'string') continue - if (IMAGE_ENVELOPE.test(part.text)) sawEnvelope = true - parts.push(part.text) - } - return sawEnvelope && parts.length > 0 ? parts.join('\n') : null +function extraTexts(content: readonly { type: string; text?: string }[]): string | null { + const parts = content.flatMap(part => part.type === 'text' && typeof part.text === 'string' && !IMAGE_ENVELOPE.test(part.text) + ? [part.text] + : []) + return parts.length > 0 ? parts.join('\n') : null } /** @@ -197,18 +187,11 @@ function fullyRendered(content: readonly unknown[]): boolean { * returns, so a running `read_image` has none and this returns null for it. * Both root and nested calls settle as ToolResultNode. Historical nested * records without presentation metadata fall back to the call's own - * `file_path` argument for the label. + * `file_path` argument for the path. * @param block - running or settled Tool block. - * @param sessionCwd - the session workspace root; a workspace-rooted absolute - * path label displays relative to it. Absent leaves the path as authored. - * @param home - host account home; a leftover POSIX home path displays as `~`. * @returns the image-card props, or null for the generic path. */ -export function imageCardModel( - block: ToolCallBlock, - sessionCwd?: string, - home?: string, -): ImageCardModel | null { +export function imageCardModel(block: ToolCallBlock): ImageCardModel | null { // Result-side only: a running call is not a ToolResultNode and carries no // content, so it has no card here. if (!('kind' in block) || block.isError) return null @@ -217,7 +200,7 @@ export function imageCardModel( const { file_path: filePath } = call.args if (typeof filePath !== 'string' || filePath.trim() === '') return null // Historical nested records without metadata use the authored file_path - // for the label. A root call with missing or malformed meta + // for the path. A root call with missing or malformed meta // declines — malformed tool data falls back to the generic card, which shows // the flattened content rather than an author-typed path. const metaPath = imageMeta(block.meta)?.path @@ -230,11 +213,11 @@ export function imageCardModel( // truth; `meta` contributes only the path, which the content does not carry. const refs = imageReferences(block.content) if (refs === null) return null - const text = imageTexts(block.content) - if (text === null) return null + // A result without the envelope is not a well-formed image read. + if (!block.content.some(part => part.type === 'text' && typeof part.text === 'string' && IMAGE_ENVELOPE.test(part.text))) return null return { - label: abbreviateHomePath(relativizeToCwd(path, sessionCwd), home), + path, images: refs.map(ref => ({ attachment: ref })), - text, + extraText: extraTexts(block.content), } } diff --git a/packages/client/ui-tool/src/client/tool/toolviews/read-image-row.tsx b/packages/client/ui-tool/src/client/tool/toolviews/read-image-row.tsx index e0095b0904..b2cd520281 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/read-image-row.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/read-image-row.tsx @@ -28,9 +28,9 @@ import { CONVERSATION_NS as NS } from '../../locale.ts' * slot this entry declares. */ export function ReadImageRow(props: ReadImageRowProps) { - const { block, cwd, home, renderSlot, loadImage } = props + const { block, renderSlot, loadImage } = props return readFamilyRow(props, { - image: imageCardModel(block, cwd, home), + image: imageCardModel(block), renderSlot, loadImage, }) diff --git a/packages/client/ui-tool/tests/image-card.client.spec.tsx b/packages/client/ui-tool/tests/image-card.client.spec.tsx index 8b0d642012..621e4cbcad 100644 --- a/packages/client/ui-tool/tests/image-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/image-card.client.spec.tsx @@ -9,7 +9,7 @@ // The image card differs from every other card in one load-bearing way: its bytes // are a session-authorized attachment, so the row cannot draw them itself. It // renders through the tool-owned `tool.call.images` slot, and an empty slot must -// still leave the media type and dimensions visible. +// still leave the path and image size visible. import { afterEach, describe, expect, it, vi } from 'vitest' import { useDisclosure } from '@deepseek-ai/dsh-client-ui-chat/src/client/chat/use-disclosure.ts' @@ -113,11 +113,9 @@ const loadImage: MessageImageLoader = vi.fn(() => Promise.reject(new Error('not describe('imageCardModel', () => { it('derives the card from settled image metadata and its raw envelope', () => { expect(imageCardModel(settled())).toEqual({ - label: 'shots/card.png', + path: 'shots/card.png', images: [{ attachment: sampleImage }], - // The fallback line is the result's own envelope, not the row's flattened - // output (which would stringify the image block). - text: ENVELOPE, + extraText: null, }) }) @@ -128,16 +126,9 @@ describe('imageCardModel', () => { expect(attachment).not.toHaveProperty('name') }) - it('relativizes a workspace-rooted path label, and leaves others as authored', () => { - expect(imageCardModel(settled({ meta: imageMeta({ path: '/w/app/shots/card.png' }) }), '/w/app')?.label) - .toBe('shots/card.png') - expect(imageCardModel(settled({ meta: imageMeta({ path: '/srv/other.png' }) }), '/w/app')?.label) - .toBe('/srv/other.png') - }) - - it('abbreviates a leftover POSIX home path label', () => { - expect(imageCardModel(settled({ meta: imageMeta({ path: '/Users/u/card.png' }) }), '/tmp/ws', '/Users/u')?.label) - .toBe('~/card.png') + it('keeps the recorded path unshortened', () => { + expect(imageCardModel(settled({ meta: imageMeta({ path: 'G:\\breeze\\shots\\card.png' }) }))?.path) + .toBe('G:\\breeze\\shots\\card.png') }) it('accepts any non-empty attachment id, because the shape is provider-owned', () => { @@ -163,7 +154,7 @@ describe('imageCardModel', () => { { type: 'image', attachment: appended }, ], } as never)) - expect(model?.text).toBe(`${ENVELOPE}\nanalysis: the card area is empty`) + expect(model?.extraText).toBe('analysis: the card area is empty') expect(model?.images).toEqual([{ attachment: sampleImage }, { attachment: appended }]) }) @@ -205,7 +196,7 @@ describe('imageCardModel', () => { // Metadata arrives unvalidated on replay, so an obsolete or hand-edited log // must never crash the tool message. A ROOT call whose meta does not match // declines to the generic card — malformed tool data must not render a - // card labeled with the author-typed path. Only a nested call (which + // card showing the author-typed path. Only a nested call (which // persists no meta by design) falls back to its own file_path argument. for (const meta of [undefined, null, 'meta', [{ path: 'a.png' }], {}, { path: '' }, { path: 7 }]) { expect(() => imageCardModel(settled({ meta }))).not.toThrow() @@ -213,7 +204,7 @@ describe('imageCardModel', () => { } const nested = imageCardModel(settled({ parentCallId: 'parent', meta: undefined })) expect(nested).not.toBeNull() - expect(nested?.label).toBe('shots/card.png') + expect(nested?.path).toBe('shots/card.png') }) it('declines a malformed attachment reference in the content', () => { @@ -257,14 +248,14 @@ describe('imageCardModel', () => { expect(imageCardModel(settled({ isError: true }))).toBeNull() // A nested call (a read_image dispatched from inside run_code) settles as a // ToolResultNode too and renders the card; it persists no presentationMeta, - // so the label falls back to the call's file_path argument. + // so the path falls back to the call's file_path argument. const nested = imageCardModel(settled({ parentCallId: 'parent', meta: undefined })) expect(nested).not.toBeNull() - expect(nested?.label).toBe('shots/card.png') + expect(nested?.path).toBe('shots/card.png') expect(nested?.images).toHaveLength(1) // Persisted meta still wins over the argument when a nested call has one. const withMeta = imageCardModel(settled({ parentCallId: 'parent', meta: { path: 'shots/persisted.png' } })) - expect(withMeta?.label).toBe('shots/persisted.png') + expect(withMeta?.path).toBe('shots/persisted.png') }) it('declines a call head that is not read_image', () => { @@ -332,7 +323,30 @@ describe('ReadImageRow keyed toolview', () => { const text = view.container.textContent ?? '' expect(text).not.toContain('"attachmentId"') expect(text).not.toContain('"type": "image"') - expect(text).toContain('image/png image, 1496x260 px') + expect(text).not.toContain('') + expect(text).not.toContain('image/png image') + }) + + it('shows the full path once, then the image size, under the picture', () => { + const view = render() + toggleRow(view) + const lines = [...view.container.querySelectorAll('[class*="imageMeta"] > div')].map(line => line.textContent) + expect(lines).toEqual(['/w/app/shots/card.png', '1496x260px,24588 字节']) + }) + + it('names the original size of a downscaled image', () => { + const downscaled = { ...sampleImage, originalDimensions: { width: 2992, height: 520 } } + const view = render() + toggleRow(view) + expect(view.container.textContent).toContain('1496x260px(由 2992x520px 缩小),24588 字节') + }) + + it('keeps appended non-envelope text under the picture', () => { + const view = render() + toggleRow(view) + expect(view.container.textContent).toContain('analysis: empty card') }) it('keeps the result text readable when the attachment slot renders nothing', () => { @@ -343,7 +357,7 @@ describe('ReadImageRow keyed toolview', () => { toggleRow(view) expect(emptySlot).toHaveBeenCalled() expect(view.container.querySelector('[data-images]')).toBeNull() - expect(view.container.textContent).toContain('image/png image, 1496x260 px') + expect(view.container.textContent).toContain('1496x260px,24588 字节') }) it('degrades to the text body when neither the slot nor the loader is supplied', () => { diff --git a/packages/client/ui-tool/tsdown.config.ts b/packages/client/ui-tool/tsdown.config.ts index cd27a6e92b..7aa03cebc1 100644 --- a/packages/client/ui-tool/tsdown.config.ts +++ b/packages/client/ui-tool/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-tool', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-trajectory/package.json b/packages/client/ui-trajectory/package.json index 1db5a4637d..d41c148836 100644 --- a/packages/client/ui-trajectory/package.json +++ b/packages/client/ui-trajectory/package.json @@ -56,8 +56,6 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:*", "@deepseek-ai/dsh-compaction": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", - "@types/react": "~18.3.1", - "@types/react-dom": "~18.3.0", "@deepseek-ai/cordis": "workspace:~", "react": "^18.2.0", "react-dom": "^18.2.0", diff --git a/packages/client/ui-trajectory/tests/views.client.spec.tsx b/packages/client/ui-trajectory/tests/views.client.spec.tsx index 2f5dd4a45e..1c6e39f0a1 100644 --- a/packages/client/ui-trajectory/tests/views.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.client.spec.tsx @@ -11,7 +11,7 @@ import type { GlobalStandardProps, PropsRenderSlots } from '@deepseek-ai/dsh-cli import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { createElement, type ComponentProps, type FC, type ReactNode } from 'react' -import { bindSnapshotSelector, SlotTestRuntime, stubConfigForm } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, bindSnapshotSelector, SlotTestRuntime, stubConfigForm } from '@deepseek-ai/dsh-client-test-runtime' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' import { EMPTY_CONVERSATION_SNAPSHOT, UiConversation, @@ -235,6 +235,7 @@ function standaloneProps( ), useSessionRetainInfo: () => undefined, useWorkspaces: emptyWorkspaces(), + ...allowAllAccess, useConversation: bindSnapshotSelector(createSnapshotStore(conversationSnapshot(trajectory))), useInput: bindSnapshotSelector(input), inputActions, @@ -365,6 +366,7 @@ function mount(fixture: Awaited>) { useSessionStatus, useSessionRetainInfo: () => undefined, useWorkspaces, + ...allowAllAccess, useProjection, useInput, inputActions, diff --git a/packages/client/ui-trajectory/tsdown.config.ts b/packages/client/ui-trajectory/tsdown.config.ts index 8e613b7399..7aa03cebc1 100644 --- a/packages/client/ui-trajectory/tsdown.config.ts +++ b/packages/client/ui-trajectory/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-trajectory', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-user-questions/package.json b/packages/client/ui-user-questions/package.json index 5307f4ea82..7df210cb91 100644 --- a/packages/client/ui-user-questions/package.json +++ b/packages/client/ui-user-questions/package.json @@ -59,7 +59,6 @@ "@deepseek-ai/dsh-system-prompt": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-user-questions": "workspace:*", - "@types/react": "~18.3.1", "@deepseek-ai/dsh-client-ui-primitives": "workspace:*", "@deepseek-ai/dsh-client-ui-slots": "workspace:*", "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", diff --git a/packages/client/ui-user-questions/src/client/question-reply.ts b/packages/client/ui-user-questions/src/client/question-reply.ts index 2d9a9f2245..68973b32df 100644 --- a/packages/client/ui-user-questions/src/client/question-reply.ts +++ b/packages/client/ui-user-questions/src/client/question-reply.ts @@ -142,13 +142,16 @@ function replySource( : null } -/** Late-reply projection owned by the questions UI. */ +/** + * Late-reply projection owned by the questions UI. It claims the reply message + * on the Chat target, so the generic message Definition never presents it. + */ export const questionReplyDefinition: ConversationNodeDefinition = { kind: 'user-question-reply', target: 'chat', match: (event) => { if (event.type !== 'user/message' || !isAppendSurfaceEvent(event)) return null - return replySource(event) === null ? null : { id: String(event.data.id), role: 'start' } + return replySource(event) === null ? null : { id: String(event.data.id), role: 'start', exclusive: true } }, start: (_context, match) => { if (match.event.type !== 'user/message') throw new Error('user-question-reply start requires user/message') diff --git a/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx b/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx index 798a2bedba..2ed8e0c27a 100644 --- a/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx +++ b/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx @@ -2,6 +2,7 @@ import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { allowAllAccess } from '@deepseek-ai/dsh-client-test-runtime' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { ToolCallId } from '@deepseek-ai/dsh-llm/brand' import { @@ -123,6 +124,7 @@ const kit: Omit = { useSessionStatus: selector => selector(attentionState), useSessionRetainInfo: () => undefined, useWorkspaces: selector => selector(workspaceState), + ...allowAllAccess, useConversation: selector => selector(conversationState), useChat: selector => selector(chatState), useTrajectory: selector => selector(trajectoryState), diff --git a/packages/client/ui-user-questions/tests/question-reply.client.spec.ts b/packages/client/ui-user-questions/tests/question-reply.client.spec.ts index f7570515d4..789371b623 100644 --- a/packages/client/ui-user-questions/tests/question-reply.client.spec.ts +++ b/packages/client/ui-user-questions/tests/question-reply.client.spec.ts @@ -2,7 +2,9 @@ import { describe, expect, it } from 'vitest' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { SessionSeq } from '@deepseek-ai/dsh-session' +import type { ConversationViewNode } from '@deepseek-ai/dsh-client-ui-conversation/client' import { messageDefinition } from '../../ui-chat/src/client/conversation-nodes/message.ts' +import { ConversationNodeAssembler } from '../../ui-conversation/src/client/conversation/assembler.ts' import { en } from '../src/client/locales.ts' import type { QuestionReplyData } from '../src/client/question-reply.ts' import { questionReplyDefinition, replyAnswerValues, replyClipboardText, replyPairsOf } from '../src/client/question-reply.ts' @@ -106,16 +108,33 @@ describe('replyClipboardText', () => { describe('questionReplyDefinition', () => { it('claims only appended user messages carrying the reply source', () => { - expect(questionReplyDefinition.match(message(replySource('answered'), answered))).toEqual({ id: 'msg-1', role: 'start' }) + expect(questionReplyDefinition.match(message(replySource('answered'), answered))) + .toEqual({ id: 'msg-1', role: 'start', exclusive: true }) expect(questionReplyDefinition.match(message({ kind: 'user' }, answered))).toBeNull() expect(questionReplyDefinition.match(message({ kind: 'plugin', plugin: 'user-questions' }, answered))).toBeNull() expect(questionReplyDefinition.match(message(replySource('answered'), answered, null))).toBeNull() }) - it('shares the message id with the generic projection', () => { + it('claims the message the generic projection also matches, so Chat holds one Node for it', () => { const event = message(replySource('answered'), answered) expect(messageDefinition.match(event)).toEqual({ id: 'msg-1', role: 'start' }) - expect(questionReplyDefinition.match(event)).toEqual(messageDefinition.match(event)) + let chat: readonly ConversationViewNode[] = [] + const definitions = [messageDefinition, questionReplyDefinition] + const assembler = new ConversationNodeAssembler( + { entries: () => definitions, fallbackEntry: () => undefined }, + { entries: () => [{ + target: 'chat', + create: () => ({ + empty: chat, + replace: ({ nodes }) => (chat = nodes), + apply: ({ upserts }) => (chat = [...chat, ...upserts]), + }), + }] }, + ) + assembler.activateTarget('chat') + assembler.replaceWindow([{ type: 'event', event }], false) + assembler.flush() + expect(chat.map(node => node.kind)).toEqual(['question-reply']) }) it('projects the outcome from the source and the pairs from the text into its own node', () => { diff --git a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx index a583a6d296..8ece0da777 100644 --- a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx +++ b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx @@ -3,6 +3,7 @@ import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import { useSyncExternalStore } from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { allowAllAccess } from '@deepseek-ai/dsh-client-test-runtime' import { ToolCallId } from '@deepseek-ai/dsh-llm' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { AskUserQuestionAnswerItem } from '@deepseek-ai/dsh-user-questions/types' @@ -122,6 +123,7 @@ const kitBase: Omit selector(attentionState), useSessionRetainInfo: () => undefined, useWorkspaces: selector => selector(workspaceState), + ...allowAllAccess, useConversation: selector => selector(conversationState), useChat: selector => selector(chatState), useTrajectory: selector => selector(trajectoryState), diff --git a/packages/client/ui-user-questions/tsdown.config.ts b/packages/client/ui-user-questions/tsdown.config.ts index a60da00031..7aa03cebc1 100644 --- a/packages/client/ui-user-questions/tsdown.config.ts +++ b/packages/client/ui-user-questions/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-user-questions', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-workflow-run/package.json b/packages/client/ui-workflow-run/package.json index 472f10f962..a1f452e556 100644 --- a/packages/client/ui-workflow-run/package.json +++ b/packages/client/ui-workflow-run/package.json @@ -69,7 +69,6 @@ "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-tool-workflow": "workspace:*", "@deepseek-ai/dsh-workflow": "workspace:*", - "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:~", "react": "^18.2.0" } diff --git a/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx b/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx index 3fa00ccc54..c91c543c99 100644 --- a/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx +++ b/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx @@ -20,7 +20,7 @@ import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types' import { SessionSeq } from '@deepseek-ai/dsh-session' import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client' import { - chatSnapshot as emptyChatSnapshot, conversationSnapshot, makeTranslate, sessionSnapshot, + allowAllAccess, chatSnapshot as emptyChatSnapshot, conversationSnapshot, makeTranslate, sessionSnapshot, stubConfigForm, TestSessions, workspaceSnapshot, } from '@deepseek-ai/dsh-client-test-runtime' import { @@ -360,6 +360,7 @@ function panelProps(data: WorkflowRunChatData, sessions = listState(), openSessi submit: () => {}, }, useWorkspaces: selector => selector(panelWorkspace), + ...allowAllAccess, useTurnData: () => undefined, useDisclosure: () => { throw new Error('unused') }, useGroupAction: () => { throw new Error('unused') }, diff --git a/packages/client/ui-workflow-run/tsdown.config.ts b/packages/client/ui-workflow-run/tsdown.config.ts index 87d4cd34c1..7aa03cebc1 100644 --- a/packages/client/ui-workflow-run/tsdown.config.ts +++ b/packages/client/ui-workflow-run/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-ui-workflow-run', ['lib/types/index.js']) +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index c0e34eb2e3..60a03011cf 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-client-ui-workspace/use-this-package: - en: b164014ee565be16 - zh: 6a741647932b93e6 + en: d042b4b1dd7e7f56 + zh: c12fa070ef5a02a4 /deepseek-ai-dsh-client-ui-workspace/use-this-package/reordering-and-view-options: en: 8673afc1d72ad077 zh: 9fbb83a4029c5d10 @@ -33,8 +33,8 @@ en: 9fb88260380bf093 zh: 3bace2b96ecd4a82 /deepseek-ai-dsh-client-ui-workspace/use-this-package/active-schedule-markers: - en: 04956ad6ba31cc86 - zh: 0e5fb72cfa932d25 + en: 8d82539e4e4e890d + zh: 7b97a3927de90ea8 /deepseek-ai-dsh-client-ui-workspace/understand-the-implementation: en: 721d5a49fe231191 zh: 3ef1dd720219e00e @@ -42,8 +42,8 @@ en: 868904dbfb5713f8 zh: 225906bfef7398bf /deepseek-ai-dsh-client-ui-workspace/understand-the-implementation/session-row-actions: - en: 24d196702ca9a5a0 - zh: 351b4896a0217ac1 + en: 5bbba6e462faf3dc + zh: 4734263208ee07c9 /deepseek-ai-dsh-client-ui-workspace/understand-the-implementation/session-row-actions/packaged-client-plugin: en: 36934671b80b11ae zh: 8e0aea6162e35e77 @@ -54,8 +54,8 @@ en: 83031e7505fc228a zh: 03e17c9428fe29fd /deepseek-ai-dsh-client-ui-workspace/understand-the-implementation/hover-cards: - en: 34668e3c2e9c77b7 - zh: c8bb72e29a3f2490 + en: ea89ec5a3ee1f167 + zh: e11f350571bcfff7 /deepseek-ai-dsh-client-ui-workspace/further-exploration: en: 2abbf27abbd71ba4 zh: ace33fa2ea042357 diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index 1616f3a27c..2654447b77 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -27,7 +27,7 @@ This package lets users browse grouped or flat Session lists, choose a Workspace ## Use this package -Use the sidebar to browse Workspaces and their Sessions, reorder them, and start new ones; use the picker in the Session Intent hero to choose a Workspace for a new session. An open Workspace shows five idle, non-blank Sessions by default. Running Sessions, including parents with running children, remain visible in their ordered positions without using that quota; the selected blank **New Session** is also an extra row until its first prompt. Each **Show more** click reveals up to five more idle Sessions; after the final batch, **Show less** restores the initial rows while keeping running Sessions visible. Closing and reopening the Workspace also restores this folded projection. +Use the sidebar to browse Workspaces and their Sessions, reorder them, and start new ones; use the picker in the Session Intent hero to choose a Workspace for a new session. An open Workspace shows its first non-blank Sessions in list order up to a per-Workspace watermark, initially five. The watermark rises to cover the last Session within the first 50 that is running, has a running child, awaits the user, has an unread completion, or is open in the main view; a Session opened from search raises it to that Session at any position. The watermark is a row count that never falls on its own, so the rows stay visible after a run or notification ends; a reorder that moves a Session past the watermark hides it. Each **Show more** click raises it by five, without the 50-row limit, and reveals the rest when five or fewer remain; after the final batch, **Show less** resets it. Folding a Workspace resets its watermark and those of its nested Workspaces; a reset watermark still covers the qualifying Sessions. The selected blank **New Session** is an extra row outside the watermark until its first prompt. Watermarks are not persisted; any unmount of the grouped tree resets them. History rows without a stored title use the localized unnamed label (未命名 / Untitled), rather than a directory name. The current blank row remains New Session; other blank rows remain hidden. Rename drafts use the stored title, or start empty when unnamed; unnamed rows do not offer title copying. @@ -71,16 +71,18 @@ Grouped and flat Session rows show a clock mark when the Session has active sche ----- -`ctx.uiWorkspace.openSession(target)` synchronously replaces the owned `mainView` reference and returns the main area to Conversation without waiting for `reference.ready`, so history loading renders inside the selected Session view. The target may be a known Session id or a durable direct-parent subagent address; an explicit address does not require a preloaded parent catalog. `openWorkspace(id, beforeOpen?)` opens a Workspace blank only if no later navigation has superseded the request; an explicit New Session creates a fresh Session. `forkSession(id)` creates the child without navigating or superseding a pending navigation. The optional synchronous preparation callback runs after the target is retained and only for a current Workspace request, so superseded requests do not move composer drafts. Navigation or owner disposal suppresses the late UI commit, not the underlying Session creation. Startup restoration retains its main reference without changing the selected panel or cancelling a later navigation. Archiving the main Session releases its reference and clears the main selection. Selection failure leaves a global panel visible. Session rows read `usePanelInfo` to suppress their selected appearance while a global panel is active; search and directory-picker focus alone do not leave that panel. +`ctx.uiWorkspace.openSession(target)` synchronously replaces the owned `mainView` reference and returns the main area to Conversation without waiting for `reference.ready`, so history loading renders inside the selected Session view. The target may be a known Session id or a durable direct-parent subagent address; an explicit address does not require a preloaded parent catalog. `openWorkspace(id, beforeOpen?, options?)` opens a Workspace blank only if no later navigation has superseded the request; an explicit New Session creates a fresh Session. `forkSession(id)` creates the child without navigating or superseding a pending navigation. The optional synchronous preparation callback runs after the target is retained and only for a current Workspace request, so superseded requests do not move composer drafts. Navigation or owner disposal suppresses the late UI commit, not the underlying Session creation. Startup restoration retains its main reference without changing the selected panel or cancelling a later navigation. Archiving the main Session releases its reference and clears the main selection. Selection failure leaves a global panel visible. Session rows read `usePanelInfo` to suppress their selected appearance while a global panel is active; search and directory-picker focus alone do not leave that panel. -Navigation and startup restoration obtain the selected Session's projections from its `follow`; neither issues a separate projection refresh for that Session or its parent. +Navigation and startup restoration obtain the selected Session's projections from its `follow`; neither issues a separate projection refresh for that Session or its parent, except that restoring a saved blank first reloads that Session's projections, because listed values are cached hints that can lag its log after an unclean Host exit. -Workspace reopening and explicit draft preparation try to acquire the first eligible blank in catalog order; startup restoration tries the saved blank. If that writer is held, navigation creates a new Session without trying other blanks. Other acquisition failures abort the request: an explicit New Session or hero Workspace pick shows the failure as a transient notice quoting the Host's code and message (for example a preset that fails to mount) or, for a failure that is not a Host refusal, that failure's own message; a request a later navigation or owner disposal superseded raises no notice, and startup restoration reports only to the console. Released blanks retain their slash-command state when reused. Later navigation cancels a pending startup selection. +Workspace reopening and explicit draft preparation name the first eligible blank in catalog order when they ask the Host for a Session; startup restoration names the saved blank. The Host reuses the named blank under the requested preset, or under its default when the request names none, and creates another Session when the blank has started, left the Workspace, or is held by another writer; navigation opens the returned Session without trying other blanks. `startSession` with draft options, `openWorkspace`, and `connectWorkspace` accept `{ agentPreset }` for a flow that knows its preset before it starts, such as the creator entry; a connection joins the latest in-flight connection to its Workspace only when both request the same preset, so the latest request always reaches the Host. Startup restoration passes the preset the saved blank's log recorded, so the draft keeps that preset; when the Host reports the preset as missing (`agent-preset/not-found`) or failing to activate (`agent-preset/invalid`), restoration asks again without a preset and the draft continues under the Host default. Other failures abort the request: an explicit New Session or hero Workspace pick shows the failure as a transient notice quoting the Host's code and message (for example a preset that fails to mount) or, for a failure that is not a Host refusal, that failure's own message; a request a later navigation or owner disposal superseded raises no notice, and startup restoration reports only to the console. Released blanks retain their slash-command state when reused. Later navigation cancels a pending startup selection. Once both Workspace and Session startup baselines are ready, an empty installation calls `workspaces.initializeDefault` and creates or reuses its blank Session. The composer becomes editable when that Session is selected; no message is submitted automatically. Later navigation or owner disposal prevents startup from selecting its result. Ineligible first use leaves the folder picker available without an error. Default Workspace creation failure shows a transient notice directing the user to Choose workspace, and is not retried until the next startup. Session creation failures use the ordinary restoration error handling. The registered Workspace remains available if Session creation or later submission fails. The first-use directory name and its stored title are fixed, so neither follows the reader's language: `workspaces.initializeDefault` carries no names, and one installation keeps one on-disk path and one stored title across language switches. A Workspace still carrying that automatic title is labeled with the localized default name wherever this package shows it — sidebar rows and their hover card, search result meta, the hero picker menu, and the rename and delete dialogs — through the controller's `workspaceDisplayTitle`. The rename dialog is seeded with the label on screen while the stored title decides whether confirming saves, so confirming the untouched prefill on such a Workspace pins that name and the row stops following the language; the duplicate-title check excludes the target by Workspace identity rather than by title. +The package injects the Client `remoteAccess` service and provides the root standard Hook `useWorkspaceAccess(workspaceId, action)`, which answers whether the current Peer may call `action` on one Workspace resource; its snapshot semantics belong to [`dsh-api-remote-access`](../../api/remote-access/README.md#client-face). + ## Understand the implementation @@ -101,7 +103,7 @@ Each registration declares a **directory-flow child hole** (`single` kind: `conv The Session row's "..." menu and its hover buttons are two `list` slots declared by the WorkspaceBrowser entry: `sidebar.workspaces.session.menu.item` and `sidebar.workspaces.session.row.action`. Every menu row and every hover button is an entry, this package's own actions included: `apply` registers `pin` (menu 100, button 200), `rename` (200), `fork` (300), `archive` (menu 400, button 100), and `session-id` (450) the way a client plugin registers its actions, so a plugin action lands wherever its `order` says, and reusing a shipped id at another `priority` shadows that action. -An entry receives only the row identity (`sessionId`, `displayTitle`) and owns everything else. It reads the Host state it cares about through its own injected hooks (the pin and archive sets, as Sets derived once per Workspace snapshot), decides its own visibility (pin renders nothing on an archived row, because the Host keeps the two sets exclusive), carries its whole behavior in its registration's `inject` face (a pin also fronts the Session in its saved orders; an archive raises the notice), and raises its own surfaces: the rename dialog, the stop-and-archive dialog, and the row-action notice are this package's `shell.overlay` entries, fed by the requests the actions inject. A menu entry renders one `role="menuitem"` button (this package's rows use `MenuItemButton` from ui-primitives, which adds the host styling and `separatorBefore` for a group start; the hairline comes and goes with the row) and dismisses the menu through the slot-level `useMenuOpenState` hook, the menu's own open state bound from the row's render occurrence; a hover button entry renders one icon button, and the strip keeps its clicks from opening the row. The browser passes no action callbacks to its rows; its remaining verbs are the search results' restore button and the title double-click, which raises the same rename request. +An entry receives only the row identity (`sessionId`, `displayTitle`) and owns everything else. It reads the Host state it cares about through its own injected hooks (the pin and archive sets, as Sets derived once per Workspace snapshot), decides its own visibility (pin renders nothing on an archived row, because the Host keeps the two sets exclusive, and fork renders nothing when `useSessionAccess(sessionId, 'session/fork')` is false), carries its whole behavior in its registration's `inject` face (a pin also fronts the Session in its saved orders; an archive raises the notice), and raises its own surfaces: the rename dialog, the stop-and-archive dialog, and the row-action notice are this package's `shell.overlay` entries, fed by the requests the actions inject. A menu entry renders one `role="menuitem"` button (this package's rows use `MenuItemButton` from ui-primitives, which adds the host styling and `separatorBefore` for a group start; the hairline comes and goes with the row) and dismisses the menu through the slot-level `useMenuOpenState` hook, the menu's own open state bound from the row's render occurrence; a hover button entry renders one icon button, and the strip keeps its clicks from opening the row. The browser passes no action callbacks to its rows; its remaining verbs are the search results' restore button and the title double-click, which raises the same rename request. The existing notice overlay dispatches `workspace.toast` by notice kind. Keyed registrations inject localized display mappings and their own actions; only archive notices subscribe to the viewing filter. They render the package-local `workspace.toast.content` Factory, which reuses the Toast primitive once for all presentations. The existing notice state, display sequence, latest-notice replacement, and dismissal remain shared; locale or filter changes do not restart the same notice's timer. @@ -182,7 +184,7 @@ Row motion belongs to [AnimatedRows](src/client/rows/AnimatedRows.tsx). It measu ### Hover cards -Workspace and Session hover cards copy the value their row clips: activating a Workspace card writes its full directory path, while activating a non-blank Session card writes its full display title. A provisional blank New Session card remains read-only because its localized label is a placeholder rather than session content. A Session card also renders its `sidebar.session.row.hover` seat between the relative time and the trailing status lines whenever the card is open, independent of the row's own state. +Workspace and Session hover cards copy the value their row clips: activating a Workspace card writes its full directory path, while activating a non-blank Session card writes its full display title. A provisional blank New Session card remains read-only because its localized label is a placeholder rather than session content. A Session card also renders its `sidebar.session.row.hover` seat between the relative time and the trailing status lines whenever the card is open, independent of the row's own state. The first Workspace or Session card in a physical list waits 800ms; after a card is displayed, moving to another Workspace or Session in the same list replaces it immediately. The list and its active portaled card form one pointer interaction: entering the card to read or copy preserves immediate handoff to a neighboring Workspace or Session. After the pointer leaves both, the shared dwell resets when the active card closes through its existing pointer-grace and close lifecycle, with no separate group timer. Leaving a list with no active card resets the dwell immediately. Small action tooltips coexist with the right-side hover card; pin, archive, Workspace new-session, and search-result unarchive tooltips use portals to stay above it. Hovering actions, including More, preserves the card; opening the More menu or starting a Session drag dismisses it.
diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index dd4a932f68..c655995453 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -27,7 +27,7 @@ kind: "package-reference" ## 使用本包 -用侧边栏浏览 Workspace 及其 Session、重排它们并新建会话;在 Session Intent 主视觉区用选择器为新会话选择 Workspace。打开的 Workspace 默认显示五条空闲的非空白 Session。正在运行的 Session(包括有子会话正在运行的父会话)始终按原顺序显示,不占用这五条配额;当前选中的空白**新会话**在首条提示词落地前也作为额外行。每次点击**展开其余**最多再显示五条空闲 Session;全部显示后,**收起**恢复初始行数,但仍显示正在运行的 Session。关闭再打开 Workspace 也会恢复该折叠投影。 +用侧边栏浏览 Workspace 及其 Session、重排它们并新建会话;在 Session Intent 主视觉区用选择器为新会话选择 Workspace。打开的 Workspace 按列表顺序显示前面的非空白 Session,直到该 Workspace 自己的水位线,水位线初始为五。水位线会升高,以覆盖前 50 条中最后一条正在运行、有子会话正在运行、等待用户、有未读完成提醒或在主视图中打开的 Session;从搜索打开的 Session 会把水位线升高到该 Session,无论其位置。水位线是一个行数,不会自行回落,因此运行或通知结束后这些行仍保持可见;重排若把某个 Session 移到水位线之后,该 Session 会被隐藏。每次点击**展开其余**将水位线升高五条,不受 50 行限制;剩余五条或更少时显示全部;最后一批显示后,**收起**重置水位线。折叠 Workspace 会重置其水位线及其嵌套 Workspace 的水位线;重置后的水位线仍覆盖符合条件的 Session。当前选中的空白**新会话**在首条提示词落地前是水位线之外的额外行。水位线不持久化;分组树的任何卸载都会重置水位线。 未保存标题的历史会话显示本地化名称「未命名 / Untitled」,不使用目录名兜底。当前空会话仍显示「新会话」,其他空会话仍隐藏。重命名草稿使用已保存标题,无标题时留空;无标题行不提供标题复制。 @@ -71,16 +71,18 @@ Session 行渲染运行时的实时 `pendingInteraction` 分类:审批显示** ----- -`ctx.uiWorkspace.openSession(target)` 会同步替换其拥有的 `mainView` reference,并让主区域返回 Conversation,而不等待 `reference.ready`,因此历史加载会显示在已经选中的 Session 视图内。目标可以是已知 Session id,也可以是持久的直接父子 subagent 地址;显式地址不要求预先加载 parent catalog。`openWorkspace(id, beforeOpen?)` 仅在请求未被后续导航替代时打开工作区空白 Session;显式新会话操作会创建新的 Session。`forkSession(id)` 创建子会话,不导航,也不替代尚未完成的导航。可选的同步准备回调在目标被 retain 后执行,并且仅对仍有效的 Workspace 请求执行,因此过期请求不会搬移 composer 草稿。后续导航或 owner 释放会阻止晚到的 UI 提交,但不取消底层 Session 创建。启动恢复会 retain 主 reference,不改变已选面板,也不取消后续导航。归档主 Session 会释放其 reference 并清除主选择。选择失败时保留当前全局面板。Session 行读取 `usePanelInfo`,在全局面板活跃时不显示 Session 选中样式;仅把焦点移到搜索框或目录选择器不会离开该面板。 +`ctx.uiWorkspace.openSession(target)` 会同步替换其拥有的 `mainView` reference,并让主区域返回 Conversation,而不等待 `reference.ready`,因此历史加载会显示在已经选中的 Session 视图内。目标可以是已知 Session id,也可以是持久的直接父子 subagent 地址;显式地址不要求预先加载 parent catalog。`openWorkspace(id, beforeOpen?, options?)` 仅在请求未被后续导航替代时打开工作区空白 Session;显式新会话操作会创建新的 Session。`forkSession(id)` 创建子会话,不导航,也不替代尚未完成的导航。可选的同步准备回调在目标被 retain 后执行,并且仅对仍有效的 Workspace 请求执行,因此过期请求不会搬移 composer 草稿。后续导航或 owner 释放会阻止晚到的 UI 提交,但不取消底层 Session 创建。启动恢复会 retain 主 reference,不改变已选面板,也不取消后续导航。归档主 Session 会释放其 reference 并清除主选择。选择失败时保留当前全局面板。Session 行读取 `usePanelInfo`,在全局面板活跃时不显示 Session 选中样式;仅把焦点移到搜索框或目录选择器不会离开该面板。 -导航和启动恢复通过所选 Session 的 `follow` 获取投影,不会另行刷新该 Session 或其父会话的投影。 +导航和启动恢复通过所选 Session 的 `follow` 获取投影,不会另行刷新该 Session 或其父会话的投影;例外是恢复已保存的空白会话时先重新加载该 Session 的投影,因为列表中的值是缓存提示,Host 非正常退出后可能落后于其日志。 -重新打开工作区和显式准备草稿尝试获取列表中第一个符合条件的空白会话;启动恢复尝试已保存的空白会话。若该写锁被占用,导航直接新建 Session,不再尝试其他空白会话。其他获取错误会中止请求:显式新建会话或在 hero 中选择工作区时,失败以短暂提示展示,引用 Host 的错误码和消息(例如 preset 挂载失败),非 Host 拒绝的失败则显示其自身消息;已被后续导航或 owner 销毁取代的请求不弹提示,启动恢复仍只报告到控制台。已释放的空白会话被复用时保留 slash 命令状态。后续导航会取消尚未完成的启动选择。 +重新打开工作区和显式准备草稿向 Host 请求 Session 时指明列表中第一个符合条件的空白会话;启动恢复指明已保存的空白会话。Host 以请求的 preset 复用该空白会话,请求未指明 preset 时使用其默认 preset;若该空白会话已开始、已离开该工作区或写锁被其他写者占用,Host 改为新建 Session;导航打开 Host 返回的 Session,不再尝试其他空白会话。带草稿选项的 `startSession`、`openWorkspace` 和 `connectWorkspace` 接受 `{ agentPreset }`,供开始前已确定 preset 的流程使用,例如创造模式入口;一次连接仅在与该工作区最近一次进行中的连接请求相同 preset 时与之合并,因此最新的请求总会到达 Host。启动恢复传入已保存空白会话日志记录的 preset,使草稿保留该 preset;当 Host 报告该 preset 不存在(`agent-preset/not-found`)或无法激活(`agent-preset/invalid`)时,启动恢复改为不带 preset 再请求一次,草稿在 Host 默认 preset 下继续。其他错误会中止请求:显式新建会话或在 hero 中选择工作区时,失败以短暂提示展示,引用 Host 的错误码和消息(例如 preset 挂载失败),非 Host 拒绝的失败则显示其自身消息;已被后续导航或 owner 销毁取代的请求不弹提示,启动恢复仍只报告到控制台。已释放的空白会话被复用时保留 slash 命令状态。后续导航会取消尚未完成的启动选择。 Workspace 和 Session 的启动基线均就绪后,空安装环境调用 `workspaces.initializeDefault`,创建或复用其空白 Session。选中该 Session 后输入框才可编辑,不会自动提交消息。后续导航或所属上下文销毁会阻止启动流程选中其结果。不符合首次使用条件时仍可选择文件夹,不显示错误。默认工作区创建失败时显示短暂提示,引导用户通过“选择工作区”选择文件夹,直到下次启动才重试。Session 创建失败沿用普通的恢复错误处理。登记成功的工作区在 Session 创建或后续提交失败时仍然保留。 首次使用的目录名与其存储标题都是固定的,都不跟随读者语言:`workspaces.initializeDefault` 不携带任何名称,同一安装环境在切换语言后仍只有一个磁盘路径和一个存储标题。仍保留该自动标题的工作区,在本包展示它的所有位置——侧栏行及其悬浮卡片、搜索结果元信息、hero 选择器菜单、重命名与删除对话框——都通过控制器的 `workspaceDisplayTitle` 显示为本地化的默认名称。重命名对话框以屏幕上的标签作为初值,而判断确认是否需要保存用的是存储标题,因此对这类工作区直接确认未改动的初值即可固定该名称,该行不再跟随语言;重名检查按工作区身份而非标题排除目标自身。 +本包注入 Client `remoteAccess` 服务,并提供根作用域标准 Hook `useWorkspaceAccess(workspaceId, action)`,回答当前 Peer 能否在某个 Workspace 资源上调用 `action`;其快照语义归 [`dsh-api-remote-access`](../../api/remote-access/README.zh.md#client-face)。 + ## 理解实现 @@ -101,7 +103,7 @@ Workspace 和 Session 的启动基线均就绪后,空安装环境调用 `works Session 行的 "..." 菜单和行尾悬停按钮是 WorkspaceBrowser 注册项声明的两个 `list` slot:`sidebar.workspaces.session.menu.item` 与 `sidebar.workspaces.session.row.action`。每一个菜单行、每一个悬停按钮都是条目,本包自己的 action 也不例外:`apply` 以客户端插件注册自己 action 的同一方式注册 `pin`(菜单 100、按钮 200)、`rename`(200)、`fork`(300)、`archive`(菜单 400、按钮 100)和 `session-id`(450),因此插件 action 落在其 `order` 所指的位置,以另一个 `priority` 复用内置 id 则遮蔽该 action。 -条目只接收行身份(`sessionId`、`displayTitle`),其余一切自己负责:用自己注入的 hook 读自己关心的 Host 状态(置顶与归档集合,以每次 Workspace 快照只派生一次的 Set 形式),自己决定是否显示(Host 规定归档与置顶互斥,所以 pin 在归档行上不渲染),整套行为放在注册项自己的 `inject` face 里(置顶成功后顺带把会话推到保存顺序最前,归档成功后发提示),浮层也自己带——重命名对话框、停止并归档对话框和行 action 的提示是本包注册在 `shell.overlay` 的条目,由 action 注入的请求驱动。菜单条目渲染一个 `role="menuitem"` 的按钮(本包自己的行用 ui-primitives 的 `MenuItemButton`,它带宿主样式,开启新分组的行加 `separatorBefore`,分隔线随行一起出现和消失),并通过 slot 级 `useMenuOpenState` hook(菜单自身的打开状态,从该行的渲染出现处绑定)关闭菜单;悬停按钮条目渲染一个图标按钮,按钮条会拦住点击、不让它打开该行。browser 不再向行传任何 action 回调,它剩下的动作只有搜索结果里的恢复按钮和标题双击,后者发出的是同一个重命名请求。 +条目只接收行身份(`sessionId`、`displayTitle`),其余一切自己负责:用自己注入的 hook 读自己关心的 Host 状态(置顶与归档集合,以每次 Workspace 快照只派生一次的 Set 形式),自己决定是否显示(Host 规定归档与置顶互斥,所以 pin 在归档行上不渲染;`useSessionAccess(sessionId, 'session/fork')` 为 false 时 fork 不渲染),整套行为放在注册项自己的 `inject` face 里(置顶成功后顺带把会话推到保存顺序最前,归档成功后发提示),浮层也自己带——重命名对话框、停止并归档对话框和行 action 的提示是本包注册在 `shell.overlay` 的条目,由 action 注入的请求驱动。菜单条目渲染一个 `role="menuitem"` 的按钮(本包自己的行用 ui-primitives 的 `MenuItemButton`,它带宿主样式,开启新分组的行加 `separatorBefore`,分隔线随行一起出现和消失),并通过 slot 级 `useMenuOpenState` hook(菜单自身的打开状态,从该行的渲染出现处绑定)关闭菜单;悬停按钮条目渲染一个图标按钮,按钮条会拦住点击、不让它打开该行。browser 不再向行传任何 action 回调,它剩下的动作只有搜索结果里的恢复按钮和标题双击,后者发出的是同一个重命名请求。 现有通知浮层按通知 kind 分派 `workspace.toast`。各 keyed 注册注入本地化展示映射与自己的操作,只有归档通知订阅查看筛选状态。它们渲染包内的 `workspace.toast.content` Factory,所有展示共用一处 Toast primitive。现有通知状态、显示序号、最新提示替换及关闭逻辑仍共享;语言或筛选变化不会重新启动同一条提示的计时。 @@ -182,7 +184,7 @@ Workspace 基线就绪后,浏览器持久化的展开状态和 Session 顺序 ### 悬浮卡片 -Workspace 与 Session 悬浮卡片会复制对应行被截断的值:激活 Workspace 卡片会写入其完整目录路径,激活非空白 Session 卡片则会写入其完整显示标题。临时的空白「新会话」卡片保持只读,因为其本地化标签是占位文案,并非会话内容。 Session 卡片在卡片打开期间还会在相对时间与行尾状态行之间渲染 `sidebar.session.row.hover` seat,与行自身状态无关。 +Workspace 与 Session 悬浮卡片会复制对应行被截断的值:激活 Workspace 卡片会写入其完整目录路径,激活非空白 Session 卡片则会写入其完整显示标题。临时的空白「新会话」卡片保持只读,因为其本地化标签是占位文案,并非会话内容。 Session 卡片在卡片打开期间还会在相对时间与行尾状态行之间渲染 `sidebar.session.row.hover` seat,与行自身状态无关。同一物理列表中的首张 Workspace 或 Session 卡片等待 800ms;实际显示一张后,移到该列表的其他 Workspace 或 Session 会立即替换卡片。列表与其当前活动的 portal 卡片构成一次指针交互:进入卡片阅读或复制后,移到相邻 Workspace 或 Session 仍会立即切换。指针离开两者后,共享等待在活动卡片通过原有指针宽限及关闭生命周期关闭时重置,不另设分组计时器。列表没有活动卡片时,指针离开便立即重置等待。小型操作提示与右侧悬浮卡片同时显示;置顶、归档、Workspace 新建会话及搜索结果取消归档的提示通过 portal 保持在卡片上层。悬停操作按钮(包括「更多」)保留卡片;打开「更多」菜单或开始拖拽 Session 会关闭卡片。
diff --git a/packages/client/ui-workspace/package.json b/packages/client/ui-workspace/package.json index 5c74cd71b7..e88301dcdf 100644 --- a/packages/client/ui-workspace/package.json +++ b/packages/client/ui-workspace/package.json @@ -29,6 +29,7 @@ "dsh": { "client": { "inject": [ + "@deepseek-ai/dsh-api-remote-access", "@deepseek-ai/dsh-api-remotes", "@deepseek-ai/dsh-api-session-controller", "@deepseek-ai/dsh-api-workspace-controller", @@ -53,9 +54,11 @@ "@deepseek-ai/cordis": "workspace:~" }, "devDependencies": { + "@deepseek-ai/dsh-api-remote-access": "workspace:*", "@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/dsh-api-remotes": "workspace:*", "@deepseek-ai/dsh-agent": "workspace:*", + "@deepseek-ai/dsh-agent-preset-registry": "workspace:*", "@deepseek-ai/dsh-api-session-controller": "workspace:*", "@deepseek-ai/dsh-api-workspace-controller": "workspace:*", "@deepseek-ai/dsh-client-connection": "workspace:*", @@ -76,7 +79,6 @@ "@deepseek-ai/dsh-typert-protocol": "workspace:*", "@deepseek-ai/dsh-util-values": "workspace:*", "@deepseek-ai/dsh-util-workspace-path": "workspace:*", - "@types/react": "~18.3.1", "clsx": "^2.0.0", "react": "^18.2.0", "@deepseek-ai/dsh-client-shortcuts": "workspace:*", diff --git a/packages/client/ui-workspace/src/client/index.ts b/packages/client/ui-workspace/src/client/index.ts index 04c4e5da2a..cc9da072c9 100644 --- a/packages/client/ui-workspace/src/client/index.ts +++ b/packages/client/ui-workspace/src/client/index.ts @@ -20,8 +20,9 @@ import type { Context } from '@deepseek-ai/cordis' import type { RemoteHostFacts } from '@deepseek-ai/dsh-api-remotes/client' import type { ISessions, SessionForkError } from '@deepseek-ai/dsh-api-session-controller/client' import type { - IWorkspaces, SessionActivity, WorkspaceArchiveError, WorkspaceSnapshot, + IWorkspaces, SessionActivity, WorkspaceArchiveError, WorkspaceId, WorkspaceSnapshot, } from '@deepseek-ai/dsh-api-workspace-controller/client' +import type { RemoteEndpoint } from '@deepseek-ai/dsh-api-remote-access/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { writeClipboard } from '@deepseek-ai/dsh-client-ui-primitives' import type { HostObservable, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' @@ -71,6 +72,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { interface GlobalStandardProps { /** Selector hook over the pure Workspace Controller snapshot. */ useWorkspaces: SnapshotSelectorHook + /** Whether the current Peer may call `action` on one Workspace resource. */ + useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean } interface LocaleNamespaceMap { @@ -97,7 +100,7 @@ const NS = 'workspace' * declaration through `slots.inject()` instead of assuming order. */ export const inject = [ - 'slots', 'sessions', 'workspaces', 'locale', 'remote', 'remote.directoryPicker', 'layout', 'shortcuts', + 'slots', 'sessions', 'workspaces', 'locale', 'remote', 'remote.directoryPicker', 'layout', 'shortcuts', 'remoteAccess', ] /** @@ -121,7 +124,14 @@ export function apply(ctx: Context): void { const uiWorkspace = new UiWorkspaceService( ctx, ctx.remote.directoryPicker, workspaces, sessions, viewInstance.actions, notify, ) - ctx.slots.provideRoot({ hooks: { workspaces: workspaces.list } }) + ctx.slots.provideRoot({ + hooks: { workspaces: workspaces.list }, + keyedHooks: { + workspaceAccess: (workspaceId, action) => action === undefined + ? undefined + : ctx.remoteAccess.source('workspace', workspaceId, action), + }, + }) ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-workspace: dictionaries') const shortcutControls = createWorkspaceShortcutControls() diff --git a/packages/client/ui-workspace/src/client/navigation.ts b/packages/client/ui-workspace/src/client/navigation.ts index 4e9bb97fa8..9c8fa97760 100644 --- a/packages/client/ui-workspace/src/client/navigation.ts +++ b/packages/client/ui-workspace/src/client/navigation.ts @@ -10,6 +10,8 @@ import type { SessionTarget, SessionListState, } from '@deepseek-ai/dsh-api-session-controller/client' +// Type-only: pulls the `agentPreset` Session projection value into this program. +import type {} from '@deepseek-ai/dsh-agent-preset-registry/types' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client' import type { @@ -27,8 +29,11 @@ interface MainSelection { readonly subagentAddress?: SubagentAddress } -/** Optional content preparation for the resolved target Session. */ -export type StartSessionOptions = DraftInitializationOptions +/** Content preparation and Agent preset for the Session a Workspace flow opens. */ +export interface StartSessionOptions extends DraftInitializationOptions { + /** Agent preset the Session runs; the Host default when omitted. */ + readonly agentPreset?: string +} /** Workspace archive and directory operations consumed by Client UI domains. */ export interface UiWorkspace { @@ -42,11 +47,16 @@ export interface UiWorkspace { * @param workspaceId - target Workspace. * @param beforeOpen - optional synchronous preparation for the selected Session, * skipped after supersession; a throw aborts the open and releases the retained reference. + * @param options - choices for the Session the connection creates or reuses. * @returns completion; a superseded request may create a Session but does not open it. * @throws on failure; a refused creation is also shown through the Workspace * notice unless a later navigation or disposal superseded the request. */ - openWorkspace(workspaceId: WorkspaceId, beforeOpen?: (sessionId: SessionId) => void): Promise + openWorkspace( + workspaceId: WorkspaceId, + beforeOpen?: (sessionId: SessionId) => void, + options?: Pick, + ): Promise /** * Fork a Session without changing the current selection. * @param sessionId - source Session. @@ -60,16 +70,20 @@ export interface UiWorkspace { options?: Pick[0], 'allowMigration'>, ): Promise /** - * Resolve the reusable or newly created blank Session for a Workspace. + * Resolve the Session for a new conversation in a Workspace: the Host reuses + * the first listed blank member while it is reusable and otherwise creates one, + * under the requested preset or the Host default. * @param workspaceId - target Workspace. + * @param options - choices for the created or reused Session. * @returns a Session already addressable through the Session Controller. */ - connectWorkspace(workspaceId: WorkspaceId): Promise + connectWorkspace(workspaceId: WorkspaceId, options?: Pick): Promise /** * Create a fresh Session, or reuse a blank when preparing an explicit draft. A creation the Host * refuses is shown through the Workspace notice and leaves the selection as it was. * @param workspaceId - explicit target; absent inherits the current or most recent Workspace. - * @param options - initial content; existing text or attachments are preserved unless clearPreviousDraft is true. + * @param options - initial content and the reused blank's preset; existing text or attachments + * are preserved unless clearPreviousDraft is true. */ startSession(workspaceId?: WorkspaceId, options?: StartSessionOptions): void /** @@ -136,7 +150,11 @@ export class DirectoryBrowseError extends Error { /** Implements Workspace archive and directory UI operations. */ class UiWorkspaceService extends Service implements UiWorkspace { - private readonly connecting = new Map>() + /** The latest in-flight connection per Workspace; a request joins it only when both name the same preset. */ + private readonly connecting = new Map + }>() private readonly lifetime = new AbortController() private readonly selection = createSnapshotStore( {}, { persist: { name: 'dsh.sessions.current' } }, @@ -172,44 +190,86 @@ class UiWorkspaceService extends Service implements UiWorkspace { }, 'ui-workspace: Workspace navigation policy') } - connectWorkspace(workspaceId: WorkspaceId): Promise { - return this.connectBlank(workspaceId, true) + connectWorkspace(workspaceId: WorkspaceId, options: Pick = {}): Promise { + return this.connectBlank(workspaceId, options) } - private async connectBlank(workspaceId: WorkspaceId, reuse: boolean): Promise { + /** + * @param reuse - preset for the reused or created blank; absent creates a fresh Session. + */ + private async connectBlank( + workspaceId: WorkspaceId, + reuse: Pick | undefined, + ): Promise { const workspace = this.workspaces.list.getSnapshot().items .find(item => item.workspaceId === workspaceId) if (workspace === undefined) { throw new Error(`uiWorkspace.connectWorkspace: unknown workspace ${workspaceId}`) } - if (!reuse) return this.sessions.create({ workspaceId }) + if (reuse === undefined) return this.sessions.create({ workspaceId }) const inflight = this.connecting.get(workspaceId) - if (inflight !== undefined) return inflight + if (inflight !== undefined && inflight.agentPreset === reuse.agentPreset) return inflight.attempt - const attempt = this.reuseOrCreateBlank(workspace) - .finally(() => { this.connecting.delete(workspaceId) }) - this.connecting.set(workspaceId, attempt) + const attempt = this.createInWorkspace(workspace, reuse).finally(() => { + if (this.connecting.get(workspaceId)?.attempt === attempt) this.connecting.delete(workspaceId) + }) + this.connecting.set(workspaceId, { agentPreset: reuse.agentPreset, attempt }) return attempt } - private reuseOrCreateBlank(workspace: WorkspaceView): Promise { + private createInWorkspace(workspace: WorkspaceView, options: Pick): Promise { const archived = this.workspaces.list.getSnapshot().archivedSessionIds const sessions = this.sessions.list.getSnapshot() - for (const id of sessions.ids) { + const blank = sessions.ids.find((id) => { const summary = sessions.byId[id] - if (summary === undefined || !summary.blank || summary.cwd !== workspace.path - || !workspace.sessionIds.includes(id) || archived.includes(id)) continue - return this.reuseBlank(workspace.workspaceId, id) - } - return this.sessions.create({ workspaceId: workspace.workspaceId }) + return summary !== undefined && summary.blank && summary.cwd === workspace.path + && workspace.sessionIds.includes(id) && !archived.includes(id) + }) + return this.createNamed({ + workspaceId: workspace.workspaceId, + ...(blank === undefined ? {} : { sessionId: blank }), + ...(options.agentPreset === undefined ? {} : { agentPreset: options.agentPreset }), + }) } - private async reuseBlank(workspaceId: WorkspaceId, sessionId: SessionId): Promise { + /** + * Create through the Host. A named blank the Host declined may still read as + * blank from stale cached list metadata, which hides it from the Session tree; + * its projections are reloaded so the row reflects its log. + */ + private async createNamed(request: { + readonly workspaceId: WorkspaceId + readonly sessionId?: SessionId + readonly agentPreset?: string + }): Promise { + const sessionId = await this.sessions.create(request) + const named = request.sessionId + if (named !== undefined && named !== sessionId) { + void this.sessions.refreshProjections(named).catch((reason: unknown) => { + console.warn('declined blank Session projection reload failed:', reason) + }) + } + return sessionId + } + + /** + * Reopen a saved blank Session under the preset its log recorded, or under + * the Host default once that preset no longer activates. Listed values are + * cached hints that can lag the log after an unclean Host exit, so the + * Session's projections are reloaded before the preset is read; a Session + * that still awaits a format migration reloads only those cached hints. + */ + private async restoreBlank(workspaceId: WorkspaceId, sessionId: SessionId): Promise { + await this.sessions.refreshProjections(sessionId) + const recorded = this.sessions.list.getSnapshot().byId[sessionId]?.projectionValues?.agentPreset + const request = { workspaceId, sessionId } + if (typeof recorded !== 'string') return this.createNamed(request) try { - return await this.sessions.create({ workspaceId, sessionId }) + return await this.createNamed({ ...request, agentPreset: recorded }) } catch (error: unknown) { - if (sessionCreateErrorOf(error)?.rpcError.code !== 'session/writer-held') throw error - return this.sessions.create({ workspaceId }) + const code = sessionCreateErrorOf(error)?.rpcError.code + if (code !== 'agent-preset/not-found' && code !== 'agent-preset/invalid') throw error + return this.createNamed(request) } } @@ -217,12 +277,18 @@ class UiWorkspaceService extends Service implements UiWorkspace { this.replaceMain(target, this.lifetime.signal, 'reveal') } - openWorkspace(workspaceId: WorkspaceId, beforeOpen?: (sessionId: SessionId) => void): Promise { - return this.openWorkspaceSession(workspaceId, beforeOpen, true) + openWorkspace( + workspaceId: WorkspaceId, + beforeOpen?: (sessionId: SessionId) => void, + options: Pick = {}, + ): Promise { + return this.openWorkspaceSession(workspaceId, beforeOpen, options) } private async openWorkspaceSession( - workspaceId: WorkspaceId, beforeOpen: ((sessionId: SessionId) => void) | undefined, reuse: boolean, + workspaceId: WorkspaceId, + beforeOpen: ((sessionId: SessionId) => void) | undefined, + reuse: Pick | undefined, ): Promise { const navigation = AbortSignal.any([this.ctx.layout.beginNavigation(), this.lifetime.signal]) let sessionId: SessionId @@ -252,7 +318,8 @@ class UiWorkspaceService extends Service implements UiWorkspace { } startSession(workspaceId?: WorkspaceId, options?: StartSessionOptions): void { - const draftOptions = options === undefined ? undefined : { ...options } + const { agentPreset, ...draft } = options ?? {} + const draftOptions = options === undefined ? undefined : draft const initializeDraft = draftOptions !== undefined && (draftOptions.prompt !== undefined || draftOptions.clearPreviousDraft === true) const workspace = this.workspaces.list.getSnapshot() @@ -274,12 +341,12 @@ class UiWorkspaceService extends Service implements UiWorkspace { return } const opening = draftOptions === undefined - ? this.openWorkspaceSession(target, undefined, false) + ? this.openWorkspaceSession(target, undefined, undefined) : this.openWorkspace(target, initializeDraft ? (id) => { const binding = this.sessions.binding(id) if (binding === undefined) this.draftPreparationFailed() this.prepareDraft(binding, draftOptions) - } : undefined) + } : undefined, agentPreset === undefined ? {} : { agentPreset }) void opening.catch( (reason: unknown) => { console.warn('new session failed:', reason) }, ) @@ -389,7 +456,7 @@ class UiWorkspaceService extends Service implements UiWorkspace { let sessionId: SessionId | undefined if (summary !== undefined && workspace !== undefined && summary.cwd === workspace.path && !workspaces.archivedSessionIds.includes(summary.id)) { - sessionId = await this.reuseBlank(workspace.workspaceId, summary.id) + sessionId = await this.restoreBlank(workspace.workspaceId, summary.id) } let target = workspace?.workspaceId ?? recentWorkspace(workspaces.items, sessions.byId) if (target === undefined && workspaces.items.length === 0 && sessions.ids.length === 0) { diff --git a/packages/client/ui-workspace/src/client/rows/Rows.module.css b/packages/client/ui-workspace/src/client/rows/Rows.module.css index 6bb4f294db..6d35a23113 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.module.css +++ b/packages/client/ui-workspace/src/client/rows/Rows.module.css @@ -252,14 +252,12 @@ flex: none; } -/* Trailing action buttons surface on hover only (figma 27:4668 / 27:4656): - bare 16px glyphs, tertiary grey; the figma gap 12 reads too airy beside - the 16px glyphs, so the buttons sit at 10. */ +/* Trailing action buttons surface on hover with bare 16px glyphs. */ .rowActions { flex: none; display: none; align-items: center; - gap: 10px; + gap: 15px; } .projectRow:hover .rowActions, diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx index 9179773872..216a6fc00f 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -294,7 +294,7 @@ export function ProjectRowItem({ group, containsCurrentDescendant = false, onTog )} /> )} - +