Merge origin/master into fix/5520-job-session-timestamps

This commit is contained in:
fz committed 2026-10-08 20:02:49 +08:00
commit 7af8741513
223 files changed
+5447 -934

No files matched your search

@@ -15,8 +15,8 @@
en: 4921a060c657b790
zh: 2f9632469c6f0b60
/agent-note-provider-routed-llm-adapters-and-a-generic-pi-ai-backend/decision/explicit-pi-ai-provider-profiles:
en: e24610c474a94bc4
zh: 96a89035cb8eabf9
en: 1346315312eb35e2
zh: 3555f91c3afb49f8
/agent-note-provider-routed-llm-adapters-and-a-generic-pi-ai-backend/decision/recorded-assistant-route-and-replay-state:
en: ed60d078eacc67df
zh: a22631faf93deee2
@@ -36,6 +36,8 @@ The adapter calls pi-ai's `streamSimple()` so each catalog model chooses its reg
pi-ai's common stream options do not expose stop sequences. `dsh-llm-pi-ai` rejects a defined Harness `stop` option with `UNSUPPORTED_OPTION` rather than silently ignoring it or growing a second provider-specific payload implementation. `dsh-llm-deepseek` continues to support `stop` through its native request serializer.
pi-ai owns protocol-specific system-message placement and the concatenation fallback for models without in-history system support. Harness preserves later system messages in transcript order and reports catalog capabilities to prompt admission and shared tool-history projection. Duplicating pi-ai's fallback or applying Anthropic's placement rules to every protocol would create a second serialization policy that can drift when pi-ai changes. Capability reporting remains a Harness responsibility because the loop needs it before serialization.
### Recorded assistant route and replay state
Assistant messages carry the request's `provider` and `model`, plus an optional JSON-serializable adapter replay state. A successful `assistant/message` session event records those fields and `deriveMessages()` returns them with the assistant message. User, system, context, and tool-result messages carry no assistant route fields. The provider/model fields are authoritative loop data; an adapter owns only its opaque replay-state payload.
@@ -36,6 +36,8 @@ Status: implemented
pi-ai 的通用流选项不支持停止序列。若 Harness `stop` 选项已定义,`dsh-llm-pi-ai` 会以 `UNSUPPORTED_OPTION` 拒绝请求,不会静默忽略,也不会增加第二套提供方专用 payload 实现。`dsh-llm-deepseek` 继续通过原生请求序列化器支持 `stop`。
pi-ai 负责各协议的系统消息位置,以及不支持历史内系统消息的模型所需的拼接回退。Harness 按对话记录顺序保留后续系统消息,并向提示词准入和共享工具历史投影报告目录能力。重复实现 pi-ai 的回退,或将 Anthropic 的位置规则应用于所有协议,会形成第二套序列化策略,并可能随 pi-ai 的变化而偏离。能力报告仍由 Harness 负责,因为循环在序列化之前就需要这些信息。
### 已记录的助手路由与回放状态
助手消息携带请求的 `provider` 和 `model`,以及可选的 JSON 可序列化适配器回放状态。成功的 `assistant/message` 会话事件记录这些字段,`deriveMessages()` 返回助手消息时也会包含它们。用户、系统、上下文与工具结果消息不携带助手路由字段。提供方/模型字段是 agent loop 的权威数据;适配器仅拥有其不透明回放状态 payload。
@@ -9,11 +9,11 @@
en: c25db2ef111b2276
zh: 557891ca9e030ae5
/agent-note-one-carrier-level-browser-trust-boundary-for-all-api-routes/decision:
en: 1a1af1e8125ba4b0
zh: dd6c282651a06af5
en: ca8044c9e0013be6
zh: d60af7af291299c3
/agent-note-one-carrier-level-browser-trust-boundary-for-all-api-routes/alternatives-considered:
en: 1a4b0ae8072e8e50
zh: d469553ad3b5d112
/agent-note-one-carrier-level-browser-trust-boundary-for-all-api-routes/consequences:
en: 8acf5e6f55a747c2
zh: bbd0b10927302965
en: e31df28a2e82a64d
zh: 4ca7097ebbc24cd2
@@ -13,9 +13,11 @@ The web GUI host serves `/api` over plain loopback HTTP (default `127.0.0.1:3080
Enforce browser trust once, at the carrier, for the entire `/api` prefix — two halves:
- **Media-type fence (dsh-client-connection)**: every `/api` POST must declare `application/json`, else 415 before parsing. Cross-site "simple" requests thereby stop existing: any cross-site attempt is forced into a CORS preflight this server never answers.
- **Authority fence (dsh-client-connection, `src/api-request-trust.ts`)**: every request must present a `Host` that is loopback or matches a `trustedHosts` entry (exact on `host:port`, any port on port-less entries, WHATWG-normalized; rebinding defense). Deliberately no shortcut for unmarked requests: over plain HTTP a browser attaches neither `Origin` nor Fetch-Metadata to reads (EventSource, images, navigations — those headers go only to trustworthy destinations), so an unmarked request may be a rebound browser read whose response the page can read, and Host is the one header rebinding cannot forge; non-browser clients pass via loopback, the derived LAN IP literals, or a declared authority. An attached `Origin` must equal the Host authority; `sec-fetch-site: cross-site` is refused outright. A `trustedHosts` entry that is not a bare, canonical authority fails the plugin load — WHATWG parsing would otherwise quietly authorize the hostname inside a typo or broaden an exact-port grant. `host.pickDirectory` loses its bespoke guard and rides the same fence.
- **Authority fence (dsh-client-connection, `src/api-request-trust.ts`)**: every request must present a `Host` that is loopback or matches a `trustedHosts` entry (exact on `host:port`, any port on port-less entries, WHATWG-normalized; rebinding defense). Deliberately no shortcut for unmarked requests: over plain HTTP a browser attaches neither `Origin` nor Fetch-Metadata to reads (EventSource, images, navigations — those headers go only to trustworthy destinations), so an unmarked request may be a rebound browser read whose response the page can read, and Host is the one header rebinding cannot forge; non-browser clients pass via loopback, a declared authority, or the listener's own bind literal that [Bind one concrete Web address](2026-09-19-concrete-web-bind-address.md) admits. An attached `Origin` must equal the Host authority; `sec-fetch-site: cross-site` is refused outright. A `trustedHosts` entry that is not a bare, canonical authority fails the plugin load — WHATWG parsing would otherwise quietly authorize the hostname inside a typo or broaden an exact-port grant. `host.pickDirectory` loses its bespoke guard and rides the same fence.
Reachability is the webserver binding's policy (`host: 127.0.0.1 | 0.0.0.0`), and this fence is a confused-deputy defense rather than identity. Connection applies the separate [browser token authentication](2026-08-24-browser-token-authentication.md) after the fence. The fence does not inspect peer socket addresses: binding expresses reachability, `trustedHosts` names accepted authorities, and the socket address adds nothing the Host/Origin checks need.
Reachability is the webserver binding's policy (`host`; [Bind one concrete Web address](2026-09-19-concrete-web-bind-address.md) owns which literals it accepts), and this fence is a confused-deputy defense rather than identity. Connection applies the separate [browser token authentication](2026-08-24-browser-token-authentication.md) after the fence. The fence does not inspect peer socket addresses: binding expresses reachability, `trustedHosts` names accepted authorities, and the socket address adds nothing the Host/Origin checks need.
`HostConnectionHandle.allowsRemoteAuthorities` projects whether any validated `trustedHosts` entry names a non-loopback hostname: a loopback listener behind a proxy can still serve remote browsers, so the directory picker requires a policy without remote authorities before it picks the native chooser.
## Alternatives considered
@@ -26,6 +28,6 @@ Reachability is the webserver binding's policy (`host: 127.0.0.1 | 0.0.0.0`), an
## Consequences
- Any future `/api` method is covered by construction; there is no per-route trust decision left to forget.
- A custom non-loopback composition must trust its serving authorities or requests are refused, then satisfy browser authentication like every loopback request. The shipped CLI rejects `--host 0.0.0.0`; `--trusted-host` only extends the Host/Origin fence and grants no identity.
- A non-loopback listener accepts its own bind IP directly ([Bind one concrete Web address](2026-09-19-concrete-web-bind-address.md)); other remote authorities require configured trust. Every accepted authority still requires browser authentication. `--trusted-host` extends only the Host/Origin fence and grants no identity.
- Clients must label POST bodies `application/json` (ours always did; raw-fetch tests gained the header).
- Host and Origin remain request-routing evidence only. The process token and signed cookie establish the browser identity used by every Host method.
@@ -13,9 +13,11 @@ Web GUI 宿主以纯 loopback HTTP 提供 `/api`(默认 `127.0.0.1:3080`;CLI
在载体层对整个 `/api` 前缀一次性执行浏览器信任检查——分为两部分:
- **媒体类型栅栏(dsh-client-connection)**:每个 `/api` POST 必须声明 `application/json`,否则在解析前以 415 拒绝。跨站「简单请求」由此不复存在:任何跨站尝试都被逼进一次本服务器从不应答的 CORS 预检。
- **权威栅栏(dsh-client-connection,`src/api-request-trust.ts`)**:每个请求的 `Host` 都必须是回环地址,或与某个 `trustedHosts` 条目匹配(带端口的 `host:port` 条目精确匹配,不带端口的条目匹配任意端口,均经 WHATWG 归一化;rebinding 防御)。刻意不为无标记请求开捷径:明文 HTTP 下浏览器的读取(EventSource、图片、导航——这些头只发给可信目标)既不带 `Origin` 也不带 Fetch-Metadata,因此无标记请求可能是被重绑页面发起且响应可被读走的读取,而 Host 是重绑唯一伪造不了的请求头;非浏览器客户端经由回环地址、推导的 LAN IP 字面量或已声明的权威通过。若带 `Origin` 则必须与 Host 权威完全一致;`sec-fetch-site: cross-site` 一律拒绝。不是单纯规范化 authority 的 `trustedHosts` 条目会导致插件加载失败——否则 WHATWG 解析会悄悄授权笔误里的 hostname,或放大精确端口授权。`host.pickDirectory` 失去专属守卫,与其他请求同栅而行。
- **权威栅栏(dsh-client-connection,`src/api-request-trust.ts`)**:每个请求的 `Host` 都必须是回环地址,或与某个 `trustedHosts` 条目匹配(带端口的 `host:port` 条目精确匹配,不带端口的条目匹配任意端口,均经 WHATWG 归一化;rebinding 防御)。刻意不为无标记请求开捷径:明文 HTTP 下浏览器的读取(EventSource、图片、导航——这些头只发给可信目标)既不带 `Origin` 也不带 Fetch-Metadata,因此无标记请求可能是被重绑页面发起且响应可被读走的读取,而 Host 是重绑唯一伪造不了的请求头;非浏览器客户端经由回环地址、已声明的权威,或[绑定一个具体的 Web 地址](2026-09-19-concrete-web-bind-address.zh.md)所接纳的监听器自身绑定字面量通过。若带 `Origin` 则必须与 Host 权威完全一致;`sec-fetch-site: cross-site` 一律拒绝。不是单纯规范化 authority 的 `trustedHosts` 条目会导致插件加载失败——否则 WHATWG 解析会悄悄授权笔误里的 hostname,或放大精确端口授权。`host.pickDirectory` 失去专属守卫,与其他请求同栅而行。
可达性由 webserver 的绑定配置(`host: 127.0.0.1 | 0.0.0.0`)控制,这道栅栏是混淆代理人防御,而不是身份。Connection 在栅栏之后应用独立的[浏览器令牌认证](2026-08-24-browser-token-authentication.zh.md)。栅栏不检查对端 socket 地址:绑定表达可达性,`trustedHosts` 点名接受的 authority,socket 地址提供不了 Host/Origin 校验需要的额外信息。
可达性由 webserver 的绑定配置(`host`;[绑定一个具体的 Web 地址](2026-09-19-concrete-web-bind-address.zh.md)拥有它接受哪些字面量)控制,这道栅栏是混淆代理人防御,而不是身份。Connection 在栅栏之后应用独立的[浏览器令牌认证](2026-08-24-browser-token-authentication.zh.md)。栅栏不检查对端 socket 地址:绑定表达可达性,`trustedHosts` 点名接受的 authority,socket 地址提供不了 Host/Origin 校验需要的额外信息。
`HostConnectionHandle.allowsRemoteAuthorities` 表示是否有通过校验的 `trustedHosts` 条目命名非回环主机名:代理后的回环监听器也能服务远程浏览器,因此目录选择器要求策略不接纳远程 authority,才会选择原生交互。
## 曾考虑的替代方案
@@ -26,6 +28,6 @@ Web GUI 宿主以纯 loopback HTTP 提供 `/api`(默认 `127.0.0.1:3080`;CLI
## 后果
- 未来任何 `/api` 方法天然在覆盖范围内;不存在会被遗忘的按路由信任决定。
- 自定义非 loopback 组合必须信任其服务 authority,否则请求会被拒绝;随后仍像每个 loopback 请求一样满足浏览器认证。随附 CLI 拒绝 `--host 0.0.0.0`;`--trusted-host` 只扩展 Host/Origin 栅栏,绝不授予身份。
- 非回环监听器直接接受自己的绑定 IP([绑定一个具体的 Web 地址](2026-09-19-concrete-web-bind-address.zh.md));其他远程 authority 需要配置的信任。每个被接受的 authority 仍须满足浏览器认证。`--trusted-host` 只扩展 Host/Origin 栅栏,绝不授予身份。
- 客户端必须给 POST 体标注 `application/json`(我们自己的客户端一向如此;裸 fetch 测试补上了该头)。
- Host 与 Origin 仍只是请求路由证据。进程令牌与签名 cookie 建立每个 Host 方法使用的浏览器身份。
@@ -72,5 +72,5 @@
en: 7433bdb522bb2083
zh: 545c17613b636f00
/agent-note-typert-gateway-targeted-method-calls/consequences:
en: c3459afdb606cfbe
zh: ec69f2bb39170a7c
en: abab48251550f584
zh: 2568c46d78dbefdf
@@ -520,7 +520,7 @@ A consumer may import a Remote contract that is not currently mounted on the Hos
Connection's general channel API must suit both the current HTTP carrier and a future WebSocket carrier. If the Client Remote or Gateway exposes `fetch`, an HTTP request, or a route handle, WebSocket migration will pierce the Remote layer again. Those physical objects must therefore remain internal to Connection.
Remote endpoints use Connection's `trusted-host` authority. Loopback is accepted by default and LAN callers require an explicit trusted-host configuration, but this layer adds no per-method caller authorization; every trusted host can invoke a mounted Remote endpoint.
Remote endpoints use Connection's `trusted-host` authority. Loopback and the listener's own bind address are accepted by default; any other LAN authority requires an explicit trusted-host configuration. This layer adds no per-method caller authorization; every accepted authority can invoke a mounted Remote endpoint.
`hasSeen()` favors strict-definition safety over SRC availability. While a strict descriptor is withdrawn, such as during HMR, the Gateway continues to claim the endpoint and reports it unavailable instead of falling back to a weak SRC descriptor. Re-registration restores it; only a Typert registry restart forgets the historical strict definition.
@@ -520,7 +520,7 @@ SRC 弱 descriptor 不验证普通 JSON 内部结构。Host Remote 签名变化
Connection 的通用 channel API 必须同时适合当前 HTTP carrier 和后续 WebSocket carrier。若 Client Remote 或 Gateway 暴露 `fetch`、HTTP request 或 route handle,WebSocket 迁移会再次穿透 Remote 层,因此这些物理对象必须留在 Connection 内部。
Remote endpoint 使用 Connection 的 `trusted-host` authority。系统默认接受 loopback;LAN 调用方必须通过显式 trusted-host 配置接入,但本层不增加逐方法调用方授权,因此每个 trusted host 都能调用已挂载的 Remote endpoint。
Remote endpoint 使用 Connection 的 `trusted-host` authority。系统默认接受 loopback 与监听器自身的绑定地址;其他 LAN authority 必须通过显式 trusted-host 配置接入。本层不增加逐方法调用方授权,因此每个被接受的 authority 都能调用已挂载的 Remote endpoint。
`hasSeen()` 优先保障 strict definition 的安全性,而非 SRC 可用性。strict descriptor 撤回时(例如 HMR 期间),Gateway 会继续认领 endpoint 并报告不可用,而不会回退到弱 SRC descriptor。重新注册即可恢复;只有重启 Typert 注册表才会忘记历史 strict definition。
@@ -9,8 +9,8 @@
en: 01293500639c9d79
zh: 6d19e4843955c811
/agent-note-browser-launch-token-authentication/decision:
en: 063f9698b8a94287
zh: 848b2c1134b7a100
en: 54efa59339f75256
zh: 0f53ead3844151c6
/agent-note-browser-launch-token-authentication/verification:
en: 189b36d0fc5f3a17
zh: 08993cb585204fc0
@@ -14,13 +14,13 @@ The Web Host runs tool-capable Sessions with the current operating-system user's
Each Host process generates a random launch token, retained by the root application context across Connection hot reloads. `dsh-web-app` prints and opens the normal root URL with that token in the query once per process. `frontend-static` asks Connection to authorize index responses: only `GET /?token=...` exchanges the process token for a cookie, then redirects to clean `./`; the token is not accepted on API paths or in an Authorization header. An obsolete token paired with a valid cookie redirects to clean `./`. Missing and invalid credentials receive one minimal 401 response. Static non-index assets remain public.
The cookie is a signed, authority-bound bearer. Its deterministic name and signed payload both include the normalized hostname plus port, so one Harness home can run independent Web ports without cookie collisions. The payload carries safe-integer issue and expiry times under an absolute lifetime; `cookieMaxAgeDays` defaults to 30. The cookie is host-only, `Path=/`, `HttpOnly`, and `SameSite=Strict`. It omits `Secure` because the shipped server uses loopback HTTP. There is no logout operation.
The cookie is a signed, authority-bound bearer. Its deterministic name and signed payload both include the normalized hostname plus port, so one Harness home can run independent Web ports without cookie collisions. The payload carries safe-integer issue and expiry times under an absolute lifetime; `cookieMaxAgeDays` defaults to 30. The cookie is host-only, `Path=/`, `HttpOnly`, and `SameSite=Strict`. The HTTP listener omits `Secure`. There is no logout operation.
The HMAC secret is a versioned `grant` record at `client-connection/browser-session` in `ctx.credentials`; the local provider stores it in `$DSH_HOME/.credentials.yaml`. Connection loads or creates the record during activation and retains the secret for synchronous request verification. An active Connection continues using its loaded secret if the durable record changes; the next activation loads the replacement or creates a missing record, so deleting the record and restarting the process revokes every existing cookie. Invalid owner payloads fail loud instead of being replaced. The launch token itself is never persisted and changes on every process start, while an unexpired cookie remains valid across restarts on the same authority.
The in-page Web Worker preview exposes no network socket. Its page-owned `postMessage` tunnel enters the real route first, then retries a 401 or 403 through the worker-local fetch handler. This keeps Connection interceptors while limiting the authentication bypass to the page that created the Host worker.
The shipped CLI continues to reject `--host 0.0.0.0`. Authentication does not imply supported network deployment, TLS, forwarding-header interpretation, or proxy configuration.
The shipped CLI accepts concrete local bind addresses but rejects wildcard addresses such as `--host 0.0.0.0`. Authentication does not configure TLS, forwarding-header interpretation, or proxies.
## Verification
@@ -14,13 +14,13 @@ Web Host 以当前操作系统用户的权限运行具有工具能力的 Session
每个 Host 进程生成随机启动令牌,并由应用根 context 跨 Connection 热重载保留。`dsh-web-app` 每个进程只打印并打开一次 query 中带该令牌的普通根 URL。`frontend-static` 请求 Connection 授权 index 响应:只有 `GET /?token=...` 会把进程令牌交换为 cookie,再重定向到干净的 `./`;API 路径和 Authorization header 都不接受该令牌。过时令牌如果同时带有有效 cookie,会重定向到干净的 `./`。缺失与无效凭据得到同一份最小 401 响应。非 index 静态资产保持公开。
cookie 是签名且绑定 authority 的 bearer。确定性名称与签名 payload 都包含规范化 hostname 和 port,因此同一 Harness home 可以在不同 Web port 运行而不发生 cookie 冲突。payload 在绝对有效期内携带安全整数形式的签发与过期时间;`cookieMaxAgeDays` 默认为 30。cookie 是 host-only、`Path=/`、`HttpOnly`、`SameSite=Strict`。随附服务器使用 loopback HTTP,因此不设置 `Secure`。这里没有 logout 操作。
cookie 是签名且绑定 authority 的 bearer。确定性名称与签名 payload 都包含规范化 hostname 和 port,因此同一 Harness home 可以在不同 Web port 运行而不发生 cookie 冲突。payload 在绝对有效期内携带安全整数形式的签发与过期时间;`cookieMaxAgeDays` 默认为 30。cookie 是 host-only、`Path=/`、`HttpOnly`、`SameSite=Strict`。HTTP 监听器不设置 `Secure`。这里没有 logout 操作。
HMAC 密钥是 `ctx.credentials` 中位于 `client-connection/browser-session` 的版本化 `grant` 记录;本地提供方将其存入 `$DSH_HOME/.credentials.yaml`。Connection 在激活期间加载或创建该记录,并保留密钥以同步校验请求。持久记录发生变化后,当前 Connection 继续使用已加载的密钥;下一次激活会加载替换记录或创建缺失记录,因此删除记录并重启进程会撤销全部既有 cookie。无效 owner payload 会明确失败,而不是被覆盖。启动令牌本身绝不持久化并在每次进程启动时变化;未过期 cookie 则能在相同 authority 上跨重启继续有效。
页内 Web Worker preview 不暴露网络 socket。其由页面持有的 `postMessage` tunnel 先进入真实 route,收到 401 或 403 后再经 worker 本地 fetch handler 重试。这样既保留 Connection interceptor,又把认证绕过限制在创建 Host worker 的页面内。
随附 CLI 继续拒绝 `--host 0.0.0.0`。认证不代表支持网络部署、TLS、转发 header 解释或代理配置。
随附 CLI 接受本机具体绑定地址,但拒绝 `--host 0.0.0.0` 等通配地址。认证不会配置 TLS、转发 header 解释或代理。
## 验证
@@ -0,0 +1,22 @@
# Bilingual-pair consistency record for 2026-09-19-concrete-web-bind-address.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-19-concrete-web-bind-address.md
/agent-note-bind-one-concrete-web-address:
en: f70f2b6b24ef16dd
zh: 64100dbc55eee33d
/agent-note-bind-one-concrete-web-address/problem:
en: ffc265e61eadc31b
zh: fcc70f0a8817914c
/agent-note-bind-one-concrete-web-address/decision:
en: 5fe4f2ddba2ee2da
zh: bb140a16f056d4b4
/agent-note-bind-one-concrete-web-address/alternatives-considered:
en: 0e4d475347796821
zh: e621e606bc5b1045
/agent-note-bind-one-concrete-web-address/consequences:
en: 09ca30632d3930b2
zh: 87d286ed53f834db
/agent-note-bind-one-concrete-web-address/testing:
en: 4bb8f4bf4ad943c3
zh: 2e24d56d01b52574
@@ -0,0 +1,33 @@
# Agent Note: Bind one concrete Web address
Status: implemented
English | [中文](2026-09-19-concrete-web-bind-address.zh.md)
## Problem
The Web listener accepted `127.0.0.1` or `0.0.0.0`. A wildcard bind exposed every interface of the machine, so the bundle sampled the LAN IPv4 addresses at bind time into a `webRuntime` service and appended them to `trustedHosts`, granting authorities for interfaces the operator never named and mixing inferred reachability into configured trust. A container or workstation that should expose exactly one address had no way to say so, IPv6 addresses were not accepted at all, and a browser dialing the exposed address sent it as `Host` only to be refused unless the sampled list happened to contain it.
## Decision
`webserver.config.host` accepts one concrete IPv4 or IPv6 address literal, loopback by default; the unspecified address in every spelling (`0.0.0.0`, `::`, `::ffff:0.0.0.0`, and their expansions) is rejected when the config loads, and the shipped `dsh web` command refuses the same spellings for `--host` before any consumer activates. An IPv6 `%zone` is kept for `listen`; classification through the exported `isWildcardHost` and `isLoopbackHost` reads the address alone.
The browser Host/Origin fence accepts the listener's own bind IP literal as `Host` on any port, independently of `trustedHosts` and ignoring the zone. A browser dialing that literal presents it as `Host`, the literal is no rebinding target, and the Origin and cross-site checks still apply. DNS names resolving to the address, other interfaces, and proxy authorities still require `--trusted-host`. Connection reads the invocation authorities directly from the `webStartup` provider; the `webRuntime` service and LAN sampling no longer exist.
The advertised URL uses IPv4 for mapped loopback binds, brackets other IPv6 binds, drops a loopback zone, and requires `--public-url` for a zoned non-loopback bind because a zone cannot appear in a URL. A non-loopback bind serves plain HTTP and warns at startup even when an HTTPS `--public-url` fronts it. `web-runtime` is a required startup entry, so a bind failure fails startup instead of leaving a silent listener-less process. Desktop pins `--host 127.0.0.1`, the address Electron dials.
## Alternatives considered
**Keep sampling LAN addresses into `trustedHosts`.** Rejected: the listener already owns the concrete bind address. Sampling grants authorities for other interfaces and mixes inferred reachability with configured trust; direct bind-IP acceptance preserves that distinction without another runtime service.
**Accept the bind IP only on the bound port.** Rejected: a port-less `trustedHosts` entry already matches any port, a proxy on the same address may listen elsewhere, and the port adds nothing to the rebinding defense the Host check provides.
**Keep `0.0.0.0` with a warning.** Rejected: binding every interface exposes remote code execution to every network the machine joins, and the operator can name each address that should be exposed.
## Consequences
An operator exposes exactly the interfaces named by `--host` and reaches the listener through that literal without further configuration; every other remote authority is a `--trusted-host` decision. Compositions that bound `0.0.0.0` fail at load with a message naming the rejected spelling. IPv6-only hosts are supported end to end, including the printed and opened URL.
## Testing
`packages/host/webserver/tests/webserver.spec.ts` classifies wildcard and loopback spellings and rejects wildcard config; `packages/bundle/web-app/tests/startup.spec.ts` refuses wildcard `--host` before consumers activate; `packages/client/connection/tests/api-request-trust.host.spec.ts` and `node-half.host.spec.ts` admit the bind literal on any port without admitting other authorities; `packages/bundle/web-app/tests/web-app.spec.ts` covers the bracketed IPv6 URL and the plain-HTTP warning; `apps/cli/tests/profiles/web/tests/public-url.expected.e2e.ts` boots the built profile on a non-loopback and a zoned loopback address; `web-failure-matrix.expected.e2e.ts` and `packages/boot/app-boot/tests/app-boot.spec.ts` report the required `web-runtime` entry.
@@ -0,0 +1,33 @@
# Agent Note: 绑定一个具体的 Web 地址
Status: implemented
[English](2026-09-19-concrete-web-bind-address.md) | 中文
## 问题
Web 监听器只接受 `127.0.0.1` 或 `0.0.0.0`。通配绑定会暴露机器的每个接口,因此 bundle 在绑定时把 LAN IPv4 地址采样进 `webRuntime` 服务并追加到 `trustedHosts`,为操作者从未点名的接口授予 authority,并把推断的可达性混入配置的信任。应当只暴露一个地址的容器或工作站无法表达这一点,IPv6 地址完全不被接受,浏览器拨号已暴露地址时把它作为 `Host` 发送,却只在采样列表恰好包含它时才被接受。
## 决策
`webserver.config.host` 接受一个具体的 IPv4 或 IPv6 地址字面量,默认回环;未指定地址的所有拼写(`0.0.0.0`、`::`、`::ffff:0.0.0.0` 及其展开形式)在配置加载时被拒绝,随附的 `dsh web` 命令在任何消费者激活前对 `--host` 拒绝同样的拼写。IPv6 的 `%zone` 保留给 `listen`;通过导出的 `isWildcardHost` 与 `isLoopbackHost` 分类时只读取地址本身。
浏览器 Host/Origin 栅栏接受监听器自身的绑定 IP 字面量作为任意端口上的 `Host`,独立于 `trustedHosts` 并忽略 zone。拨号该字面量的浏览器把它作为 `Host` 呈现,该字面量不是重绑目标,Origin 与跨站检查仍然生效。解析到该地址的 DNS 名称、其他接口与代理 authority 仍需 `--trusted-host`。Connection 直接从 `webStartup` 提供方读取调用 authority;`webRuntime` 服务与 LAN 采样不再存在。
公告 URL 对 mapped 回环绑定使用 IPv4,为其他 IPv6 绑定加方括号、剥离回环 zone,并因 zone 不能出现在 URL 中而要求带 zone 的非回环绑定显式提供 `--public-url`。非回环绑定提供明文 HTTP,即使前置 HTTPS 的 `--public-url` 也会在启动时警告。`web-runtime` 是必需的启动条目,因此绑定失败会使启动失败,而不是留下一个没有监听器的静默进程。Desktop 固定 `--host 127.0.0.1`,即 Electron 拨号的地址。
## 备选方案
**继续把采样的 LAN 地址加入 `trustedHosts`。** 否决:监听器已经持有具体绑定地址。采样会授权其他接口的 authority,并把推断的可达性混入配置的信任;直接接受绑定 IP 可以保留两者的区分,无需另设运行时服务。
**只在绑定端口上接受绑定 IP。** 否决:不带端口的 `trustedHosts` 条目本就匹配任意端口,同一地址上的代理可能监听其他端口,端口对 Host 检查提供的重绑防御没有增益。
**保留 `0.0.0.0` 并给出警告。** 否决:绑定所有接口会向机器加入的每个网络暴露远程代码执行,而操作者可以逐个点名应暴露的地址。
## 影响
操作者只暴露 `--host` 点名的接口,并无需额外配置即可通过该字面量访问监听器;其他每个远程 authority 都是一个 `--trusted-host` 决定。曾绑定 `0.0.0.0` 的组合在加载时失败,并给出点名被拒绝拼写的消息。纯 IPv6 主机得到端到端支持,包括打印与打开的 URL。
## 测试
`packages/host/webserver/tests/webserver.spec.ts` 分类通配与回环拼写并拒绝通配配置;`packages/bundle/web-app/tests/startup.spec.ts` 在消费者激活前拒绝通配 `--host`;`packages/client/connection/tests/api-request-trust.host.spec.ts` 与 `node-half.host.spec.ts` 在任意端口上接纳绑定字面量而不接纳其他 authority;`packages/bundle/web-app/tests/web-app.spec.ts` 覆盖带方括号的 IPv6 URL 与明文 HTTP 警告;`apps/cli/tests/profiles/web/tests/public-url.expected.e2e.ts` 在非回环地址与带 zone 的回环地址上启动已构建的 profile;`web-failure-matrix.expected.e2e.ts` 与 `packages/boot/app-boot/tests/app-boot.spec.ts` 报告必需的 `web-runtime` 条目。
@@ -30,8 +30,8 @@
en: 743f2dceda3072aa
zh: f4d53ddc13a4127e
/agent-note-projection-cache-read-only-face-matches-the-lifecycle-identity-the-client-store-separates-cached-and-sequenced-rows/decision/the-client-store-separates-cached-and-sequenced-rows:
en: 8a4e9e9ac2ada06f
zh: b5884535cd934f72
en: dd1cc7ac96a5a25d
zh: bdea08d20f166e65
/agent-note-projection-cache-read-only-face-matches-the-lifecycle-identity-the-client-store-separates-cached-and-sequenced-rows/decision/what-stays-untouched:
en: 1cefba02bbfb2647
zh: 5a96942565ab9d72
@@ -97,7 +97,7 @@ Writer classification:
| the `projections` block of an `api-session/added` summary (`manager.handleSessionAdded`) | by the block's `kind`; the summary comes from a live session, so in practice `sequenced` |
| the history first page's `projections` (`projections.seed` in `session.ts`) | sequenced |
| the control baseline, live sessions only (`manager.replaceControlBaseline`) | sequenced |
| the `session.projections` result of `refreshProjections` (a body observation) | sequenced |
| the `session.projections` result of `refreshProjections` | sequenced for an exact read; cached only when [migration is required](2026-09-29-explicit-session-history-preparation.md) |
| push frames (the `projection` frame handled by `manager`) | sequenced |
| the `title` after a successful rename (`session.ts`) | sequenced |
@@ -97,7 +97,7 @@ seq 比较只在同一条 Host 连接内发生:`handleConnected` 先整表 `cl
| `api-session/added` 摘要的 `projections` block(`manager.handleSessionAdded`) | 按 block 的 `kind`;该摘要来自活会话,实际为 `sequenced` |
| history 首页 `projections`(`session.ts` 的 `projections.seed`) | sequenced |
| control 基线,仅活会话(`manager.replaceControlBaseline`) | sequenced |
| `refreshProjections` 的 `session.projections` 结果(正文观察) | sequenced |
| `refreshProjections` 的 `session.projections` 结果 | 准确读取为 sequenced;仅[需要迁移](2026-09-29-explicit-session-history-preparation.zh.md)时为 cached |
| 推送 frame(`manager` 处理 `projection` frame) | sequenced |
| rename 成功后的 `title`(`session.ts`) | sequenced |
@@ -0,0 +1,19 @@
# Bilingual-pair consistency record for 2026-09-29-explicit-session-history-preparation.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-29-explicit-session-history-preparation.md
/agent-note-open-the-session-when-migration-is-required:
en: d409772933407317
zh: ac258a6d1743965f
/agent-note-open-the-session-when-migration-is-required/problem:
en: ab63c8127d68d294
zh: d3f99489c96ff1d4
/agent-note-open-the-session-when-migration-is-required/decision:
en: 32627d744a6e7410
zh: 2836a95ee5390111
/agent-note-open-the-session-when-migration-is-required/alternatives-considered:
en: baea781018a49252
zh: bcc452029bf229fd
/agent-note-open-the-session-when-migration-is-required/consequences:
en: 979eebb53a6bf711
zh: 013743b0b7a000ce
@@ -0,0 +1,43 @@
# Agent Note: Open the Session when migration is required
Status: implemented
English | [中文](2026-09-29-explicit-session-history-preparation.zh.md)
## Problem
Expanding a subagent catalog or forking from the sidebar can trigger an old Session's format migration.
## Decision
The WorkspaceBrowser row-menu fork request carries `allowMigration: false`, which the Host handles by checking `stat` format information before calling the original fork flow. Only a source format requiring migration is refused, with an app-wide Toast offering to open the original Session. That action only navigates and does not retry fork. Neither an absent live Session nor unopened Client history justifies refusal.
Other fork callers omit that option and allow automatic migration.
`session.projections` also checks the stored format first; current-format cold history still uses the original observation flow for exact projections. Success returns `kind: sequenced` and its real `asOfSeq`; required migration returns `kind: migration-required` hints. The Client retains that read state, and cached values cannot replace sequenced values published by history or control.
The `formatStatus` returned by `stat` carries the migration library's existing classification; `header` remains the current logical format. It describes the stored format, not migration-task progress or prepared in-memory history.
When migration is required and `subagentCatalog` is absent, Web marks the Session's row “Migration required” and keeps its history-opening actions without descendant expansion or changing its subagent mode. Opening the Session supplies its catalog through history and control projections. Available catalogs remain usable, including empty catalogs. Session-list lineage resolves navigation addresses, not missing catalog membership. Ordinary loading, cancellation, and storage errors do not become this migration branch.
Create/adopt, full-text search, and ordinary history opening allow automatic migration.
This decision adds a migration exception to [catalog projection reads](../../archived/simplification/2026-09-08-web-subagent-catalog-projections.md). Exact reads and leases in [Session observations](2026-08-25-session-observations-and-projection-owned-client-state.md), and identity checks and cached/sequenced ordering in [projection-cache reads](2026-09-19-projection-cache-listing-identity-and-cached-rows.md), still apply.
## Alternatives considered
**Treat every cold Session as needing migration.** Residency and stored format are different; this would reject otherwise valid forks and discard catalog information available on disk.
**Globally forbid forks that automatically migrate on the Host.** The sidebar's interaction choice does not govern other callers, so its request explicitly carries the restriction.
**Automatically open and retry the sidebar fork.** Opening is an explicit user action, and fork does not run automatically after migration.
**Derive catalogs from Session-list parent ids.** This adds a second membership derivation; explicit Session opening obtains the catalog.
**Give cached hints `asOfSeq: -1`.** Hints have no event cut in current history and cannot participate in sequence comparisons.
## Consequences
Sessions requiring migration use the existing opening flow to prepare history; Sessions that need no migration remain cold-readable and forkable. Catalog hints may lag the log and yield to subsequent exact projections.
A separate follow-redirection proposal discusses future single-Session migration progress; this decision does not implement that flow.
@@ -0,0 +1,43 @@
# Agent Note: 需要迁移时引导打开会话
Status: implemented
[English](2026-09-29-explicit-session-history-preparation.md) | 中文
## 问题
展开 subagent 目录或从侧栏 fork 可能触发旧会话的格式迁移。
## 决定
WorkspaceBrowser 行菜单的 fork 请求携带 `allowMigration: false`,由 Host 在调用原 fork 流程前检查 `stat` 的格式信息。只有源格式需要迁移时才拒绝,并显示带“打开原会话”操作的应用级 Toast。该操作只导航,不自动重发 fork。没有 live Session 或 Client 尚未打开历史不构成拒绝理由。
其他 fork 调用者省略该选项,允许自动迁移。
`session.projections` 同样先检查存储格式,当前格式冷历史继续通过原观察流程读取准确投影。成功时返回 `kind: sequenced` 和真实 `asOfSeq`;需要迁移时返回 `kind: migration-required` 的缓存提示。Client 保留这一读取状态,缓存值不能覆盖 history 或 control 已发布的有序值。
`stat` 返回的 `formatStatus` 携带迁移库已有的分类;`header` 仍是当前逻辑格式。它描述存储格式,不描述迁移任务进度或内存准备缓存。
在需要迁移且缺少 `subagentCatalog` 时,Web 将该会话行标为“需要迁移”,保留打开历史的操作,但不能展开下级,也不改变子会话的 mode。打开会话后,历史与 control 投影提供其目录。已有目录仍可使用,包括空目录。会话列表的谱系用于解析导航地址,不用于补齐缺失的目录成员。普通加载、取消和存储错误不转换成这一迁移分支。
create/adopt、全文搜索和普通历史打开允许自动迁移。
本决策为[目录投影查询](../../archived/simplification/2026-09-08-web-subagent-catalog-projections.md)增加迁移例外。[会话观察](2026-08-25-session-observations-and-projection-owned-client-state.zh.md)的准确读取和租约,以及[投影缓存读取](2026-09-19-projection-cache-listing-identity-and-cached-rows.zh.md)的身份校验与缓存/有序分层继续适用。
## 考虑过的替代方案
**把所有冷会话都视作需要迁移。** 是否驻留与存储格式不同;这样会拒绝可以正常完成的 fork,并丢失能够从磁盘读取的目录信息。
**Host 全局禁止自动迁移的 fork。** 侧栏的交互选择不适用于其他调用者,因此限制由该请求显式携带。
**侧栏 fork 自动打开并重试。** 打开会话由用户明确选择,fork 不在迁移后自动执行。
**按会话列表中的父 id 派生目录。** 这需要第二套成员派生逻辑;显式打开会话即可获取目录。
**让缓存提示携带 `asOfSeq: -1`。** 提示没有当前历史的事件切点,不能参与序号比较。
## 影响
需要迁移的会话通过现有打开流程完成准备;无需迁移的会话仍可冷读和 fork。目录的缓存提示可能落后于日志,后续准确投影替换它们。
后续单会话迁移进度由独立的 follow 重定向提案讨论,本决策不实现该流程。
@@ -0,0 +1,25 @@
# Bilingual-pair consistency record for 2026-09-29-session-migration-follow-redirect.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-29-session-migration-follow-redirect.md
/agent-note-present-single-session-migration-through-follow-redirection:
en: 294f1872bbef5f59
zh: c4bf2852374fbba4
/agent-note-present-single-session-migration-through-follow-redirection/problem:
en: 8d019bda1e854df3
zh: 49cbd8b8d869e406
/agent-note-present-single-session-migration-through-follow-redirection/proposal:
en: b5d43cb845d165ed
zh: c5b3aa8eb7799134
/agent-note-present-single-session-migration-through-follow-redirection/proposal/completion-milestone-remains-open:
en: 2281727adab80d51
zh: 4795be5613b37ede
/agent-note-present-single-session-migration-through-follow-redirection/alternatives-considered:
en: bfc5d2325e710601
zh: 4f285a9f317cd6af
/agent-note-present-single-session-migration-through-follow-redirection/acceptance-criteria:
en: e5d810300cec5f99
zh: 7bcef7a5ef99501e
/agent-note-present-single-session-migration-through-follow-redirection/risks:
en: 1704cb15dc3c0b5e
zh: cc4576d6e47c3b22
@@ -0,0 +1,43 @@
# Agent Note: Present single-Session migration through follow redirection
Status: proposed
English | [中文](2026-09-29-session-migration-follow-redirect.zh.md)
## Problem
Opening historical Sessions can wait for lengthy format conversion. Ordinary loading cannot explain the current phase or show migration failure; placing progress inside history events would contaminate the model log and change journal sequence semantics.
## Proposal
Add a typed business redirect to single-Session history reads: page or follow returns a migration reference when preparation is required, the Client follows that migration for its current phase and subsequent progress, then returns to the original history read after success. “302” denotes a business result, not a browser HTTP redirect, and accepts no arbitrary URL.
The Session object layer owns redirection, waiting, failure, and reopening. React must observe an explicit migration state and render a dedicated component; failure retains its cause and retry action. Migration success returns only to history loading, and a valid snapshot establishes history readiness. Paging and reconnect retain existing history and drafts.
The actual persistence preparation owner retains migration ownership; multiple waiters share work and late joiners receive current state immediately. Closing one view releases only its waiter. Started durable publication retains its run-to-settlement policy. Progress enters neither the Session log nor `session.control` for this flow.
Create/adopt and full-text search keep automatic processing; fork and catalog reads use the [migration prerequisite](../../implemented/architecture/2026-09-29-explicit-session-history-preparation.md).
### Completion milestone remains open
Read-open currently produces validated in-memory history; write-open encodes, verifies, and publishes a new generation. If redirection waits only for read preparation, its reference must retain the prepared result until the original follow takes ownership rather than relying on an evictable short-lived cache. Waiting for publication instead adds write-permission and publication-time requirements to opening old history. Implementation must choose this behavior rather than using an ambiguous done state for both milestones.
## Alternatives considered
**Push migration progress through global control.** One history-opening operation can own its waiting, failure, and retry without independent global progress synchronization.
**Insert progress before the ordinary history journal's opening frame.** The generic journal remains snapshot-first; the Session adapter consumes redirection and migration frames, forwarding only ordinary history to the journal.
**Automatically replay every RPC that encounters migration.** Commands with committed effects cannot be replayed safely. This proposal covers recoverable history reads rather than a general command-retry platform.
## Acceptance criteria
- The Client shows actual migration phases before receiving the history snapshot; failure remains visible without an automatic retry loop.
- Concurrent joiners, independent waiter cancellation, and reconnect observe the same operation's current state; an old attempt cannot overwrite a new one.
- The resumed history read cannot restart the same migration after cache eviction; cancellation never rolls back a published generation.
- Unknown totals produce no fabricated percentages. Unrelated lightweight requests and progress frames receive execution time during migration, with responsiveness measured separately.
- Create/search, fork/catalog policies, and existing Session data retain their respective guarantees.
## Risks
Redirection spans two requests and needs explicit retention and cancellation ownership. Progress alone does not remove synchronous Host parsing and validation stalls; scheduling changes require measurements. The choice between read preparation and write publication remains an implementation prerequisite.
@@ -0,0 +1,43 @@
# Agent Note: 单会话迁移通过 follow 重定向呈现
Status: proposed
[English](2026-09-29-session-migration-follow-redirect.md) | 中文
## 问题
打开历史会话可能等待较长的格式转换。普通 loading 无法解释当前阶段或显示迁移失败;把进度放入历史事件又会污染模型日志,并改变历史流的序号语义。
## 提案
对单会话历史读取增加 typed 业务重定向:page 或 follow 在需要迁移时返回迁移引用,Client 转到迁移 follow,接收当前阶段和后续进度;成功后回到原历史读取。这里的“302”是业务结果,不是浏览器 HTTP 跳转,也不接受任意 URL。
Session 对象层编排跳转、等待、失败和重新读取。React 必须看到迁移中的明确状态并显示专门组件;失败保留原因和重试入口。迁移成功只回到历史加载态,收到有效 snapshot 才表示历史就绪。分页或重连已经拥有的历史与草稿保留。
迁移操作仍由实际持久化准备入口拥有;多个等待者共享任务,后加入者立即取得当前状态。关闭一个视图只释放其等待,不取消其他等待者。已开始的 durable publication 遵守既有运行至结算规则。进度不写入 Session log,不使用 `session.control` 作为本流程的进度通道。
create/adopt 和全文搜索保持自动处理;fork 和目录读取采用[迁移前置检查](../../implemented/architecture/2026-09-29-explicit-session-history-preparation.zh.md)。
### 完成里程碑仍待确定
现有 read-open 只生成经验证的内存结果;write-open 才编码、校验并发布新 generation。若重定向只等待读准备,迁移引用必须将准备结果保留到原 follow 接手,不能依赖可淘汰的短期缓存。若等待完整发布,则打开旧历史会额外要求写权限并等待发布。实现前必须确定这一行为,不以一个模糊的 done 混用两种里程碑。
## 考虑过的替代方案
**通过全局 control 推送迁移进度。** 单次打开历史的等待、错误与重试更适合由该读取流程拥有,不需要增加独立的全局进度状态同步。
**直接在普通 history journal 首帧前插入进度。** 通用 journal 保持 snapshot-first;Session 适配层消费重定向和迁移流,只把正常历史交给 journal。
**所有 RPC 遇到迁移都自动重发。** 已执行副作用的命令不能安全重放。此提案限定可安全恢复的历史读取,不扩展成通用命令重试平台。
## 验收条件
- 迁移等待开始后,Client 在收到历史 snapshot 前显示真实阶段;失败保持可见且不会自动循环重试。
- 并发加入、单等待者取消和重连均取得同一操作的当前状态;旧尝试的通知不覆盖新尝试。
- 完成后的历史读取不会因为缓存淘汰重新启动同一迁移;已发布 generation 不因取消而回滚。
- 未知总量不显示伪造百分比。迁移时无关轻量请求和进度帧仍能得到执行机会,响应性单独测量。
- create/search、fork/catalog 策略及已有 Session 数据保持各自约定。
## 风险
重定向跨越两次请求,需要明确保活与取消所有权。迁移进度并不会消除 Host 主线程的同步解析和验证暂停;相关调度调整必须依据实测。只读准备与写入发布的完成定义仍是实施前置选择。
@@ -0,0 +1,46 @@
# Bilingual-pair consistency record for 2026-10-02-plugin-market-index-format.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/feature/2026-10-02-plugin-market-index-format.md
/agent-note-plugin-market-index-format:
en: 8da20a8601d7cb29
zh: 74368eed4d4b8e7c
/agent-note-plugin-market-index-format/problem:
en: ce5e4a362f558af6
zh: 29d93e0655b99217
/agent-note-plugin-market-index-format/proposal:
en: ed9148e95b2ad7c2
zh: 25a7add3c56fd681
/agent-note-plugin-market-index-format/proposal/example:
en: 1b1c960c966cf912
zh: 97be87cb14893c26
/agent-note-plugin-market-index-format/proposal/root-fields:
en: 4fac5a9b8261342d
zh: f9168a8a4209feab
/agent-note-plugin-market-index-format/proposal/plugin-fields:
en: 19d743bd04dc65b3
zh: 810a7f98c4fe8301
/agent-note-plugin-market-index-format/proposal/version-fields:
en: d6c0fa99e1cbb068
zh: 3d2a7b85299c679a
/agent-note-plugin-market-index-format/proposal/install-arguments:
en: c748cdc4d8ce349a
zh: 5740fa0d6106e637
/agent-note-plugin-market-index-format/proposal/dsh-compatibility:
en: 00fe507b9b5dfea7
zh: 55de6aaeb3865e84
/agent-note-plugin-market-index-format/proposal/generator-obligations:
en: 8e848a102fe9d86e
zh: feff226ef8321e00
/agent-note-plugin-market-index-format/proposal/reader-rules:
en: 92903cbe87d9c0ca
zh: 54af6cb33e9da252
/agent-note-plugin-market-index-format/alternatives-considered:
en: 3d7cf519bc8e0593
zh: 328dc0747836eaa8
/agent-note-plugin-market-index-format/acceptance-criteria:
en: 753abee6adf39812
zh: 1102bcfdd48f7e90
/agent-note-plugin-market-index-format/risks:
en: 41170d483b17df80
zh: 1bfd8f44e0f09036
@@ -0,0 +1,234 @@
# Agent Note: Plugin market index format
Status: proposed
English | [中文](2026-10-02-plugin-market-index-format.zh.md)
## Problem
DSH installs third-party bundles only from a spec the user already knows: an npm name, a Git URL, a tarball, or a local path. Nothing lets a user browse installable bundles. No official market exists, and one may be added later, so a market mechanism must work with indexes that third parties publish.
The smallest possible index is a list of npm names or Git repository URLs, one per line. It identifies every plugin, but a client rendering it must fetch each package's manifest, locale files, and icon before it can show a list. A list page that issues one or more requests per row is slow, fails partially, and puts metadata resolution in every client.
An index must therefore carry everything a list page displays: localized title and description, icon, author, available versions, compatibility, and publish time. Markets also want to publish their own data, such as categories, tags, downloads, and ratings, without DSH knowing what those values mean.
## Proposal
Separate the **index** from the **index generator**. DSH defines and reads one JSON index format. How an index is produced, and from which source list, belongs to the generator. DSH's reader, its Discover page, and a reference generator are separate changes that consume this format. Indexes list [profile plugin bundles](../../implemented/architecture/2026-08-05-profile-plugin-bundles.md) and install them through [guided plugin installation](../../implemented/architecture/2026-09-15-guided-plugin-installation.md).
The market reuses npm's package names, versions, ranges, distribution tags, and person declarations. Readers and generators use maintained npm parsers and validators. Additional fields or changed meanings require a plugin-market requirement that npm does not meet.
### Example
```json
{
"format": 1,
"title": { "en": "Example Market", "zh": "示例市场" },
"description": { "en": "Community DSH bundles", "zh": "社区 DSH bundle" },
"metadata": {
"categories": {
"productivity": { "title": { "en": "Productivity", "zh": "效率" } }
},
"labels": {
"official": { "title": { "en": "Official", "zh": "官方" } }
},
"sortKeys": {
"downloads": { "title": { "en": "Downloads", "zh": "下载量" }, "order": "desc" },
"rating": { "title": { "en": "Rating", "zh": "评分" }, "order": "desc" }
}
},
"plugins": [
{
"name": "@example/dsh-auto-review",
"title": { "en": "Auto Review", "zh": "自动审查" },
"description": { "en": "Per-tool LLM authorization review", "zh": "逐工具 LLM 授权审查" },
"icon": "icons/auto-review.svg",
"author": "Example Labs",
"homepage": "https://example.com/dsh-auto-review",
"metadata": {
"category": "productivity",
"labels": ["official"],
"keywords": ["review", "authorization"],
"sortKeys": { "downloads": 12034, "rating": 4.6 }
},
"dist-tags": { "latest": "0.2.0-rc.1", "next": "0.2.0-rc.2" },
"versions": [
{
"version": "0.2.0-rc.2",
"publishTimestamp": 1790476800,
"source": { "type": "npm" },
"engines": { "dsh": ">=0.2.0 <0.3.0" }
},
{
"version": "0.2.0-rc.1",
"publishTimestamp": 1789872000,
"source": { "type": "npm" },
"engines": { "dsh": "0.2.0 - 0.2.4 || >=0.3.0 <0.4.0" }
}
]
},
{
"name": "dsh-plugin-foo",
"title": "Foo",
"icon": "https://example.com/foo.png",
"dist-tags": { "latest": "1.0.0" },
"versions": [
{
"version": "1.0.0",
"publishTimestamp": 1788000000,
"source": {
"type": "git",
"url": "https://github.com/author/dsh-plugins",
"commit": "3f9c2e1d8a4b6c0e7f1a2b3c4d5e6f708192a3b4",
"path": "packages/foo"
},
"engines": { "dsh": "*" }
}
]
}
]
}
```
`LocalizedText` below is the `LocalizedText` type of [`dsh-package-manifest`](../../../../packages/util/package-manifest/src/types.ts), a string or an object of strings that contains `en`, with two additional rules: object keys are lowercase language ids matching `^[a-z]{2,8}(-[a-z0-9]{1,8})*$`, and every string contains at least one non-whitespace character.
### Root fields
| Field | Required | Rule |
|---|---|---|
| `format` | yes | Integer `1`. |
| `title` | yes | `LocalizedText` naming the market. |
| `description` | no | `LocalizedText`. |
| `metadata` | no | Object holding the three declaration maps below. |
| `metadata.categories` | no | Object mapping category id → `{ title: LocalizedText }`. |
| `metadata.labels` | no | Object mapping label id → `{ title: LocalizedText }`. |
| `metadata.sortKeys` | no | Object mapping sort-key id → `{ title: LocalizedText, order: "asc" \| "desc" }`; `order` is the initial sort direction. |
| `plugins` | yes | Array of plugins, possibly empty; its order is the index's default ordering. |
Category, label, and sort-key ids are non-empty strings. Readers look ids up as own properties, so ids such as `constructor` have no special meaning.
### Plugin fields
| Field | Required | Rule |
|---|---|---|
| `name` | yes | Package name accepted by npm's maintained package-name validator for new packages; unique within the index. |
| `title` | no | `LocalizedText`; a reader displays `name` when absent. |
| `description` | no | `LocalizedText`. |
| `icon` | no | Either `data:<type>;base64,<payload>`, where `<type>` is `image/svg+xml`, `image/png`, `image/jpeg`, or `image/webp`, `<payload>` is canonically padded base64, and the decoded payload is at most 256 KiB; or a URL reference whose resolution against the index URL is an `https:` URL or has the index URL's origin. |
| `author` | no | String with at least one non-whitespace character. |
| `homepage` | no | Absolute `https:` or `http:` URL of a page about the plugin. |
| `metadata` | no | Object holding the values below. |
| `metadata.category` | no | One declared category id. |
| `metadata.labels` | no | Array of distinct declared label ids. |
| `metadata.keywords` | no | Array of free-form strings; no root declaration required. |
| `metadata.sortKeys` | no | Object mapping a declared sort-key id → finite number. |
| `dist-tags` | no | Object mapping npm distribution-tag names to exact listed version strings; absent means no recorded channels. |
| `versions` | yes | Non-empty array of versions; see below. Array order does not select the installation version. |
`metadata.keywords` conventionally comes from npm `package.json` `keywords`, but generators may use another source, add strings, or adjust values. Neither presence nor equality with the package manifest is required. Readers preserve the strings and their order; an empty array is valid.
### Version fields
| Field | Required | Rule |
|---|---|---|
| `version` | yes | Exact version parsed by npm's `node-semver`; preserve the complete string, including Git build metadata. The exact string is unique within the plugin. |
| `publishTimestamp` | yes | Integer Unix timestamp in seconds, in `[0, 10^11)`. |
| `source` | yes | Where `version` is installed from; see below. |
| `engines` | yes | Object containing `dsh`. |
| `engines.dsh` | yes | Non-blank valid npm SemVer range declared in the source manifest; copied unchanged. `*` explicitly declares support for any DSH version. |
A relative `icon` reference resolves against the index URL, as an HTML image reference resolves against its page. For an index at `https://market.example/dsh/index.json`, `"icon": "icons/foo.svg"` names `https://market.example/dsh/icons/foo.svg`. A generator can therefore publish the index and its icon files as one directory on any static host without knowing the final URL. Absolute icon URLs on other origins must use `https:`; the same-origin allowance lets an `http:` index on a loopback or intranet host serve its own icons. Readers resolve the reference when they read the index, so clients receive absolute URLs.
`source` takes one of two forms, and readers reject any other `type`:
- `{ "type": "npm", "registry"?: string }`. `registry` is an `http:` or `https:` URL without credentials, query, or fragment; `http:` is admitted for intranet mirrors, as in DSH's [install registries](../../implemented/architecture/2026-09-18-plugin-install-registries.md).
- `{ "type": "git", "url": string, "commit": string, "path"?: string }`. `url` is a canonical `https:` URL (equal to its WHATWG URL serialization) with at least two non-empty path segments and no trailing slash, without credentials, query, or fragment, and not ending in `.tgz` or `.tar.gz`. `commit` is a lowercase 40-hex-digit SHA. `path` is a `/`-separated relative directory whose segments contain only ASCII letters, digits, and `._@+-`, and are neither `.` nor `..`; absent means the repository root.
### Install arguments
A client installs one version with exactly these pnpm arguments:
- npm: the spec `<name>@<version>`. Without `registry`, the client asks its configured registries as it does for any install. With `registry`, the client follows DSH's [registry plan](../../implemented/architecture/2026-09-18-plugin-install-registries.md): it asks `registry` first and falls back to its other configured registries only when `registry` is one of them; any other `registry` is asked alone, so a private registry never falls through to a public one that may publish a different package under the same name and version.
- git: the spec `git+<url>.git#<commit>`, without doubling a trailing `.git`, followed by `&path:/<path>` when `path` is present. The `git+` prefix makes pnpm treat URLs with more than two path segments, such as GitLab subgroups, as Git repositories.
### DSH compatibility
Every market-listed version must declare valid `engines.dsh`. The reader and market-origin installation evaluate that range against the running DSH version with npm's `node-semver` and `includePrerelease: true`. There is no peer fallback or range synthesis. npm package peer resolution remains outside market host compatibility.
Installation rechecks the source manifest before accepting a market-origin package. An exact local `name@version` exemption may permit a valid range that rejects the runtime; it cannot make a missing or invalid declaration eligible. Exemptions are not published in the index.
### Generator obligations
A reader cannot verify these rules; a generator that violates them publishes a misleading index.
- `name` and each `version` equal the package manifest of the installed source.
- `publishTimestamp` is the registry publish time of `version` for npm sources and the committer time of `commit` for Git sources.
- `engines.dsh` copies the manifest's declared range unchanged. A version with a missing, blank, or invalid range is omitted with a diagnostic. A plugin with no eligible version is omitted.
- `dist-tags` snapshots npm's [distribution tags](https://docs.npmjs.com/cli/v11/commands/npm-dist-tag/), retaining only targets present among the eligible listed versions. For Git sources, the publisher or market supplies the same exact tag-to-version mapping.
- Each listed version declares `dsh.bundle`. Installation independently refuses a package without a bundle patch.
- `title`, `description`, and `icon` come from the highest listed version's locale metadata, manifest fields, and icon under the installed-plugin display rules. Use npm's `node-semver.rsort()` order, retaining input order for ties. This metadata choice does not select the installation version. `homepage` copies that version's manifest `homepage` and is absent without one.
- `author` comes from the same highest listed version's npm author declaration. Copy an object's `name`; parse shorthand strings with a maintained npm person normalizer. Omit an absent or whitespace-only name and contact fields. Normalize a fresh projection, leaving the source manifest untouched.
### Reader rules
A reader rejects the whole index when the document violates any rule in Root fields, Plugin fields, Version fields, or the `source` forms, including an undeclared category, label, or sort-key reference, an unlisted distribution-tag target, a duplicate `name` or exact `version` string, or a `format` other than `1`. Each such defect is a generator bug, and dropping entries silently would hide it. Readers may bound the document size.
A reader ignores members that this note does not define, at every level; an unknown `source.type` value is rejected. Format 1 can then gain optional members without breaking existing readers; a change that existing readers would misinterpret uses a new `format` value.
Tag names must parse as registry tag specs under npm's maintained `npm-package-arg`; targets are exact listed version strings. npm sources use canonical published identifiers (`node-semver.valid(version) === version`); Git sources retain build metadata and pin their exact source by commit.
Categories, labels, and sort keys are opaque: a reader filters and sorts by them, displays their `title`, and never interprets an id. Readers display declarations in the order of their localized `title`. When sorting plugins, a plugin without a value for the selected sort key follows all plugins with a value in either direction, and equal values keep the index order. Sorting by publish time uses a plugin's greatest `publishTimestamp`.
A reader evaluates the required `engines.dsh` range under DSH compatibility. Versions use npm's `node-semver.rsort()` order, retaining index order for ties. Exact tag targets, not this display order, select installation versions.
The initial channel is `latest`. Its exact target is the publisher- or market-selected version. Clients install that recorded version or commit, never `name@latest`. Missing or incompatible `latest` requires explicit version or channel selection; clients do not switch automatically. Installation confirmation identifies the version, selected channel if any, prerelease status, index URL, and source.
## Alternatives considered
**A plain list of npm names or repository URLs.** It identifies every plugin but forces one or more requests per row before a list renders, which is the cost this format removes.
**One opaque install spec string per version.** A reader cannot verify that a free-form spec is pinned. A structured `source` with an exact `version`, or a `commit`, lets the reader verify pinning and lets the client build the spec it installs.
**Installing the latest version instead of a pinned one.** The installed build could then differ from the metadata the list displayed, and a package release would change what an unchanged index installs.
**One entry per version.** Every version would repeat the title, description, icon, author, and market metadata, and readers would group entries by `name` to show one plugin.
**One `source` per plugin, with only the pin per version.** A plugin that moves to another repository, directory, or registry between releases could not list its older versions.
**Display fields per version.** A list shows one row per plugin, so older display text, icons, authors, and homepages would cost index size without a reader that shows them.
**Inline icons only.** A list without extra requests needs inline icons, but 256 KiB of raw bytes is about 341 KiB as base64, so a 100-plugin index could reach about 35 MB. URL icons trade extra requests, and disclosure of the user's IP address to the image host, for a small index.
**Inferring host compatibility from peer dependencies.** The market defines a new standard requiring an author-declared `engines.dsh`. Existing packages must add that declaration before listing; no fallback, workspace mapping, or range intersection is needed.
**Copying the complete npm person object into the display field.** The list needs a name. The generator supports npm's original declaration while omitting unused contact fields.
**Opaque sort values for time, including ISO date strings.** Publish time has fixed semantics that clients format and sort, so it is a typed field. Without time, sort values can be numbers only, which needs no per-key value-type rule.
**Root-level `labels` and `sortKeys`, and a plugin field named `sort`.** Grouping market-defined declarations and values under `metadata` at both levels, with matching member names except the single-valued `category`, separates them from fields that clients interpret.
**Declaration order as display order.** JavaScript reorders integer-like object keys, so declaration order would need either an id pattern or array declarations. Clients sort declarations by their localized title instead.
**Selecting the greatest compatible version.** npm's `latest` can name an older release. Inferring a target from SemVer ranking loses the recorded channel choice and is ambiguous for equally ranked builds.
**Automatic prerelease fallback.** Missing or incompatible channel targets do not mean that the user selected a prerelease.
**Rejecting unknown members.** Every optional addition would then require a new `format` value and break every deployed reader.
## Acceptance criteria
- A validator accepts the example above and rejects one violation of each rule in Root fields, Plugin fields, Version fields, and the `source` forms, with a diagnostic naming the JSON path.
- Install arguments built from every example version are accepted by DSH's install-spec parser and by pnpm.
- A validator ignores unknown members at every level.
- Generator and validator refuse missing, blank, and invalid `engines.dsh`; omitted versions leave no dangling tag target. Index and market-origin installation agree on valid ranges, including hyphen ranges, prereleases, and valid ranges that reject the runtime. Exemptions never bypass the declaration requirement.
- Exact tag targets select the same source after equally ranked Git build versions are reordered. Missing targets are rejected; absent or incompatible `latest` causes no automatic switch.
- npm author objects and shorthand strings produce the declared name without contact fields or mutation of source identity.
## Risks
- An index is unsigned. Titles, authors, labels, and scores are market claims; an `official` label is not DSH verification. Clients must show the index URL and each version's `source`.
- A Git version's `name`, `version`, and `engines.dsh` are claims about the repository. A repository can claim any name, including an `@deepseek-ai/dsh-` name, and the installed manifest decides what is actually installed.
- An npm version's `source.registry` is equally a claim: it directs the install of any `name`, including an `@deepseek-ai/dsh-` name, to a registry the market chooses, and that registry decides what is installed.
- URL icons disclose the user's IP address to image hosts.
- Pinned versions and distribution tags stay stale until the index is regenerated.
- Market text reaches users, and models through any client tool that lists plugins, without review by DSH.
@@ -0,0 +1,234 @@
# Agent Note: 插件市场索引格式
Status: proposed
[English](2026-10-02-plugin-market-index-format.md) | 中文
## 问题
DSH 只能从用户已知的 spec 安装第三方组合包(bundle):npm 包名、Git URL、tarball 或本地路径。用户无法浏览可安装的组合包。目前没有官方市场,以后可能会增加官方市场,因此市场机制必须支持第三方发布的索引。
最小的索引是一份 npm 包名或 Git 仓库 URL 列表,每行一项。它能标识每个插件,但客户端渲染列表前必须为每个包获取 manifest(元数据清单)、语言文件和图标。列表页面每行发出一个或多个请求时,加载慢、可能部分失败,并且每个客户端都要实现元数据解析。
因此,索引必须包含列表页面显示的全部内容:本地化标题和描述、图标、作者、可用版本、兼容性和发布时间。市场还需要发布自己的数据,例如分类、标签、下载量和评分,而 DSH 不需要理解这些值的含义。
## 提案
将**索引**与**索引生成器**分开。DSH 定义并读取一种 JSON 索引格式。索引如何生成、基于哪份源列表生成,由生成器负责。DSH 的读取器、Discover 页面和参考生成器是使用此格式的独立变更。索引列出[profile 插件组合包](../../implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md),并通过[引导式插件安装](../../implemented/architecture/2026-09-15-guided-plugin-installation.zh.md)安装它们。
市场复用 npm 的包名、版本、范围、发布标签和 person 声明。读取器与生成器使用维护中的 npm 解析器和校验器。新增字段或改变含义需要 npm 无法满足的插件市场需求。
### 示例
```json
{
"format": 1,
"title": { "en": "Example Market", "zh": "示例市场" },
"description": { "en": "Community DSH bundles", "zh": "社区 DSH bundle" },
"metadata": {
"categories": {
"productivity": { "title": { "en": "Productivity", "zh": "效率" } }
},
"labels": {
"official": { "title": { "en": "Official", "zh": "官方" } }
},
"sortKeys": {
"downloads": { "title": { "en": "Downloads", "zh": "下载量" }, "order": "desc" },
"rating": { "title": { "en": "Rating", "zh": "评分" }, "order": "desc" }
}
},
"plugins": [
{
"name": "@example/dsh-auto-review",
"title": { "en": "Auto Review", "zh": "自动审查" },
"description": { "en": "Per-tool LLM authorization review", "zh": "逐工具 LLM 授权审查" },
"icon": "icons/auto-review.svg",
"author": "Example Labs",
"homepage": "https://example.com/dsh-auto-review",
"metadata": {
"category": "productivity",
"labels": ["official"],
"keywords": ["review", "authorization"],
"sortKeys": { "downloads": 12034, "rating": 4.6 }
},
"dist-tags": { "latest": "0.2.0-rc.1", "next": "0.2.0-rc.2" },
"versions": [
{
"version": "0.2.0-rc.2",
"publishTimestamp": 1790476800,
"source": { "type": "npm" },
"engines": { "dsh": ">=0.2.0 <0.3.0" }
},
{
"version": "0.2.0-rc.1",
"publishTimestamp": 1789872000,
"source": { "type": "npm" },
"engines": { "dsh": "0.2.0 - 0.2.4 || >=0.3.0 <0.4.0" }
}
]
},
{
"name": "dsh-plugin-foo",
"title": "Foo",
"icon": "https://example.com/foo.png",
"dist-tags": { "latest": "1.0.0" },
"versions": [
{
"version": "1.0.0",
"publishTimestamp": 1788000000,
"source": {
"type": "git",
"url": "https://github.com/author/dsh-plugins",
"commit": "3f9c2e1d8a4b6c0e7f1a2b3c4d5e6f708192a3b4",
"path": "packages/foo"
},
"engines": { "dsh": "*" }
}
]
}
]
}
```
下文中的 `LocalizedText` 即 [`dsh-package-manifest`](../../../../packages/util/package-manifest/src/types.ts) 中的 `LocalizedText` 类型,即字符串或包含 `en` 的字符串对象,并附加两条规则:对象的键是匹配 `^[a-z]{2,8}(-[a-z0-9]{1,8})*$` 的小写语言 id,每个字符串至少包含一个非空白字符。
### 根字段
| 字段 | 必填 | 规则 |
|---|---|---|
| `format` | 是 | 整数 `1`。 |
| `title` | 是 | 市场名称,类型为 `LocalizedText`。 |
| `description` | 否 | `LocalizedText`。 |
| `metadata` | 否 | 包含下列三个声明映射的对象。 |
| `metadata.categories` | 否 | 对象,将分类 id 映射为 `{ title: LocalizedText }`。 |
| `metadata.labels` | 否 | 对象,将标签 id 映射为 `{ title: LocalizedText }`。 |
| `metadata.sortKeys` | 否 | 对象,将排序键 id 映射为 `{ title: LocalizedText, order: "asc" \| "desc" }`;`order` 是初始排序方向。 |
| `plugins` | 是 | 插件数组,可以为空;数组顺序即索引的默认排序。 |
分类、标签和排序键的 id 均为非空字符串。读取器按自有属性查找 id,因此 `constructor` 等 id 没有特殊含义。
### 插件字段
| 字段 | 必填 | 规则 |
|---|---|---|
| `name` | 是 | npm 维护的包名校验器接受的新包名称;在索引内唯一。 |
| `title` | 否 | `LocalizedText`;缺省时读取器显示 `name`。 |
| `description` | 否 | `LocalizedText`。 |
| `icon` | 否 | 以下两种形式之一:`data:<type>;base64,<payload>`,其中 `<type>` 为 `image/svg+xml`、`image/png`、`image/jpeg` 或 `image/webp`,`<payload>` 是按规范填充的 base64,解码后的载荷不超过 256 KiB;或者 URL 引用,它相对于索引 URL 解析的结果是 `https:` URL,或与索引 URL 同源。 |
| `author` | 否 | 至少包含一个非空白字符的字符串。 |
| `homepage` | 否 | 介绍该插件的页面的绝对 `https:` 或 `http:` URL。 |
| `metadata` | 否 | 包含下列值的对象。 |
| `metadata.category` | 否 | 一个已声明的分类 id。 |
| `metadata.labels` | 否 | 由互不相同的已声明标签 id 组成的数组。 |
| `metadata.keywords` | 否 | 自由字符串数组;无需在根部声明。 |
| `metadata.sortKeys` | 否 | 对象,将已声明的排序键 id 映射为有限数值。 |
| `dist-tags` | 否 | 将 npm 发布标签名称映射为已列出的精确版本字符串的对象;缺省表示未记录发布渠道。 |
| `versions` | 是 | 非空的版本数组;见下文。数组顺序不选择安装版本。 |
`metadata.keywords` 按约定来自 npm `package.json` 的 `keywords`,但生成器可以使用其他来源、添加字符串或调整值。不要求提供此字段,也不要求它与包 manifest 一致。读取器保留字符串及其顺序;允许空数组。
### 版本字段
| 字段 | 必填 | 规则 |
|---|---|---|
| `version` | 是 | npm 的 `node-semver` 解析的精确版本;保留完整字符串,包括 Git 版本编译信息。精确字符串在插件内唯一。 |
| `publishTimestamp` | 是 | 以秒为单位的整数 Unix 时间戳,取值范围为 `[0, 10^11)`。 |
| `source` | 是 | `version` 的安装来源;见下文。 |
| `engines` | 是 | 包含 `dsh` 的对象。 |
| `engines.dsh` | 是 | 源 manifest 声明的非空白、有效 npm SemVer 范围;原样复制。`*` 显式声明支持任意 DSH 版本。 |
相对的 `icon` 引用相对于索引 URL 解析,与 HTML 图片引用相对于所在页面解析的方式相同。对于位于 `https://market.example/dsh/index.json` 的索引,`"icon": "icons/foo.svg"` 指向 `https://market.example/dsh/icons/foo.svg`。因此,生成器可以把索引和图标文件作为一个目录发布到任意静态主机,无需知道最终 URL。指向其他源的绝对图标 URL 必须使用 `https:`;同源规则允许位于 loopback 或内网主机上的 `http:` 索引提供自己的图标。读取器在读取索引时解析该引用,因此客户端收到的是绝对 URL。
`source` 采用以下两种形式之一,读取器拒绝其他任何 `type`:
- `{ "type": "npm", "registry"?: string }`。`registry` 是不含凭据、查询或片段的 `http:` 或 `https:` URL;允许 `http:` 是为了支持内网镜像,与 DSH 的[安装 registry](../../implemented/architecture/2026-09-18-plugin-install-registries.zh.md) 一致。
- `{ "type": "git", "url": string, "commit": string, "path"?: string }`。`url` 是规范的 `https:` URL(等于其 WHATWG URL 序列化结果),至少包含两个非空路径段且不以斜杠结尾,不含凭据、查询或片段,且不以 `.tgz` 或 `.tar.gz` 结尾。`commit` 是 40 位小写十六进制 SHA。`path` 是以 `/` 分隔的相对目录,各段只包含 ASCII 字母、数字和 `._@+-`,且不是 `.` 或 `..`;缺省表示仓库根目录。
### 安装参数
客户端安装某个版本时恰好使用以下 pnpm 参数:
- npm:spec 为 `<name>@<version>`。没有 `registry` 时,客户端像任何安装一样询问它配置的 registry。存在 `registry` 时,客户端遵循 DSH 的 [registry 计划](../../implemented/architecture/2026-09-18-plugin-install-registries.zh.md):先询问 `registry`,仅当它属于客户端配置的 registry 之一时才回退到其他已配置 registry;其他 `registry` 单独询问,因此私有 registry 不会回退到可能以相同名称和版本发布不同包的公共 registry。
- git:spec 为 `git+<url>.git#<commit>`,`url` 已以 `.git` 结尾时不重复添加;存在 `path` 时在其后追加 `&path:/<path>`。`git+` 前缀使 pnpm 将路径段多于两个的 URL(例如 GitLab 子组)视为 Git 仓库。
### DSH 兼容性
市场列出的每个版本都必须声明有效的 `engines.dsh`。读取器与市场来源的安装使用 npm 的 `node-semver` 和 `includePrerelease: true`,根据当前运行的 DSH 版本求值。不存在对等依赖回退或范围合成。npm 包的对等依赖解析不属于市场宿主兼容性检查。
安装接受市场来源的包前,会重新检查源 manifest。精确的本地 `name@version` 豁免可以允许有效范围拒绝当前运行时的情况,但不能使缺失或无效声明符合列出条件。豁免不发布在索引中。
### 生成器义务
读取器无法验证以下规则;违反这些规则的生成器会发布误导性的索引。
- `name` 和每个 `version` 与所安装来源中的包 manifest 一致。
- 对于 npm 来源,`publishTimestamp` 是 `version` 在 registry 中的发布时间;对于 Git 来源,它是 `commit` 的提交者时间。
- `engines.dsh` 原样复制 manifest 声明的范围。缺失、空白或无效范围的版本会被省略并给出诊断。没有合格版本的插件会被省略。
- `dist-tags` 是 npm [发布标签](https://docs.npmjs.com/cli/v11/commands/npm-dist-tag/)的快照,只保留指向合格且已列出版本的目标。对于 Git 来源,发布者或市场提供同样的精确标签到版本映射。
- 列出的每个版本都声明了 `dsh.bundle`。安装时也会独立拒绝没有组合包 patch 的包。
- `title`、`description` 和 `icon` 来自所列最高版本的语言元数据、manifest 字段和图标,采用已安装插件的显示规则。使用 npm 的 `node-semver.rsort()` 顺序,相等时保留输入顺序。元数据选择不选择安装版本。`homepage` 复制该版本 manifest 的 `homepage`,manifest 没有该字段时省略。
- `author` 来自同一最高版本的 npm 作者声明。复制对象的 `name`;使用维护中的 npm person 归一化器解析简写字符串。省略缺失或全空白的名称以及联系字段。归一化新建的投影,不修改源 manifest。
### 读取器规则
文档违反根字段、插件字段、版本字段或 `source` 形式中的任何规则时,读取器拒绝整个索引,包括引用了未声明的分类、标签或排序键,发布标签指向未列出的目标,`name` 或精确的 `version` 字符串重复,以及 `format` 不是 `1`。每个此类缺陷都是生成器 bug,静默丢弃条目会掩盖它。读取器可以限制文档大小。
读取器在每一层都忽略本 Agent Note 未定义的成员;未知的 `source.type` 值会被拒绝。这样格式 1 可以增加可选成员而不破坏现有读取器;现有读取器会误解的变更使用新的 `format` 值。
标签名称必须能被 npm 维护的 `npm-package-arg` 解析为 registry 标签 spec;目标是已列出的精确版本字符串。npm 来源使用规范发布标识(`node-semver.valid(version) === version`);Git 来源保留版本编译信息并用提交固定精确来源。
分类、标签和排序键对读取器不透明:读取器按它们筛选和排序,显示其 `title`,从不解释 id 的含义。读取器按本地化 `title` 对声明排序后显示。对插件排序时,无论排序方向如何,没有所选排序键值的插件都排在所有有值的插件之后,值相同的插件保持索引顺序。按发布时间排序时使用插件最大的 `publishTimestamp`。
读取器按 DSH 兼容性一节的规则求值必填的 `engines.dsh` 范围。版本使用 npm 的 `node-semver.rsort()` 顺序,相等时保留索引顺序。安装版本由精确的标签目标选择,而不是此显示顺序。
初始渠道是 `latest`。它的精确目标是发布者或市场选择的版本。客户端安装记录的版本或提交,绝不安装 `name@latest`。缺失或不兼容的 `latest` 需要显式选择版本或渠道;客户端不会自动切换。安装确认标明版本、所选渠道(如果有)、先行版本状态、索引 URL 和来源。
## 考虑过的替代方案
**纯 npm 包名或仓库 URL 列表。** 它能标识每个插件,但列表渲染前每行需要一个或多个请求,而此格式正是为消除这一开销而设计。
**每个版本使用一个不透明的安装 spec 字符串。** 读取器无法验证自由格式的 spec 是否固定了版本。结构化的 `source` 配合精确的 `version` 或 `commit`,读取器可以验证版本固定,客户端可以构建要安装的 spec。
**安装最新版本而不是固定版本。** 这样安装的构建可能与列表显示的元数据不一致,并且包发布新版本会改变未修改的索引所安装的内容。
**每个版本一个条目。** 每个版本都会重复标题、描述、图标、作者和市场元数据,读取器还要按 `name` 分组才能显示一个插件。
**每个插件一个 `source`,每个版本只记录固定点。** 在两次发布之间换了仓库、目录或 registry 的插件将无法列出旧版本。
**每个版本各有显示字段。** 列表每个插件只显示一行,旧的显示文本、图标、作者和主页会增加索引大小,却没有读取器显示它们。
**只允许内联图标。** 不发额外请求的列表需要内联图标,但 256 KiB 原始字节编码为 base64 后约为 341 KiB,因此 100 个插件的索引可能达到约 35 MB。URL 图标以额外请求和向图片主机暴露用户 IP 地址为代价,换取较小的索引。
**从对等依赖推断宿主兼容性。** 市场定义要求作者声明 `engines.dsh` 的新标准。现有包必须添加此声明才能列出;不需要回退、workspace 映射或范围求交。
**将完整 npm person 对象复制到显示字段。** 列表需要名称。生成器支持 npm 的原始声明,同时省略不用的联系字段。
**用不透明排序值表示时间,包括 ISO 日期字符串。** 发布时间具有固定语义,客户端会对其格式化和排序,因此它是一个有类型的字段。排除时间后,排序值可以只用数值,无需为每个键规定值类型。
**根级 `labels` 和 `sortKeys`,以及名为 `sort` 的插件字段。** 在两个层级都把市场定义的声明和值归入 `metadata`,并使用对应的成员名(单值的 `category` 除外),可以将它们与客户端解释的字段区分开。
**以声明顺序作为显示顺序。** JavaScript 会重排类整数的对象键,因此要保留声明顺序,就需要限定 id 格式或改用数组声明。客户端改为按本地化标题对声明排序。
**选择最高兼容版本。** npm 的 `latest` 可以指向较旧的发布版本。从 SemVer 排名推断目标会丢失记录的渠道选择,而且在构建排名相等时存在歧义。
**自动回退到先行版本。** 渠道目标缺失或不兼容,不表示用户选择了先行版本。
**拒绝未知成员。** 这样每次增加可选成员都需要新的 `format` 值,并会破坏所有已部署的读取器。
## 验收标准
- 校验器接受上面的示例,并对根字段、插件字段、版本字段和 `source` 形式中的每条规则各拒绝一个违例,诊断信息给出对应的 JSON 路径。
- 根据每个示例版本构建的安装参数可被 DSH 的安装 spec 解析器和 pnpm 接受。
- 校验器在每一层都忽略未知成员。
- 生成器和校验器拒绝缺失、空白和无效的 `engines.dsh`;省略版本后不留下悬空的标签目标。索引和市场来源的安装对有效范围结果一致,包括连字符范围、先行版本以及拒绝当前运行时的有效范围。豁免绝不绕过声明要求。
- 排名相等的 Git 构建版本重新排序后,精确标签目标仍选择同一来源。缺失的目标被拒绝;`latest` 缺省或不兼容时不自动切换。
- npm 作者对象与简写字符串产生声明的名称,不包含联系字段,也不修改源身份。
## 风险
- 索引没有签名。标题、作者、标签和评分是市场的声明;`official` 标签不是 DSH 验证。客户端必须显示索引 URL 以及每个版本的 `source`。
- Git 版本的 `name`、`version` 和 `engines.dsh` 是关于仓库的声明。仓库可以声明任意名称,包括 `@deepseek-ai/dsh-` 名称,实际安装的内容由已安装的 manifest 决定。
- npm 版本的 `source.registry` 同样是一种声明:它把任意 `name`(包括 `@deepseek-ai/dsh-` 名称)的安装引向市场选择的 registry,由该 registry 决定实际安装的内容。
- URL 图标会向图片主机暴露用户的 IP 地址。
- 固定版本和发布标签在索引重新生成之前一直保持陈旧。
- 市场文本未经 DSH 审核即到达用户,并通过任何列出插件的客户端工具到达模型。
+2 -2
View File
@@ -21,8 +21,8 @@
en: 8d2cfff9bab7327f
zh: 21515ab6e6798504
/dsh-cli-behavior-reference/web-profile:
en: 536c6883a0d54b14
zh: 6dd2469b71189c87
en: 8a805800d806e0e9
zh: ba88c38a0dd4f51b
/dsh-cli-behavior-reference/shared-deployment-behavior:
en: 6a7abc9339e8f37f
zh: 133adb834c8e195d
+4 -3
View File
@@ -106,19 +106,20 @@ Git-hosted plugins that ship sources build during install through their `prepare
## Web profile
`dsh web` uses the profile shorthand. Launcher flags are parsed first; the remaining flags belong to the web app, whose ordinary bundle provider parses them. `--host` and `--port` override the composed values of the rows that carry them, repeatable `--trusted-host` contributes invocation authorities through `ctx.webRuntime.trustedHosts` (a deployment expression concatenates its own authorities), and `--no-open` disables the default-browser handoff for this invocation. The client-plugin HMR receiver is always mounted and stays idle until `pnpm run dev:web` rebuilds client bundles; that command builds once, starts this same launcher, and keeps client bundles rebuilt, or with `--no-serve` runs only the watchers beside a `dsh web` started elsewhere.
`dsh web` uses the profile shorthand. Launcher flags are parsed first; the remaining flags belong to the web app, whose ordinary bundle provider parses them. `--host` and `--port` override the composed listener values. The host must name one concrete local address, not a wildcard (`--host 0.0.0.0` exits with a usage error); `hostname -i` may list several, so pass one. Repeatable `--trusted-host` values are collected in `ctx.webStartup.trustedHosts`; a deployment expression may add its own authorities. `--no-open` disables the default-browser handoff for this invocation. The client-plugin HMR receiver is always mounted and stays idle until `pnpm run dev:web` rebuilds client bundles; that command builds once, starts this same launcher, and keeps client bundles rebuilt, or with `--no-serve` runs only the watchers beside a `dsh web` started elsewhere.
`--public-url <url>` advertises one HTTP(S) application root — with an optional forwarding prefix — in place of the listener's loopback URL. It grants no trust, so the browser-visible authority still has to be named with `--trusted-host`; [Publish the Web UI behind a reverse proxy](../../../docs/user/guide/public-deployments.md) lists what the proxy in front must provide.
`--public-url <url>` advertises one HTTP(S) application root — with an optional forwarding prefix — in place of the listener's bind-address URL (loopback for a loopback bind). It grants no trust, so the browser-visible authority still has to be named with `--trusted-host`; [Publish the Web UI behind a reverse proxy](../../../docs/user/guide/public-deployments.md) lists what the proxy in front must provide.
```sh
dsh web
dsh web --host "$(hostname -i | awk '{print $1}')" --public-url https://app.example/ --trusted-host app.example
dsh web --no-open
dsh web --patch ./extra.cordis.yml
dsh web --dump-config
dsh web --help
```
The production Web runner needs built package and frontend artifacts (`pnpm run build`). It serves `http://127.0.0.1:3080` by default and, for a local launch, opens that canonical host URL — the advertised root when `--public-url` names one — only after the complete Loader tree settles. A non-empty inherited `SSH_CONNECTION` or `SSH_TTY` suppresses the browser handoff because the SSH client or editor owns the local forwarded address; the host URL is still printed. The CLI intentionally does not support `--host 0.0.0.0` and exits with a usage error. Immediately before a local handoff it prints `dsh web: opening the default browser; pass --no-open to disable`; if the operating-system handoff fails, a diagnostic on stderr states the reason, leaves the server running, and names the URL for manual use. `--trusted-host` adds named authorities accepted by the `/api` browser-trust fence.
The production Web runner needs built package and frontend artifacts (`pnpm run build`). It prints the advertised `--public-url` when configured, otherwise the listener's bind-address URL (default `http://127.0.0.1:3080`). The Host/Origin fence accepts the bind IP directly; a proxy or DNS authority still needs `--trusted-host`. The startup line and default-browser handoff wait for the complete Loader tree. A non-empty inherited `SSH_CONNECTION` or `SSH_TTY` suppresses browser opening, not the startup line. Immediately before a browser handoff it prints `dsh web: opening the default browser; pass --no-open to disable`; if the operating-system handoff fails, stderr names the reason and points to the startup URL while the server keeps running.
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.
+4 -3
View File
@@ -108,19 +108,20 @@ dsh --profile tui
## Web Profile
`dsh web` 使用 profile 简写。启动器先解析自身的 flag,其余 flag 属于 web 应用,由组合包中的普通提供方解析。`--host` 和 `--port` 覆盖承载它们的那些行的组合取值,可重复的 `--trusted-host` 通过 `ctx.webRuntime.trustedHosts` 提供本次调用的 authority(部署表达式会拼接自己的 authority),`--no-open` 则只对本次调用关闭默认浏览器交接。客户端插件 HMR(热模块替换)接收器始终挂载,在 `pnpm run dev:web` 重建客户端 bundle 之前保持空闲;该命令先构建一次,再启动这同一个启动器并持续重建客户端 bundle,加 `--no-serve` 则只运行 watcher、配合别处启动的 `dsh web`。
`dsh web` 使用 profile 简写。启动器先解析自身的 flag,其余 flag 属于 web 应用,由组合包中的普通提供方解析。`--host` 与 `--port` 覆盖组合的监听器取值。host 必须指名一个具体的本机地址,不能是通配地址(`--host 0.0.0.0` 会以用法错误退出);`hostname -i` 可能列出多个,请只传一个。可重复的 `--trusted-host` 值会收集到 `ctx.webStartup.trustedHosts`;部署表达式可以添加自己的 authority。`--no-open` 只对本次调用关闭默认浏览器交接。客户端插件 HMR(热模块替换)接收器始终挂载,在 `pnpm run dev:web` 重建客户端 bundle 之前保持空闲;该命令先构建一次,再启动这同一个启动器并持续重建客户端 bundle,加 `--no-serve` 则只运行 watcher、配合别处启动的 `dsh web`。
`--public-url <url>` 公告唯一的 HTTP(S) 应用根——可带转发前缀——替代监听器的 loopback URL。它不授予信任,因此浏览器可见的 authority 仍需用 `--trusted-host` 点名;[在反向代理之后发布 Web UI](../../../docs/user/guide/public-deployments.zh.md)列出了前置代理必须提供的内容。
`--public-url <url>` 公告唯一的 HTTP(S) 应用根——可带转发前缀——替代监听器的绑定地址 URL(回环绑定时为回环地址)。它不授予信任,因此浏览器可见的 authority 仍需用 `--trusted-host` 点名;[在反向代理之后发布 Web UI](../../../docs/user/guide/public-deployments.zh.md)列出了前置代理必须提供的内容。
```sh
dsh web
dsh web --host "$(hostname -i | awk '{print $1}')" --public-url https://app.example/ --trusted-host app.example
dsh web --no-open
dsh web --patch ./extra.cordis.yml
dsh web --dump-config
dsh web --help
```
生产 Web 运行器需要已构建的包和前端产物(`pnpm run build`)。默认服务地址是 `http://127.0.0.1:3080`;本机启动时,只在完整 Loader 配置树结算后才用默认浏览器打开该规范宿主机 URL(配置了 `--public-url` 时即为公告根)。继承的 `SSH_CONNECTION` 或 `SSH_TTY` 非空时会跳过浏览器交接,因为本地转发地址由 SSH 客户端或编辑器持有;宿主机 URL 仍会打印。CLI 有意不支持 `--host 0.0.0.0`,并会以用法错误退出。本机交接前会打印英文提示 `dsh web: opening the default browser; pass --no-open to disable`;若操作系统交接失败,stderr 诊断会说明原因、给出 URL 供手动访问,服务器仍继续运行。`--trusted-host` 可添加 `/api` 浏览器信任围栏接受的具名 authority。
生产 Web 运行器需要已构建的包和前端产物(`pnpm run build`)。配置了 `--public-url` 时打印公告地址,否则打印监听器的绑定地址 URL(默认为 `http://127.0.0.1:3080`)。Host/Origin 栅栏直接接受绑定 IP;代理或 DNS authority 仍需 `--trusted-host`。启动日志与默认浏览器交接都会等待完整 Loader 配置树结算。继承的 `SSH_CONNECTION` 或 `SSH_TTY` 非空时会抑制浏览器打开,但仍打印启动 URL。浏览器交接前会打印 `dsh web: opening the default browser; pass --no-open to disable`;若操作系统交接失败,stderr 会说明原因并指向启动 URL,服务器仍继续运行。
进程关闭时,插件树最多有 5 秒完成 dispose。首次收到 `SIGINT` 或 `SIGTERM` 时会开始优雅排空:`SIGTERM` 是监督进程发出的常规停止请求,在所有运行模式下都以 0 退出;`SIGINT` 则报告 130。第二次收到信号时会立即强制退出。如果一次性运行在正常结束时已经卡在 dispose 阶段,第一次按下 `Ctrl+C` 就会直接升级为强制退出,而不会被忽略。
-1
View File
@@ -381,7 +381,6 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
})
expect(wildcardHost.code).toBe(1)
expect(wildcardHost.stdout).toBe('')
expect(wildcardHost.stderr).toContain('--host 0.0.0.0 is intentionally not supported yet for safety: it would expose remote code execution to the network; use 127.0.0.1 instead')
expect(wildcardHost.stderr).not.toContain('dsh web: http://')
const headlessHelp = await runBuiltBin(['headless', '--help'], {
@@ -0,0 +1,36 @@
# pi-ai Kimi route with dynamic prompt and tool updates over the shipped headless profile.
- id: headless-startup
disabled: true
- id: headless-runner
disabled: true
- id: llm-deepseek
disabled: true
- id: llm-pi-ai
config:
providers:
moonshotai:
apiKeyEnv: MOONSHOT_API_KEY
baseURL: !!js process.env.DSH_SNAPSHOT_BASE_URL
streamIdleTimeoutMs: 1000
- id: agent-loop
config:
agents:
- id: main
provider: moonshotai
model: kimi-k3
cwd: !!js process.cwd()
- id: agent-instructions
disabled: true
# The fixture counts every model request, so title generation stays off.
- id: session-title-llm
disabled: true
- insert:
- id: dynamic-tool-prompt-updates
name: '../../../../../../../packages/test-support/session-snapshot/tests/fixtures/dynamic-tool-prompt-updates.ts'
@@ -42,6 +42,7 @@ const tsconfigPath = fileURLToPath(new URL('../../../../../../tsconfig.json', im
const reasoningConfigPath = fileURLToPath(new URL('./fixtures/cli.patch.yml', import.meta.url))
const deepseekDefaultsConfigPath = fileURLToPath(new URL('./fixtures/deepseek-defaults.patch.yml', import.meta.url))
const piAiDefaultsConfigPath = fileURLToPath(new URL('./fixtures/pi-ai-defaults.patch.yml', import.meta.url))
const piAiConversationUpdatesConfigPath = fileURLToPath(new URL('./fixtures/pi-ai-conversation-updates.patch.yml', import.meta.url))
const headlessOverlayPath = fileURLToPath(new URL('./fixtures/headless-profile.patch.yml', import.meta.url))
const headlessSessionExpected = join(goldensDir, 'headless-profile', 'session.expected.jsonl')
const headlessReasoningExpected = join(goldensDir, 'headless-profile', 'reasoning.stderr.expected.txt')
@@ -148,6 +149,58 @@ async function deepseekDefaultsServer(
}
}
/** Script Chat Completions responses in request order: `read`, then `snapshot_ping`, then text. */
async function piAiConversationServer(): Promise<DeepSeekDefaultsServer> {
const requests: JsonObject[] = []
const agentReplies = [
{ tool_calls: [{ index: 0, id: 'call_read', type: 'function', function: { name: 'read', arguments: '{"file_path":"task.txt"}' } }] },
{ tool_calls: [{ index: 0, id: 'call_ping', type: 'function', function: { name: 'snapshot_ping', arguments: '{}' } }] },
{ content: 'DONE' },
]
const server = createServer((request: IncomingMessage, response: ServerResponse) => {
let body = ''
request.setEncoding('utf8')
request.on('data', (chunk: string) => { body += chunk })
request.on('end', () => {
requests.push(JSON.parse(body) as JsonObject)
const delta = agentReplies[requests.length - 1] ?? { content: 'UNEXPECTED' }
const finish = 'tool_calls' in delta ? 'tool_calls' : 'stop'
response.writeHead(200, { 'content-type': 'text/event-stream' })
response.end([
`data: ${JSON.stringify({ choices: [{ delta }] })}`,
`data: ${JSON.stringify({ choices: [{ delta: {}, finish_reason: finish }], usage: { prompt_tokens: 3, completion_tokens: 1 } })}`,
'data: [DONE]',
'',
].join('\n\n'))
})
})
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
const address = server.address()
if (address === null || typeof address === 'string') throw new Error('pi-ai conversation snapshot server has no port')
return {
url: `http://127.0.0.1:${address.port}`,
requests,
paths: [],
close: () => new Promise(resolve => server.close(() => { resolve() })),
}
}
/** Project one Chat Completions request to its fixture-owned declarations and message roles, tools, and prompt markers. */
function piAiTranscript(request: JsonObject): JsonObject {
const names = (tools: unknown): string[] => Array.isArray(tools)
? tools.map(tool => ((tool as { function: { name: string } }).function.name))
: []
return {
// Profile tools change independently of this scenario; only the fixture's tool is recorded.
tools: names(request.tools).filter(name => name === 'snapshot_ping'),
messages: (request.messages as JsonObject[]).map(message => ({
role: message.role,
...message.tools === undefined ? {} : { tools: names(message.tools) },
...typeof message.content === 'string' && message.content.includes('Dynamic tool guidance') ? { guidance: true } : {},
})),
}
}
function parseJsonl(content: string): JsonObject[] {
return content.split('\n')
.filter(line => line.trim().length > 0)
@@ -745,6 +798,113 @@ describe('headless stream-json snapshots', () => {
}
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
it('sends pi-ai Kimi prompt and tool updates in conversation history', async () => {
const server = await piAiConversationServer()
try {
const result = await runLoaderSmoke({
label: 'pi-ai Kimi conversation updates headless stream-json snapshot',
tempDirPrefix: 'headless-snapshot-pi-ai-conversation-updates-',
binScript,
libBinScript: binScript,
configPath: piAiConversationUpdatesConfigPath,
binArgs: [
piAiConversationUpdatesConfigPath,
'read task.txt and follow it',
],
tsconfigPath,
prepare: cwd => writeFile(join(cwd, 'task.txt'), 'Call snapshot_ping once.\n'),
env: {
MOONSHOT_API_KEY: 'snapshot-key',
DSH_SNAPSHOT_BASE_URL: server.url,
NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '),
},
})
expect(result.stderr).toBe('')
expect(parseJsonl(result.stdout).at(-1)).toMatchObject({ type: 'result', output: 'DONE' })
expect(server.requests.map(piAiTranscript)).toMatchInlineSnapshot(`
[
{
"messages": [
{
"role": "system",
},
{
"role": "user",
},
{
"role": "user",
},
],
"tools": [],
},
{
"messages": [
{
"role": "system",
},
{
"role": "user",
},
{
"role": "user",
},
{
"role": "assistant",
},
{
"role": "tool",
},
{
"guidance": true,
"role": "system",
},
{
"role": "system",
"tools": [
"snapshot_ping",
],
},
],
"tools": [],
},
{
"messages": [
{
"role": "system",
},
{
"role": "user",
},
{
"role": "user",
},
{
"role": "assistant",
},
{
"role": "tool",
},
{
"guidance": true,
"role": "system",
},
{
"role": "assistant",
},
{
"role": "tool",
},
],
"tools": [],
},
]
`)
} finally {
await server.close()
}
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
it('runs a keyless Agent Team with peer mail, dependent tasks, waiting, and Lead aggregation', async () => {
let projection: unknown
const result = await runLoaderSmoke({
@@ -1,4 +1,4 @@
/** Built Web-profile acceptance for the advertised public root and browser authentication behind a prefix-stripping proxy. */
/** Built Web-profile acceptance for concrete bind addresses, the advertised public root, and browser authentication. */
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { request as httpRequest, type IncomingMessage } from 'node:http'
@@ -9,6 +9,7 @@ import { Readable } from 'node:stream'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { execa } from 'execa'
import { describe, expect, it } from 'vitest'
import { probeNonLoopbackIpv4, probeZonedLoopback } from '../../../../../../scripts/test-non-loopback-address.ts'
import { startPrefixProxy } from '../../../../../../apps/web/tests/prefix-proxy.ts'
import { PROCESS_SHUTDOWN_TIMEOUT_MS } from '../../../../src/process-shutdown.ts'
@@ -19,6 +20,18 @@ const builtArtifactsExist = existsSync(dshBin)
/** Browser-facing mount the reference proxy serves and strips. */
const MOUNT = '/tools/dsh/'
const NON_LOOPBACK_IPV4 = builtArtifactsExist ? await probeNonLoopbackIpv4() : undefined
const ZONED_LOOPBACK = builtArtifactsExist ? await probeZonedLoopback() : undefined
/**
* Every spelling that binds IPv6 loopback. `::0.0.0.1` is the same address as
* `::1`, and the zoned rows reuse the probed scope on that same address.
*/
const IPV6_LOOPBACKS = ZONED_LOOPBACK === undefined
? ['::1', '::0.0.0.1']
: ['::1', '::0.0.0.1', ZONED_LOOPBACK, ZONED_LOOPBACK.replace('::1%', '::0.0.0.1%')]
/** One live Web child, as `execa` types it. */
type WebChild = ReturnType<typeof spawnWeb>
@@ -156,7 +169,8 @@ function expectCleanExit(stopped: StoppedWeb): void {
/**
* One HTTP request that connects to an explicit transport address while
* presenting an explicit browser-facing authority, as a proxy would.
* presenting an explicit browser-facing authority, so a concrete-bind case can
* dial the bound address instead of loopback.
*/
async function request(
dial: string, port: number, path: string, host: string, method: 'GET' | 'POST', cookie?: string,
@@ -190,7 +204,7 @@ async function exchange(
return { cookie: setCookie.split(';', 1)[0]!, setCookie }
}
describe.skipIf(!builtArtifactsExist)('dsh Web profile advertised public root', () => {
describe.skipIf(!builtArtifactsExist)('dsh Web profile bind address and advertised root', () => {
it('serves authenticated requests at the advertised mount and at loopback', async () => {
await withWeb(['--public-url', `http://gateway.example${MOUNT.slice(0, -1)}`, '--trusted-host', 'gateway.example'], async (web) => {
expect(web.startup.origin).toBe('http://gateway.example')
@@ -218,4 +232,89 @@ describe.skipIf(!builtArtifactsExist)('dsh Web profile advertised public root',
expectCleanExit(await web.stop())
})
})
it.skipIf(NON_LOOPBACK_IPV4 === undefined)('serves the bound address and a declared authority, refusing any other name', async () => {
const trustedHost = 'dsh-direct.example'
await withWeb(['--host', NON_LOOPBACK_IPV4!, '--trusted-host', trustedHost], async (web) => {
expect(web.startup.origin).toBe(`http://${NON_LOOPBACK_IPV4!}:${String(web.port)}`)
expect(web.startup.pathname).toBe('/')
const token = web.startup.searchParams.get('token')
expect(token).not.toBeNull()
const authority = `${NON_LOOPBACK_IPV4!}:${String(web.port)}`
const { cookie, setCookie } = await exchange(NON_LOOPBACK_IPV4!, web.port, `/${web.startup.search}`, authority)
// The carrier is plain HTTP, where a browser never returns a Secure cookie.
expect(setCookie).not.toContain('; Secure')
const authenticated = await request(NON_LOOPBACK_IPV4!, web.port, '/api/session/create', authority, 'POST', cookie)
expect(authenticated.status).toBe(200)
expect(await authenticated.json()).toMatchObject({ result: { ok: true } })
// A declared authority is admitted without being advertised; any other
// name for the same socket is a rebound Host.
const declared = `${trustedHost}:${String(web.port)}`
const declaredSession = await exchange(NON_LOOPBACK_IPV4!, web.port, `/${web.startup.search}`, declared)
const viaDeclared = await request(NON_LOOPBACK_IPV4!, web.port, '/api/session/create', declared, 'POST', declaredSession.cookie)
expect(viaDeclared.status).toBe(200)
await viaDeclared.arrayBuffer()
const rebound = await request(NON_LOOPBACK_IPV4!, web.port, '/api/session/create', `evil.example:${String(web.port)}`, 'POST', cookie)
expect(rebound.status).toBe(403)
await rebound.arrayBuffer()
const stopped = await web.stop()
expectCleanExit(stopped)
expect(stopped.stderr).toContain(`listening on ${NON_LOOPBACK_IPV4!} over plain HTTP`)
expect(stopped.stderr).not.toContain(token!)
})
})
it.skipIf(NON_LOOPBACK_IPV4 === undefined)('warns about the plain-HTTP bind even when an HTTPS root is advertised', async () => {
await withWeb(['--host', NON_LOOPBACK_IPV4!, '--public-url', 'https://app.example/', '--trusted-host', 'app.example'], async (web) => {
expect(web.startup.origin).toBe('https://app.example')
expect(web.startup.host).toBe('app.example')
const token = web.startup.searchParams.get('token')
expect(token).not.toBeNull()
const { cookie } = await exchange(NON_LOOPBACK_IPV4!, web.port, `/${web.startup.search}`, 'app.example')
const authenticated = await request(NON_LOOPBACK_IPV4!, web.port, '/api/session/create', 'app.example', 'POST', cookie)
expect(authenticated.status).toBe(200)
expect(await authenticated.json()).toMatchObject({ result: { ok: true } })
const stopped = await web.stop()
expectCleanExit(stopped)
expect(stopped.stderr).toContain(`listening on ${NON_LOOPBACK_IPV4!} over plain HTTP`)
})
})
it.each([
'::ffff:127.0.0.1',
'::ffff:7f00:1',
])('prints an IPv4 root for mapped loopback %s and serves it', async (host) => {
await withWeb(['--host', host], async (web) => {
expect(web.startup.origin).toBe(`http://127.0.0.1:${String(web.port)}`)
const { cookie } = await exchange(web.startup.hostname, web.port, `/${web.startup.search}`, web.startup.host)
const authenticated = await request(
web.startup.hostname, web.port, '/api/session/create', web.startup.host, 'POST', cookie,
)
expect(authenticated.status).toBe(200)
expect(await authenticated.json()).toMatchObject({ result: { ok: true } })
const stopped = await web.stop()
expectCleanExit(stopped)
expect(stopped.stderr).not.toContain('plain HTTP')
})
})
it.each(IPV6_LOOPBACKS)('prints the IPv6 loopback root for %s and serves it', async (host) => {
await withWeb(['--host', host], async (web) => {
expect(web.startup.origin).toBe(`http://[::1]:${String(web.port)}`)
const authority = `[::1]:${String(web.port)}`
const { cookie } = await exchange('::1', web.port, `/${web.startup.search}`, authority)
const authenticated = await request('::1', web.port, '/api/session/create', authority, 'POST', cookie)
expect(authenticated.status).toBe(200)
expect(await authenticated.json()).toMatchObject({ result: { ok: true } })
const stopped = await web.stop()
expectCleanExit(stopped)
expect(stopped.stderr).not.toContain('plain HTTP')
})
})
})
@@ -296,7 +296,7 @@ describe.skipIf(!builtArtifactsExist)('dsh Web profile best-effort startup', ()
expect(result.stderr).toContain('Failed plugins (1):')
expect(result.stderr).toContain(' webserver (required)\n Package: @deepseek-ai/dsh-host-webserver')
expect(result.stderr).toContain('Plugins waiting for services (')
expect(result.stderr).toMatch(/connection \(required\) +webRuntime/u)
expect(result.stderr).toMatch(/web-runtime \(required\) +webServer/u)
expect(result.stderr).toContain('at Server.setupListenHandle')
const summary = result.stderr.split(/\n\n(?:Full diagnostics:|dsh: warning:)/u)[0]!
expect(summary.match(/EADDRINUSE/gu)).toHaveLength(1)
@@ -319,7 +319,7 @@ describe.skipIf(!builtArtifactsExist)('dsh Web profile best-effort startup', ()
expect(report).toContain('configurationPath:')
expect(report).toContain("code: 'EADDRINUSE'")
expect(report).toContain(`port: ${String(address.port)}`)
expect(report).toContain("module: '@deepseek-ai/dsh-client-connection'")
expect(report).toContain("module: '@deepseek-ai/dsh-web-app'")
expect(report).toContain('at auditStartupEntries')
} finally {
await new Promise<void>((resolve, reject) => {
@@ -59,8 +59,13 @@ function fixture() {
' }, 20)',
' ctx.effect(() => () => { clearInterval(timer) })',
' ctx.inject(["webServer", "connection"], scope => {',
' writeFileSync(config.url, scope.connection.authenticatedUrl(`http://127.0.0.1:${scope.webServer.port}`))',
' scope.effect(() => () => { rmSync(config.url, { force: true }) })',
' let disposed = false',
' scope.effect(() => () => { disposed = true; rmSync(config.url, { force: true }) })',
// A recovery URL is a readiness signal; sibling HMR work must settle first.
' void scope.get("loader").await().then(() => {',
' if (disposed || !scope.get("webServer") || !scope.get("connection")) return',
' writeFileSync(config.url, scope.connection.authenticatedUrl(`http://127.0.0.1:${scope.webServer.port}`))',
' })',
' })',
'}',
'',
@@ -107,8 +112,8 @@ function fixture() {
return { root, home, events, diagnostics, serverUrl, states, observer, stop, patch, watcher, render, url, config, witness, target }
}
function start(f: ReturnType<typeof fixture>, extra: string[] = []) {
const child = execa(process.execPath, [bin, '--profile', 'web', '--patch', f.watcher, ...extra, '--no-open', '--port', '0'], {
function start(f: ReturnType<typeof fixture>, extra: string[] = [], nodeArgs: string[] = []) {
const child = execa(process.execPath, [...nodeArgs, bin, '--profile', 'web', '--patch', f.watcher, ...extra, '--no-open', '--port', '0'], {
cwd: f.root,
env: { ...process.env, DSH_HOME: f.home, DSH_AGENTS_HOME: join(f.root, '.agents'), DSH_TELEMETRY_DISABLED: '1', DEEPSEEK_API_KEY: 'keyless-matrix-no-call', NODE_NO_WARNINGS: '1' },
input: '', reject: false, timeout: 110_000, killSignal: 'SIGKILL',
@@ -167,6 +172,38 @@ function exit(result: { timedOut: boolean; signal?: string | undefined; exitCode
}
describe.skipIf(!built)('Web process failure matrix', () => {
it('rejects a non-loopback zone bind through the required Web runtime before readiness', async () => {
const f = fixture()
const host = 'fe80::1%dsh-test'
const network = join(f.root, 'zone-listen.mjs')
// Mock only the OS bind: the real WebServer retains its configured zone,
// and the shipped runtime row, Loader dependencies, and boot audit stay real.
writeFileSync(network, [
"import { Server } from 'node:net'",
'const listen = Server.prototype.listen',
'Server.prototype.listen = function (...args) {',
` if (args[1] === ${JSON.stringify(host)}) {`,
' args[1] = "127.0.0.1"',
' }',
' return Reflect.apply(listen, this, args)',
'}',
'',
].join('\n'))
const app = start(f, ['--host', host], ['--import', pathToFileURL(network).href])
try {
const result = await app.child
exit(result, 1)
expect(result.stderr).toContain('dsh: startup failed: 1 required plugin did not activate')
expect(result.stderr).toContain(' web-runtime (required)\n Package: @deepseek-ai/dsh-web-app')
expect(result.stderr).toContain(`bind address "${host}" carries an interface zone id`)
expect(result.stderr).toContain('pass --public-url')
expect(result.stdout).not.toContain('dsh web: http')
expect(app.events()).toBe('witness apply 0\nwitness dispose 0\n')
} finally {
exit(await app.close(), 1)
}
})
for (const required of [false, true]) {
const id = required ? 'acp' : 'matrix-optional'
it.each(failures)(`${required ? 'required' : 'optional'} startup %s`, async (failure, diagnostic) => {
@@ -408,7 +445,7 @@ export function apply(ctx, config) {
// Entry-level injection requirements are captured when the fiber is created.
writeFileSync(f.patch, f.render('matrix-optional') + `- id: ${id}\n disabled: true\n`)
await app.wait(() => app.state(id) === FiberState.DISPOSED)
const inject = id === 'connection' ? ['webRuntime', 'matrixMissingWebDependency'] : ['matrixMissingWebDependency']
const inject = id === 'connection' ? ['webStartup', 'matrixMissingWebDependency'] : ['matrixMissingWebDependency']
const pending = f.render('matrix-optional', undefined, 2) + `- id: ${id}\n inject: ${JSON.stringify(inject)}\n`
writeFileSync(f.patch, pending)
await app.wait(() => app.state(id) === FiberState.PENDING && app.events().includes('witness apply 2\n'))
+1 -1
View File
@@ -27,7 +27,7 @@ async function main(): Promise<void> {
profile: 'desktop',
resolvedProfile: { profile, installAnchor },
patchFiles: [],
args: ['--no-open', '--port', '0'],
args: ['--no-open', '--host', '127.0.0.1', '--port', '0'],
...(process.argv[5] === undefined ? {} : {
packageManager: {
command: process.execPath,
+2 -2
View File
@@ -3,8 +3,8 @@
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write apps/desktop/README.md
/deepseek-harness-desktop:
en: 0f0f6ea914eff51e
zh: 4934245b04e9f1c0
en: ccc7ba3d64aa38ef
zh: 846aea7879b0fe4e
/deepseek-harness-desktop/terminal-command:
en: bef92953c237363f
zh: d21d6effd21b8c00
+2
View File
@@ -6,6 +6,8 @@ Desktop analytics follows the [product collection policy](../../packages/client/
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.
Desktop binds `127.0.0.1`, the address Electron dials for the ready URL and the WebSocket credential filter.
The first application-menu command, **About DeepSeek Harness**, opens Electron's native About panel with the application icon, product name, and installed release version. The menu follows the Desktop shell locale. On macOS, Hide, Hide Others, Show All, and Quit use localized labels; Hide and Quit include the DeepSeek Harness product name. These commands retain their native actions and shortcuts. macOS reads the icon from its application bundle, so an unpackaged development launch displays Electron's icon; Windows receives the packaged PNG.
Desktop’s local native directory flow opens an Electron folder dialog attached to the application window, restoring, showing, and focusing that window first. Concurrent requests share one dialog; cancellation returns no path and failures remain retryable. Ordinary Web uses the Host chooser. Browse mode lists Host directories. On Linux without zenity or kdialog, automatic selection uses browse instead of the Electron dialog.
+2
View File
@@ -6,6 +6,8 @@
桌面应用是完整 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 覆盖。
Desktop 绑定 `127.0.0.1`,即 Electron 为就绪 URL 与 WebSocket 凭据过滤器拨号的地址。
应用菜单第一项“**关于 DeepSeek Harness**”打开 Electron 原生关于面板,展示应用图标、产品名称和当前安装的发布版本。菜单文案跟随桌面壳的语言。macOS 的隐藏、隐藏其他、显示全部和退出条目使用本地化文案,隐藏和退出条目包含 DeepSeek Harness 产品名称。这些条目保留原生动作和快捷键。macOS 从应用包读取图标,因此未打包的开发启动会显示 Electron 图标;Windows 使用随包分发的 PNG。
Desktop 的本地原生目录流程打开绑定应用窗口的 Electron 文件夹对话框,并先恢复、显示和聚焦该窗口。并发请求共用一个对话框;取消不返回路径,失败后可以重试。普通 Web 使用 Host 选择器。浏览模式列出 Host 目录。Linux 缺少 zenity 或 kdialog 时,自动选择使用浏览模式,不使用 Electron 对话框。
+1
View File
@@ -46,6 +46,7 @@
"@deepseek-ai/dsh-experimental-webworker-packer": "workspace:*",
"@deepseek-ai/dsh-experimental-webworker-runtime": "workspace:*",
"@deepseek-ai/dsh-remote-mock": "workspace:*",
"@deepseek-ai/dsh-session-projection-cache": "workspace:*",
"@deepseek-ai/dsh-util-values": "workspace:*",
"@types/node": "^22.20.1",
"@types/react": "~18.3.1",
@@ -29,7 +29,7 @@
- text: deepseek-flash
- textbox "显示名称 1":
- /placeholder: 显示名称
- text: DeepSeek-V41-Flash
- text: DeepSeek-V4.1-Flash
- button "模型选项 1"
- button "删除模型 1"
- textbox "模型 ID 2":
@@ -29,7 +29,7 @@
- text: deepseek-flash
- textbox "显示名称 1":
- /placeholder: 显示名称
- text: DeepSeek-V41-Flash
- text: DeepSeek-V4.1-Flash
- button "模型选项 1" [expanded]
- button "删除模型 1"
- text: 上下文窗口
@@ -0,0 +1,13 @@
# Before opening the child
- tree "Subagent sessions":
- treeitem "historical researcher Migration required · not running" [level=1]:
- text: historical researcher Migration required · not running
- button "Open historical researcher in sidebar"
# After opening the child
- tree "Subagent sessions":
- treeitem "historical editor Migration required · not running" [level=1]:
- text: historical editor Migration required · not running
- button "Open historical editor in sidebar"
+33 -2
View File
@@ -41,6 +41,7 @@ const RUNNING_DRAFT_EXPECTED = join(SNAPSHOT_DIR, 'running-draft.expected.md')
const ERROR_EXPECTED = join(SNAPSHOT_DIR, 'error-auth.expected.md')
const RETRY_EXPECTED = join(SNAPSHOT_DIR, 'retry.expected.md')
const RETRY_EXPANDED_EXPECTED = join(SNAPSHOT_DIR, 'retry-expanded.expected.md')
const RETRY_REQUEST_EXPECTED = join(SNAPSHOT_DIR, 'retry-request.expected.md')
const RETRY_EXHAUSTED_EXPECTED = join(SNAPSHOT_DIR, 'retry-exhausted.expected.md')
const MODE = webSnapshotMode()
const AUTH_PROVIDER_MESSAGE = 'Authentication Fails, Your api key: sk-preview-secret is invalid'
@@ -342,11 +343,22 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
// sourced from the recording, never copied into a committed sidecar.
{ at: 1, entry: derived[0]! },
],
}))
}), {
mode: 'normal', maxRetries: 5, retryableCodes: ['SERVER'],
backoff: { initialDelayMs: 25, maxDelayMs: 25, jitterRatio: 0 },
})
onTestFailed(() => saveFailureShot(page, 'web-e2e-retry'))
const releaseFailure = Promise.withResolvers<undefined>()
const retryReady = Promise.withResolvers<undefined>()
const releaseRetry = Promise.withResolvers<undefined>()
const requestStates: string[] = []
let attempts = 0
// Retirement must follow a visible partial, not race the first browser paint.
const stopHolding = scaffold!.ctx.on('llm/stream', async function* (_request, next) {
if (++attempts === 2) {
retryReady.resolve(undefined)
await releaseRetry.promise
}
for await (const chunk of next()) {
yield chunk
if (chunk.type === 'text-delta' && chunk.text === RETRY_PARTIAL) await releaseFailure.promise
@@ -356,9 +368,20 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
const { settled } = await sendPrompt(60_000)
await page.getByText(RETRY_PARTIAL, { exact: true }).waitFor({ timeout: 15_000 })
releaseFailure.resolve(undefined)
await retryReady.promise
await page.getByRole('tab', { name: 'Trajectory', exact: true }).click()
await page.getByRole('table').getByRole('button', { name: 'Request #1', exact: true }).click()
const overview = page.getByRole('tabpanel').locator('dl').first()
await overview.getByText('Pending', { exact: true }).waitFor()
await overview.getByText('Last attempt error', { exact: true }).waitFor()
expect(await overview.getByText('Error', { exact: true }).count()).toBe(0)
requestStates.push('# Retrying request', await overview.ariaSnapshot())
await page.getByRole('tab', { name: 'Chat', exact: true }).click()
releaseRetry.resolve(undefined)
await settled
} finally {
releaseFailure.resolve(undefined)
releaseRetry.resolve(undefined)
stopHolding()
}
expect(turnEndReasons(sessionEvents).at(-1)).toBe('completed')
@@ -379,6 +402,14 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
scaffold!.workspaceCwd,
)
await compareOrRefreshGolden(RETRY_EXPANDED_EXPECTED, expanded, MODE)
await page.getByRole('tab', { name: 'Trajectory', exact: true }).click()
await page.getByRole('table').getByRole('button', { name: 'Request #1', exact: true }).click()
const overview = page.getByRole('tabpanel').locator('dl').first()
await overview.getByText('Completed', { exact: true }).waitFor()
await overview.getByText('Last attempt error', { exact: true }).waitFor()
expect(await overview.getByText('Error', { exact: true }).count()).toBe(0)
requestStates.push('# Recovered request', await overview.ariaSnapshot())
await compareOrRefreshGolden(RETRY_REQUEST_EXPECTED, requestStates.join('\n\n'), MODE)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
}, 120_000)
@@ -420,7 +451,7 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
await assertFixtureInventory(SNAPSHOT_DIR, [
'session.v3.jsonl', 'cancel.expected.md', 'cancel-expanded.expected.md',
'loading.expected.md', 'running-draft.expected.md', 'error-auth.expected.md',
'retry.expected.md', 'retry-expanded.expected.md', 'retry-exhausted.expected.md',
'retry.expected.md', 'retry-expanded.expected.md', 'retry-request.expected.md', 'retry-exhausted.expected.md',
])
})
})
+84 -7
View File
@@ -3,16 +3,17 @@
// calls) and pins the settled conversation aria after the footers are
// focus-revealed — the surface package jsdom tests cannot substitute for
// (docs/testing.md snapshot rule).
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { Browser, Page } from 'playwright'
import { zstdCompressSync } from 'node:zlib'
import type { Browser, Page, Request as BrowserRequest } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import { SessionId } from '@deepseek-ai/dsh-session'
import { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session'
import {
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
launchWebScaffold, parseSeedFixture, renderSeedFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
launchWebScaffold, parseSeedFixture, realizeSeedFixture, renderSeedFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { openSettings, newEnglishPage, pinBrowserClock, saveFailureShot, WEB_FIXTURE_TIME } from './support.ts'
@@ -22,6 +23,7 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/message-actio
const SEED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v3.jsonl', import.meta.url))
const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
const FORK_EXPECTED = join(SNAPSHOT_DIR, 'fork.expected.md')
const UNOPENED_FORK_EXPECTED = join(SNAPSHOT_DIR, 'fork-unopened.expected.md')
const MODE = webSnapshotMode()
const SEED_ID = 'message-actions-web-e2e'
@@ -192,7 +194,8 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
let unpinBrowserClock: (() => void) | undefined
beforeAll(async () => {
scaffold = await launchWebScaffold({})
scaffold = await launchWebScaffold()
await mkdir(fileURLToPath(new URL('../../../.artifacts/screenshots/non-migrating-session-reads', import.meta.url)), { recursive: true })
const sessionCwd = join(scaffold.workspaceCwd, 'workspace')
await mkdir(sessionCwd, { recursive: true })
await writeFile(join(sessionCwd, 'a.txt'), 'alpha\n')
@@ -222,7 +225,7 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
})
it.skipIf(MODE === 'record')('enables branch only on the completed transcript tail', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-message-actions'))
onTestFailed(() => saveFailureShot(page, 'screenshots/non-migrating-session-reads/web-e2e-message-actions'))
const groupRow = page.locator('[role="treeitem"]').first()
await groupRow.waitFor({ timeout: 15_000 })
await groupRow.click()
@@ -423,9 +426,83 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
await compareOrRefreshGolden(FORK_EXPECTED, tree, MODE)
})
it.skipIf(MODE === 'record')('forks an unopened current-format Session from its sidebar row', async () => {
onTestFailed(() => saveFailureShot(page, 'screenshots/non-migrating-session-reads/current-cold-fork'))
const id = SessionId('message-actions-current-cold')
await seedSession(scaffold, completedTailFixture(await readFile(SEED, 'utf8')), id)
const warningStart = tripwire.warnings.length
await page.reload({ waitUntil: 'load' })
const row = page.locator(`[data-row-key="session:${id}"]`)
await row.waitFor()
acknowledgeReloadConnectionLoss(tripwire, warningStart)
expect(scaffold.ctx.agents.get(id)).toBeUndefined()
await row.hover()
await row.locator('button[aria-label^="Session actions for "]').click()
await page.getByRole('menuitem', { name: 'Fork session', exact: true }).click()
await expect.poll(() => scaffold.ctx.agents.list().some(agent => agent.session.header.parentSession === id)).toBe(true)
expect(scaffold.ctx.agents.get(id)).toBeUndefined()
expect(await row.getAttribute('aria-selected')).not.toBe('true')
})
it.skipIf(MODE === 'record')('guides only a migration-required sidebar fork to opening its source', async () => {
onTestFailed(() => saveFailureShot(page, 'screenshots/non-migrating-session-reads/migration-required-fork'))
const id = SessionId('message-actions-needs-migration')
const [header, ...events] = realizeSeedFixture(scaffold, await readFile(SEED, 'utf8'), id).trimEnd().split('\n')
.map((line): Record<string, unknown> => JSON.parse(line) as Record<string, unknown>)
expect(header?.version).toBe(3)
const projects = (await readdir(scaffold.persistenceRoot, { withFileTypes: true })).filter(entry => entry.isDirectory())
expect(projects).toHaveLength(1)
const directory = join(scaffold.persistenceRoot, projects[0]!.name, id)
const predecessor = join(directory, 'session.v3.jsonl.zstd')
const createdAt = WEB_FIXTURE_TIME - 90_000
const rows = [{ ...header, id, cwd: scaffold.workspaceCwd, createdAt },
...events.map((event, seq) => ({ ...event, seq, time: createdAt + seq + 1 }))]
const source = Buffer.concat(rows.map(row => zstdCompressSync(Buffer.from(`${JSON.stringify(row)}\n`))))
await mkdir(directory, { recursive: true })
await writeFile(predecessor, source)
await expect(scaffold.ctx.sessionPersistence.stat(id)).resolves.toMatchObject({ formatStatus: 'migration-required' })
const warningStart = tripwire.warnings.length
await page.reload({ waitUntil: 'load' })
await page.getByRole('button', { name: /^Show \d+ more sessions$/ }).click()
const row = page.locator(`[data-row-key="session:${id}"]`)
await row.waitFor()
acknowledgeReloadConnectionLoss(tripwire, warningStart)
const forkRequests: string[] = []
const trackFork = (request: BrowserRequest): void => {
const path = new URL(request.url()).pathname
if (path === '/api/session/fork') forkRequests.push(path)
}
page.on('request', trackFork)
try {
await row.hover()
await row.locator('button[aria-label^="Session actions for "]').click()
await page.getByRole('menuitem', { name: 'Fork session', exact: true }).click()
const notice = page.getByRole('alert').filter({ hasText: 'Open the original session to complete migration before creating a branch.' })
await notice.waitFor()
expect(await page.getByRole('menuitem', { name: 'Fork session', exact: true }).count()).toBe(0)
expect(forkRequests).toEqual(['/api/session/fork'])
expect(scaffold.ctx.agents.get(id)).toBeUndefined()
expect(await readdir(directory)).toEqual(['session.v3.jsonl.zstd'])
expect(await readFile(predecessor)).toEqual(source)
await compareOrRefreshGolden(UNOPENED_FORK_EXPECTED,
await captureStableAria(page, '[role="alert"]', scaffold.workspaceCwd), MODE)
await notice.getByRole('button', { name: 'Open original session', exact: true }).click()
await expect.poll(() => row.getAttribute('aria-selected')).toBe('true')
await expect.poll(() => scaffold.ctx.agents.get(id)).toBeDefined()
await expect(scaffold.ctx.sessionPersistence.stat(id)).resolves.toMatchObject({ formatStatus: 'current' })
expect(await readdir(directory)).toContain(`session.v${SESSION_FORMAT_VERSION}.jsonl.zstd`)
expect(await readFile(predecessor)).toEqual(source)
expect(forkRequests).toEqual(['/api/session/fork'])
expect(scaffold.ctx.agents.list().filter(agent => agent.session.header.parentSession === id)).toEqual([])
} finally {
page.off('request', trackFork)
}
})
it.skipIf(MODE === 'record')('issued zero model calls and kept a closed inventory', async () => {
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, ['compact.expected.md', 'fork.expected.md', 'ui.expected.md'])
await assertFixtureInventory(SNAPSHOT_DIR, ['compact.expected.md', 'fork.expected.md', 'fork-unopened.expected.md', 'ui.expected.md'])
})
})
@@ -208,7 +208,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
await deepSeek.locator('xpath=ancestor::li').getByRole('button', { name: '编辑' }).click()
await settings.getByText('自定义设置').click()
expect(await settings.getByLabel('模型 ID 1').inputValue()).toBe('deepseek-flash')
expect(await settings.getByLabel('显示名称 1').inputValue()).toBe('DeepSeek-V41-Flash')
expect(await settings.getByLabel('显示名称 1').inputValue()).toBe('DeepSeek-V4.1-Flash')
expect(await settings.getByLabel('模型 ID 2').inputValue()).toBe('deepseek-v4-pro')
expect(await settings.getByRole('button', { name: /删除模型/ }).count()).toBe(2)
await settings.getByRole('button', { name: '模型选项 1' }).click()
+52 -5
View File
@@ -18,6 +18,7 @@ const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/ptc-round/ui.e
const TRAJECTORY_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/ptc-round/trajectory.expected.md', import.meta.url))
const CODE_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/ptc-round/code.expected.md', import.meta.url))
const PREPARING_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/ptc-round/preparing.expected.md', import.meta.url))
const REQUEST_LIFECYCLE_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/ptc-round/request-lifecycle.expected.md', import.meta.url))
const MODE = webSnapshotMode()
// Elicits the successful and failed sub-rows this scenario asserts.
@@ -55,6 +56,7 @@ describe('web e2e: PTC mode round renders nested sub-calls', () => {
it('drives the recorded prompt to a settled turn (all modes)', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-ptc-drive'))
const requestStates: string[] = []
if (MODE !== 'record') {
expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT])
}
@@ -62,7 +64,11 @@ describe('web e2e: PTC mode round renders nested sub-calls', () => {
await input.waitFor({ timeout: 10_000 })
const settled = scaffold.whenTurnSettled()
const gate = Promise.withResolvers<undefined>()
const waiting = Promise.withResolvers<undefined>()
const start = Promise.withResolvers<undefined>()
const dispose = scaffold.ctx.on('llm/stream', async function* (_options, next) {
waiting.resolve(undefined)
await start.promise
let held = false
for await (const chunk of next()) {
yield chunk
@@ -72,11 +78,21 @@ describe('web e2e: PTC mode round renders nested sub-calls', () => {
}
}
}, { prepend: true })
releasePreparation = () => { gate.resolve(undefined); dispose() }
releasePreparation = () => { start.resolve(undefined); gate.resolve(undefined); dispose() }
const observePreparation = async () => {
try {
await input.fill(PROMPT)
await input.press('Enter')
await waiting.promise
await page.getByRole('tab', { name: 'Trajectory', exact: true }).click()
const request = page.getByRole('table').getByRole('button', { name: 'Request #1', exact: true })
await request.click()
const panel = page.getByRole('tabpanel')
await panel.locator('dl').first().getByText('Pending', { exact: true }).waitFor()
expect(await panel.getByRole('button', { name: 'Assistant Message', exact: true }).count()).toBe(0)
requestStates.push('# Waiting for output', await panel.locator('dl').first().ariaSnapshot())
await page.getByRole('tab', { name: 'Chat', exact: true }).click()
start.resolve(undefined)
const row = page.locator('[data-chat-call-id] [data-state="preparing"]').first()
await row.waitFor({ state: 'attached', timeout: 30_000 })
await expandOwningTurnProcess(page, row)
@@ -88,14 +104,45 @@ describe('web e2e: PTC mode round renders nested sub-calls', () => {
expect(await row.locator('pre').count()).toBe(0)
const group = row.locator('xpath=ancestor::*[@data-chat-group-key][1]')
await compareOrRefreshGolden(PREPARING_EXPECTED, await group.ariaSnapshot(), MODE)
await page.getByRole('tab', { name: 'Trajectory', exact: true }).click()
const assistant = page.locator('tr[data-kind="message"][data-running="true"]').first()
await assistant.click()
await panel.locator('dl').first().getByText('Pending', { exact: true }).waitFor()
expect(await panel.getByText('Completed', { exact: true }).count()).toBe(0)
requestStates.push('# Streaming assistant', await panel.locator('dl').first().ariaSnapshot())
await panel.getByRole('button', { name: 'Request Timing', exact: true }).click()
expect(await panel.getByText('Not recorded', { exact: true }).count()).toBe(0)
expect(await panel.locator('dt').filter({ hasText: /^Started$/ }).locator('..').getByRole('button').count()).toBe(1)
await request.click()
await panel.locator('dl').first().getByText('Pending', { exact: true }).waitFor()
requestStates.push('# Streaming request', await panel.locator('dl').first().ariaSnapshot())
await page.getByRole('tab', { name: 'Chat', exact: true }).click()
} finally {
releasePreparation?.()
}
}
const [sessionId] = await Promise.all([settled, observePreparation()])
await expect.poll(() => page.locator('[data-state="preparing"]').count(), { timeout: 15_000 }).toBe(0)
if (MODE === 'record') {
await recordFixture(scaffold, sessionId, FIXTURE)
try {
const [sessionId] = await Promise.all([settled, observePreparation()])
expect(sessionEvents.filter(event => event.type === 'turn/end')).toMatchObject([
{ data: { reason: { kind: 'completed' } } },
])
await expect.poll(() => page.locator('[data-state="preparing"]').count(), { timeout: 15_000 }).toBe(0)
if (MODE === 'record') {
await recordFixture(scaffold, sessionId, FIXTURE)
} else {
await page.getByRole('tab', { name: 'Trajectory', exact: true }).click()
await page.getByRole('table').getByRole('button', { name: 'Request #1', exact: true }).click()
const panel = page.getByRole('tabpanel')
await panel.getByText('Completed', { exact: true }).waitFor()
requestStates.push('# Settled request', await panel.locator('dl').first().ariaSnapshot())
await panel.getByRole('button', { name: 'Assistant Message', exact: true }).click()
await panel.getByText('Completed', { exact: true }).waitFor()
requestStates.push('# Settled assistant', await panel.locator('dl').first().ariaSnapshot())
await compareOrRefreshGolden(REQUEST_LIFECYCLE_EXPECTED, requestStates.join('\n\n'), MODE)
}
} finally {
releasePreparation?.()
await page.getByRole('tab', { name: 'Chat', exact: true }).click()
}
}, 200_000)
@@ -140,6 +140,7 @@ describe('web e2e: skill invocation policy through the real host', () => {
await option.click()
const skill = input.locator('[data-composer-text-ref]').filter({ hasText: '/policy-shared' })
const file = input.locator('[data-composer-chip]')
await file.locator('span').first().waitFor({ state: 'visible' })
const draft = await input.textContent()
const alignment = await input.evaluate((el) => {
const skill = el.querySelector<HTMLElement>('[data-composer-text-ref]')!
+171 -8
View File
@@ -1,10 +1,11 @@
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import { dirname, join } from 'node:path'
import { zstdCompressSync } from 'node:zlib'
import type { Browser, Page } 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 { prepareSessionSnapshotFixtureForComparison } from '@deepseek-ai/dsh-llm-replay'
import {
SESSION_FORMAT_VERSION, SessionId as sessionId, SessionLogOffset, SessionSeq,
@@ -12,10 +13,11 @@ import {
} from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-agent'
import { snapshotSubagentDescriptor, SUBAGENT_DESCRIPTOR_VERSION } from '@deepseek-ai/dsh-subagent'
import { checkpointRecord, projectionCacheDomainSpec } from '@deepseek-ai/dsh-session-projection-cache'
import {
acknowledgeReloadConnectionLoss, captureExpandedTurnProcessAria, captureStableAria,
compareOrRefreshGolden,
launchWebScaffold, readPersistedEvents, selectedSessionFixture, watchConsole,
launchWebScaffold, readPersistedEvents, realizeSeedFixture, seedSession, selectedSessionFixture, watchConsole,
webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { connectFreshWorkspace, newEnglishPage, saveFailureShot, writeComposerDraft } from './support.ts'
@@ -32,6 +34,9 @@ const SIDEBAR_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-
const SIDEBAR_CHAT_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-conversation/sidebar-chat.expected.md', import.meta.url))
const UNAVAILABLE_GRANDCHILD_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-conversation/nested.expected.md', import.meta.url))
const FORK_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-conversation/fork.expected.md', import.meta.url))
const MIGRATING_CHILD_EXPECTED = fileURLToPath(new URL(
'./expected/subagent-conversation/migration-required-child.expected.md', import.meta.url,
))
const MODE = webSnapshotMode()
const LABEL = 'event-sourcing researcher'
const ONE_SHOT_LABEL = 'event-sourcing reviewer'
@@ -120,6 +125,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
paceMs: 25,
})
browser = await chromium.launch()
await mkdir(fileURLToPath(new URL('../../../.artifacts/screenshots/non-migrating-session-reads', import.meta.url)), { recursive: true })
page = await newEnglishPage(browser)
page.on('request', (request) => {
const path = new URL(request.url()).pathname
@@ -320,6 +326,13 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
},
])
}
await waitForCacheRow(scaffold, childHeader)
const cache = scaffold.ctx.storageDomain.get(projectionCacheDomainSpec.name)
if (cache === undefined) throw new Error('projection cache is not open')
const table = cache.table('sessions')
const record = checkpointRecord.parse(table.get(childId))
const { subagentCatalog: _catalog, ...rows } = record.rows
await table.put(childId, { ...record, rows })
// These two cold fixtures were authored after the page's initial
// session.list and intentionally emitted no api-session/added event. Reload
// to exercise the restart baseline that discovers their full lineage.
@@ -362,8 +375,8 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
await writeComposerDraft(page, input, '')
})
it('expands a persisted grandchild progressively without activating either level', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-tree'))
it('recovers a current-format cold child catalog cache miss without activating either level', async () => {
onTestFailed(() => saveFailureShot(page, 'screenshots/non-migrating-session-reads/web-e2e-subagent-tree'))
await page.getByRole('button', { name: '2 subagents' }).hover()
const catalogTree = page.getByRole('tree', { name: 'Subagent sessions' })
expect(await catalogTree.evaluate((element) => {
@@ -379,12 +392,22 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
expect(await oneShotRow.locator('[data-state="done"]').count()).toBe(1)
expect(await oneShotRow.getByText('~6mo 12d', { exact: true }).count()).toBe(1)
expect(await oneShotRow.getAttribute('aria-label')).toContain('192d 00h 00m 00s')
await page.getByRole('button', { name: `Expand ${LABEL} descendants` }).click()
const observe = vi.spyOn(scaffold.ctx.sessionQuery, 'observeSession')
try {
const projections = page.waitForResponse(response => new URL(response.url()).pathname === '/api/session/projections')
await page.getByRole('button', { name: `Expand ${LABEL} descendants` }).click()
await projections
await page.getByRole('treeitem', { name: new RegExp(NESTED_LABEL) }).waitFor({ timeout: 15_000 })
expect(observe.mock.calls.some(([id]) => id === childId)).toBe(true)
expect(observe.mock.calls.some(([id]) => id === grandchildId)).toBe(false)
} finally {
observe.mockRestore()
}
const childRow = page.getByRole('treeitem', { name: new RegExp(LABEL) })
const childLabel = await childRow.getAttribute('aria-label')
await page.waitForTimeout(1_100)
expect(await childRow.getAttribute('aria-label')).toBe(childLabel)
await page.getByRole('treeitem', { name: new RegExp(NESTED_LABEL) }).waitFor({ timeout: 15_000 })
expect(await page.getByRole('treeitem', { name: new RegExp(NESTED_LABEL) }).textContent()).toContain('continuable')
expect(scaffold.ctx.agents.get(childId)).toBeUndefined()
expect(scaffold.ctx.agents.get(grandchildId)).toBeUndefined()
const snapshot = await captureStableAria(
@@ -657,3 +680,143 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
await expect.poll(() => scaffold.ctx.agents.get(childId), { timeout: 10_000 }).toBeUndefined()
})
})
describe('web e2e: migration-required subagent catalog', () => {
const parentId = sessionId('migration-catalog-parent')
const childId = sessionId('migration-catalog-child')
const grandchildId = sessionId('migration-catalog-grandchild')
const childLabel = 'historical researcher'
const grandchildLabel = 'historical editor'
const predecessors: { id: SessionId; directory: string; path: string; source: Buffer }[] = []
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
if (MODE === 'record') throw new Error('migration-required subagent catalog is a keyless assembled snapshot')
scaffold = await launchWebScaffold()
const fixture = await readFile(BASE_FIXTURE, 'utf8')
const createdAt = 1784998084442
await seedSession(scaffold, fixture, parentId, undefined, { createdAt })
const parentHandle = await scaffold.ctx.sessionPersistence.open(parentId, 'write')
try {
const { events } = await parentHandle.read()
const last = events.at(-1)
if (last === undefined) throw new Error('parent seed has no events')
await parentHandle.append([{
type: 'subagent/catalog', seq: SessionSeq(last.seq + 1), time: last.time + 1,
data: { version: 1, childId, childCreatedAt: createdAt + 1, mode: 'continuable', label: childLabel },
}])
} finally {
await parentHandle.close()
}
const projects = (await readdir(scaffold.persistenceRoot, { withFileTypes: true })).filter(entry => entry.isDirectory())
expect(projects).toHaveLength(1)
const project = projects[0]
if (project === undefined) throw new Error('parent seed has no project directory')
// Keep historical bytes on disk; seeding through the writer would migrate them before the browser opens.
for (const [id, parentSession, label, depth] of [
[childId, parentId, childLabel, 1],
[grandchildId, childId, grandchildLabel, 2],
] as const) {
const [header, ...events] = realizeSeedFixture(scaffold, fixture, id).trimEnd().split('\n')
.map((line): Record<string, unknown> => JSON.parse(line) as Record<string, unknown>)
expect(header?.version).toBe(3)
const body = [
...events,
{ type: 'subagent/descriptor', data: snapshotSubagentDescriptor({ mode: 'continuable', provider: 'spawn', label }) },
...id === childId ? [{
type: 'subagent/catalog',
data: { version: 0, childId: grandchildId, childCreatedAt: createdAt + 2, mode: 'continuable', label: grandchildLabel },
}] : [],
]
const rows = [
{ ...header, id, createdAt: createdAt + depth, cwd: scaffold.workspaceCwd, parentSession, origin: 'subagent', delegationDepth: depth },
...body.map((event, seq) => ({ ...event, seq, time: createdAt + depth + seq + 1 })),
]
const directory = join(scaffold.persistenceRoot, project.name, id)
const path = join(directory, 'session.v3.jsonl.zstd')
const source = Buffer.concat(rows.map(row => zstdCompressSync(Buffer.from(`${JSON.stringify(row)}\n`))))
await mkdir(directory, { recursive: true })
await writeFile(path, source)
predecessors.push({ id, directory, path, source })
}
for (const { id } of predecessors) {
const stored = await scaffold.ctx.sessionPersistence.stat(id)
expect(stored?.formatStatus).toBe('migration-required')
if (stored === undefined) throw new Error(`historical fixture ${id} is missing`)
expect(scaffold.ctx.sessionProjectionCache.cachedSnapshot(stored.header)).toBeUndefined()
}
browser = await chromium.launch()
page = await newEnglishPage(browser)
tripwire = watchConsole(page)
})
afterAll(async () => {
const failures: unknown[] = []
await browser?.close().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, 'migration-required subagent Web teardown failed')
})
it('stops at a migration-required child until its history opens', async () => {
onTestFailed(() => saveFailureShot(page, 'screenshots/non-migrating-session-reads/migration-required-subagent'))
const observe = vi.spyOn(scaffold.ctx.sessionQuery, 'observeSession')
const projections = vi.spyOn(scaffold.ctx.sessionController, 'projections')
const model = vi.spyOn(scaffold.ctx.llm, 'stream')
try {
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
const workspaceRow = page.getByRole('tree', { name: 'Sessions' }).getByRole('treeitem').first()
await workspaceRow.click()
await page.locator(`[data-row-key="session:${parentId}"]`).click()
await page.getByRole('button', { name: '1 subagent', exact: true }).hover()
const tree = page.getByRole('tree', { name: 'Subagent sessions' })
const childRow = tree.getByRole('treeitem', { name: new RegExp(childLabel) })
await childRow.waitFor()
await expect.poll(() => childRow.textContent()).toContain('Migration required')
await expect.poll(() => childRow.getByRole('button', { name: `Expand ${childLabel} descendants`, exact: true }).count()).toBe(0)
expect(await childRow.getAttribute('aria-expanded')).toBeNull()
expect(await childRow.textContent()).toContain('Migration required')
expect(await childRow.textContent()).not.toContain('unknown mode')
expect(await childRow.getByRole('button', { name: `Open ${childLabel} in sidebar`, exact: true }).isEnabled()).toBe(true)
expect(await tree.getByRole('treeitem').count()).toBe(1)
for (const { id, directory, path, source } of predecessors) {
expect(scaffold.ctx.agents.get(id)).toBeUndefined()
expect(await readdir(directory)).toEqual(['session.v3.jsonl.zstd'])
expect(await readFile(path)).toEqual(source)
}
const before = await captureStableAria(page, '[role="tree"][aria-label="Subagent sessions"]', scaffold.workspaceCwd)
expect(observe.mock.calls.some(([id]) => id === childId || id === grandchildId)).toBe(false)
expect(projections).not.toHaveBeenCalled()
await childRow.click()
await page.getByRole('button', { name: `Switch subagent: ${childLabel}`, exact: true }).waitFor()
await page.getByRole('button', { name: '1 subagent', exact: true }).hover()
const grandchildRow = tree.getByRole('treeitem', { name: new RegExp(grandchildLabel) })
await grandchildRow.waitFor()
expect(await grandchildRow.textContent()).toContain('Migration required')
expect(await grandchildRow.getAttribute('aria-expanded')).toBeNull()
expect(await grandchildRow.getByRole('button', { name: `Expand ${grandchildLabel} descendants`, exact: true }).count()).toBe(0)
expect(projections).not.toHaveBeenCalled()
expect(observe.mock.calls.some(([id]) => id === childId)).toBe(true)
const after = await captureStableAria(page, '[role="tree"][aria-label="Subagent sessions"]', scaffold.workspaceCwd)
if (MODE === 'refresh') await mkdir(dirname(MIGRATING_CHILD_EXPECTED), { recursive: true })
await compareOrRefreshGolden(MIGRATING_CHILD_EXPECTED,
`# Before opening the child\n\n${before}\n\n# After opening the child\n\n${after}`, MODE)
for (const { id, path, source } of predecessors) {
expect(scaffold.ctx.agents.get(id)).toBeUndefined()
expect(await readFile(path)).toEqual(source)
}
expect(observe.mock.calls.some(([id]) => id === grandchildId)).toBe(false)
expect(model).not.toHaveBeenCalled()
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
} finally {
projections.mockRestore()
model.mockRestore()
observe.mockRestore()
}
})
})
+4 -1
View File
@@ -173,7 +173,9 @@ Usage: dsh --profile web [options]
Serve the DeepSeek Harness browser UI.
Options:
--host <host> bind host
--host <host> bind address: one concrete IPv4 or IPv6 literal
of a local interface; wildcard addresses are
rejected
--no-open do not open the Web UI in the default browser
--port <port> listen port; pass 0 to let the OS pick a free
one
@@ -188,6 +190,7 @@ Examples:
dsh --profile web serve on the composed host and port
dsh --profile web --no-open serve without opening a browser
dsh --profile web --port 8080 serve on another port
dsh --profile web --host 10.0.0.7 bind one local interface address
dsh --profile web --public-url https://app.example/ui/ --trusted-host app.example
advertise a prefix-stripping HTTPS proxy entry and admit its authority
+4 -1
View File
@@ -175,7 +175,9 @@ Usage: dsh --profile web [options]
Serve the DeepSeek Harness browser UI.
Options:
--host <host> bind host
--host <host> bind address: one concrete IPv4 or IPv6 literal
of a local interface; wildcard addresses are
rejected
--no-open do not open the Web UI in the default browser
--port <port> listen port; pass 0 to let the OS pick a free
one
@@ -190,6 +192,7 @@ Examples:
dsh --profile web serve on the composed host and port
dsh --profile web --no-open serve without opening a browser
dsh --profile web --port 8080 serve on another port
dsh --profile web --host 10.0.0.7 bind one local interface address
dsh --profile web --public-url https://app.example/ui/ --trusted-host app.example
advertise a prefix-stripping HTTPS proxy entry and admit its authority
+21 -16
View File
@@ -220,7 +220,7 @@ 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:79`](../packages/api/session-controller/src/index.ts)
- `source`: [`packages/api/session-controller/src/index.ts:80`](../packages/api/session-controller/src/index.ts)
```ts config-catalog
/** Session Controller deployment policy. */
@@ -444,11 +444,9 @@ export interface ConnectionConfig {
recovery?: ConnectionRecoveryConfig
/**
* Authorities this deployment serves beyond loopback: exact `host:port`, or
* port-less `host` matching any port. The /api trust fence refuses any
* request whose Host is neither loopback nor listed here, so a
* non-loopback (`0.0.0.0`) deployment must declare the names it is reached
* by; the Web runtime derives LAN IP literals from an active all-interface
* bind. An entry that is not a bare, canonical authority fails plugin load.
* port-less `host` matching any port. The fence accepts loopback and the
* listener's own bind IP independently; other remote authorities require
* an entry here. A non-canonical or non-bare authority fails plugin load.
*/
trustedHosts?: string[]
/** Absolute browser-session lifetime in days. Default: 30. */
@@ -1626,13 +1624,22 @@ export interface Config {
## `@deepseek-ai/dsh-host-webserver`
- `source`: [`packages/host/webserver/src/index.ts:59`](../packages/host/webserver/src/index.ts)
- `source`: [`packages/host/webserver/src/index.ts:60`](../packages/host/webserver/src/index.ts)
```ts config-catalog
/** Web server listen and response-compression config. */
export interface Config {
/** Listen host; the two supported values are loopback and all-interfaces. */
host: '127.0.0.1' | '0.0.0.0'
/**
* Listen address: a concrete IPv4 or IPv6 literal of one local interface,
* for example the container's own Pod address from `hostname -i`. A loopback
* literal (any address in 127/8, `::1`, or a mapped form of either) keeps the
* server on this machine; any other literal serves the network that address
* belongs to over plain HTTP, because the carrier adds no TLS. The
* unspecified address — IPv4 any, IPv6 any, and the IPv4-mapped forms of
* IPv4 any — is rejected at load: it would expose the port on every interface
* at once.
*/
host: string
/** Listen port; zero requests an OS-assigned port. */
port: number
/** Response compression for socket-backed HTTP requests. @default 'none' */
@@ -4205,7 +4212,7 @@ export interface WebRuntimeConfig {
## `@deepseek-ai/dsh-web-app`
- `inject`: `webServer`
- `source`: [`packages/bundle/web-app/src/index.ts:46`](../packages/bundle/web-app/src/index.ts)
- `source`: [`packages/bundle/web-app/src/index.ts:43`](../packages/bundle/web-app/src/index.ts)
```ts config-catalog
/** Plugin config: composed deployment settings plus per-invocation command-line values. */
@@ -4224,13 +4231,11 @@ export interface Config {
/**
* 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 `/`. Advertisement only;
* see [public deployments](../README.md#public-deployments). Absent or YAML
* `null` advertises the loopback URL.
* `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.
*/
publicUrl?: string
/** Explicit `--trusted-host` authorities from this invocation. */
trustedHosts: string[]
}
```
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-web-app -->
@@ -4515,7 +4520,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-host-directory-picker-auto` | `webServer` · `loader` | [`packages/host/directory-picker-auto/src/index.ts`](../packages/host/directory-picker-auto/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) |
| `@deepseek-ai/dsh-llm` | — | [`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts) |
+21 -16
View File
@@ -222,7 +222,7 @@ 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:79`](../packages/api/session-controller/src/index.ts)
- `source`: [`packages/api/session-controller/src/index.ts:80`](../packages/api/session-controller/src/index.ts)
```ts config-catalog
/** Session Controller deployment policy. */
@@ -446,11 +446,9 @@ export interface ConnectionConfig {
recovery?: ConnectionRecoveryConfig
/**
* Authorities this deployment serves beyond loopback: exact `host:port`, or
* port-less `host` matching any port. The /api trust fence refuses any
* request whose Host is neither loopback nor listed here, so a
* non-loopback (`0.0.0.0`) deployment must declare the names it is reached
* by; the Web runtime derives LAN IP literals from an active all-interface
* bind. An entry that is not a bare, canonical authority fails plugin load.
* port-less `host` matching any port. The fence accepts loopback and the
* listener's own bind IP independently; other remote authorities require
* an entry here. A non-canonical or non-bare authority fails plugin load.
*/
trustedHosts?: string[]
/** Absolute browser-session lifetime in days. Default: 30. */
@@ -1628,13 +1626,22 @@ export interface Config {
## `@deepseek-ai/dsh-host-webserver`
- `source`: [`packages/host/webserver/src/index.ts:59`](../packages/host/webserver/src/index.ts)
- `source`: [`packages/host/webserver/src/index.ts:60`](../packages/host/webserver/src/index.ts)
```ts config-catalog
/** Web server listen and response-compression config. */
export interface Config {
/** Listen host; the two supported values are loopback and all-interfaces. */
host: '127.0.0.1' | '0.0.0.0'
/**
* Listen address: a concrete IPv4 or IPv6 literal of one local interface,
* for example the container's own Pod address from `hostname -i`. A loopback
* literal (any address in 127/8, `::1`, or a mapped form of either) keeps the
* server on this machine; any other literal serves the network that address
* belongs to over plain HTTP, because the carrier adds no TLS. The
* unspecified address — IPv4 any, IPv6 any, and the IPv4-mapped forms of
* IPv4 any — is rejected at load: it would expose the port on every interface
* at once.
*/
host: string
/** Listen port; zero requests an OS-assigned port. */
port: number
/** Response compression for socket-backed HTTP requests. @default 'none' */
@@ -4207,7 +4214,7 @@ export interface WebRuntimeConfig {
## `@deepseek-ai/dsh-web-app`
- `inject`: `webServer`
- `source`: [`packages/bundle/web-app/src/index.ts:46`](../packages/bundle/web-app/src/index.ts)
- `source`: [`packages/bundle/web-app/src/index.ts:43`](../packages/bundle/web-app/src/index.ts)
```ts config-catalog
/** Plugin config: composed deployment settings plus per-invocation command-line values. */
@@ -4226,13 +4233,11 @@ export interface Config {
/**
* 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 `/`. Advertisement only;
* see [public deployments](../README.md#public-deployments). Absent or YAML
* `null` advertises the loopback URL.
* `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.
*/
publicUrl?: string
/** Explicit `--trusted-host` authorities from this invocation. */
trustedHosts: string[]
}
```
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-web-app -->
@@ -4517,7 +4522,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-host-directory-picker-auto` | `webServer` · `loader` | [`packages/host/directory-picker-auto/src/index.ts`](../packages/host/directory-picker-auto/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) |
| `@deepseek-ai/dsh-llm` | — | [`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts) |
+6 -6
View File
@@ -22,11 +22,11 @@ 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:611`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:591`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:618`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:597`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:604`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `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) |
| `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`) | - |
@@ -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:34`](../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:35`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `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`) | - |
+6 -6
View File
@@ -24,11 +24,11 @@
| `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:611`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:591`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:618`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:597`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:604`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `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) |
| `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`) | - |
@@ -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:34`](../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:35`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `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`) | - |
+7 -6
View File
@@ -458,11 +458,6 @@ flowchart TD
pkg_credentials_local --> pkg_credentials
pkg_credentials_local --> pkg_home_paths
pkg_credentials_local --> pkg_launch_environment
pkg_host_directory_picker_auto --> pkg_client_ui_directory_picker_browse
pkg_host_directory_picker_auto --> pkg_client_ui_directory_picker_native
pkg_host_directory_picker_auto --> pkg_host_directory_picker_browse
pkg_host_directory_picker_auto --> pkg_host_directory_picker_native
pkg_host_directory_picker_auto --> pkg_host_webserver
pkg_anonymous_user_id --> pkg_brand
pkg_anonymous_user_id --> pkg_home_paths
pkg_lsp --> pkg_brand
@@ -499,6 +494,12 @@ flowchart TD
pkg_experimental_webworker_runtime --> pkg_client_modules
pkg_experimental_webworker_runtime --> pkg_host_webserver
pkg_skill_badge --> pkg_skill
pkg_host_directory_picker_auto --> pkg_client_connection
pkg_host_directory_picker_auto --> pkg_client_ui_directory_picker_browse
pkg_host_directory_picker_auto --> pkg_client_ui_directory_picker_native
pkg_host_directory_picker_auto --> pkg_host_directory_picker_browse
pkg_host_directory_picker_auto --> pkg_host_directory_picker_native
pkg_host_directory_picker_auto --> pkg_host_webserver
pkg_host_frontend_static --> pkg_client_connection
pkg_host_frontend_static --> pkg_host_webserver
pkg_persona --> pkg_system_prompt
@@ -1562,7 +1563,6 @@ flowchart TD
| [`computer-use`](../packages/computer-use/computer-use) | `computer-use` | [`brand`](../packages/util/brand) |
| [`authorization`](../packages/credentials/authorization) | `credentials` | [`credentials`](../packages/credentials/credentials), [`llm`](../packages/llm/llm) |
| [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment) |
| [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse), [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver) |
| [`anonymous-user-id`](../packages/identity/anonymous-user-id) | `identity` | [`brand`](../packages/util/brand), [`home-paths`](../packages/util/home-paths) |
| [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) |
| [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`storage`](../packages/storage/storage) |
@@ -1584,6 +1584,7 @@ flowchart TD
| [`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) |
| [`skill-badge`](../packages/experimental/skill-badge) | `experimental` | [`skill`](../packages/skill/skill) |
| [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`client-connection`](../packages/client/connection), [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse), [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver) |
| [`host-frontend-static`](../packages/host/frontend-static) | `host` | [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver) |
| [`persona`](../packages/preset/persona) | `preset` | [`system-prompt`](../packages/core/system-prompt) |
| [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
+7 -6
View File
@@ -460,11 +460,6 @@ flowchart TD
pkg_credentials_local --> pkg_credentials
pkg_credentials_local --> pkg_home_paths
pkg_credentials_local --> pkg_launch_environment
pkg_host_directory_picker_auto --> pkg_client_ui_directory_picker_browse
pkg_host_directory_picker_auto --> pkg_client_ui_directory_picker_native
pkg_host_directory_picker_auto --> pkg_host_directory_picker_browse
pkg_host_directory_picker_auto --> pkg_host_directory_picker_native
pkg_host_directory_picker_auto --> pkg_host_webserver
pkg_anonymous_user_id --> pkg_brand
pkg_anonymous_user_id --> pkg_home_paths
pkg_lsp --> pkg_brand
@@ -501,6 +496,12 @@ flowchart TD
pkg_experimental_webworker_runtime --> pkg_client_modules
pkg_experimental_webworker_runtime --> pkg_host_webserver
pkg_skill_badge --> pkg_skill
pkg_host_directory_picker_auto --> pkg_client_connection
pkg_host_directory_picker_auto --> pkg_client_ui_directory_picker_browse
pkg_host_directory_picker_auto --> pkg_client_ui_directory_picker_native
pkg_host_directory_picker_auto --> pkg_host_directory_picker_browse
pkg_host_directory_picker_auto --> pkg_host_directory_picker_native
pkg_host_directory_picker_auto --> pkg_host_webserver
pkg_host_frontend_static --> pkg_client_connection
pkg_host_frontend_static --> pkg_host_webserver
pkg_persona --> pkg_system_prompt
@@ -1564,7 +1565,6 @@ flowchart TD
| [`computer-use`](../packages/computer-use/computer-use) | `computer-use` | [`brand`](../packages/util/brand) |
| [`authorization`](../packages/credentials/authorization) | `credentials` | [`credentials`](../packages/credentials/credentials), [`llm`](../packages/llm/llm) |
| [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment) |
| [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse), [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver) |
| [`anonymous-user-id`](../packages/identity/anonymous-user-id) | `identity` | [`brand`](../packages/util/brand), [`home-paths`](../packages/util/home-paths) |
| [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) |
| [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`storage`](../packages/storage/storage) |
@@ -1586,6 +1586,7 @@ flowchart TD
| [`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) |
| [`skill-badge`](../packages/experimental/skill-badge) | `experimental` | [`skill`](../packages/skill/skill) |
| [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`client-connection`](../packages/client/connection), [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse), [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver) |
| [`host-frontend-static`](../packages/host/frontend-static) | `host` | [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver) |
| [`persona`](../packages/preset/persona) | `preset` | [`system-prompt`](../packages/core/system-prompt) |
| [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
+4 -4
View File
@@ -1338,8 +1338,8 @@
en: 2786fef560851bb4
zh: 5d787475356b2606
/session-persistence-event-catalog/resolved-persistence-types/string:
en: fe700e5ee40054b1
zh: 1e350d5ddf23122c
en: 39a3e2f91cd3d4ca
zh: d45607166d7367e8
/session-persistence-event-catalog/resolved-persistence-types/string~2:
en: 0089620bf4638263
zh: 1922bef1713094bb
@@ -1824,8 +1824,8 @@
en: 79db83dd2c430faf
zh: 173f568241f5ce86
/session-persistence-event-catalog/resolved-persistence-types/kind-user:
en: aacb77f1cead58ba
zh: e6ce9dac4bdd07b7
en: cb5d3fbab4debac6
zh: 72add55254356149
/session-persistence-event-catalog/resolved-persistence-types/kind-user-approval:
en: a52d2dfbaf844127
zh: 4c5306ca40f20873
+2 -2
View File
@@ -5548,7 +5548,7 @@ Array of `number`.
SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b`
Sources: [`packages/api/session-controller/src/types.ts:398`](../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/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)
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/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`
@@ -7519,7 +7519,7 @@ Sources: [`packages/core/tools/src/index.ts:35`](../packages/core/tools/src/inde
SHA-256: `6a4f72e2e179e17b922f2a9392c0e1c8f8c707f32494372454850a3eb184a6e7`
Sources: [`packages/api/session-controller/src/types.ts:403`](../packages/api/session-controller/src/types.ts)
Sources: [`packages/api/session-controller/src/types.ts:408`](../packages/api/session-controller/src/types.ts)
| Property | Presence | Type |
|---|---|---|
+2 -2
View File
@@ -5550,7 +5550,7 @@ SHA-256: `5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78`
SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b`
来源:[`packages/api/session-controller/src/types.ts:398`](../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/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)
来源:[`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/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`
@@ -7521,7 +7521,7 @@ SHA-256: `8fcb0b44a575e998665c053643d1ab04c82f39253ebd190ac0c975b78e40b543`
SHA-256: `6a4f72e2e179e17b922f2a9392c0e1c8f8c707f32494372454850a3eb184a6e7`
来源:[`packages/api/session-controller/src/types.ts:403`](../packages/api/session-controller/src/types.ts)
来源:[`packages/api/session-controller/src/types.ts:408`](../packages/api/session-controller/src/types.ts)
| 属性 | 存在性 | 类型 |
|---|---|---|
+2 -2
View File
@@ -23448,7 +23448,7 @@
"packages/workflow/workflow/src/types.ts#WorkflowRunId"
],
"sources": [
"packages/api/session-controller/src/types.ts:398",
"packages/api/session-controller/src/types.ts:403",
"packages/attachment/attachment/src/brand.ts:6",
"packages/compaction/compaction/src/brand.ts:4",
"packages/core/session/src/types.ts:20",
@@ -23908,7 +23908,7 @@
"digest": "6a4f72e2e179e17b922f2a9392c0e1c8f8c707f32494372454850a3eb184a6e7",
"names": [],
"sources": [
"packages/api/session-controller/src/types.ts:403"
"packages/api/session-controller/src/types.ts:408"
]
},
{
+2
View File
@@ -311,6 +311,8 @@ type SessionPersistenceRevision = Branded<'SessionPersistenceRevision'>
interface SessionPersistenceSnapshot {
/** Detached metadata for one stored session. */
readonly header: SessionHeader
/** Header-only format classification; absent without versioned artifact metadata. */
readonly formatStatus?: 'current' | 'migration-required'
/** Opaque change token; see {@link SessionPersistence.stat}. */
readonly revision: SessionPersistenceRevision
/** Logical event count, when the backend can provide it cheaply from metadata; otherwise absent. */
+2
View File
@@ -311,6 +311,8 @@ type SessionPersistenceRevision = Branded<'SessionPersistenceRevision'>
interface SessionPersistenceSnapshot {
/** Detached metadata for one stored session. */
readonly header: SessionHeader
/** Header-only format classification; absent without versioned artifact metadata. */
readonly formatStatus?: 'current' | 'migration-required'
/** Opaque change token; see {@link SessionPersistence.stat}. */
readonly revision: SessionPersistenceRevision
/** Logical event count, when the backend can provide it cheaply from metadata; otherwise absent. */
+2
View File
@@ -24,6 +24,8 @@ interface SessionRecord {
live: boolean
/** Whether the active persistence backend currently lists the id, including a created-but-unmaterialized session it already observes. */
persisted: boolean
/** Current for live Sessions; otherwise the persistence format status when reported. */
formatStatus?: 'current' | 'migration-required'
}
```
+2
View File
@@ -24,6 +24,8 @@ interface SessionRecord {
live: boolean
/** Whether the active persistence backend currently lists the id, including a created-but-unmaterialized session it already observes. */
persisted: boolean
/** Current for live Sessions; otherwise the persistence format status when reported. */
formatStatus?: 'current' | 'migration-required'
}
```
+5 -5
View File
@@ -922,10 +922,10 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' |
* Fork one cold-readable exact event prefix into a new Session. An omitted
* boundary selects the latest completed-turn prefix; an open cut receives
* synthetic fork closers.
* @param request - source Session and optional exact inclusive event boundary.
* @param request - source Session, optional exact inclusive event boundary, and migration preflight choice.
* @returns the new Session identity.
*/
@Remote('fork') fork(request: SessionForkRequest): Promise<SessionForkValue>
@Remote('fork') async fork(request: SessionForkRequest): Promise<SessionForkValue>
/**
* Admit one prompt after explicitly resuming its Session.
@@ -974,10 +974,10 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' |
@Remote({ mode: 'stream' }) follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable<SessionFollowFrame>
/**
* Read all registered projections without activating an Agent.
* Read exact projections, returning cached hints only when migration is required.
* @param request - Session whose current values are required.
* @param signal - cancellation for the Session observation.
* @returns complete baseline, or null when the Session does not exist.
* @param signal - cancellation for the Session read.
* @returns a sequenced baseline, cached hints when migration is deferred, or null when absent.
*/
@Remote('projections') async projections(request: SessionProjectionsRequest, signal: AbortSignal): Promise<SessionProjectionsValue>
+5 -5
View File
@@ -926,10 +926,10 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' |
* Fork one cold-readable exact event prefix into a new Session. An omitted
* boundary selects the latest completed-turn prefix; an open cut receives
* synthetic fork closers.
* @param request - source Session and optional exact inclusive event boundary.
* @param request - source Session, optional exact inclusive event boundary, and migration preflight choice.
* @returns the new Session identity.
*/
@Remote('fork') fork(request: SessionForkRequest): Promise<SessionForkValue>
@Remote('fork') async fork(request: SessionForkRequest): Promise<SessionForkValue>
/**
* Admit one prompt after explicitly resuming its Session.
@@ -978,10 +978,10 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' |
@Remote({ mode: 'stream' }) follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable<SessionFollowFrame>
/**
* Read all registered projections without activating an Agent.
* Read exact projections, returning cached hints only when migration is required.
* @param request - Session whose current values are required.
* @param signal - cancellation for the Session observation.
* @returns complete baseline, or null when the Session does not exist.
* @param signal - cancellation for the Session read.
* @returns a sequenced baseline, cached hints when migration is deferred, or null when absent.
*/
@Remote('projections') async projections(request: SessionProjectionsRequest, signal: AbortSignal): Promise<SessionProjectionsValue>
+2 -2
View File
@@ -9,8 +9,8 @@
en: 940bc78642c990b0
zh: 901d2ba7e354a766
/http-server/config:
en: 30118110a0ea3702
zh: 940f2b69d2dd54af
en: d5bd005d51a49acd
zh: 9b652e39a41a4b6b
/http-server/the-service:
en: e304734ab2143438
zh: 065f9c2acbbb07ca
+12 -3
View File
@@ -31,8 +31,17 @@ Match order is fixed: exact table first, then longest matching prefix, then the
```ts type-equiv
/** Web server listen and response-compression config. */
interface Config {
/** Listen host; the two supported values are loopback and all-interfaces. */
host: '127.0.0.1' | '0.0.0.0'
/**
* Listen address: a concrete IPv4 or IPv6 literal of one local interface,
* for example the container's own Pod address from `hostname -i`. A loopback
* literal (any address in 127/8, `::1`, or a mapped form of either) keeps the
* server on this machine; any other literal serves the network that address
* belongs to over plain HTTP, because the carrier adds no TLS. The
* unspecified address — IPv4 any, IPv6 any, and the IPv4-mapped forms of
* IPv4 any — is rejected at load: it would expose the port on every interface
* at once.
*/
host: string
/** Listen port; zero requests an OS-assigned port. */
port: number
/** Response compression for socket-backed HTTP requests. @default 'none' */
@@ -44,7 +53,7 @@ interface Config {
}
```
`host` accepts only `127.0.0.1` (default posture) and `0.0.0.0` (deliberate network exposure). The carrier itself owns no TLS, authentication, or Origin policy, so a non-loopback bind exposes the server unless the composition supplies those controls. `compression` defaults to `none`; the shipped Web bundle selects gzip level 1 with a 1024-byte threshold. The shipped `dsh web` command selects loopback and rejects `--host 0.0.0.0`; its Connection plugin supplies Host/Origin checks plus browser-session authentication for every Host API route and stream. Other compositions own their bind and route-authentication policy. The dist location is an assembly fact of the frontend plugin that claims the seat.
No composition can bind every interface at once: the unspecified address is rejected at load. The exported `isWildcardHost` and `isLoopbackHost` classify a bind address from its parsed value, and an IPv6 `%zone` is kept for `listen` while classification reads the address alone. The carrier itself owns no TLS, authentication, or Origin policy, so a non-loopback bind serves plain HTTP on that network unless the composition supplies those controls. `compression` defaults to `none`; the shipped Web bundle selects gzip level 1 with a 1024-byte threshold. The shipped `dsh web` command selects loopback by default and accepts one concrete `--host` address, warning at startup when the bind is not loopback; its Connection plugin supplies Host/Origin checks plus browser-session authentication for every Host API route and stream, and the bind address plus `--trusted-host` govern which browser Host is accepted; `--public-url` changes only the advertised URL. Other compositions own their bind and route-authentication policy. The dist location is an assembly fact of the frontend plugin that claims the seat.
## The service
+12 -3
View File
@@ -31,8 +31,17 @@ interface WebRoute {
```ts type-equiv
/** Web server listen and response-compression config. */
interface Config {
/** Listen host; the two supported values are loopback and all-interfaces. */
host: '127.0.0.1' | '0.0.0.0'
/**
* Listen address: a concrete IPv4 or IPv6 literal of one local interface,
* for example the container's own Pod address from `hostname -i`. A loopback
* literal (any address in 127/8, `::1`, or a mapped form of either) keeps the
* server on this machine; any other literal serves the network that address
* belongs to over plain HTTP, because the carrier adds no TLS. The
* unspecified address — IPv4 any, IPv6 any, and the IPv4-mapped forms of
* IPv4 any — is rejected at load: it would expose the port on every interface
* at once.
*/
host: string
/** Listen port; zero requests an OS-assigned port. */
port: number
/** Response compression for socket-backed HTTP requests. @default 'none' */
@@ -44,7 +53,7 @@ interface Config {
}
```
`host` 只接受 `127.0.0.1`(默认姿态)和 `0.0.0.0`(刻意的网络暴露)。载体本身不拥有 TLS、认证或 Origin 策略,因此绑定到非回环地址会暴露服务器,除非组合层提供这些控制。`compression` 默认为 `none`;随附的 Web 组合选择 gzip level 1 和 1024 字节阈值。随附的 `dsh web` 命令选择 loopback 并拒绝 `--host 0.0.0.0`;其 Connection 插件为每个 Host API route 与 stream 提供 Host/Origin 校验和浏览器会话认证。其他组合自行拥有绑定与路由认证策略。dist 位置是认领席位的前端插件的组装事实。
没有组合能一次性绑定所有网络接口:未指定地址在加载时即被拒绝。导出的 `isWildcardHost` 与 `isLoopbackHost` 按解析后的地址值分类,IPv6 的 `%zone` 会保留给 `listen`,而分类只读取地址本身。载体本身不拥有 TLS、认证或 Origin 策略,因此绑定到非回环地址时会在该网络上提供明文 HTTP,除非组合层提供这些控制。`compression` 默认为 `none`;随附的 Web 组合选择 gzip level 1 和 1024 字节阈值。随附的 `dsh web` 命令默认选择 loopback 并接受一个具体的 `--host` 地址,绑定非 loopback 时会在启动时警告;其 Connection 插件为每个 Host API route 与 stream 提供 Host/Origin 校验和浏览器会话认证,绑定地址加 `--trusted-host` 决定接受哪个浏览器 Host;`--public-url` 只改变公告的 URL。其他组合自行拥有绑定与路由认证策略。dist 位置是认领席位的前端插件的组装事实。
## 服务
@@ -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/web-listener-trust-config/guide.md
/:
en: af5832d4e9eece99
zh: 8f97dad6921ed8a0
/web-listener-and-trust-configuration-moves-to-webstartup:
en: de3788e700062659
zh: 9def0e8f38896304
/web-listener-and-trust-configuration-moves-to-webstartup/change:
en: 19f901fb2974aab5
zh: b4ef886f85b61637
/web-listener-and-trust-configuration-moves-to-webstartup/migration:
en: 30917eb37ead3e10
zh: fd1b26eada570dd3
@@ -0,0 +1,36 @@
---
kind: upgrade-guide
description: "The Web profile replaces the `webRuntime` service and the `web-runtime` row's `trustedHosts` config with `webStartup`, and rejects a wildcard listener host."
---
# Web listener and trust configuration moves to `webStartup`
English | [中文](guide.zh.md)
## Change
The Web profile no longer provides the `webRuntime` service, and the `web-runtime` row no longer declares a `trustedHosts` config value. A profile, overlay, or `--patch` file that injects `webRuntime` now waits for a service that never mounts, and an expression reading `ctx.webRuntime.trustedHosts` fails to evaluate, so the required Connection cannot start.
The `web-startup` row provides one `webStartup` service carrying the invocation's `--trusted-host` authorities, and the `connection` row reads them from `ctx.webStartup.trustedHosts`. The listener host must also be one concrete IPv4 or IPv6 address of a local interface: `host: 0.0.0.0`, `--host ::`, and every other unspecified address are rejected at load, because binding every interface exposes the port to the network.
## Migration
1. Replace `inject: [webRuntime]` with `inject: [webStartup]` on every row that needs invocation trust.
2. Replace `ctx.webRuntime.trustedHosts` expressions with `ctx.webStartup.trustedHosts`, and drop `ctx.webRuntime.lanAddresses`, which no longer exists.
3. Rewrite the `connection` overlay for `webStartup`, and move any `trustedHosts` value that the `web-runtime` row carried into its expression. A patch replaces the matched row's whole `config`, so restate that config completely, and write an array-valued `!!js` expression as a quoted scalar:
```yaml
# before
- id: connection
inject: [webRuntime]
config:
trustedHosts: !!js "['app.internal', ...ctx.webRuntime.trustedHosts]"
# after
- id: connection
inject: [webStartup]
config:
trustedHosts: !!js "['app.internal', ...ctx.webStartup.trustedHosts]"
```
4. Replace a wildcard `host` with one concrete address of a local interface. The bind IP itself is accepted by the Host fence with no `--trusted-host` entry; a proxy or DNS authority still needs one.
5. Boot with the migrated overlay: `dsh --profile web --patch ./extra.yml --no-open` must print the `dsh web:` URL line and serve. A row still waiting on `webRuntime` leaves the required Connection pending and reports an activation failure, and a rejected `host` fails at load. `dsh --profile web --patch ./extra.yml --dump-config` prints the composed patch before boot. [The Web bundle README](../../../../packages/bundle/web-app/README.md) owns the composed rows.
@@ -0,0 +1,36 @@
---
kind: upgrade-guide
description: "Web profile 用 `webStartup` 取代 `webRuntime` 服务与 `web-runtime` 行的 `trustedHosts` 配置,并拒绝通配监听地址。"
---
# Web 监听与信任配置迁移到 `webStartup`
[English](guide.md) | 中文
## 变更
Web profile 不再提供 `webRuntime` 服务,`web-runtime` 行也不再声明 `trustedHosts` 配置值。仍注入 `webRuntime` 的 profile、overlay 或 `--patch` 文件会等待一个永不挂载的服务,读取 `ctx.webRuntime.trustedHosts` 的表达式则会求值失败,导致必需的 Connection 无法启动。
`web-startup` 行提供唯一的 `webStartup` 服务,携带本次调用的 `--trusted-host` authority;`connection` 行通过 `ctx.webStartup.trustedHosts` 读取它们。监听 host 现在也必须是本机网卡的一个具体 IPv4 或 IPv6 地址:`host: 0.0.0.0`、`--host ::` 及其他未指定地址都会在加载时被拒绝,因为绑定所有接口会把端口暴露到网络。
## 迁移
1. 将所有需要调用信任的行的 `inject: [webRuntime]` 换成 `inject: [webStartup]`。
2. 将 `ctx.webRuntime.trustedHosts` 表达式换成 `ctx.webStartup.trustedHosts`,并删除已不存在的 `ctx.webRuntime.lanAddresses`。
3. 按 `webStartup` 改写 `connection` overlay,并把原先由 `web-runtime` 行携带的 `trustedHosts` 值并入其表达式。patch 会整体替换所匹配行的 `config`,因此必须完整重述该配置;数组值的 `!!js` 表达式要写成带引号的标量:
```yaml
# before
- id: connection
inject: [webRuntime]
config:
trustedHosts: !!js "['app.internal', ...ctx.webRuntime.trustedHosts]"
# after
- id: connection
inject: [webStartup]
config:
trustedHosts: !!js "['app.internal', ...ctx.webStartup.trustedHosts]"
```
4. 将通配 `host` 换成本机网卡的一个具体地址。所绑定的 IP 会被 Host 栅栏直接接受,无需 `--trusted-host`;代理或 DNS authority 仍需显式配置。
5. 使用迁移后的 overlay 启动:`dsh --profile web --patch ./extra.yml --no-open` 必须打印 `dsh web:` URL 行并正常服务。仍在等待 `webRuntime` 的行会让必需的 Connection 保持 pending 并报告激活失败;被拒绝的 `host` 会在加载时失败。`dsh --profile web --patch ./extra.yml --dump-config` 可在启动前打印组合后的 patch。[Web 组合包 README](../../../../packages/bundle/web-app/README.zh.md)说明这些行。
+4 -4
View File
@@ -12,8 +12,8 @@
en: 8dd9d910a93fe54d
zh: 1e9e21d9949c8ab2
/publish-the-web-ui-behind-a-reverse-proxy/trust-the-authority-browsers-use:
en: 907b5f90c9860733
zh: dcbffd3f00d6c64f
en: b85ae06cb83c23fe
zh: 4197ab824ff5a6f8
/publish-the-web-ui-behind-a-reverse-proxy/secure-the-external-leg:
en: a833880da5c8bbf4
zh: 6f9bd565b7266241
en: ddcdd76a0739a38c
zh: 07940b66df2335f5
+2 -2
View File
@@ -22,7 +22,7 @@ dsh --profile web --public-url https://app.example/ui/ --trusted-host app.exampl
## Trust the authority browsers use
The fence accepts loopback plus every authority `--trusted-host` names. A browser that reaches the deployment under any other authority gets 403 for every API call, however correct the proxy is, so name the browser-visible authority with `--trusted-host`; advertising it with `--public-url` is display only and does not admit it. An entry without a port matches any port, which suits a tunnel that binds a different one each time. The fence only admits the request; the launch token in the printed URL and the signed session cookie authenticate it.
The fence accepts loopback, the listener's own bind address, and every authority `--trusted-host` names. A browser that reaches the deployment under any other authority gets 403 for every API call, however correct the proxy is, so name the browser-visible authority with `--trusted-host`; advertising it with `--public-url` is display only and does not admit it. An entry without a port matches any port, which suits a tunnel that binds a different one each time. The fence only admits the request; the launch token in the printed URL and the signed session cookie authenticate it.
Neither the advertised URL nor the fence protects the listening port itself, so restrict the port to the trusted proxy or network.
@@ -30,4 +30,4 @@ Neither the advertised URL nor the fence protects the listening port itself, so
Terminate TLS at the proxy. An `http://` advertised root sends the launch token and the session cookie unencrypted, and an `https://` root encrypts only the browser-to-proxy leg. The printed URL carries a process credential, so share it only with intended users.
The [Web app reference](../../../packages/bundle/web-app/README.md#public-deployments) owns the `--public-url` and `--trusted-host` command-line contract and the `publicUrl` and `trustedHosts` fields.
The [Web app reference](../../../packages/bundle/web-app/README.md#public-deployments) documents the `--public-url` and `--trusted-host` command-line options. In the Web profile, `publicUrl` configures the `web-runtime` row; `trustedHosts` configures the [Connection row](../../../packages/client/connection/README.md).
+2 -2
View File
@@ -22,7 +22,7 @@ dsh --profile web --public-url https://app.example/ui/ --trusted-host app.exampl
## 信任浏览器使用的 authority
栅栏接受 loopback,以及每个由 `--trusted-host` 点名的 authority。浏览器若以其他任何 authority 访问部署,无论代理多么正确,每个 API 调用都会得到 403,因此请用 `--trusted-host` 点名浏览器可见的 authority;用 `--public-url` 公告它只是展示,并不等于接纳。不带端口的条目匹配任意端口,适合每次绑定不同端口的隧道。栅栏只放行请求;打印 URL 中的启动 token 与签名会话 cookie 才完成认证。
栅栏接受 loopback、监听器自身的绑定地址,以及每个由 `--trusted-host` 点名的 authority。浏览器若以其他任何 authority 访问部署,无论代理多么正确,每个 API 调用都会得到 403,因此请用 `--trusted-host` 点名浏览器可见的 authority;用 `--public-url` 公告它只是展示,并不等于接纳。不带端口的条目匹配任意端口,适合每次绑定不同端口的隧道。栅栏只放行请求;打印 URL 中的启动 token 与签名会话 cookie 才完成认证。
无论是公告 URL 还是栅栏都不保护监听端口本身,因此请把端口限制在可信代理或网络内。
@@ -30,4 +30,4 @@ dsh --profile web --public-url https://app.example/ui/ --trusted-host app.exampl
在代理处终止 TLS。`http://` 公告根会以明文发送启动 token 与会话 cookie,而 `https://` 根只加密浏览器到代理这一段。打印的 URL 携带进程凭据,只应与预期用户分享。
[Web 应用参考](../../../packages/bundle/web-app/README.zh.md#public-deployments)负责 `--public-url` 与 `--trusted-host` 的命令行约定,以及 `publicUrl` 与 `trustedHosts` 字段。
[Web 应用参考](../../../packages/bundle/web-app/README.zh.md#public-deployments)说明 `--public-url` 与 `--trusted-host` 命令行选项。在 Web profile 中,`publicUrl` 配置在 `web-runtime` 行,`trustedHosts` 配置在 [Connection 行](../../../packages/client/connection/README.zh.md)。
@@ -15,8 +15,8 @@
en: 1fdf707322fb6996
zh: 0204f7e9d4aa3cce
/session-controller/use-this-package:
en: ae2715276ec70675
zh: 0937d1335c21eff2
en: d26a58a8f2406cd5
zh: 82135eceb9adb628
/session-controller/client-references:
en: 41bdda1ac32df558
zh: aa5d0e325924066b
+3 -1
View File
@@ -31,7 +31,9 @@ History pages and follow opening snapshots carry one `{ type: 'event', event: Se
The Client journal validates current Session event envelopes before publishing follow snapshots, live entries, or history pages. It reuses the browser-safe Session validators for required surface markers, exact replacement endpoints, earlier unique source seqs, embedded Assistant provider metadata, request-header omissions, and tool-error consistency. Invalid records fail without field stripping or normalization; range membership and source existence remain durable-log checks on the Host.
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` reads one complete baseline through a live-preferred Session observation without activating an Agent. It returns null for a missing Session and serves any registered projection keys. 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. Initial reads and live projection frames use the same sequence ordering.
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.
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.
+3 -1
View File
@@ -31,7 +31,9 @@ kind: "package-reference"
Client journal 在发布 follow 快照、live entry 或历史页之前验证当前 Session 事件 envelope。它复用浏览器安全的 Session validator,检查必需的 surface marker、精确的 replacement endpoint、更早且唯一的 source seq、内嵌 Assistant 提供方元数据、request header 可选字段的省略规则以及工具错误一致性。无效 record 直接失败,不删除字段或归一化;范围成员与来源存在性仍由 Host 的持久日志检查。
每个 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` 通过一次 live-preferred Session observation 读取完整基线,不激活 Agent。Session 不存在时返回 null,并可提供任意已注册的 projection key。Client 通过 `projectionsBySession` 暴露共享值和显式读取状态,由领域选择自身的 key。Session 列表摘要携带 `agentAvailable`,通过已有摘要与状态事件更新,与持久化 projection 相互独立。初始读取与实时 projection 帧使用相同的序号排序规则。
每个 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 帧使用相同的序号排序规则;缓存读取只补充提示,不替换有序值。
列表行在状态可用时携带 `formatStatus`,Client 无需请求其投影即可识别需要迁移的 Session。
Host 仅在配置的工作时间片耗尽时,于完整条目之间让出事件循环;剩余工作较少时直接返回,不强制让出。每行及每次让出之后检查请求取消,取消时拒绝返回部分结果。每行同步计算;列表不承诺跨 Session 的统一快照。已经进入冷态队列的 Session 若在让出期间接入,仍返回缓存行:Client 对进行中请求的变更重放会纠正其可用性,带序号的投影会覆盖缓存提示。
@@ -144,6 +144,7 @@
"@deepseek-ai/dsh-scope": "workspace:*",
"@deepseek-ai/dsh-session": "workspace:*",
"@deepseek-ai/dsh-session-persistence": "workspace:*",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:*",
"@deepseek-ai/dsh-session-projection": "workspace:*",
"@deepseek-ai/dsh-session-projection-cache": "workspace:*",
"@deepseek-ai/dsh-session-query": "workspace:*",
@@ -91,12 +91,12 @@ export interface ISessions {
/**
* Resolve an already discovered direct-parent address without opening it.
* @param id - possible addressed child id.
* @returns a retained or loaded-catalog address, without retaining a new selection or scope.
* @returns a retained, catalog-derived, or unknown-mode listed address, without retaining a new selection or scope.
*/
subagentAddress(id: SessionId): SubagentAddress | undefined
/**
* Load all Session projections once per connection; retry an unsuccessful initial read.
* Read projections without starting migration; retry failed or migration-deferred reads.
* @param sessionId - Session to inspect without opening its conversation.
* @returns completion of the current or newly started refresh.
*/
@@ -134,6 +134,8 @@ export interface ISessions {
atSeq?: number
increaseTitle?: boolean
onCreated?: (childId: SessionId) => void
/** False refuses source migration; omission preserves the Host default. */
allowMigration?: boolean
}): Promise<SessionId>
/**
* Borrow an already-retained Agent-scoped Context without extending its lifetime.
@@ -27,6 +27,8 @@ export interface SessionListEntry {
parentSessionId?: SessionId
/** Coarse durable origin for navigation filtering; not a continuation capability. */
origin?: 'subagent'
/** Format classification supplied by the Host list. */
formatStatus?: SessionSummary['formatStatus']
cwd?: string
/** Current host-computed projection values for list consumers. */
projectionValues?: Readonly<Partial<SessionProjectionMap>>
@@ -61,7 +61,7 @@ export interface SessionListSnapshot {
/** Shared projection values and the lifecycle of their explicit baseline read. */
export interface SessionProjectionSnapshot {
readonly values: Readonly<Partial<SessionProjectionMap>>
readonly state: 'idle' | 'loading' | 'ready' | 'error'
readonly state: 'idle' | 'loading' | 'ready' | 'migration-required' | 'error'
readonly error: RemoteFailure | null
}
@@ -153,8 +153,9 @@ export class SessionManager {
/**
* Resolve an address for breadcrumb navigation without retaining transport authority.
* @param sessionId - possible child id in an already-loaded catalog.
* @returns A retained or catalog-derived direct-parent address.
* @param sessionId - possible child id in received Session metadata.
* @returns A retained address, then a catalog address, then an unknown-mode address
* from a listed subagent header; undefined without that evidence.
*/
subagentAddress(sessionId: SessionId): SubagentAddress | undefined {
const retained = this.addresses.get(sessionId)
@@ -170,7 +171,13 @@ export class SessionManager {
}
}
}
return undefined
const summary = this.summaries.find(summary => summary.sessionId === sessionId)
if (summary?.origin !== 'subagent' || summary.parentSessionId === undefined) return undefined
return {
parentSessionId: summary.parentSessionId,
childSessionId: sessionId,
mode: 'unknown',
}
}
// ---- Instance management ----
@@ -332,7 +339,7 @@ export class SessionManager {
}
/**
* Load a complete projection baseline once per connection; retry unsuccessful reads.
* Read projections without starting migration; retry failed or migration-deferred reads.
* @param sessionId - Session to inspect without opening its conversation.
* @returns completion of the current or newly started read.
*/
@@ -350,13 +357,26 @@ export class SessionManager {
const result = await this.remote.session.projections({ sessionId }, controller.signal)
if (controller.signal.aborted) return
if (result.ok) {
if (result.value !== null) {
store.seed({ ...result.value, asOfSeq: sessionSeqCursor(result.value.asOfSeq) })
const value = result.value
if (value !== null) {
switch (value.kind) {
case 'sequenced':
store.seed({ values: value.values, asOfSeq: sessionSeqCursor(value.asOfSeq) })
break
case 'migration-required':
store.applyCached(value.values)
break
default:
assertNever(value, 'session projections result')
}
} else if (store.values() === initialValues) {
// A later frame proves existence independently of an earlier missing read.
store.clear()
}
this.projectionLoads.set(sessionId, { state: 'ready', error: null })
this.projectionLoads.set(sessionId, {
state: value?.kind === 'migration-required' ? 'migration-required' : 'ready',
error: null,
})
} else {
this.projectionLoads.set(sessionId, { state: 'error', error: result.error })
}
@@ -526,16 +546,17 @@ export class SessionManager {
* preserve it or lower it after an exact cut before the first `turn/start`;
* lineage rides parentSessionId. A child published before Workspace
* attachment fails is also reconciled into the list.
* @param opts - source session and the optional exact inclusive boundary seq.
* @param opts - source session, optional exact inclusive boundary seq, and permission to start migration.
* @returns the fork result (the child session id).
*/
async fork(
opts: { sessionId: SessionId; atSeq?: SessionSeq },
opts: { sessionId: SessionId; atSeq?: SessionSeq; allowMigration?: boolean },
): Promise<RemoteResult<{ sessionId: SessionId }>> {
const source = this.summaries.find(s => s.sessionId === opts.sessionId)
const result = await this.remote.session.fork({
sessionId: opts.sessionId,
...opts.atSeq === undefined ? {} : { atSeq: opts.atSeq },
...opts.allowMigration === undefined ? {} : { allowMigration: opts.allowMigration },
})
const childId = result.ok
? result.value.sessionId
@@ -774,6 +795,7 @@ export class SessionManager {
&& prev.blank === entry.blank
&& prev.parentSessionId === entry.parentSessionId && prev.cwd === entry.cwd
&& prev.origin === entry.origin && prev.title === entry.title && prev.depth === entry.depth
&& prev.formatStatus === entry.formatStatus
&& prev.projectionValues === entry.projectionValues
) return prev
this.entryCache.set(entry.sessionId, entry)
@@ -813,6 +835,7 @@ function applyMutation(summaries: readonly SessionSummary[], mutation: SessionLi
...(mutation.kind === 'upsert' ? {
agentAvailable: mutation.summary.agentAvailable,
running: mutation.summary.running,
...(mutation.summary.formatStatus === undefined ? {} : { formatStatus: mutation.summary.formatStatus }),
} : {}),
...(existing.cwd === undefined && mutation.summary.cwd !== undefined ? { cwd: mutation.summary.cwd } : {}),
...(existing.parentSessionId === undefined && mutation.summary.parentSessionId !== undefined
@@ -823,6 +846,7 @@ function applyMutation(summaries: readonly SessionSummary[], mutation: SessionLi
if (filled.cwd === existing.cwd && filled.parentSessionId === existing.parentSessionId
&& filled.origin === existing.origin && filled.blank === existing.blank
&& filled.agentAvailable === existing.agentAvailable && filled.running === existing.running
&& filled.formatStatus === existing.formatStatus
) return [...summaries]
return summaries.map(summary => summary.sessionId === mutation.summary.sessionId ? filled : summary)
}
@@ -6,8 +6,8 @@
* (`{ value, seq }`) comes from the connected Host — a follow opening
* baseline, a Session Controller `projection` frame, a list block the Host
* computed for an attached Session — and merges under **higher seq wins**
* against other sequenced rows. A `cached` row (`{ value }`) comes from the
* session list's view of the persisted checkpoint, carries no comparable
* against other sequenced rows. A `cached` row (`{ value }`) comes from
* a Host view of the persisted checkpoint, carries no comparable
* seq, and yields to every sequenced write. No client-side domain folding
* exists: a domain ships projection support with zero client code. Per-key
* bare observable faces feed `useProjection` (ui-renderer binds them).
@@ -166,7 +166,7 @@ export class ProjectionValueStore {
}
/**
* Fill keys from a session-list block the Host labeled `cached`: a zero-I/O
* Fill keys from a Host block labeled `cached`: a zero-I/O
* view of the persisted checkpoint. A cached value lands only where no
* sequenced row exists: a connected Session has already answered for such
* a key, and the list's view of the persisted checkpoint cannot be newer
@@ -21,6 +21,7 @@ import { SessionManager } from './manager.ts'
import type { SessionRemotes } from './remotes.ts'
import type { SessionListPhase, SessionSearchResultItem, SessionProjectionSnapshot } from './manager.ts'
import type { Session } from './session.ts'
import type { SessionListEntry } from './lineage.ts'
/** Session list row projected from the host list RPC plus live stream increments. */
export interface SessionSummary {
@@ -33,6 +34,8 @@ export interface SessionSummary {
parentId?: SessionId
/** Coarse durable origin for navigation filtering; not a continuation capability. */
origin?: 'subagent'
/** Format classification supplied by the Host list, when available. */
formatStatus?: SessionListEntry['formatStatus']
/** Host running state for `ids` members; a display fallback for other rows. */
running: boolean
/** Local ownership counts; Host metadata refreshes cannot overwrite them. */
@@ -332,14 +335,14 @@ export class ClientSessions implements ISessions {
* Resolve an already discovered direct-parent address without opening it.
* Feature plugins use this to avoid Agent-bound RPCs in persisted child views.
* @param id - possible addressed child id.
* @returns A retained or loaded-catalog address, without retaining a new selection or scope.
* @returns A retained, catalog-derived, or unknown-mode listed address, without retaining a new selection or scope.
*/
subagentAddress(id: SessionId): SubagentAddress | undefined {
return this.manager.subagentAddress(id)
}
/**
* Load all Session projections once per connection; retry an unsuccessful initial read.
* Read projections without starting migration; retry failed or migration-deferred reads.
* @param sessionId - Session to inspect without opening its conversation.
*/
refreshProjections(sessionId: SessionId): Promise<void> {
@@ -443,7 +446,7 @@ export class ClientSessions implements ISessions {
* seq (a real event seq the caller already knows; a cut inside an open
* turn is balanced Host-side with synthetic closers, and omission selects
* the latest completed-turn prefix), and whether to increment an
* inherited durable title before resolving.
* inherited durable title before resolving, plus permission to start source migration.
* @returns the child session id.
* @throws {SessionForkError} with the source id.
* @throws {Error} when a requested child-title rename fails after creation.
@@ -453,6 +456,7 @@ export class ClientSessions implements ISessions {
atSeq?: number
increaseTitle?: boolean
onCreated?: (childId: SessionId) => void
allowMigration?: boolean
}): Promise<SessionId> {
const sourceTitle = opts.increaseTitle
? this.list.getSnapshot().byId[opts.sessionId]?.title
@@ -460,6 +464,7 @@ export class ClientSessions implements ISessions {
const result = await this.manager.fork({
sessionId: opts.sessionId,
...(opts.atSeq === undefined ? {} : { atSeq: SessionSeq(opts.atSeq) }),
...(opts.allowMigration === undefined ? {} : { allowMigration: opts.allowMigration }),
})
if (!result.ok) throw new SessionForkError(result.error, opts.sessionId)
this.projectList()
@@ -622,6 +627,7 @@ export class ClientSessions implements ISessions {
retainedBy: this.retentionSnapshot(entry.sessionId).retainedBy,
blank: entry.blank,
updatedAt: entry.updatedAt,
formatStatus: entry.formatStatus,
...(entry.projectionValues === undefined
? {}
: { projectionValues: entry.projectionValues }),
+40 -9
View File
@@ -11,6 +11,7 @@ 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 {
@@ -415,11 +416,19 @@ export class SessionController extends TypertRemoteService {
* Fork one cold-readable exact event prefix into a new Session. An omitted
* boundary selects the latest completed-turn prefix; an open cut receives
* synthetic fork closers.
* @param request - source Session and optional exact inclusive event boundary.
* @param request - source Session, optional exact inclusive event boundary, and migration preflight choice.
* @returns the new Session identity.
*/
@Remote('fork')
fork(request: SessionForkRequest): Promise<SessionForkValue> {
async fork(request: SessionForkRequest): Promise<SessionForkValue> {
if (request.allowMigration === false && this.ctx.sessions.get(request.sessionId) === undefined) {
const stored = await this.ctx.get('sessionPersistence')?.stat(request.sessionId)
if (stored?.formatStatus === 'migration-required' && this.ctx.sessions.get(request.sessionId) === undefined) {
throw new RemoteError('session/migration-required', 'Open the source Session to complete migration before forking', {
sessionId: request.sessionId,
})
}
}
return this.commands.fork(request)
}
@@ -489,10 +498,10 @@ export class SessionController extends TypertRemoteService {
}
/**
* Read all registered projections without activating an Agent.
* Read exact projections, returning cached hints only when migration is required.
* @param request - Session whose current values are required.
* @param signal - cancellation for the Session observation.
* @returns complete baseline, or null when the Session does not exist.
* @param signal - cancellation for the Session read.
* @returns a sequenced baseline, cached hints when migration is deferred, or null when absent.
*/
@Remote('projections')
async projections(request: SessionProjectionsRequest, signal: AbortSignal): Promise<SessionProjectionsValue> {
@@ -501,16 +510,29 @@ export class SessionController extends TypertRemoteService {
throw new RemoteError('gateway/bad-request', 'sessionId must not be empty', {})
}
try {
signal.throwIfAborted()
const live = this.liveProjections(sessionId)
if (live !== undefined) return live
const stored = await this.ctx.get('sessionPersistence')?.stat(sessionId, { signal })
signal.throwIfAborted()
const attached = this.liveProjections(sessionId)
if (attached !== undefined) return attached
if (stored === undefined) return null
if (stored.formatStatus === 'migration-required') {
const cached = this.ctx.get('sessionProjectionCache')?.cachedSnapshot(stored.header)
return { kind: 'migration-required', values: { ...cached?.values } as SessionProjectionValues }
}
using observation = await this.ctx.sessionQuery.observeSession(sessionId, { signal })
const projections = observation.projections
if (projections === undefined) {
throw new RemoteError('session/projections-unavailable', 'Session projections are unavailable', {})
}
return { asOfSeq: projections.asOfSeq, values: projections.values as SessionProjectionValues }
return { kind: 'sequenced', asOfSeq: projections.asOfSeq, values: projections.values as SessionProjectionValues }
} catch (error: unknown) {
if (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') return null
if (signal.aborted
|| (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_ABORTED')) {
if (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') {
return this.liveProjections(sessionId) ?? null
}
if (signal.aborted || (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_ABORTED')) {
throw new RemoteError('gateway/cancelled', 'Session projection read was cancelled', {}, { cause: error })
}
if (error instanceof RemoteError) throw error
@@ -518,6 +540,15 @@ export class SessionController extends TypertRemoteService {
}
}
private liveProjections(
sessionId: SessionId,
): Extract<SessionProjectionsValue, { readonly kind: 'sequenced' }> | undefined {
const session = this.ctx.sessions.get(sessionId)
if (session === undefined) return undefined
const snapshot = this.ctx.sessionProjections.snapshot(session)
return { kind: 'sequenced', asOfSeq: snapshot.asOfSeq, values: snapshot.values as SessionProjectionValues }
}
/**
* Stream a complete live-control baseline followed by replacement frames.
* @param signal - cancellation owned by the Remote stream carrier.
+8 -6
View File
@@ -8,7 +8,7 @@ import type { ImageAttachmentLimits } from '@deepseek-ai/dsh-attachment'
import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import type { ProjectionSnapshot } from '@deepseek-ai/dsh-session-projection'
import type {} from '@deepseek-ai/dsh-session-projection-cache'
import { SessionQueryError, type SessionSearchCursor } from '@deepseek-ai/dsh-session-query'
import { SessionQueryError, type SessionRecord, type SessionSearchCursor } from '@deepseek-ai/dsh-session-query'
import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
import { z } from 'zod'
import {
@@ -116,6 +116,7 @@ export class ApiSessionList {
sessionId: session.id,
updatedAt: updatedAt(session.header, metadata),
agentAvailable: this.ctx.agents.get(session.id)?.session === session,
formatStatus: 'current',
running: this.ctx.agents.get(session.id)?.status === 'running',
blank: metadata?.blank ?? session.seq === 0,
...listFields(session.header),
@@ -133,7 +134,7 @@ export class ApiSessionList {
const records = await this.ctx.sessionQuery.listSessions(signal)
signal?.throwIfAborted()
const items: SessionSummary[] = []
const cold: SessionHeader[] = []
const cold: SessionRecord[] = []
let yieldDeadline = performance.now() + this.workSliceMs
for (const record of records) {
signal?.throwIfAborted()
@@ -141,7 +142,7 @@ export class ApiSessionList {
if (live !== undefined) {
items.push(this.summaryFor(live))
} else if (record.header.cwd !== undefined) {
cold.push(record.header)
cold.push(record)
}
if (performance.now() >= yieldDeadline) {
await scheduler.yield()
@@ -149,9 +150,9 @@ export class ApiSessionList {
yieldDeadline = performance.now() + this.workSliceMs
}
}
for (const header of cold) {
for (const record of cold) {
signal?.throwIfAborted()
items.push(this.summarizeCold(header))
items.push(this.summarizeCold(record))
if (performance.now() >= yieldDeadline) {
await scheduler.yield()
signal?.throwIfAborted()
@@ -163,13 +164,14 @@ export class ApiSessionList {
return items
}
private summarizeCold(header: SessionHeader): SessionSummary {
private summarizeCold({ header, formatStatus }: SessionRecord): SessionSummary {
const projections = this.projectionsFor(header, undefined)
const metadata = projections?.values.sessionListMetadata
return {
sessionId: header.id,
updatedAt: updatedAt(header, metadata),
agentAvailable: false,
...(formatStatus === undefined ? {} : { formatStatus }),
running: false,
// A large, metadata-less, or inaccessible cache miss remains unknown and visible.
blank: metadata?.blank ?? false,
+10 -2
View File
@@ -177,6 +177,8 @@ export type QueueAction =
export interface SessionSummary {
/** Whether this Session currently owns a live Agent. */
readonly agentAvailable: boolean
/** Current for attached Sessions; otherwise the persistence format status when reported. */
readonly formatStatus?: 'current' | 'migration-required'
readonly sessionId: SessionId
readonly updatedAt: number
readonly running: boolean
@@ -224,6 +226,7 @@ declare module '@deepseek-ai/dsh-typert-protocol' {
'session/steer-unavailable': { readonly itemId: MessageId }
'session/title-invalid': { readonly sessionId: SessionId }
'session/fork-unavailable': { readonly sessionId: SessionId }
'session/migration-required': { readonly sessionId: SessionId }
'subagent/not-found': {
readonly parentSessionId: SessionId
readonly childSessionId: SessionId
@@ -322,6 +325,8 @@ export interface SessionForkRequest {
readonly sessionId: SessionId
/** Exact inclusive source event seq; omission selects the latest completed-turn prefix. */
readonly atSeq?: number
/** False asks the Host to check for required migration before forking. Defaults to true. */
readonly allowMigration?: boolean
}
/** Identity of a newly forked Session. */
@@ -419,8 +424,11 @@ export interface SessionProjectionsRequest {
readonly sessionId: SessionId
}
/** Complete Session projection baseline; null when the Session does not exist. */
export type SessionProjectionsValue = SessionProjectionBaseline | null
/** Exact projections carry their event cut; required migration returns sequence-free cache hints. Null means absent. */
export type SessionProjectionsValue =
| (SessionProjectionBaseline & { readonly kind: 'sequenced' })
| { readonly kind: 'migration-required'; readonly values: SessionProjectionValues }
| null
/** One raw Session event in the Remote journal. */
export interface SessionEventEntry {
@@ -4,6 +4,7 @@
*/
import { describe, expect, onTestFinished, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client'
import { SessionSeq } from '@deepseek-ai/dsh-session/types'
@@ -14,6 +15,7 @@ import {
} from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts'
import type {} from '@deepseek-ai/dsh-session-title/client'
import { SessionManager } from '../src/client/sessions/manager.ts'
import { ClientSessions } from '../src/client/sessions/service.ts'
import type { SessionRemotes } from '../src/client/sessions/remotes.ts'
import { entries, plainTurn } from './event-script.client.ts'
import { FOLLOW, err, followScript, sessionWorld } from './remote/session.client.ts'
@@ -647,6 +649,7 @@ describe('list lifecycle', () => {
const items = manager.getListSnapshot().items
// Cold row: title surfaces straight from the list block — no open, no history.
expect(items.find(item => item.sessionId === S1)?.title).toBe('Cold cached')
expect(manager.getListSnapshot().projectionsBySession[S1]?.state).toBe('idle')
// A cached list block never displaces a sequenced value, whatever watermark its record holds.
expect(items.find(item => item.sessionId === S2)?.title).toBe('Pushed')
})
@@ -741,12 +744,83 @@ describe('subagent catalogs', () => {
expect(manager.getListSnapshot().items).toEqual([])
})
it('prefers retained and catalog addresses to header-derived unknown modes', ({ mock, remote }) => {
const manager = makeManager(mock, remote)
onTestFinished(() => manager.dispose())
manager.handleSessionAdded(summary(S2, { parentSessionId: S1, origin: 'subagent' }))
manager.handleControlFrame({
type: 'projection', sessionId: S1, key: 'subagentCatalog', seq: 0,
value: [{ id: S2, createdAt: 1, mode: 'continuable', label: 'worker' }],
})
expect(manager.subagentAddress(S2)).toEqual({
parentSessionId: S1, childSessionId: S2, mode: 'continuable',
})
const retained: SubagentAddress = { parentSessionId: S1, childSessionId: S2, mode: 'one-shot' }
manager.resolveTarget(retained)
expect(manager.subagentAddress(S2)).toBe(retained)
expect(remote.session.list).not.toHaveBeenCalled()
expect(remote.session.projections).not.toHaveBeenCalled()
})
it.for([
['ordinary session', {}],
['ordinary fork', { parentSessionId: S1 }],
['parentless subagent', { origin: 'subagent' }],
] as const)('does not derive a subagent address for an %s', ([, fields], { mock, remote }) => {
const manager = makeManager(mock, remote)
onTestFinished(() => manager.dispose())
manager.handleSessionAdded(summary(S2, fields))
expect(manager.subagentAddress(S2)).toBeUndefined()
expect(manager.subagentAddress(S1)).toBeUndefined()
expect(manager.resolveTarget(S2)).toBe(S2)
expect(manager.get(S2).getSnapshot().subagent).toBeNull()
expect(remote.session.list).not.toHaveBeenCalled()
expect(remote.session.projections).not.toHaveBeenCalled()
})
it('retains a header-discovered ancestor through subagent follow without discovery reads', async ({ mock, remote, start }) => {
const grandchild = 'header-grandchild' as SessionId
const descendantAddress: SubagentAddress = {
parentSessionId: S2, childSessionId: grandchild, mode: 'continuable',
}
const ancestorAddress: SubagentAddress = { parentSessionId: S1, childSessionId: S2, mode: 'unknown' }
mock.load(sessionWorld)
remote.session.list.mockResolvedValueOnce(ok({ items: [
summary(S1),
summary(S2, { parentSessionId: S1, origin: 'subagent' }),
summary(grandchild, { parentSessionId: S2, origin: 'subagent' }),
] }))
const client = await start()
const ctx = new Context()
onTestFinished(() => ctx.fiber.dispose())
const sessions = new ClientSessions(ctx, client.ctx.remote)
await sessions.refresh()
using descendant = sessions.retain(descendantAddress, { source: 'mainView' })
await descendant.ready
using ancestor = sessions.retain(S2, { source: 'mainView' })
await ancestor.ready
expect(mock.log.requests(FOLLOW)).toMatchObject([
{ address: { kind: 'subagent', ...descendantAddress } },
{ address: { kind: 'subagent', ...ancestorAddress } },
])
expect(ancestor.binding.session.getSnapshot()).toMatchObject({
openState: 'open', subagent: { address: ancestorAddress },
})
expect(remote.session.list).toHaveBeenCalledOnce()
expect(remote.session.projections).not.toHaveBeenCalled()
expect(mock.log.requests('session/page')).toEqual([])
})
it('keeps a catalog-discovered child address across identity resolution and status frames', async ({ mock, remote, start }) => {
remote.session.list.mockImplementation(() => Promise.resolve(ok({ items: [
summary(S1),
summary(S2, { parentSessionId: S1, origin: 'subagent' }),
] as never[] })))
remote.session.projections.mockImplementation(() => Promise.resolve(ok({ asOfSeq: 0, values: { subagentCatalog: [{ createdAt: 1,
remote.session.projections.mockImplementation(() => Promise.resolve(ok({ kind: 'sequenced', asOfSeq: 0, values: { subagentCatalog: [{ createdAt: 1,
id: S2, mode: 'continuable', label: 'worker',
}] } })))
mock.load(sessionWorld)
@@ -859,6 +933,74 @@ describe('subagent catalogs', () => {
expect(remote.session.projections.mock.calls.map(([request]) => request)).toEqual([])
})
it.for(['control', 'follow'] as const)('replaces cached projection hints with the %s baseline', async (source, { mock, remote, start }) => {
mock.load(sessionWorld)
remote.session.projections.mockResolvedValueOnce(ok({ kind: 'migration-required', values: {
title: 'Cached title',
subagentCatalog: [{ id: S2, createdAt: 1, mode: 'unknown' }],
} }))
const client = await start()
const manager = new SessionManager(client.ctx.remote)
onTestFinished(() => manager.dispose())
await manager.refreshProjections(S1)
expect(manager.getListSnapshot().projectionsBySession[S1]).toMatchObject({
state: 'migration-required', error: null,
values: { title: 'Cached title', subagentCatalog: [{ id: S2, mode: 'unknown' }] },
})
expect(remote.session.follow).not.toHaveBeenCalled()
const session = manager.get(S1)
expect(session.projections.seqOf('subagentCatalog')).toBeUndefined()
const baseline = { asOfSeq: 0, values: {
subagentCatalog: [{ id: S2, createdAt: 1, mode: 'one-shot' as const, label: 'Loaded child' }],
} }
if (source === 'control') {
manager.handleControlFrame({ type: 'baseline', value: { projections: { [S1]: baseline } } })
} else {
mock.stream(FOLLOW, followScript(ok({ records: [], hasMore: false, projections: baseline })))
await session.open()
}
expect(manager.getListSnapshot().projectionsBySession[S1]?.values).toEqual(baseline.values)
expect(session.projections.seqOf('subagentCatalog')).toBe(0)
expect(session.projections.get('title')).toBeUndefined()
})
it.for(['control', 'follow'] as const)('keeps %s projections when a cached read arrives late', async (source, { mock, remote, start }) => {
mock.load(sessionWorld)
const response = Promise.withResolvers<Awaited<ReturnType<typeof remote.session.projections>>>()
remote.session.projections.mockReturnValueOnce(response.promise)
const client = await start()
const manager = new SessionManager(client.ctx.remote)
const read = manager.refreshProjections(S1)
const baseline = { asOfSeq: 4, values: {
title: 'Loaded title',
subagentCatalog: [{ id: S2, createdAt: 1, mode: 'continuable' as const, label: 'Loaded child' }],
} }
try {
expect(remote.session.projections).toHaveBeenCalledOnce()
if (source === 'control') {
manager.handleControlFrame({ type: 'baseline', value: { projections: { [S1]: baseline } } })
} else {
mock.stream(FOLLOW, followScript(ok({ records: [], hasMore: false, projections: baseline })))
await manager.get(S1).open()
}
response.resolve(ok({ kind: 'migration-required', values: {
title: 'Cached title', subagentCatalog: [{ id: S2, createdAt: 1, mode: 'unknown' }],
} }))
await read
expect(manager.getListSnapshot().projectionsBySession[S1]).toMatchObject({
state: 'migration-required', error: null, values: baseline.values,
})
expect(manager.get(S1).projections.seqOf('subagentCatalog')).toBe(4)
} finally {
response.resolve(ok(null))
await read
await manager.dispose()
}
})
it('keeps newer pushed membership when the initial read returns an older baseline', async ({ mock, remote }) => {
const response = Promise.withResolvers<Awaited<ReturnType<typeof remote.session.projections>>>()
remote.session.projections.mockImplementation(() => response.promise)
@@ -868,12 +1010,27 @@ describe('subagent catalogs', () => {
type: 'projection', sessionId: S1, key: 'subagentCatalog', seq: 5,
value: [{ id: S2, createdAt: 1, mode: 'continuable', label: 'pushed' }],
})
response.resolve(ok({ asOfSeq: 3, values: { subagentCatalog: [] } }))
response.resolve(ok({ kind: 'sequenced', asOfSeq: 3, values: { subagentCatalog: [] } }))
await read
expect(manager.getListSnapshot().projectionsBySession[S1]?.values.subagentCatalog?.[0]?.label).toBe('pushed')
expect(remote.session.projections.mock.calls.map(([request]) => request)).toHaveLength(1)
})
it('retries a migration-deferred projection read and accepts the completed catalog', async ({ mock, remote }) => {
const manager = makeManager(mock, remote)
remote.session.projections.mockResolvedValueOnce(ok({ kind: 'migration-required', values: {} }))
await manager.refreshProjections(S1)
expect(manager.getListSnapshot().projectionsBySession[S1]).toMatchObject({
state: 'migration-required', values: {},
})
remote.session.projections.mockResolvedValueOnce(ok({ kind: 'sequenced', asOfSeq: 0, values: { subagentCatalog: [] } }))
await manager.refreshProjections(S1)
expect(manager.getListSnapshot().projectionsBySession[S1]).toMatchObject({
state: 'ready', values: { subagentCatalog: [] },
})
expect(remote.session.projections).toHaveBeenCalledTimes(2)
})
it.for([false, true])('keeps live parent availability when an earlier initial read settles (failure: %s)', async (failure, { mock, remote }) => {
const response = Promise.withResolvers<Awaited<ReturnType<typeof remote.session.projections>>>()
remote.session.projections.mockImplementation(() => response.promise)
@@ -885,7 +1042,7 @@ describe('subagent catalogs', () => {
manager.handleSessionStatus(S1, false)
response.resolve(failure
? err(new RemoteError('gateway/internal', 'offline', {}))
: ok({ asOfSeq: 0, values: { subagentCatalog: [] } }))
: ok({ kind: 'sequenced', asOfSeq: 0, values: { subagentCatalog: [] } }))
await read
expect(child.getSnapshot().subagent?.parentAvailable).toBe(true)
expect(remote.session.projections.mock.calls.map(([request]) => request)).toHaveLength(1)
@@ -925,7 +1082,7 @@ describe('subagent catalogs', () => {
const read = manager.refreshProjections(S1)
expect(manager.refreshProjections(S1)).toBe(read)
expect(manager.getListSnapshot().projectionsBySession[S1]?.state).toBe('loading')
response.resolve(ok({ asOfSeq: 0, values: {
response.resolve(ok({ kind: 'sequenced', asOfSeq: 0, values: {
subagentCatalog: [{ id: S2, createdAt: 1, mode: 'one-shot' }],
title: 'cold parent',
} }))
@@ -946,7 +1103,7 @@ describe('subagent catalogs', () => {
const read = manager.refreshProjections(S1)
manager.handleSessionAdded(summary(S2, { origin: 'subagent', parentSessionId: S1 }))
manager.handleSessionStatus(S2, true)
response.resolve(ok({ asOfSeq: 0, values: {
response.resolve(ok({ kind: 'sequenced', asOfSeq: 0, values: {
subagentCatalog: [{ id: S2, createdAt: 1, mode: 'continuable', label: 'worker' }],
} }))
await read
@@ -980,7 +1137,7 @@ describe('subagent catalogs', () => {
value: [{ id: S2, createdAt: 1, mode: 'one-shot' }] })
await manager.refreshProjections(S1)
expect(manager.getListSnapshot().projectionsBySession[S1]).toMatchObject({ state: 'error', values: { subagentCatalog: [{ id: S2 }] } })
remote.session.projections.mockImplementation(() => Promise.resolve(ok({ asOfSeq: 2, values: { subagentCatalog: [] } })))
remote.session.projections.mockImplementation(() => Promise.resolve(ok({ kind: 'sequenced', asOfSeq: 2, values: { subagentCatalog: [] } })))
await manager.refreshProjections(S1)
expect(manager.getListSnapshot().projectionsBySession[S1]).toMatchObject({ state: 'ready', values: { subagentCatalog: [{ id: S2 }] } })
})
@@ -991,7 +1148,7 @@ describe('subagent catalogs', () => {
const manager = makeManager(mock, remote)
const read = manager.refreshProjections(S1)
manager.handleSessionRemoved(S1)
response.resolve(ok({ asOfSeq: 0, values: { subagentCatalog: [] } }))
response.resolve(ok({ kind: 'sequenced', asOfSeq: 0, values: { subagentCatalog: [] } }))
await read
expect(manager.getListSnapshot().items.some(item => item.sessionId === S1)).toBe(false)
expect(remote.session.projections.mock.calls.map(([request]) => request)).toHaveLength(1)
@@ -1081,6 +1238,20 @@ describe('remaining branches', () => {
expect(manager.getListSnapshot().items[0]).not.toHaveProperty('cwd')
})
it.for([undefined, false, true])('forwards fork migration permission %s to the Remote request', async (allowMigration, { mock, remote }) => {
const manager = makeManager(mock, remote)
onTestFinished(() => manager.dispose())
remote.session.fork.mockResolvedValueOnce(ok({ sessionId: S2 }))
const permission = allowMigration === undefined ? {} : { allowMigration }
await expect(manager.fork({ sessionId: S1, atSeq: SessionSeq(7), ...permission }))
.resolves.toEqual(ok({ sessionId: S2 }))
expect(remote.session.fork).toHaveBeenCalledExactlyOnceWith({ sessionId: S1, atSeq: 7, ...permission })
expect(remote.session.follow).not.toHaveBeenCalled()
expect(manager.getListSnapshot().items[0]).toMatchObject({ sessionId: S2, parentSessionId: S1 })
})
it('reconciles a fork child published before workspace attachment fails', async ({ mock, remote }) => {
remote.session.fork.mockResolvedValue(err(new RemoteError('session/workspace-attach-failed', 'forked but unattached', {
sessionId: S2, workspaceId: 'w1',
@@ -1317,7 +1488,7 @@ describe('connected generation', () => {
summary(S1),
summary(S2, { parentSessionId: S1, origin: 'subagent', running: hostRunning }),
] as never[] })))
remote.session.projections.mockImplementation(() => Promise.resolve(ok({ asOfSeq: 0, values: { subagentCatalog: [{ createdAt: 1, id: S2, mode: 'continuable', label: 'worker' }] } })))
remote.session.projections.mockImplementation(() => Promise.resolve(ok({ kind: 'sequenced', asOfSeq: 0, values: { subagentCatalog: [{ createdAt: 1, id: S2, mode: 'continuable', label: 'worker' }] } })))
const manager = makeManager(mock, remote)
try {
await manager.refreshList()
@@ -1341,13 +1512,13 @@ describe('connected generation', () => {
const previous = Promise.withResolvers<Awaited<ReturnType<typeof remote.session.projections>>>()
remote.session.projections
.mockReturnValueOnce(previous.promise)
.mockResolvedValue(ok({ asOfSeq: 0, values: { subagentCatalog: [] } }))
.mockResolvedValue(ok({ kind: 'sequenced', asOfSeq: 0, values: { subagentCatalog: [] } }))
const manager = makeManager(mock, remote)
try {
const read = manager.refreshProjections(S1)
manager.handleConnected()
expect(remote.session.projections.mock.calls.map(([request]) => request)).toHaveLength(2)
previous.resolve(ok({ asOfSeq: 0, values: { subagentCatalog: [{ createdAt: 1, id: S2, mode: 'continuable', label: 'worker' }] } }))
previous.resolve(ok({ kind: 'sequenced', asOfSeq: 0, values: { subagentCatalog: [{ createdAt: 1, id: S2, mode: 'continuable', label: 'worker' }] } }))
await read
await vi.waitFor(() => {
expect(manager.getListSnapshot().projectionsBySession[S1]).toMatchObject({
@@ -1395,7 +1566,7 @@ describe('connected generation', () => {
manager.handleConnected()
expect(manager.get(S2).getSnapshot().subagent).toEqual({ address })
parent.resolve(ok({ asOfSeq: 0, values: { subagentCatalog: [{ createdAt: 1,
parent.resolve(ok({ kind: 'sequenced', asOfSeq: 0, values: { subagentCatalog: [{ createdAt: 1,
id: S2, mode: 'continuable', label: 'worker',
}] } }))
@@ -0,0 +1,424 @@
/** Projection reads distinguish ordinary cold history from history that still needs migration. */
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
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 SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import SessionProjectionCache, {
checkpointRecord,
projectionCacheDomainSpec,
type CheckpointRecord,
} from '@deepseek-ai/dsh-session-projection-cache'
import { titleProjectionDefinition } from '@deepseek-ai/dsh-session-title'
import Storage from '@deepseek-ai/dsh-storage'
import * as StorageDomain from '@deepseek-ai/dsh-storage-domain'
import * as StorageJson from '@deepseek-ai/dsh-storage-json'
import { subagentCatalogProjectionDefinition } from '@deepseek-ai/dsh-subagent/src/catalog.ts'
import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
import { createSessionTestController } from './test-remote.ts'
const contexts: Context[] = []
const roots: string[] = []
const readAudits: Array<() => void> = []
afterEach(async () => {
try {
for (const audit of readAudits.splice(0)) audit()
} finally {
vi.restoreAllMocks()
try {
await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
} finally {
await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true })))
}
}
})
async function harness(withPersistence = true) {
const root = await mkdtemp(join(tmpdir(), 'dsh-projection-discovery-'))
roots.push(root)
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(SessionProjectionRegistry)
ctx.sessionProjections.register(subagentCatalogProjectionDefinition)
ctx.sessionProjections.register(titleProjectionDefinition)
if (withPersistence) {
await ctx.plugin(JsonlSessionPersistence, { root: join(root, 'logs'), compression: 'none' })
const list = vi.spyOn(ctx.sessionPersistence, 'list').mockRejectedValue(new Error('unexpected corpus listing'))
readAudits.push(() => {
expect(list).not.toHaveBeenCalled()
})
}
const controller = createSessionTestController(ctx, {
defaultModelSelection: () => ({ provider: 'test', model: 'test' }),
cwd: root,
})
const list = vi.spyOn(ctx.sessionQuery, 'listSessions').mockRejectedValue(new Error('unexpected Session listing'))
readAudits.push(() => {
expect(list).not.toHaveBeenCalled()
})
return { ctx, root, controller }
}
type Harness = Awaited<ReturnType<typeof harness>>
async function stored(
fixture: Harness,
id: SessionId,
fields: Partial<Pick<SessionHeader, 'createdAt' | 'parentSession' | 'origin' | 'isSeeded'>> = {},
version = SESSION_FORMAT_VERSION,
) {
const header: SessionHeader = {
version: SESSION_FORMAT_VERSION,
id,
createdAt: 10,
delegationDepth: 0,
isSeeded: false,
...fields,
}
const path = join(fixture.root, 'logs', '_no-cwd', id, `session.v${version}.jsonl`)
const bytes = `${JSON.stringify({ type: 'session', ...header, version })}\n`
await mkdir(dirname(path), { recursive: true })
await writeFile(path, bytes)
return { header, path, bytes }
}
async function seedCache(
fixture: Harness,
header: SessionHeader,
catalog: 'child' | 'empty' | 'absent',
matchingIdentity = true,
) {
const rows: CheckpointRecord['rows'] = {
title: { ver: titleProjectionDefinition.stateVersion, seq: SessionSeq(50), val: 'cached title' },
}
if (catalog !== 'absent') {
const empty = subagentCatalogProjectionDefinition.init(header, SessionLogOffset(0))
const state = catalog === 'empty' ? empty : subagentCatalogProjectionDefinition.apply(empty, {
type: 'subagent/catalog',
seq: SessionSeq(0),
time: 1,
data: {
version: 1,
childId: SessionId('cached-child'),
childCreatedAt: 2,
mode: 'one-shot',
label: 'cached child',
},
})
const record = checkpointRecord.parse({
identity: { createdAt: header.createdAt },
rows: { subagentCatalog: { ver: subagentCatalogProjectionDefinition.stateVersion, seq: 50, val: state } },
})
Object.assign(rows, record.rows)
}
const record: CheckpointRecord = {
identity: {
formatVersion: SESSION_FORMAT_VERSION,
createdAt: matchingIdentity ? header.createdAt : header.createdAt + 1,
cwd: header.cwd,
isSeeded: header.isSeeded,
inheritedEventCount: SessionLogOffset(0),
},
rows,
}
const cacheRoot = join(fixture.root, 'cache')
const path = join(cacheRoot, projectionCacheDomainSpec.name, 'sessions', `${header.id}.json`)
const bytes = JSON.stringify({ version: projectionCacheDomainSpec.version, record })
await mkdir(dirname(path), { recursive: true })
await writeFile(path, bytes)
await fixture.ctx.plugin(Storage)
await fixture.ctx.plugin(StorageJson, { root: cacheRoot })
await fixture.ctx.plugin(StorageDomain, { backend: 'json' })
await fixture.ctx.plugin(SessionProjectionCache, { writeEveryEvents: 100, writeIntervalMs: 60_000 })
const write = vi.spyOn(fixture.ctx.sessionProjectionCache, 'write').mockRejectedValue(new Error('unexpected cache write'))
const hydrate = vi.spyOn(fixture.ctx.sessionProjectionCache, 'hydratePrepared').mockImplementation(() => {
throw new Error('unexpected cached hydration')
})
readAudits.push(() => {
expect(write).not.toHaveBeenCalled()
expect(hydrate).not.toHaveBeenCalled()
})
return { path, bytes }
}
describe('Session projection discovery', () => {
it('defers V3 migration without reading child bodies or publishing a successor', async () => {
const fixture = await harness()
const parent = await stored(fixture, SessionId('parent'), {}, 3)
const child = await stored(fixture, SessionId('child'), { origin: 'subagent', parentSession: parent.header.id, createdAt: 20 }, 3)
const grandchild = await stored(fixture, SessionId('grandchild'), { origin: 'subagent', parentSession: child.header.id, createdAt: 30 }, 3)
const fork = await stored(fixture, SessionId('ordinary-fork'), { parentSession: parent.header.id, isSeeded: true }, 3)
const unrelated = await stored(fixture, SessionId('unrelated'), { origin: 'subagent', parentSession: SessionId('another-parent') }, 3)
const open = vi.spyOn(fixture.ctx.sessionPersistence, 'open')
const observe = vi.spyOn(fixture.ctx.sessionQuery, 'observeSession')
await expect(fixture.controller.projections({ sessionId: parent.header.id }, new AbortController().signal)).resolves.toEqual({
kind: 'migration-required',
values: {},
})
expect(open).not.toHaveBeenCalled()
expect(observe).not.toHaveBeenCalled()
expect(await fixture.ctx.sessionPersistence.stat(parent.header.id)).toMatchObject({
formatStatus: 'migration-required',
})
expect(fixture.ctx.sessions.list()).toEqual([])
for (const source of [parent, child, grandchild, fork, unrelated]) {
expect(await readFile(source.path, 'utf8')).toBe(source.bytes)
expect(await readdir(dirname(source.path))).toEqual(['session.v3.jsonl'])
}
})
it.each(['child', 'empty'] as const)('serves a valid %s catalog cache when migration is required', async (catalog) => {
const fixture = await harness()
const parent = await stored(fixture, SessionId('cached-parent'), {}, 3)
const cache = await seedCache(fixture, parent.header, catalog)
const list = vi.spyOn(fixture.ctx.sessionQuery, 'listSessions').mockRejectedValue(new Error('unexpected fallback'))
await expect(fixture.controller.projections({ sessionId: parent.header.id }, new AbortController().signal)).resolves.toEqual({
kind: 'migration-required',
values: {
title: 'cached title',
subagentCatalog: catalog === 'empty' ? [] : [{ id: 'cached-child', createdAt: 2, mode: 'one-shot', label: 'cached child' }],
},
})
expect(list).not.toHaveBeenCalled()
expect(await readFile(cache.path, 'utf8')).toBe(cache.bytes)
})
it('serves other cached hints without inventing a missing catalog', async () => {
const fixture = await harness()
const parent = await stored(fixture, SessionId('title-parent'), {}, 3)
await stored(fixture, SessionId('title-child'), { origin: 'subagent', parentSession: parent.header.id })
const cache = await seedCache(fixture, parent.header, 'absent')
await expect(fixture.controller.projections({ sessionId: parent.header.id }, new AbortController().signal)).resolves.toEqual({
kind: 'migration-required',
values: { title: 'cached title' },
})
expect(fixture.ctx.sessionProjectionCache.cachedSnapshot(parent.header)?.values).toEqual({ title: 'cached title' })
expect(await readFile(cache.path, 'utf8')).toBe(cache.bytes)
})
it('returns no hints from an unrelated cache identity', async () => {
const fixture = await harness()
const parent = await stored(fixture, SessionId('recreated-parent'), {}, 3)
await seedCache(fixture, parent.header, 'child', false)
await expect(fixture.controller.projections({ sessionId: parent.header.id }, new AbortController().signal)).resolves.toEqual({
kind: 'migration-required', values: {},
})
})
it.each([true, false])('returns null for an absent Session (persistence mounted: %s)', async (withPersistence) => {
const fixture = await harness(withPersistence)
const list = vi.spyOn(fixture.ctx.sessionQuery, 'listSessions')
await expect(fixture.controller.projections({ sessionId: SessionId('missing') }, new AbortController().signal)).resolves.toBeNull()
expect(list).not.toHaveBeenCalled()
})
it('reads a current-format cold catalog from disk when its cache is absent', async () => {
const fixture = await harness()
const parent = await stored(fixture, SessionId('current-cold'))
const event = {
type: 'subagent/catalog', seq: 0, time: 1,
data: { version: 1, childId: 'current-child', childCreatedAt: 2, mode: 'continuable', label: 'worker' },
}
await writeFile(parent.path, `${parent.bytes}${JSON.stringify(event)}\n`)
const open = vi.spyOn(fixture.ctx.sessionPersistence, 'open')
const observe = vi.spyOn(fixture.ctx.sessionQuery, 'observeSession')
const signal = new AbortController().signal
await expect(fixture.controller.projections({ sessionId: parent.header.id }, signal)).resolves.toMatchObject({
kind: 'sequenced', asOfSeq: 0,
values: { subagentCatalog: [{ id: 'current-child', mode: 'continuable', label: 'worker' }] },
})
expect(observe).toHaveBeenCalledWith(parent.header.id, { signal })
expect(open).toHaveBeenCalledWith(parent.header.id, 'read', { signal })
expect(fixture.ctx.sessions.get(parent.header.id)).toBeUndefined()
expect(fixture.ctx.agents.get(parent.header.id)).toBeUndefined()
})
it('reports read-prepared historical files as migration-required until a successor is published', async () => {
const fixture = await harness()
const parent = await stored(fixture, SessionId('prepared-cold'), {}, 3)
const observation = await fixture.ctx.sessionQuery.observeSession(parent.header.id)
observation[Symbol.dispose]()
const open = vi.spyOn(fixture.ctx.sessionPersistence, 'open').mockRejectedValue(new Error('unexpected body reread'))
expect(await fixture.ctx.sessionPersistence.stat(parent.header.id)).toMatchObject({
formatStatus: 'migration-required',
})
await expect(fixture.controller.projections({ sessionId: parent.header.id }, new AbortController().signal))
.resolves.toEqual({ kind: 'migration-required', values: {} })
expect(open).not.toHaveBeenCalled()
expect(fixture.ctx.sessions.get(parent.header.id)).toBeUndefined()
expect(await readFile(parent.path, 'utf8')).toBe(parent.bytes)
expect(await readdir(dirname(parent.path))).toEqual(['session.v3.jsonl'])
})
it('reads sequenced projections after write publication replaces the disk format status', async () => {
const fixture = await harness()
const parent = await stored(fixture, SessionId('published-cold'), {}, 3)
using observation = await fixture.ctx.sessionQuery.observeSession(parent.header.id)
const expected = observation.projections
const signal = new AbortController().signal
await expect(fixture.controller.projections({ sessionId: parent.header.id }, signal))
.resolves.toEqual({ kind: 'migration-required', values: {} })
const writer = await fixture.ctx.sessionPersistence.open(parent.header.id, 'write')
try {
await writer.flush()
} finally {
await writer.close()
}
expect(await fixture.ctx.sessionPersistence.stat(parent.header.id)).toMatchObject({
formatStatus: 'current',
})
await expect(fixture.controller.projections({ sessionId: parent.header.id }, signal))
.resolves.toEqual({ kind: 'sequenced', ...expected })
expect(fixture.ctx.sessions.get(parent.header.id)).toBeUndefined()
expect(await readFile(parent.path, 'utf8')).toBe(parent.bytes)
// POSIX retains the write-lock inode after close; Windows uses a semaphore.
expect((await readdir(dirname(parent.path))).filter(name => name !== 'session.lock').sort())
.toEqual(['session.v3.jsonl', `session.v${SESSION_FORMAT_VERSION}.jsonl`].sort())
})
it('reports unsupported current-format events instead of deferring migration', async () => {
const fixture = await harness()
const parent = await stored(fixture, SessionId('corrupt-current'))
await writeFile(parent.path, `${parent.bytes}${JSON.stringify({ type: 'missing/plugin-event', seq: 0, time: 1, data: {} })}\n`)
await expect(fixture.controller.projections({ sessionId: parent.header.id }, new AbortController().signal))
.rejects.toMatchObject({ code: 'gateway/internal' })
})
it('reports unavailable projections from an otherwise readable observation', async () => {
const fixture = await harness()
const parent = await stored(fixture, SessionId('without-projections'))
using source = await fixture.ctx.sessionQuery.observeSession(parent.header.id)
const { projections: _projections, ...observation } = source
vi.spyOn(fixture.ctx.sessionQuery, 'observeSession').mockResolvedValueOnce(observation)
await expect(fixture.controller.projections({ sessionId: parent.header.id }, new AbortController().signal))
.rejects.toMatchObject({ code: 'session/projections-unavailable' })
})
it.each([false, true])('rechecks live history when the stored source disappears after preflight (attached: %s)', async (attached) => {
const fixture = await harness()
const parent = await stored(fixture, SessionId('missing-after-preflight'))
const snapshot = await fixture.ctx.sessionPersistence.stat(parent.header.id)
vi.spyOn(fixture.ctx.sessionPersistence, 'stat')
.mockResolvedValueOnce(snapshot)
.mockImplementationOnce(() => {
if (attached) fixture.ctx.sessions.create(parent.header.id, { meta: parent.header })
return Promise.resolve(undefined)
})
const result = await fixture.controller.projections({ sessionId: parent.header.id }, new AbortController().signal)
expect(result).toEqual(attached
? { kind: 'sequenced', ...fixture.ctx.sessionProjections.snapshot(fixture.ctx.sessions.get(parent.header.id)!) }
: null)
})
it('serves the live registry cut instead of a conflicting cached hint', async () => {
const fixture = await harness()
const live = fixture.ctx.sessions.create(SessionId('live'), { meta: { cwd: fixture.root } })
live.append('turn/start', { turn: 1 })
const expected = fixture.ctx.sessionProjections.snapshot(live)
await seedCache(fixture, live.header, 'child')
const cached = vi.spyOn(fixture.ctx.sessionProjectionCache, 'cachedSnapshot')
const stat = vi.spyOn(fixture.ctx.sessionPersistence, 'stat').mockRejectedValue(new Error('metadata unavailable'))
await expect(fixture.controller.projections({ sessionId: live.id }, new AbortController().signal)).resolves.toEqual({
kind: 'sequenced', ...expected,
})
expect(stat).not.toHaveBeenCalled()
expect(cached).not.toHaveBeenCalled()
})
it.each([true, false])('prefers a Session attached during stat (stored header present: %s)', async (present) => {
const fixture = await harness()
const parent = await stored(fixture, SessionId('stat-race'), {}, 3)
const snapshot = await fixture.ctx.sessionPersistence.stat(parent.header.id)
const entered = Promise.withResolvers<undefined>()
const finish = Promise.withResolvers<SessionPersistenceSnapshot | undefined>()
vi.spyOn(fixture.ctx.sessionPersistence, 'stat').mockImplementation(() => {
entered.resolve(undefined)
return finish.promise
})
const operation = fixture.controller.projections({ sessionId: parent.header.id }, new AbortController().signal)
try {
await entered.promise
const live = fixture.ctx.sessions.create(parent.header.id, { meta: parent.header })
live.append('turn/start', { turn: 1 })
finish.resolve(present ? snapshot : undefined)
await expect(operation).resolves.toEqual({ kind: 'sequenced', ...fixture.ctx.sessionProjections.snapshot(live) })
} finally {
finish.resolve(undefined)
await Promise.allSettled([operation])
}
})
it('reports target metadata I/O failures instead of an empty result', async () => {
const fixture = await harness()
const parent = await stored(fixture, SessionId('failed-metadata'))
const failure = new Error('target metadata unavailable')
vi.spyOn(fixture.ctx.sessionPersistence, 'stat').mockRejectedValue(failure)
await expect(fixture.controller.projections({ sessionId: parent.header.id }, new AbortController().signal))
.rejects.toMatchObject({ code: 'gateway/internal', cause: failure })
})
it('honors cancellation while waiting for target metadata', async () => {
const fixture = await harness()
const parent = await stored(fixture, SessionId('cancelled-metadata'))
const snapshot = await fixture.ctx.sessionPersistence.stat(parent.header.id)
const entered = Promise.withResolvers<undefined>()
const finish = Promise.withResolvers<undefined>()
vi.spyOn(fixture.ctx.sessionPersistence, 'stat').mockImplementation(async () => {
entered.resolve(undefined)
await finish.promise
return snapshot
})
const abort = new AbortController()
const operation = fixture.controller.projections({ sessionId: parent.header.id }, abort.signal)
try {
await entered.promise
abort.abort(new Error('cancelled by caller'))
finish.resolve(undefined)
await expect(operation).rejects.toMatchObject({ code: 'gateway/cancelled' })
} finally {
finish.resolve(undefined)
await Promise.allSettled([operation])
}
})
it('rejects an already-cancelled read before metadata access', async () => {
const fixture = await harness()
const stat = vi.spyOn(fixture.ctx.sessionPersistence, 'stat')
await expect(fixture.controller.projections({ sessionId: SessionId('cancelled') }, AbortSignal.abort()))
.rejects.toMatchObject({ code: 'gateway/cancelled' })
expect(stat).not.toHaveBeenCalled()
})
it('preserves Remote failures while reading the migration cache metadata', async () => {
const fixture = await harness()
const parent = await stored(fixture, SessionId('typed-failures'), {}, 3)
const failure = new RemoteError('gateway/internal', 'metadata failure', {})
vi.spyOn(fixture.ctx.sessionPersistence, 'stat').mockRejectedValueOnce(failure)
await expect(fixture.controller.projections({ sessionId: parent.header.id }, new AbortController().signal)).rejects.toBe(failure)
})
it('rejects an empty identity without querying metadata', async () => {
const fixture = await harness()
const stat = vi.spyOn(fixture.ctx.sessionPersistence, 'stat')
await expect(fixture.controller.projections({ sessionId: SessionId('') }, new AbortController().signal))
.rejects.toMatchObject({ code: 'gateway/bad-request' })
expect(stat).not.toHaveBeenCalled()
})
})
@@ -145,7 +145,7 @@ export const sessionWorld: RemoteTable = {
data: 'AA==',
}),
'session/page': pageRule(ok({ records: [], hasMore: false })),
'session/projections': ok({ asOfSeq: -1, values: { subagentCatalog: [] } }),
'session/projections': ok({ kind: 'sequenced', asOfSeq: -1, values: { subagentCatalog: [] } }),
'subagents/prompt': ok({ messageId: 'fake-message' }),
'subagents/interruptByParent': ok({ accepted: true }),
},
@@ -77,6 +77,53 @@ function conversationEvents(): SessionEvent[] {
}
describe('sessions.list cold merge', () => {
it('preserves listed format status and prefers live readiness without extra persistence reads', async () => {
const ctx = new Context()
try {
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
const historical = header('format-historical', 100)
const current = header('format-current', 200)
const unknown = header('format-unknown', 300)
const shared = ctx.sessions.create(sid('format-shared'), { meta: { createdAt: 400, cwd: '/proj' } }).header
const liveOnly = ctx.sessions.create(sid('format-live'), { meta: { cwd: '/proj' } })
const snapshots: SessionPersistenceSnapshot[] = [
{ ...statSnapshot(historical), formatStatus: 'migration-required' },
{ ...statSnapshot(current), formatStatus: 'current' },
statSnapshot(unknown),
{ ...statSnapshot(shared), formatStatus: 'migration-required' },
]
const list = vi.fn(async () => snapshots)
const stat = vi.fn()
const open = vi.fn()
ctx.provide('sessionPersistence', { list, stat, open } as never)
const remote = createSessionTestRemote(ctx, {
defaultModelSelection: () => ({ provider: 'p', model: 'm' }),
cwd: '/tmp',
})
const observe = vi.spyOn(ctx.sessionQuery, 'observeSession')
try {
const response = await remote.list(request({}))
if (!response.ok) throw new Error('list failed')
const byId = new Map(response.value.items.map(item => [item.sessionId, item]))
expect(byId.get(historical.id)).toMatchObject({ formatStatus: 'migration-required' })
expect(byId.get(current.id)).toMatchObject({ formatStatus: 'current' })
expect(byId.get(unknown.id)).toMatchObject({ sessionId: unknown.id })
expect(byId.get(unknown.id)).not.toHaveProperty('formatStatus')
expect(byId.get(shared.id)).toMatchObject({ formatStatus: 'current' })
expect(byId.get(liveOnly.id)).toMatchObject({ formatStatus: 'current' })
expect(list).toHaveBeenCalledTimes(1)
expect(stat).not.toHaveBeenCalled()
expect(open).not.toHaveBeenCalled()
expect(observe).not.toHaveBeenCalled()
} finally {
observe.mockRestore()
}
} finally {
await ctx.fiber.dispose()
}
})
it('uses a predecessor title hint with zero cold stat or body reads', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
@@ -11,6 +11,7 @@ import { ToolCallId, createMessage, createUserMessage, ReasoningEffortId } from
import type { LlmCallConfig } from '@deepseek-ai/dsh-llm'
import SessionStore, { TOOL_OUTCOME_UNKNOWN, SESSION_FORMAT_VERSION, SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import { SessionPersistenceRevision, type SessionPersistenceSnapshot } from '@deepseek-ai/dsh-session-persistence'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import TokenMeter from '@deepseek-ai/dsh-token-meter'
import type { Workspace } from '@deepseek-ai/dsh-workspace'
@@ -95,6 +96,151 @@ const remote = (ctx: Context) => createSessionTestRemote(ctx, {
})
describe('sessions.fork', () => {
it.each([
{ allowMigration: undefined, status: 'current' },
{ allowMigration: false, status: 'current' },
{ allowMigration: false, status: undefined },
{ allowMigration: undefined, status: 'migration-required' },
{ allowMigration: true, status: 'migration-required' },
] as const)('forks cold history with allowMigration=$allowMigration and format=$status', async ({ allowMigration, status }) => {
const ctx = await composed()
try {
const source = ctx.sessions.prepare(sid('current-cold'), { meta: { cwd: '/proj' } })
source.append('turn/start', { turn: 1 })
source.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
const snapshot: SessionPersistenceSnapshot = {
header: source.header,
revision: SessionPersistenceRevision('test:cold-source'),
...status === undefined ? {} : { formatStatus: status },
}
ctx.provide('sessionPersistence', testSessionPersistence(ctx, {
list: () => Promise.resolve([source.header]),
stat: () => Promise.resolve(snapshot),
inspect: () => Promise.resolve({ meta: source.header, events: source.snapshotEvents() }),
}) as never)
const open = vi.spyOn(ctx.sessionPersistence, 'open')
const response = await remote(ctx).fork({
sessionId: source.id,
...allowMigration === undefined ? {} : { allowMigration },
})
if (!response.ok) throw response.error
expect(open).toHaveBeenCalledWith(source.id, 'read', undefined)
expect(ctx.sessions.get(source.id)).toBeUndefined()
expect(ctx.sessions.get(response.value.sessionId)?.inheritedEventCount).toBe(2)
} finally {
await ctx.fiber.dispose()
}
})
it('reports required migration before creating a fork when the caller disallows it', async () => {
const ctx = await composed()
try {
const sourceId = sid('requires-migration')
const source = ctx.sessions.prepare(sourceId, { meta: { cwd: '/proj' } })
const snapshot: SessionPersistenceSnapshot = {
header: source.header,
revision: SessionPersistenceRevision('test:requires-migration'),
formatStatus: 'migration-required',
}
const stat = vi.fn(async () => snapshot)
ctx.provide('sessionPersistence', testSessionPersistence(ctx, { stat }) as never)
const open = vi.spyOn(ctx.sessionPersistence, 'open')
const observe = vi.spyOn(ctx.sessionQuery, 'observeSession')
const create = vi.spyOn(ctx.agents, 'create')
await expect(remote(ctx).fork({ sessionId: sourceId, allowMigration: false })).resolves.toMatchObject({
ok: false, error: { code: 'session/migration-required', details: { sessionId: sourceId } },
})
expect(stat).toHaveBeenCalledExactlyOnceWith(sourceId)
expect(open).not.toHaveBeenCalled()
expect(observe).not.toHaveBeenCalled()
expect(create).not.toHaveBeenCalled()
} finally {
await ctx.fiber.dispose()
}
})
it('forks a resident Session without consulting disk metadata or requiring a live Agent', async () => {
const ctx = await composed()
try {
const source = ctx.sessions.create(sid('resident-source'), { meta: { cwd: '/proj' } })
source.append('turn/start', { turn: 1 })
source.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
const stat = vi.fn(() => Promise.reject(new Error('unexpected metadata read')))
ctx.provide('sessionPersistence', testSessionPersistence(ctx, { stat }) as never)
const result = await remote(ctx).fork({ sessionId: source.id, allowMigration: false })
if (!result.ok) throw result.error
expect(stat).not.toHaveBeenCalled()
expect(ctx.agents.get(source.id)).toBeUndefined()
expect(ctx.sessions.get(result.value.sessionId)?.inheritedEventCount).toBe(2)
} finally {
await ctx.fiber.dispose()
}
})
it('forks a source attached while its historical format stat is pending', async () => {
const ctx = await composed()
try {
const sourceId = sid('attached-during-stat')
const source = ctx.sessions.prepare(sourceId, { meta: { cwd: '/proj' } })
const snapshot: SessionPersistenceSnapshot = {
header: source.header,
revision: SessionPersistenceRevision('test:attaching-source'),
formatStatus: 'migration-required',
}
const entered = Promise.withResolvers<undefined>()
const finish = Promise.withResolvers<SessionPersistenceSnapshot | undefined>()
const stat = vi.fn(() => {
entered.resolve(undefined)
return finish.promise
})
ctx.provide('sessionPersistence', testSessionPersistence(ctx, { stat }) as never)
const operation = remote(ctx).fork({ sessionId: sourceId, allowMigration: false })
try {
await entered.promise
const live = ctx.sessions.create(sourceId, { meta: source.header })
live.append('turn/start', { turn: 1 })
live.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
finish.resolve(snapshot)
const result = await operation
if (!result.ok) throw result.error
expect(stat).toHaveBeenCalledExactlyOnceWith(sourceId)
expect(ctx.sessions.get(result.value.sessionId)?.inheritedEventCount).toBe(2)
} finally {
finish.resolve(undefined)
await Promise.allSettled([operation])
}
} finally {
await ctx.fiber.dispose()
}
})
it('reports a stat failure without treating it as required migration or opening the source', async () => {
const ctx = await composed()
try {
const sourceId = sid('failed-source-stat')
const stat = vi.fn(() => Promise.reject(new Error('source metadata unavailable')))
ctx.provide('sessionPersistence', testSessionPersistence(ctx, { stat }) as never)
const observe = vi.spyOn(ctx.sessionQuery, 'observeSession')
const create = vi.spyOn(ctx.agents, 'create')
await expect(remote(ctx).fork({ sessionId: sourceId, allowMigration: false })).resolves.toMatchObject({
ok: false, error: { code: 'gateway/internal', message: 'source metadata unavailable' },
})
expect(stat).toHaveBeenCalledExactlyOnceWith(sourceId)
expect(observe).not.toHaveBeenCalled()
expect(create).not.toHaveBeenCalled()
} finally {
await ctx.fiber.dispose()
}
})
it('keeps the completed manual compaction checkpoint when atSeq is omitted', async () => {
const ctx = new Context()
try {
@@ -79,6 +79,31 @@ async function feedList(b: Bench, rows: FeedRow[]): Promise<void> {
}
describe('list store projection', () => {
it('publishes format-status changes and preserves a newer update over an in-flight list', async ({ bench }) => {
const b = bench()
const historical = {
sessionId: sid('s1'), updatedAt: 1, running: false, blank: false, agentAvailable: false,
formatStatus: 'migration-required' as const,
}
b.mock.remote.session.list.mockResolvedValue(ok({ items: [historical] }))
await b.svc.refresh()
await Promise.resolve()
expect(b.svc.list.getSnapshot().byId[sid('s1')]?.formatStatus).toBe('migration-required')
const pending = Promise.withResolvers<Awaited<ReturnType<typeof b.mock.remote.session.list>>>()
b.unblock.push(() => { pending.resolve(ok({ items: [historical] })) })
b.mock.remote.session.list.mockReturnValueOnce(pending.promise)
const refreshing = b.svc.refresh()
b.svc.handleSessionAdded({ ...historical, formatStatus: 'current' })
await Promise.resolve()
expect(b.svc.list.getSnapshot().byId[sid('s1')]?.formatStatus).toBe('current')
pending.resolve(ok({ items: [historical] }))
await refreshing
await Promise.resolve()
expect(b.svc.list.getSnapshot().byId[sid('s1')]?.formatStatus).toBe('current')
expect(b.mock.remote.session.projections).not.toHaveBeenCalled()
})
it('projects durable titles separately from cwd/id display fallbacks and parent links', async ({ bench }) => {
const b = bench()
b.svc.handleControlFrame({
@@ -730,7 +755,7 @@ describe('catalog-addressed navigation', () => {
const b = bench()
await feedList(b, [{ id: 'root' }])
b.mock.remote.session.projections.mockResolvedValue(ok({
asOfSeq: 0, values: { subagentCatalog: [{ id: sid('child'), createdAt: 1, mode: 'continuable', label: 'Child' }] },
kind: 'sequenced', asOfSeq: 0, values: { subagentCatalog: [{ id: sid('child'), createdAt: 1, mode: 'continuable', label: 'Child' }] },
}))
await b.svc.refreshProjections(sid('root'))
const address = b.svc.subagentAddress(sid('child'))!
@@ -761,7 +786,7 @@ describe('catalog-addressed navigation', () => {
type: 'projection', sessionId: sid('one-shot'), key: 'title', value: title, seq: 2,
})
b.mock.remote.session.projections.mockResolvedValue(ok({
asOfSeq: 0, values: { subagentCatalog: [{ id: sid('one-shot'), createdAt: 1, mode: 'one-shot' }] },
kind: 'sequenced', asOfSeq: 0, values: { subagentCatalog: [{ id: sid('one-shot'), createdAt: 1, mode: 'one-shot' }] },
}))
await b.svc.refreshProjections(sid('root'))
expect(b.svc.list.getSnapshot().byId[sid('one-shot')]?.displayTitle).toBe(title ?? 'one-shot')
@@ -780,16 +805,16 @@ describe('catalog-addressed navigation', () => {
b.mock.remote.session.projections.mockImplementation((payload) => {
const { sessionId } = payload as { sessionId: SessionId }
if (sessionId === sid('root')) {
return Promise.resolve(ok({ asOfSeq: 0, values: { subagentCatalog: [{ createdAt: 1,
return Promise.resolve(ok({ kind: 'sequenced', asOfSeq: 0, values: { subagentCatalog: [{ createdAt: 1,
id: sid('child'), mode: 'continuable', label: 'Child',
}] } }))
}
if (sessionId === sid('child')) {
return Promise.resolve(ok({ asOfSeq: 0, values: { title: 'Child session title', subagentCatalog: [{ createdAt: 1,
return Promise.resolve(ok({ kind: 'sequenced', asOfSeq: 0, values: { title: 'Child session title', subagentCatalog: [{ createdAt: 1,
id: sid('grandchild'), mode: 'continuable', label: 'Grandchild',
}] } }))
}
return Promise.resolve(ok({ asOfSeq: 0, values: { subagentCatalog: [] } }))
return Promise.resolve(ok({ kind: 'sequenced', asOfSeq: 0, values: { subagentCatalog: [] } }))
})
await feedList(b, [
{ id: 'root' },
@@ -819,16 +844,16 @@ describe('catalog-addressed navigation', () => {
b.mock.remote.session.projections.mockImplementation((payload) => {
const { sessionId } = payload as { sessionId: SessionId }
if (sessionId === sid('root')) {
return Promise.resolve(ok({ asOfSeq: 0, values: { subagentCatalog: [{ createdAt: 1,
return Promise.resolve(ok({ kind: 'sequenced', asOfSeq: 0, values: { subagentCatalog: [{ createdAt: 1,
id: sid('child'), mode: 'continuable', label: 'Child',
}] } }))
}
if (sessionId === sid('child')) {
return Promise.resolve(ok({ asOfSeq: 0, values: { subagentCatalog: [{ createdAt: 1,
return Promise.resolve(ok({ kind: 'sequenced', asOfSeq: 0, values: { subagentCatalog: [{ createdAt: 1,
id: sid('grandchild'), mode: 'continuable', label: 'Grandchild',
}] } }))
}
return Promise.resolve(ok({ asOfSeq: 0, values: { subagentCatalog: [] } }))
return Promise.resolve(ok({ kind: 'sequenced', asOfSeq: 0, values: { subagentCatalog: [] } }))
})
await feedList(b, [{ id: 'root' }])
await b.svc.refreshProjections(sid('root'))
@@ -907,6 +932,38 @@ describe('create', () => {
})
describe('fork', () => {
it.for([undefined, false, true])('forwards migration permission %s without opening the source', async (allowMigration, { bench }) => {
const b = bench()
b.mock.remote.session.fork.mockResolvedValueOnce(ok({ sessionId: sid('child') }))
const source = sid('source')
const permission = allowMigration === undefined ? {} : { allowMigration }
await expect(b.svc.fork({ sessionId: source, atSeq: 7, ...permission })).resolves.toBe(sid('child'))
expect(b.mock.remote.session.fork).toHaveBeenCalledExactlyOnceWith({ sessionId: source, atSeq: 7, ...permission })
expect(b.svc.binding(source)).toBeUndefined()
expect(b.mock.log.requests(FOLLOW)).toHaveLength(0)
})
it('preserves the Host migration refusal without creating or opening a Session', async ({ bench }) => {
const b = bench()
const source = sid('source')
const error = new RemoteError('session/migration-required', 'migration required', { sessionId: source })
const onCreated = vi.fn<(childId: SessionId) => void>()
b.mock.remote.session.fork.mockResolvedValueOnce(err(error))
const failure = await b.svc.fork({ sessionId: source, allowMigration: false, onCreated })
.catch((cause: unknown) => cause)
expect(failure).toBeInstanceOf(SessionForkError)
expect(failure).toMatchObject({ sourceSessionId: source, rpcError: error })
expect(b.mock.remote.session.fork).toHaveBeenCalledExactlyOnceWith({ sessionId: source, allowMigration: false })
expect(onCreated).not.toHaveBeenCalled()
expect(b.mock.remote.session.rename).not.toHaveBeenCalled()
expect(b.svc.list.getSnapshot().ids).toEqual([])
expect(b.mock.log.requests(FOLLOW)).toHaveLength(0)
})
it('propagates a failed fork without creating or retaining a child', async ({ bench }) => {
const b = bench()
const error = new RemoteError('session/not-found', 'source missing', { sessionId: sid('source') })
@@ -2,12 +2,10 @@ import { Context } from '@deepseek-ai/cordis'
import { provideWorkingDirectoryFixture } from '@deepseek-ai/dsh-agent-loop-testkit'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import SessionStore, { SESSION_FORMAT_VERSION, SessionLogOffset, SessionSeq, SessionId } from '@deepseek-ai/dsh-session'
import SessionStore, { SESSION_FORMAT_VERSION, SessionSeq, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
import { SessionQueryError } from '@deepseek-ai/dsh-session-query'
import type { SessionObservation } from '@deepseek-ai/dsh-session-query'
import SubagentRuntime from '@deepseek-ai/dsh-subagent'
import { describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
createSessionTestController,
installSessionReadTestServices,
@@ -41,8 +39,15 @@ const events: SessionEvent[] = [{
},
}]
const contexts: Context[] = []
afterEach(async () => {
vi.restoreAllMocks()
await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
})
async function bench() {
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
ctx.provide('sessionPersistence', testSessionPersistence(ctx, {
@@ -82,20 +87,48 @@ describe('SessionController subagent catalog', () => {
}
})
it('requests a stored parent catalog by id without loading an Agent', async () => {
it.each([false, true])('reads an uncached parent catalog without loading an Agent (explicit current format: %s)', async (withFormat) => {
const { ctx, controller } = await bench()
const stored = await ctx.sessionPersistence.stat(PARENT)
if (stored === undefined) throw new Error('parent fixture is missing')
const stat = vi.spyOn(ctx.sessionPersistence, 'stat').mockResolvedValueOnce(withFormat
? { ...stored, formatStatus: 'current' }
: stored)
const observe = vi.spyOn(ctx.sessionQuery, 'observeSession')
const open = vi.spyOn(ctx.sessionPersistence, 'open')
const resume = vi.spyOn(ctx.agents, 'resume').mockRejectedValue(new Error('unexpected Agent resume'))
const signal = new AbortController().signal
await expect(controller.projections({ sessionId: PARENT }, signal)).resolves.toMatchObject({ values: { subagentCatalog: [{
id: CHILD,
mode: 'continuable',
label: 'worker',
}] } })
expect(observe).toHaveBeenCalledOnce()
expect(observe).toHaveBeenCalledWith(PARENT, {
signal,
await expect(controller.projections({ sessionId: PARENT }, signal)).resolves.toMatchObject({
kind: 'sequenced', values: { subagentCatalog: [{ id: CHILD, mode: 'continuable', label: 'worker' }] },
})
expect(stat).toHaveBeenCalledWith(PARENT, { signal })
expect(observe).toHaveBeenCalledWith(PARENT, { signal })
expect(open).toHaveBeenCalledWith(PARENT, 'read', { signal })
expect(resume).not.toHaveBeenCalled()
expect(ctx.sessions.get(PARENT)).toBeUndefined()
expect(ctx.agents.get(PARENT)).toBeUndefined()
})
it('uses disk migration status instead of the normalized header version to defer a cold catalog read', async () => {
const { ctx, controller } = await bench()
const stored = await ctx.sessionPersistence.stat(PARENT)
if (stored === undefined) throw new Error('parent fixture is missing')
vi.spyOn(ctx.sessionPersistence, 'stat').mockResolvedValueOnce({
...stored,
formatStatus: 'migration-required',
})
const observe = vi.spyOn(ctx.sessionQuery, 'observeSession')
const open = vi.spyOn(ctx.sessionPersistence, 'open')
expect(stored.header.version).toBe(SESSION_FORMAT_VERSION)
await expect(controller.projections({ sessionId: PARENT }, new AbortController().signal))
.resolves.toEqual({ kind: 'migration-required', values: {} })
expect(observe).not.toHaveBeenCalled()
expect(open).not.toHaveBeenCalled()
expect(ctx.sessions.get(PARENT)).toBeUndefined()
expect(ctx.agents.get(PARENT)).toBeUndefined()
})
it('serves a live parent from its maintained registry state without folding its log', async () => {
@@ -113,7 +146,7 @@ describe('SessionController subagent catalog', () => {
const hydrate = vi.spyOn(ctx.sessionProjections, 'hydrate')
const result = await controller.projections({ sessionId: PARENT }, new AbortController().signal)
expect(result).toMatchObject({ values: { subagentCatalog: [{
expect(result).toMatchObject({ kind: 'sequenced', values: { subagentCatalog: [{
id: CHILD,
mode: 'one-shot',
label: 'live worker',
@@ -140,7 +173,7 @@ describe('SessionController subagent catalog', () => {
const unavailable = await controller.list({}, new AbortController().signal)
expect(unavailable.items.find(item => item.sessionId === PARENT)?.agentAvailable).toBe(false)
const result = await controller.projections({ sessionId: PARENT }, new AbortController().signal)
expect(result).toMatchObject({ asOfSeq: 0, values: { subagentCatalog: [{ id: CHILD, createdAt: 8, mode: 'one-shot' }] } })
expect(result).toMatchObject({ kind: 'sequenced', asOfSeq: 0, values: { subagentCatalog: [{ id: CHILD, createdAt: 8, mode: 'one-shot' }] } })
})
it('does not republish a Session when its Agent outlives the Session registration', async () => {
@@ -158,6 +191,7 @@ describe('SessionController subagent catalog', () => {
it('reads registered projections without a subagent provider or catalog key', async () => {
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
installSessionReadTestServices(ctx)
@@ -165,7 +199,8 @@ describe('SessionController subagent catalog', () => {
const session = ctx.sessions.create(PARENT, { meta: { cwd: '/workspace' } })
const expected = ctx.sessionProjections.snapshot(session)
expect(expected.values.subagentCatalog).toBeUndefined()
await expect(controller.projections({ sessionId: PARENT }, new AbortController().signal)).resolves.toEqual(expected)
await expect(controller.projections({ sessionId: PARENT }, new AbortController().signal))
.resolves.toEqual({ kind: 'sequenced', ...expected })
expect(ctx.agents.get(PARENT)).toBeUndefined()
})
@@ -183,36 +218,19 @@ describe('SessionController subagent catalog', () => {
})
})
it('maps query cancellation, missing projections, and unexpected failures', async () => {
it('reports a live snapshot failure without falling back to stored metadata', async () => {
const { ctx, controller } = await bench()
const observe = vi.spyOn(ctx.sessionQuery, 'observeSession')
observe.mockRejectedValueOnce(new SessionQueryError(
'query cancelled',
'SESSION_QUERY_ABORTED',
))
await expect(controller.projections({ sessionId: PARENT }, new AbortController().signal)).rejects.toMatchObject({
code: 'gateway/cancelled',
const parent = ctx.sessions.create(PARENT, { meta: { cwd: '/workspace' } })
const failure = new Error('projection snapshot failed')
const snapshot = vi.spyOn(ctx.sessionProjections, 'snapshot').mockImplementationOnce(() => {
throw failure
})
observe.mockRestore()
// An observation without the catalog state means no registry served it.
const stateless: SessionObservation = {
source: 'prepared',
header,
inheritedEventCount: SessionLogOffset(0),
events,
cursor: SessionSeq(0),
retain: () => stateless,
[Symbol.dispose]: () => {},
}
vi.spyOn(ctx.sessionQuery, 'observeSession').mockResolvedValueOnce(stateless)
await expect(controller.projections({ sessionId: PARENT }, new AbortController().signal)).rejects.toMatchObject({
code: 'session/projections-unavailable',
})
vi.spyOn(ctx.sessionQuery, 'observeSession').mockRejectedValueOnce(new Error('storage offline'))
const stat = vi.spyOn(ctx.sessionPersistence, 'stat')
await expect(controller.projections({ sessionId: PARENT }, new AbortController().signal)).rejects.toMatchObject({
code: 'gateway/internal',
cause: failure,
})
expect(snapshot).toHaveBeenCalledWith(parent)
expect(stat).not.toHaveBeenCalled()
})
})
+2 -2
View File
@@ -21,8 +21,8 @@
en: 2fdfe02f2ed4a258
zh: d748228ece579c4a
/deepseek-ai-dsh-app-boot/use-this-package/starting-the-app:
en: 1099528bde0e2aee
zh: b68772bdb9bb7856
en: 4a424cd401556f20
zh: 0b34dd18e0db6cea
/deepseek-ai-dsh-app-boot/use-this-package/profiles:
en: d50b0fdaf62353dd
zh: 0fccb6968602e5de
+1 -1
View File
@@ -40,7 +40,7 @@ installFailLoud('dsh')
const ctx = await boot('dsh', resolveConfigPath(argv[2], process.env.DSH_SNAPSHOT))
```
`installFailLoud` writes one labelled `util.inspect` diagnostic to stderr for an unhandled rejection or an uncaught exception, awaits the surface's release hook under a fixed timeout, and exits 1; control never returns to the failed operation, because only the throw site knows which state is intact, and the event loop runs only until the release settles or times out. With that entry point, startup keeps every plugin that can activate. An enabled failed plugin produces a labelled warning. A failed required entry makes startup dispose the whole app and exit nonzero; required ids absent from a profile and disabled required entries do not affect startup. The global required list covers shared Agent execution, application endpoints, and Web bootstrap/transport: `agent-loop`, `webserver`, `modules`, `connection`, `headless-runner`, `acp`, and `sdk-jsonrpc-server`.
`installFailLoud` writes one labelled `util.inspect` diagnostic to stderr for an unhandled rejection or an uncaught exception, awaits the surface's release hook under a fixed timeout, and exits 1; control never returns to the failed operation, because only the throw site knows which state is intact, and the event loop runs only until the release settles or times out. With that entry point, startup keeps every plugin that can activate. An enabled failed plugin produces a labelled warning. A failed required entry makes startup dispose the whole app and exit nonzero; required ids absent from a profile and disabled required entries do not affect startup. The global required list covers shared Agent execution, application endpoints, and Web bootstrap/transport: `agent-loop`, `webserver`, `web-runtime`, `modules`, `connection`, `headless-runner`, `acp`, and `sdk-jsonrpc-server`.
<a id="profiles"></a>
### Profiles
+1 -1
View File
@@ -40,7 +40,7 @@ installFailLoud('dsh')
const ctx = await boot('dsh', resolveConfigPath(argv[2], process.env.DSH_SNAPSHOT))
```
`installFailLoud` 会为未处理 rejection 或未捕获异常向 stderr 写一条带标签的 `util.inspect` 诊断,在固定超时内等待界面的 release 钩子,然后以 1 退出;控制流不会回到失败的操作,因为只有抛出点知道哪些状态仍然完整,事件循环只运行到 release 结束或超时。有了这个入口,启动会保留所有能够激活的插件。启用但失败的插件会产生带标签的警告。required entry 失败时,启动会拆卸整个应用并以非零码退出;profile 中不存在的 required id 和已禁用的 required entry 不影响启动。全局 required list 覆盖共享 Agent 执行、应用 endpoint,以及 Web 启动与传输:`agent-loop`、`webserver`、`modules`、`connection`、`headless-runner`、`acp` 和 `sdk-jsonrpc-server`。
`installFailLoud` 会为未处理 rejection 或未捕获异常向 stderr 写一条带标签的 `util.inspect` 诊断,在固定超时内等待界面的 release 钩子,然后以 1 退出;控制流不会回到失败的操作,因为只有抛出点知道哪些状态仍然完整,事件循环只运行到 release 结束或超时。有了这个入口,启动会保留所有能够激活的插件。启用但失败的插件会产生带标签的警告。required entry 失败时,启动会拆卸整个应用并以非零码退出;profile 中不存在的 required id 和已禁用的 required entry 不影响启动。全局 required list 覆盖共享 Agent 执行、应用 endpoint,以及 Web 启动与传输:`agent-loop`、`webserver`、`web-runtime`、`modules`、`connection`、`headless-runner`、`acp` 和 `sdk-jsonrpc-server`。
<a id="profiles"></a>
### Profile
+1
View File
@@ -748,6 +748,7 @@ const FIBER_DISPOSED = 4 as FiberState.DISPOSED
const requiredStartupEntryIds = new Set<string>([
'agent-loop',
'webserver',
'web-runtime',
'modules',
'connection',
'headless-runner',
Loaded 100 of 223 files, more files were not shown because too many files have changed in this diff. Show more