Merge pull request #4583 from deepseek-harness/worktree/v4-lossless-tool-results-20260919
fix(session): preserve extensions while flattening V3 tool results
This commit is contained in:
commit
2e061ec93f
256 files changed
+5546
-1939
No files matched your search
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md
|
||||
2026-08-31-released-session-format-migrations.md: 379b4b735e1aaacb664faac57831b842de82c743
|
||||
2026-08-31-released-session-format-migrations.zh.md: 0a2790f81987deb145845295064b1ef5a3bbcba7
|
||||
2026-08-31-released-session-format-migrations.md: 5e885e110e9c720f2b72a374c9bcacd17d6addfe
|
||||
2026-08-31-released-session-format-migrations.zh.md: a079ae8f47b4b941accf6c759f3d386117a6a548
|
||||
+2
-2
@@ -79,7 +79,7 @@ Preset renames cover the creation header and every selection event because the l
|
||||
|
||||
A source inherited count can be unknown before EOF: V2 derives it from seed markers, and V1→V2 can change cardinality. The chain passes that absence to the next stage instead of fabricating a count. The [V2-to-V3 inheritance rules](../../../../packages/session/session-format-v2-to-v3/README.md#sequence-references) support this case; older stages that require a header-supplied count still refuse when it is absent. This permits seeded multi-hop restoration without retaining an intermediate artifact array.
|
||||
|
||||
The [version and release-status reference](../../../../docs/session-format-status.md) owns the published-format record and identifies the code’s writer authority. Released formats retain their semantics; committed generations remain byte-preserved during migration. A subsequent structural change requires the next adjacent edge under the [versioning rule](2026-08-10-session-log-version-mechanism.md), not an amendment to a released conversion. Ordinary event additions follow that rule’s required-event refusal mechanism rather than automatically allocating a version. A current-format file does not rerun its incoming migration; integration tests use isolated disposable homes and unchanged historical inputs.
|
||||
The [version and release-status reference](../../../../docs/session-format-status.md) owns the published-format record and identifies the code’s writer authority. Released formats retain their semantics; committed generations remain byte-preserved during migration. A breaking change to the released target format requires the next adjacent edge under the [versioning rule](2026-08-10-session-log-version-mechanism.md). Incoming-converter fixes affect later historical conversions, not existing target files; the [cookbook](../../../../docs/cookbook/adding-a-session-format-version.md) owns compatibility review and alpha support policy. Ordinary event additions follow that rule’s required-event refusal mechanism rather than automatically allocating a version. Integration tests use isolated disposable homes and unchanged historical inputs.
|
||||
|
||||
The [committed-corpus inventory](../../../../packages/test-support/llm-replay/tests/session-format-corpus-inventory.ts) identifies deliberately unsupported historical conversions by source path, generation, and exact refusal reason. Retaining those artifacts must not force chronology-changing migration or permit a blanket skip: every listed artifact must still raise the typed migration refusal, and unlisted artifacts must restore. Native current-generation fixtures cannot be classified as unsupported, because they do not traverse an incoming edge. Headerless test-harness protocol examples remain a separate explicit class. The corpus test checks source bytes after both successful and refused restoration; it does not rewrite historical evidence to satisfy the current reader.
|
||||
|
||||
@@ -129,7 +129,7 @@ Existing write handles retain the process-local claim and kernel-backed cross-pr
|
||||
|
||||
### Parent catalog prerequisites
|
||||
|
||||
V3→V4 completes parent child discovery from retained direct-child headers and own descriptors. Storage supplies compact descriptor evidence to the catalog assembly, which binds it into the V3→V4 stage factory; the Stage waits for the parent's final inherited cut before validating catalog payloads and requiring complete own discovery facts. Nested seed markers discard inherited catalog candidates without interpreting their payloads. An existing catalog entry survives an unavailable descriptor, including a child that failed before its first step. Descriptor v1 denotes continuable mode; v2/v3 explicitly record mode. Available discovery fields must agree with the parent; absent entries require a supported descriptor. Missing evidence refuses migration rather than reporting parent corruption. JSONL rechecks related membership and source revisions when preparing, reusing, and publishing. An unsupported generation or unreadable header elsewhere in that scan refuses migration because it cannot prove unrelatedness. Source drift invalidates preparation; read open retries once automatically, while write open refuses publication. Diagnostics retain the offending child path and distinguish unsupported migration evidence from malformed child data. The storage decoder classifies physical JSON and compression failures before the collector adds child context; filesystem reads and cancellation keep their original errors. The V0–V3-only catalog avoids recursive child migration. Current V4 reads skip that scan but apply the same own-catalog field, uniqueness, and current delivery ownership checks as strict restoration. See the [format specification](../../../../packages/session/session-format-v3-to-v4/README.md).
|
||||
V3→V4 completes parent child discovery from retained direct-child headers and own descriptors. Storage supplies compact descriptor evidence to the catalog assembly, which binds it into the V3→V4 stage factory; the Stage waits for the parent's final inherited cut before validating catalog payloads and deriving available own discovery facts. Nested seed markers discard inherited catalog candidates without interpreting their payloads. An existing catalog entry survives an unavailable descriptor, including a child that failed before its first step. Descriptor v1 denotes continuable mode; v2/v3 explicitly record mode. Available unambiguous discovery fields must agree with the parent; absent entries require one supported descriptor. Missing, unsupported, or multiple descriptors preserve the logs without backfilling that child, as recorded in [incomplete child evidence](../bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.md). JSONL rechecks related membership and source revisions when preparing, reusing, and publishing. An unsupported generation or unreadable header elsewhere in that scan refuses migration because it cannot prove unrelatedness. Source drift invalidates preparation; read open retries once automatically, while write open refuses publication. Diagnostics retain the offending child path and distinguish unsupported migration evidence from malformed child data. The storage decoder classifies physical JSON and compression failures before the collector adds child context; filesystem reads and cancellation keep their original errors. The V0–V3-only catalog avoids recursive child migration. Current V4 reads skip that scan but apply the same own-catalog field, uniqueness, and current delivery ownership checks as strict restoration. See the [format specification](../../../../packages/session/session-format-v3-to-v4/README.md).
|
||||
|
||||
Raw parent/child migration tests retain 24 historical creation-time conflicts as rejection evidence. Detached controls align only the child creation time and prove event-preserving restoration, including a published child without a descriptor. Headless/ACP and SDK snapshot adapters validate live parent/child clocks before normalization and preserve their equality during refresh; committed predecessor generations remain unchanged.
|
||||
|
||||
|
||||
+2
-2
@@ -79,7 +79,7 @@ Chain 中不存在 `flatMap`、spread expansion、中间 event array 或 schedul
|
||||
|
||||
源继承数量在 EOF 前可能未知:V2 从种子标记推导它,而 V1→V2 可以改变事件数量。迁移链将这种缺失传递给下一个 Stage,而不伪造数量。[V2 到 V3 继承规则](../../../../packages/session/session-format-v2-to-v3/README.zh.md#sequence-references)支持此情况;需要 header 提供数量的旧 Stage 仍在数量缺失时拒绝。这使有种子的多跳恢复无需保留中间产物数组。
|
||||
|
||||
[版本与发布状态参考](../../../../docs/session-format-status.zh.md)拥有已发布格式记录,并指明代码中的写入器真源。已发布格式保留其语义;迁移期间已提交代际的字节保持不变。后续结构性变更必须按[版本规则](2026-08-10-session-log-version-mechanism.zh.md)添加下一条相邻迁移边,而非修改已发布转换。普通事件新增遵循该规则的必需事件拒绝机制,而非自动分配版本。当前格式文件不会重新执行入边迁移;集成测试使用隔离、可丢弃的 home 和未变更的历史输入。
|
||||
[版本与发布状态参考](../../../../docs/session-format-status.zh.md)拥有已发布格式记录,并指明代码中的写入器真源。已发布格式保留其语义;迁移期间已提交代际的字节保持不变。对已发布目标格式的破坏性变更必须按[版本规则](2026-08-10-session-log-version-mechanism.zh.md)添加下一条相邻迁移边。传入转换器修复影响之后的历史转换,不影响已有目标格式文件;[cookbook](../../../../docs/cookbook/adding-a-session-format-version.zh.md)拥有兼容性评审和 alpha 支持政策。普通事件新增遵循该规则的必需事件拒绝机制,而非自动分配版本。集成测试使用隔离、可丢弃的 home 和未变更的历史输入。
|
||||
|
||||
[已提交语料清单](../../../../packages/test-support/llm-replay/tests/session-format-corpus-inventory.ts) 按源路径、代际与精确拒绝原因标识有意不支持的历史转换。保留这些产物不能迫使迁移改变时序,也不能允许统一跳过:每个清单中的产物仍必须抛出类型化迁移拒绝,未列入的产物必须还原。原生当前代际 fixture 不经过入边,因此不能被归为不支持。没有版本 header 的测试框架协议示例保持为独立的显式类别。语料测试在还原成功和拒绝后都检查源字节;它不通过改写历史证据来满足当前 reader。
|
||||
|
||||
@@ -129,7 +129,7 @@ POSIX publication 使用 hard-link creation 加目录 sync;Windows 使用 no-o
|
||||
|
||||
### 父目录前置事实
|
||||
|
||||
V3→V4 使用保留的直属子 Session header 和自身 descriptor 补齐父 Session 的子代理发现记录。存储将紧凑的 descriptor 证据提供给 catalog 组装处,由其绑定到 V3→V4 stage 工厂;Stage 等待父 Session 的最终继承截点确定后,才校验目录 payload 并要求完整的自身发现事实。嵌套的 seed 标记丢弃继承目录候选,不解释其 payload。已有目录记录允许 descriptor 不可用,包括在首次 step 之前失败的子 Session。Descriptor v1 表示 continuable 模式;v2/v3 显式记录模式。可获得的发现字段必须与父目录一致;缺失记录要求受支持的 descriptor。证据不足会拒绝迁移,而不是报告父日志损坏。JSONL 在准备、复用和发布时重新检查关联成员与来源修订。扫描中其他任何不支持代际或不可读 header 都会拒绝迁移,因为无法证明它与父 Session 无关。来源变化使准备缓存失效;只读打开自动重试一次,写打开则拒绝发布。诊断保留出错子日志的路径,并区分不支持的迁移证据与畸形子数据。存储解码器先将物理 JSON 与压缩错误归类,再由收集器补充子日志信息;文件系统读取与取消保留原始错误。仅支持 V0–V3 的目录避免递归迁移子 Session。当前 V4 读取跳过该扫描,但会执行与严格恢复相同的自身目录字段、唯一性及当前投递归属检查。见[格式规范](../../../../packages/session/session-format-v3-to-v4/README.zh.md)。
|
||||
V3→V4 使用保留的直属子 Session header 和自身 descriptor 补齐父 Session 的子代理发现记录。存储将紧凑的 descriptor 证据提供给 catalog 组装处,由其绑定到 V3→V4 stage 工厂;Stage 等待父 Session 的最终继承截点确定后,才校验目录 payload 并导出可获得的自身发现事实。嵌套的 seed 标记丢弃继承目录候选,不解释其 payload。已有目录记录允许 descriptor 不可用,包括在首次 step 之前失败的子 Session。Descriptor v1 表示 continuable 模式;v2/v3 显式记录模式。可获得且明确的发现字段必须与父目录一致;缺失记录要求一个受支持的 descriptor。Descriptor 缺失、版本不受支持或存在多个时,保留日志但不补填对应子 Session,理由见[不完整子目录证据](../bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.zh.md)。JSONL 在准备、复用和发布时重新检查关联成员与来源修订。扫描中其他任何不支持代际或不可读 header 都会拒绝迁移,因为无法证明它与父 Session 无关。来源变化使准备缓存失效;只读打开自动重试一次,写打开则拒绝发布。诊断保留出错子日志的路径,并区分不支持的迁移证据与畸形子数据。存储解码器先将物理 JSON 与压缩错误归类,再由收集器补充子日志信息;文件系统读取与取消保留原始错误。仅支持 V0–V3 的目录避免递归迁移子 Session。当前 V4 读取跳过该扫描,但会执行与严格恢复相同的自身目录字段、唯一性及当前投递归属检查。见[格式规范](../../../../packages/session/session-format-v3-to-v4/README.zh.md)。
|
||||
|
||||
原始父子迁移测试保留 24 个历史创建时间冲突作为拒绝证据。独立的内存对照只对齐子创建时间,证明恢复保留事件,包括已发布但没有 descriptor 的子 Session。Headless/ACP 与 SDK 快照适配器在规范化之前校验实时父子时钟,并在刷新时保持两者相等;已提交的前代文件保持不变。
|
||||
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-09-producer-owned-message-sources.md
|
||||
2026-09-09-producer-owned-message-sources.md: 484a082607a02d469c63e17d66dc7726b3a5453a
|
||||
2026-09-09-producer-owned-message-sources.zh.md: 4fc80b66b9f1c918a52e03d636224d363edefb1b
|
||||
2026-09-09-producer-owned-message-sources.md: 83f8cb9368b516b7b6fde8717f9e5f8e9d4f7d4e
|
||||
2026-09-09-producer-owned-message-sources.zh.md: f5cdfc48286f7b92883938c8e316a7751580f52c
|
||||
@@ -6,11 +6,11 @@ English | [中文](2026-09-09-producer-owned-message-sources.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Released V3 writers stamped every plugin-produced message with the shared wrapper `{ kind: 'plugin', plugin: '<producer>' }` plus the producer's context-form fields, so consumers had to treat one synthetic kind specially instead of switching on the producer identity. Once tool results became first-class tool-role messages and the current generation moved to V4, the format layer also had to keep reading released V3 files whose rows still carry the wrapper.
|
||||
Released V3 plugin attribution uses the shared wrapper `{ kind: 'plugin', plugin: '<producer>' }` plus the producer's context-form fields, so consumers had to treat one synthetic kind specially instead of switching on the producer identity. Once tool results became first-class tool-role messages and the current generation moved to V4, the format layer also had to keep reading released V3 files whose rows still carry the wrapper.
|
||||
|
||||
## Decision
|
||||
|
||||
Message sources are producer-owned: the producer identity is the `kind` itself, declared per plugin through `MessageSourceMap` augmentation, and the released `plugin` field no longer exists in current rows. The V3-to-V4 migration rewrites released plugin attribution onto producer kinds with a frozen rename table in `packages/session/session-format-v3-to-v4/src/sources.ts`: `@deepseek-ai/dsh-system-prompt` becomes `system-prompt` for system-role messages and `runtime-context` otherwise; `compact` becomes `compact-checkpoint`; `tools-code-mode` and `tools-ptc` become `ptc-mode`; `dsh-compaction-basic` becomes `compact-basic`; any other plugin string keeps its own name as the kind. The wrapper drops without losing identity, context-form fields survive, and tool results lift from released user-role wrapper rows into first-class tool-role messages in the same migration. Rename lookup uses only own keys, and reconstruction preserves every own JSON metadata property, including `__proto__`; legal external producer names cannot resolve to JavaScript prototype members.
|
||||
Message sources are producer-owned: `kind` identifies the producer, with declarations contributed through `MessageSourceMap`. The V3-to-V4 migration uses its fixed producer table: `@deepseek-ai/dsh-system-prompt` becomes `system-prompt` for system-role messages and `runtime-context` otherwise; `compact` becomes `compact-checkpoint`; `tools-code-mode` and `tools-ptc` become `ptc-mode`; `dsh-compaction-basic` becomes `compact-basic`; known same-name producers retain their kind. Unknown plugin names receive `plugin:` before the complete original name, while direct source kinds retain their original names. The prefix keeps a plugin named `user` separate from the human-user kind; this edge leaves direct attribution unchanged. Original context-form fields and other own JSON metadata survive; only the old wrapper's identity fields receive their specified conversion. Rename lookup uses own keys, so `__proto__` and `constructor` remain data.
|
||||
|
||||
Native V4 source admission rejects the retired `kind: 'plugin'` wrapper in every declared durable message slot, including inbox and title-request messages. This refusal applies before recoverable scanning can discard a suffix. Complete source-slot validation runs in the shared known-event pass used by both the format catalog and native JSONL scanner, before either exposes a restored artifact or handle. Unknown nonempty attribution kinds and their extra JSON fields survive without their producer installed. Native compaction and title relationship checks use the producer kinds directly; the [mandatory validation decision](2026-09-17-native-v4-read-validation.md) owns common lifecycle rules.
|
||||
|
||||
|
||||
+2
-2
@@ -6,11 +6,11 @@ Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
已发布的 V3 写入方把每个插件产出的消息都盖上共享包装`{ kind: 'plugin', plugin: '<producer>' }` 外加生产者的上下文形态字段,消费方因此必须特殊对待一个合成 kind,而不是直接按生产者身份切换。工具结果成为一等 tool-role 消息、当前世代进入 V4 之后,格式层还必须继续读取仍带包装的已发布 V3 文件。
|
||||
已发布 V3 的插件归属使用共享包装 `{ kind: 'plugin', plugin: '<producer>' }` 外加生产者的上下文形态字段,消费方因此必须特殊对待一个合成 kind,而不是直接按生产者身份切换。工具结果成为一等 tool-role 消息、当前世代进入 V4 之后,格式层还必须继续读取仍带包装的已发布 V3 文件。
|
||||
|
||||
## Decision
|
||||
|
||||
消息源是生产者归属的:生产者身份就是 `kind` 本身,由各插件通过 `MessageSourceMap` 声明合并,当前行中不再存在已发布的 `plugin` 字段。V3→V4 迁移用 `packages/session/session-format-v3-to-v4/src/sources.ts`中的冻结重命名表把已发布 plugin 归属改写到生产者 kind:`@deepseek-ai/dsh-system-prompt` 在 system-role 消息上变成 `system-prompt`,否则变成 `runtime-context`;`compact` 变成 `compact-checkpoint`;`tools-code-mode` 与 `tools-ptc` 变成 `ptc-mode`;`dsh-compaction-basic` 变成 `compact-basic`;其它 plugin 字符串保留自身名字作为 kind。包装丢弃时不丢失身份,上下文形态字段保留,工具结果在同一迁移中从已发布的 user-role 包装行提升为一等 tool-role 消息。重命名查找只读取自有键,重建时保留所有自有 JSON 元数据属性,包括 `__proto__`;合法的外部生产者名字不会被解析为 JavaScript 原型成员。
|
||||
消息源由生产者拥有:`kind` 标识生产者,各方通过 `MessageSourceMap` 提供声明。V3-to-V4 迁移使用固定生产者表:`@deepseek-ai/dsh-system-prompt` 在 system-role 消息上变成 `system-prompt`,否则变成 `runtime-context`;`compact` 变成 `compact-checkpoint`;`tools-code-mode` 与 `tools-ptc` 变成 `ptc-mode`;`dsh-compaction-basic` 变成 `compact-basic`;已知同名生产者保留 kind。未知插件名在完整原名之前添加 `plugin:`,直接 source kind 则保留原名。该前缀将名为 `user` 的插件与人类用户 kind 区分开;本边保持直接归属不变。原上下文形态字段及其他自有 JSON 元数据保留;只有旧包装的身份字段执行指定转换。重命名查找使用自有键,因此 `__proto__` 和 `constructor` 仍是数据。
|
||||
|
||||
原生 V4 消息源准入在每个声明的持久化消息槽位拒绝退役的 `kind: 'plugin'` 包装,包括 inbox 和标题请求消息。可恢复扫描丢弃后缀之前就会执行此拒绝。完整消息源槽位校验在格式目录和原生 JSONL 扫描器共用的已知事件校验中执行,先于两者公开恢复产物或句柄。即使生产者未安装,未知的非空归属 kind 及额外 JSON 字段仍原样保留。原生压缩和标题关系校验直接使用生产者 kind;[强制校验决策](2026-09-17-native-v4-read-validation.zh.md)负责通用生命周期规则。
|
||||
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-15-first-class-tool-role-messages.md
|
||||
2026-09-15-first-class-tool-role-messages.md: c16bc44116b1b2c20c988db4b6977ee31bea4ab4
|
||||
2026-09-15-first-class-tool-role-messages.zh.md: f9eb581f6c080655145b3c9ab1c55d052fe871b8
|
||||
2026-09-15-first-class-tool-role-messages.md: 3749ad698890073634b1753d3370f9c9dc9a2e18
|
||||
2026-09-15-first-class-tool-role-messages.zh.md: c97d84a7b3ceaee17ebb6fe828bd739dba5fb0ea
|
||||
@@ -12,7 +12,7 @@ The released Session format stores tool results inside a user-role `tool-result`
|
||||
|
||||
Session format V4 stores one tool result as a first-class `role: 'tool'` message. Its `toolCallId`, tool source call id, result content, and optional error flag are validated together. The V3-to-V4 edge lifts exactly one released wrapper and refuses nested wrappers before publication, retaining the source generation. Current V4 decoding is native-only; wrapper rows are accepted only by the adjacent migration edge.
|
||||
|
||||
Outer message JSON extensions retain their own properties during lifting, including `__proto__` and `constructor`. An existing `toolCallId` must match the source call; an existing boolean `isError` must match the wrapper's error state, with omission meaning non-error. Unknown wrapper fields and conflicting outer fields refuse migration because the new representation provides no safe interpretation for them. Rebuilding only known message fields would silently delete admitted V3 data; copying wrapper extensions onto the message would invent their scope.
|
||||
Historical message and result extension fields keep distinct `plugin:message:` and `plugin:result:` names when their wrapper is removed. Complete original field names remain in each suffix, preventing collisions without adding core metadata fields. Opaque retention avoids inventing a shared field owner when the wrapper disappears. It retains duplicate values from distinct owners, and plugin consumers must use the V4 names. The wrapper alone supplies the interpreted call id and error flag; an old outer field with the same spelling remains opaque. The [conversion specification](../../../../packages/session/session-format-v3-to-v4/README.md#tool-results) owns exact preservation and refusal rules.
|
||||
|
||||
The conversation role map is closed. A model-visible role must have a persisted Session event and an adapter projection, so adapters do not silently turn an unlogged extension role into user text. Provider-specific source kinds remain merge-extensible and are owned by their producers.
|
||||
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ Status: implemented
|
||||
|
||||
Session format V4 将一次工具结果存储为一等 `role: 'tool'` 消息。它的 `toolCallId`、工具来源调用标识、结果内容和可选错误标记一起校验。V3 到 V4 的边缘迁移只提升一个已发布的包装,并在发布前拒绝嵌套包装,保留源代际。当前 V4 解码仅接受原生形式;包装行只由相邻迁移边缘接受。
|
||||
|
||||
外层消息的 JSON 扩展在提升时保留为自有属性,包括 `__proto__` 和 `constructor`。已有 `toolCallId` 必须与 source call 一致;已有布尔 `isError` 必须与 wrapper 的错误状态一致,省略表示非错误。未知 wrapper 字段和冲突的外层字段会拒绝迁移,因为新表示没有安全的解释方式。只重建已知消息字段会静默删除 V3 已接受的数据;将 wrapper 扩展复制到消息上则会凭空改变其作用范围。
|
||||
移除历史 wrapper 时,消息与结果的扩展字段分别保留为 `plugin:message:` 和 `plugin:result:` 名称。后缀保留完整原字段名,避免冲突且不添加核心 metadata 字段。不透明保留避免在 wrapper 消失时虚构共同字段 owner。不同 owner 的重复值仍分别保留,插件消费者必须使用 V4 名称。只有 wrapper 提供具有解释语义的调用 id 和错误标志;同名的旧外层字段保持不透明。[转换规范](../../../../packages/session/session-format-v3-to-v4/README.zh.md#tool-results)拥有精确的保留与拒绝规则。
|
||||
|
||||
对话角色 map 是封闭的。模型可见角色必须有持久 Session 事件和适配器投影,因此适配器不会把未记录的扩展角色静默转换为 user 文本。提供方特有的来源 kind 仍然可合并扩展,并由各生产方拥有。
|
||||
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-17-persistence-attribution-policy.md
|
||||
2026-09-17-persistence-attribution-policy.md: 0fbf5ba83a65e71e7fba1c1c223931aa0962b944
|
||||
2026-09-17-persistence-attribution-policy.zh.md: 235e4dba19951a2bd34ad397e09f449ee5c4708d
|
||||
2026-09-17-persistence-attribution-policy.md: 19bebc8bc186f4b2b7e2e561fb6a53ceadcebabf
|
||||
2026-09-17-persistence-attribution-policy.zh.md: 3561c1d1afca5402cb94b1578603c4f1a2fc89a4
|
||||
@@ -16,7 +16,7 @@ The tmux-context producer qualifies its location attribution. Its own projection
|
||||
|
||||
`RequestMessage` accepts a durable `Message` or a user-only `RequestUserInput` with content and no `id` or `source`. The LLM service and provider serializers accept both; Session writes, Agent delivery, and persisted title-request inputs require durable messages. Auto Review's outer prompt and the compaction summarizer's final instruction use request-only inputs and retain their content and freezing. The caller receives no implicit durable conversion. Assistant replay metadata and tool-result correlation retain their existing requirements.
|
||||
|
||||
The finalized V4 acknowledgement records the user-source policy and removes the two request-only source registrations. Its original predecessors and header transition remain intact. This does not grant a general exception for removing accepted variants: the [producer-source migration](2026-09-09-producer-owned-message-sources.md) preserves its frozen rename and collision rules, including historical request-only kinds, independently of active source declarations. Catalog generation and comparison remain owned by the [persistence reference](../../../../docs/persistence-changes/README.md#compatibility-rules).
|
||||
The finalized V4 acknowledgement records the user-source policy and removes the two request-only source registrations. Its original predecessors and header transition remain intact. This does not grant a general exception for removing accepted variants: the [producer-source migration](2026-09-09-producer-owned-message-sources.md) preserves its producer mappings and extension namespaces, including historical request-only kinds, independently of active source declarations. Catalog generation and comparison remain owned by the [persistence reference](../../../../docs/persistence-changes/README.md#compatibility-rules).
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ tmux-context 生产者将位置归属标记为符合条件。其自身投影使
|
||||
|
||||
`RequestMessage` 接受持久化 `Message`,或仅含 content、没有 `id` 和 `source` 的 user 专用 `RequestUserInput`。LLM 服务和 provider 序列化器接受两者;Session 写入、Agent 交付及持久化标题请求输入要求持久化消息。Auto Review 的外层提示和压缩摘要器的最终指令使用仅供请求使用的输入,并保留内容与冻结行为。调用方不会获得隐式持久化转换。Assistant 回放元数据和工具结果关联保持原有要求。
|
||||
|
||||
已定稿的 V4 确认记录保存 user-source 策略,并移除两个仅供请求使用的 source 注册。原有前驱和 header 迁移保持不变。这不构成删除已接受分支的通用例外:[producer-source 迁移](2026-09-09-producer-owned-message-sources.zh.md)独立于活动 source 声明保留冻结的重命名和冲突规则,包括历史的仅供请求使用的 kind。目录生成和比较仍由[持久化参考](../../../../docs/persistence-changes/README.zh.md#compatibility-rules)负责。
|
||||
已定稿的 V4 确认记录保存 user-source 策略,并移除两个仅供请求使用的 source 注册。原有前驱和 header 迁移保持不变。这不构成删除已接受分支的通用例外:[producer-source 迁移](2026-09-09-producer-owned-message-sources.zh.md)独立于活动 source 声明保留生产者映射与扩展命名空间,包括历史的仅供请求使用的 kind。目录生成和比较仍由[持久化参考](../../../../docs/persistence-changes/README.zh.md#compatibility-rules)负责。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-19-lossless-projection-checkpoint-json.md
|
||||
2026-09-19-lossless-projection-checkpoint-json.md: 9c7d15dbd2d570eaa544f168d2c2ef882558d5ca
|
||||
2026-09-19-lossless-projection-checkpoint-json.zh.md: 8c2b720358351af33a6d07888002c65d0cc30363
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
# Agent Note: Lossless projection checkpoint JSON
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-19-lossless-projection-checkpoint-json.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Projection checkpoints can contain opaque extension data and message metadata. A JSON key named `__proto__` is ordinary recorded data. The Zod JSON parser drops that own key while rebuilding objects, so reopening a valid checkpoint can yield different projection state from replaying the Session log.
|
||||
|
||||
## Decision
|
||||
|
||||
The checkpoint value schema uses the existing `isJsonValue` predicate from `dsh-util-values`. It enforces the same lossless JSON rules as the checkpoint writer's `snapshotJsonValue` without rebuilding valid objects. Validation still rejects non-JSON and lossy values. Storage-domain table values are immutable borrowed records; the validator does not supply a defensive-copy guarantee.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Keep `z.json()`.** Its object reconstruction removes valid own keys, so a successful validation can still change the checkpoint value.
|
||||
- **Validate and clone with `snapshotJsonValue`.** This preserves keys but copies already immutable stored values. The read-only predicate matches the storage-domain borrowed-value contract.
|
||||
|
||||
## Consequences
|
||||
|
||||
Domain reads preserve every valid own key in checkpoint values, including nested `__proto__` and `constructor` properties. A projection's `stateSchema` still owns its hydration value; opaque fields must use validation that preserves their keys. The borrowed-value validator does not create an independent copy or project to JSON Schema.
|
||||
|
||||
The domain version remains unchanged because the stored JSON representation is unchanged; this corrects its reader. Regression coverage opens a synthetic fixture produced by the real writer, then writes, closes, and reopens it through `StorageDomain`. Session format versions and historical generations do not change. [Predecessor recovery and Session-format binding](2026-09-02-projcache-cross-version-read-compat.md) continue to own cache-version and identity compatibility.
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
# Agent Note: 无损投影检查点 JSON
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-19-lossless-projection-checkpoint-json.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
投影检查点可以包含不透明扩展数据和消息元数据。名为 `__proto__` 的 JSON 键是普通记录数据。Zod JSON 解析器在重建对象时丢弃该自有键,因此重新打开有效检查点可能得到与回放 Session 日志不同的投影状态。
|
||||
|
||||
## 决策
|
||||
|
||||
检查点值 schema 使用 `dsh-util-values` 中既有的 `isJsonValue` 谓词。它执行与检查点写入器 `snapshotJsonValue` 相同的无损 JSON 规则,不重建有效对象。校验仍拒绝非 JSON 和有损值。Storage-domain 表值是不可变的借用记录;校验器不提供防御性复制保证。
|
||||
|
||||
## 备选方案
|
||||
|
||||
- **保留 `z.json()`。** 其对象重建会移除有效自有键,因此校验成功仍可能改变检查点值。
|
||||
- **用 `snapshotJsonValue` 校验并复制。** 这能保留键,但会复制已经不可变的存储值。只读谓词符合 storage-domain 的借用值约定。
|
||||
|
||||
## 影响
|
||||
|
||||
Domain 读取保留检查点值中的每个有效自有键,包括嵌套的 `__proto__` 和 `constructor` 属性。Projection 的 `stateSchema` 仍拥有其 hydration 值;不透明字段必须使用保留其键的校验器。借用值校验器不生成独立副本,也不投影为 JSON Schema。
|
||||
|
||||
存储 JSON 表示未变,因此 domain 版本不变;本修改修正其读取器。回归测试打开真实写入器生成的合成 fixture,再通过 `StorageDomain` 写入、关闭并重新打开。Session 格式版本与历史代际不变。[前代恢复与 Session 格式绑定](2026-09-02-projcache-cross-version-read-compat.zh.md) 继续负责缓存版本与身份兼容性。
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.md
|
||||
2026-09-19-v3-incomplete-child-catalog-evidence.md: 22ca91502425257ed30aa4962f5a38497b8a8a92
|
||||
2026-09-19-v3-incomplete-child-catalog-evidence.zh.md: c11e6de2c27b9892a72c28f327b6d06834d84038
|
||||
@@ -0,0 +1,29 @@
|
||||
# Agent Note: Preserve V3 Sessions with incomplete child catalog evidence
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-19-v3-incomplete-child-catalog-evidence.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
A valid V3 child log can have no own descriptor, an unsupported descriptor version, or multiple descriptors. Its header still establishes its identity and parent relationship. Requiring one supported descriptor to migrate the parent makes unavailable discovery fields prevent access to otherwise readable history.
|
||||
|
||||
## Decision
|
||||
|
||||
The V3→V4 migration appends a missing parent catalog entry only when exactly one supported own child descriptor supplies its discovery fields. Other descriptor counts and unsupported versions contribute no new catalog fact. Existing parent entries and child events remain intact. This replaces only the missing-evidence refusal in the [adjacent migration decision](../architecture/2026-08-31-released-session-format-migrations.md).
|
||||
|
||||
Known child creation times must still match existing parent entries. Mode/label and descriptor fields are checked only for exactly one supported own descriptor. Invalid fields in that descriptor, corrupt logs, unreadable membership, unsupported selected generations, and changed source revisions retain their existing failures.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Refuse the whole parent.** Discovery metadata is insufficient to justify making valid parent history unavailable.
|
||||
|
||||
**Choose the first or last descriptor.** [`foldSubagentDescriptor()`](../../../../packages/subagent/subagent/src/descriptor.ts) takes the first under the establishing provider’s exactly-once rule; the [identity projection](../../../../packages/subagent/subagent/src/projection.ts) takes the last to override inherited identities. Multiple own records violate that rule. Backfill requires one own record rather than choosing between these consumer policies.
|
||||
|
||||
**Infer missing fields.** Invented mode or label fields would become durable catalog facts without evidence.
|
||||
|
||||
## Consequences
|
||||
|
||||
A historical child without a usable descriptor remains readable by id but may be absent from its parent's direct-child catalog. Existing catalog entries remain visible. Once a V4 successor is published, opening it does not rescan historical children; automatic later catalog repair is outside this migration. Preparation still rechecks child revisions before publication, so newly available evidence cannot silently bypass source consistency checks.
|
||||
|
||||
Unit, JSONL read/write, and Preview packing tests cover unavailable evidence, unchanged source bytes, retained entries, identity conflicts, and evidence changes before publication.
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
# Agent Note: 保留子目录证据不完整的 V3 Session
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-19-v3-incomplete-child-catalog-evidence.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
有效 V3 子日志可能没有自身 descriptor、descriptor 版本不受支持,或存在多个 descriptor。其 header 仍能确定身份及父子关系。要求一个受支持的 descriptor 才迁移父日志,会让不可获得的发现字段阻止读取原本可读的历史。
|
||||
|
||||
## Decision
|
||||
|
||||
V3→V4 迁移只在恰好一个受支持的自身子 descriptor 提供发现字段时,追加缺失的父目录项。其他 descriptor 数量及不受支持的版本不贡献新的目录事实。已有父目录项和子事件保持完整。这只替代[相邻迁移决策](../architecture/2026-08-31-released-session-format-migrations.zh.md)中的证据缺失拒绝规则。
|
||||
|
||||
已知子创建时间仍必须与已有父目录项一致。只有恰好一个受支持的自身 descriptor 时,才校验其字段并比较 mode/label。该 descriptor 字段无效、日志损坏、成员关系不可读、所选代际不受支持以及源修订变化仍保留原有失败行为。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**拒绝整个父 Session。** 发现元数据不足以成为禁止读取有效父历史的理由。
|
||||
|
||||
**选择首个或末个 descriptor。** [`foldSubagentDescriptor()`](../../../../packages/subagent/subagent/src/descriptor.ts) 依据建立提供方恰好写入一次的规则取首个;[身份投影](../../../../packages/subagent/subagent/src/projection.ts)取末个以覆盖继承身份。多个自身记录违反该规则。补填要求一个自身记录,而不在这两种消费者策略之间做选择。
|
||||
|
||||
**推断缺失字段。** 推断的 mode 或 label 会在没有证据的情况下成为持久目录事实。
|
||||
|
||||
## Consequences
|
||||
|
||||
没有可用 descriptor 的历史子 Session 仍可按 id 读取,但可能不出现在其父级的直属子目录中。已有目录项仍可见。V4 后继发布后,打开它不会重新扫描历史子日志;后续自动修复目录不属于本迁移。准备过程仍会在发布前复查子修订,因此新出现的证据无法悄悄绕过源一致性检查。
|
||||
|
||||
单元、JSONL 读写和 Preview 打包测试覆盖不可用证据、源字节不变、条目保留、身份冲突以及发布前的证据变化。
|
||||
@@ -1,45 +0,0 @@
|
||||
# Agent Note: Session unarchive Settings page
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-12-session-unarchive-settings-page.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Archiving a Session removed it from every Workspace grouping surface, and nothing brought it back. The archive set is a durable display filter, so a hidden Session kept its log, its Workspace accounting slot, and its position, but the only recovered route was editing the domain state by hand. The archived feature record predicted the restore surface as "one UI surface plus one inverse RPC"; both now exist, and the Client had no place that listed archived Sessions at all.
|
||||
|
||||
## Decision
|
||||
|
||||
`WorkspaceRegistry.unarchiveSession(sessionId)` drops one id from the registry-global `archivedSessionIds` set in a single durable `setState`, running on the same operation chain as archive, create, and delete. The idempotent check-then-write pair sits inside one chain slot, so a concurrent archive cannot interleave and a lost race resolves as a no-op. An id that is not archived resolves without writing.
|
||||
|
||||
Unarchiving runs no session-existence probe. Archive verifies that the Session is live or persisted because it adds a reference that must resolve; unarchive only removes an id, so it cannot introduce an unknown referent and an entry whose Session is gone still restores. `@Remote('unarchiveSession')` on `WorkspaceController` returns the complete `WorkspaceArchiveValue`, matching `archiveSession`, and `IWorkspaces.unarchiveSession` plus `UiWorkspace.unarchiveSession` carry the verb to the browser. Both verbs answer with the complete set, so `ClientWorkspaceModel` installs a reply only while it is still the latest archive-set request: a later request, or a set pushed by the follow stream, supersedes an in-flight answer and keeps the projection on the newer state.
|
||||
|
||||
The new Web Settings page `@deepseek-ai/dsh-client-ui-settings-unarchive-sessions` owns the surface. It registers one localized `settings.section` contribution with id `archived-sessions` at nav order 25, joins the archive set from `useWorkspaces` with the loaded Session summaries from `useSessions`, lists the newest archive first with the owning Workspace title or the ungrouped label and a relative last-activity time, filters by title or Workspace name, and offers one Unarchive action per row. A rejected write is logged as a console diagnostic and leaves the row for another attempt. An archive entry whose Session summary is missing produces no row, so the page never renders an action that cannot restore anything.
|
||||
|
||||
Restoring is a page whose subject is archived Sessions, and the archived Sessions themselves are hidden from every grouping surface, so no Session row can host the action. The archived-sessions page joins the Settings navigation beside General, Models, and Plugins, whose rail already carries the archive glyph this page declares in `SettingsRoot`.
|
||||
|
||||
The `{ type: 'archived', archivedSessionIds }` follow increment already carries the complete set, so a restore reuses it: the Remote answer installs locally, and every other Client converges through the same increment. No frame type, persisted field, or `SESSION_FORMAT_VERSION` change accompanies the verb.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**An Unarchive action in the Session row menu.** The row menu owns Archive, but a restored Session has no visible row until it is restored, so the action would have nowhere to live in the state that needs it; a disabled entry on every row would be decoration without a subject.
|
||||
|
||||
**A second frame type carrying a removal delta.** The existing `archived` increment is a complete-set replacement, so a removal delta would add a redundant representation that every consumer would have to merge; the full-snapshot posture is what lets the archive echo be reused unchanged.
|
||||
|
||||
**Rendering an archive entry whose Session is gone as a disabled row.** Such a row could explain the gap but not restore anything, and the registry and the Remote still accept the id, so the page lists what it can act on and leaves the orphan id to a future cleanup surface.
|
||||
|
||||
**A Session-existence probe on unarchive, mirroring archive.** The check exists to keep added references resolvable, and a removal cannot break that invariant; probing would only turn restoring a deleted history into a failure with nothing to repair.
|
||||
|
||||
## Consequences
|
||||
|
||||
The archive set stays the only durable state a restore rewrites; the `workspace` domain version, the Session log, and the Workspace accounting slot are untouched, and a restored Session returns to its recorded position. Archive and unarchive now enforce different Session checks, a deliberate asymmetry recorded in the [Workspace registry limitations](../../../../packages/workspace/workspace/README.md#known-limitations-and-deferred-work).
|
||||
|
||||
The restore surface is bounded by what the page can display: an archived id whose summary is not loaded has no row and no Unarchive action even though the registry method and the Remote accept it, so the page reports an unavailable set instead of an empty archive. Restoring through automation stays available, and a future cleanup surface can address the remaining orphan entries.
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/workspace/workspace/tests/workspace.spec.ts` pins the registry method: removal keeps the surviving archive order, the accounting slot stays, a repeat and a never-archived id neither rewrite the medium nor emit a change, an entry whose Session is gone resolves without a persistence listing, and the surviving set reloads across a restart. `packages/api/workspace-controller/tests/workspace-controller.host.spec.ts` pins the verb's idempotent complete-set answer and the unchanged `archived` follow increment, and `packages/api/workspace-controller/tests/model.client.spec.ts` pins the Client model echo and its refusal to install a failed answer, plus the four stale-reply races: overlapping archive or unarchive requests settling out of order, and a follow increment or baseline arriving while a reply is in flight. `packages/client/ui-settings-unarchive-sessions/tests/components.client.spec.tsx` pins newest-first ordering, the ungrouped label, the hidden entry whose Session is gone, the reading and empty states, search, and the console diagnostic on rejection, while `tests/browser-plugin.client.spec.tsx` pins the section registration.
|
||||
|
||||
## Related
|
||||
|
||||
- [Session archive (registry-global set)](../../archived/feature/2026-07-31-session-archive-global-set.md) — the frozen record of the archive set, the follow increment, and the predicted restore surface.
|
||||
@@ -1,45 +0,0 @@
|
||||
# Agent Note: Session unarchive Settings page
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-12-session-unarchive-settings-page.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
归档会话会把它从每一个 Workspace 分组界面中移除,而没有任何东西能把它带回来。归档集合是持久的显示过滤器,因此被隐藏的会话保留其日志、Workspace 记账位置与所在位置,但唯一的恢复途径是手工编辑领域状态。归档功能记录把恢复界面预测为「一个 UI 界面加一个反向 RPC」;两者如今都已存在,而 Client 此前根本没有列出已归档会话的地方。
|
||||
|
||||
## 决策
|
||||
|
||||
`WorkspaceRegistry.unarchiveSession(sessionId)` 在一次持久化 `setState` 中把某个 id 从注册表全局的 `archivedSessionIds` 集合里移除,并与 archive、create、delete 跑在同一条操作链上。幂等的「先检查再写入」位于同一个链槽内,因此并发归档无法插入其间,输掉竞态的一方会解析为无操作。未被归档的 id 不写盘即解析完成。
|
||||
|
||||
取消归档不做会话存在性探测。归档会校验会话处于实时或已持久化状态,因为它加入的引用必须可解析;取消归档只是移除一个 id,因此不可能引入未知引用,会话已不存在的条目也仍然能恢复。`WorkspaceController` 上的 `@Remote('unarchiveSession')` 返回完整的 `WorkspaceArchiveValue`,与 `archiveSession` 一致,而 `IWorkspaces.unarchiveSession` 与 `UiWorkspace.unarchiveSession` 把该动词带到浏览器。两个动词都返回完整集合,因此 `ClientWorkspaceModel` 只在该应答仍是最新归档集合请求时安装它:更晚的请求或 follow 流推送的集合都会取代在途应答,让投影停留在更新的状态上。
|
||||
|
||||
新的 Web 设置页 `@deepseek-ai/dsh-client-ui-settings-unarchive-sessions` 拥有该界面。它注册一个 id 为 `archived-sessions`、导航顺序为 25 的本地化 `settings.section` 贡献,把来自 `useWorkspaces` 的归档集合与来自 `useSessions` 的已加载 Session 摘要合并,按归档时间由新到旧列出,并显示为其记账的 Workspace 标题或未分组标签,以及相对最近活动时间;它按标题或 Workspace 名称过滤,并为每行提供一个取消归档操作。写入被拒绝时会记录一条 console 诊断,并保留该行以便再次尝试。缺失 Session 摘要的归档条目不产生行,因此该页面绝不会渲染无法恢复任何东西的操作。
|
||||
|
||||
恢复操作所在的页面以已归档会话为主题,而已归档会话本身从每一个分组界面中隐藏,因此没有任何 Session 行能承载该操作。「已归档会话」页在设置导航中与「通用」「模型」「插件」并列,而该导航轨道本就带有本页在 `SettingsRoot` 中声明的归档字形。
|
||||
|
||||
`{ type: 'archived', archivedSessionIds }` follow 增量本就携带完整集合,因此恢复复用它:Remote 应答在本地安装,其他每个 Client 都通过同一个增量收敛。该动词不伴随任何帧类型、持久字段或 `SESSION_FORMAT_VERSION` 变更。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**在 Session 行菜单放一个取消归档操作。** 行菜单拥有 Archive,但被恢复的会话在恢复之前没有可见的行,因此该操作在真正需要它的状态里无处安放;给每一行加一个禁用条目只是没有主体的装饰。
|
||||
|
||||
**新增一种携带移除增量的帧类型。** 现有的 `archived` 增量是完整集合替换,因此移除增量会增加一种每个消费方都必须合并的冗余表示;正是全快照姿态让归档回声得以原样复用。
|
||||
|
||||
**把会话已不存在的归档条目渲染为禁用行。** 这样的行可以解释缺口,却无法恢复任何东西,而注册表与 Remote 仍然接受该 id,因此该页面只列出它能操作的条目,把遗留 id 留给未来的清理界面。
|
||||
|
||||
**像归档一样,在取消归档时做会话存在性探测。** 该检查的存在是为了让新增的引用保持可解析,而移除不可能破坏这一不变式;探测只会把恢复已删除的历史变成一次无从修复的失败。
|
||||
|
||||
## 后果
|
||||
|
||||
归档集合仍是恢复唯一重写的持久状态;`workspace` 领域版本、会话日志与 Workspace 记账位置都不受影响,被恢复的会话回到其记录的位置。归档与取消归档现在执行不同的会话校验,这是刻意的不对称,并记录在 [Workspace registry 的限制](../../../../packages/workspace/workspace/README.zh.md#known-limitations-and-deferred-work)中。
|
||||
|
||||
恢复界面受该页面能显示的内容限制:摘要未加载的已归档 id 没有行也没有取消归档操作,尽管注册表方法与 Remote 都接受它;因此页面报告没有可恢复条目,而不是把归档集合说成空的。通过自动化恢复仍然可用,未来的清理界面可以处理剩余的遗留条目。
|
||||
|
||||
## 测试
|
||||
|
||||
`packages/workspace/workspace/tests/workspace.spec.ts` 固定注册表方法:移除后幸存的归档顺序保持不变、记账位置仍在、重复取消归档与从未归档的 id 既不重写介质也不发出变更、会话已不存在的条目无需持久化列表即可解析、幸存集合在重启后重新加载。`packages/api/workspace-controller/tests/workspace-controller.host.spec.ts` 固定该动词幂等的完整集合应答与不变的 `archived` follow 增量,`packages/api/workspace-controller/tests/model.client.spec.ts` 固定 Client model 的回声及其拒绝安装失败应答的行为,以及四种过期应答竞态:归档或取消归档请求重叠且乱序返回,以及应答在途时到达的 follow 增量或 baseline。`packages/client/ui-settings-unarchive-sessions/tests/components.client.spec.tsx` 固定由新到旧的排序、未分组标签、会话已不存在条目的隐藏、读取与空状态、搜索,以及被拒绝时的 console 诊断,`tests/browser-plugin.client.spec.tsx` 则固定分区注册。
|
||||
|
||||
## 相关
|
||||
|
||||
- [会话归档(注册表全局集合)](../../archived/feature/2026-07-31-session-archive-global-set.md)——归档集合、follow 增量与被预测的恢复界面的冻结记录。
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-12-session-unarchive-settings-page.md
|
||||
2026-09-12-session-unarchive-settings-page.md: 3b1e364e3abc6cea0a8b29ad78abb0ff7c274dbe
|
||||
2026-09-12-session-unarchive-settings-page.zh.md: 61e108dd50e61b9e6e9f72098973c067e3257407
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-18-session-pin-and-sidebar-archive.md
|
||||
2026-09-18-session-pin-and-sidebar-archive.md: eb0c82ba84b1ebc94b23bff4d613ec6de9565d71
|
||||
2026-09-18-session-pin-and-sidebar-archive.zh.md: d971c7b4ff2b6de6a0f49c41974b7654843b29ea
|
||||
@@ -0,0 +1,79 @@
|
||||
# Agent Note: Session pinning and the sidebar archived filter
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-18-session-pin-and-sidebar-archive.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The sidebar session list had no way to keep important sessions at the top, and archived sessions could only be reached through a settings page (`ui-settings-unarchive-sessions`) far from the list they came from. Users archived a session and then lost sight of it.
|
||||
|
||||
A visibility filter can hide a Session without removing it from its Workspace. Saving filtered rows as the account's complete order loses hidden positions. Independent pin ordering also makes dragging depend on a second sequence instead of the Session order the user edits.
|
||||
|
||||
## Decision
|
||||
|
||||
The `dsh-workspace` registry stores registry-global pin and archive sets as Session id arrays (`pinnedSessionIds` and `archivedSessionIds`), with durable `pinSession`/`unpinSession` operations. The pin array keeps the most recently pinned id first. Pinning and archival are mutually exclusive: archiving drops the session's pin in the same durable write, and pinning an archived session fails with `WorkspaceArchivedSessionPinError`.
|
||||
|
||||
### Complete Session order
|
||||
|
||||
Each browser-local account has one complete Session sequence, including pinned and archived members. Workspace membership bounds each account; registry-global pin and archive sets never add a Session to an unrelated Workspace. Grouped, Ungrouped, and flat views use the same rules.
|
||||
|
||||
- Existing members retain their positions. Missing pinned members prepend in the pin array's order. Other missing members append by descending Session `updatedAt`, with missing archived members last. Supplementation never interleaves existing positions or duplicates an id.
|
||||
- Supplementation is an in-memory projection, not an automatic storage migration. A Session-order write records the complete supplemented sequence, including hidden archived rows. Filtering alone never rewrites Session positions.
|
||||
- A successful Pin action marks the Session pinned and moves its position to the front of its complete account sequence. Completion uses current saved positions and the latest Workspace membership, preserving reorders completed while the request was pending. Unpin removes the mark without restoring an earlier position. Both pinned and ordinary drags edit the same complete sequence; display indices never replace its hidden members.
|
||||
- The current blank New Session precedes both partitions; other pinned Sessions lead unpinned Sessions. Manual preserves the complete sequence's relative order within both partitions. Last updated sorts both partitions strictly by descending `updatedAt`, with Session id as the tie-break. Pin records carry only Session identities, not timestamps.
|
||||
- Archive takes precedence if both sets contain a Session. Archiving clears the Host pin but retains the Session's account position. The archive filter changes visibility and disabled-row presentation, not membership or ordering.
|
||||
|
||||
The existing [mode-switch and provisional-New-Session rules](../simplification/2026-09-10-derived-workspace-recency.md) remain: a drag selects Manual, Last updated discards the prior manual layout, entering Manual captures the current chronological order, and the selected blank Session retains its provisional first slot. An explicit Pin updates saved positions without changing the selected mode; chronological display still ignores those positions.
|
||||
|
||||
### Archive restoration
|
||||
|
||||
`WorkspaceRegistry.unarchiveSession` removes one id from the global archive set on the same serialized operation chain as archive and other registry writes. An absent id is an idempotent no-op with no durable write or notification. Restoration preserves Workspace membership and Session logs, and it neither restores a pin nor changes saved ordering.
|
||||
|
||||
Unarchive performs no Session-existence probe: removing an id cannot introduce an unknown referent, so even an entry whose Session is gone can be removed without listing persistence. The Remote returns the complete archive set, and the existing `archived` increment carries the same state to other Clients. A Client installs a unary reply only while it remains the latest archive-set request; a newer request, follow increment, or replacement baseline supersedes it. Failed writes leave the set unchanged, and the sidebar logs the rejection while retaining the row.
|
||||
|
||||
### Archived visibility and row motion
|
||||
|
||||
The sidebar derives its visible rows in `ui-workspace`:
|
||||
|
||||
- Archived rows keep their accounting slots, render grayed, and are not openable. The view-options menu owns an `ArchivedFilter` (`default` hide / `show` / `only`) applied to lists and search alike; the settings-page archived list is deleted with its package.
|
||||
- Archiving raises a toast with undo and filter-archived actions. Parent rerenders do not extend its lifetime; the fully faded banner cannot retain clickable actions.
|
||||
- Row motion uses a private React component with native position and opacity animations. It captures row positions around structural commits without observers or polling; removed rows leave inert fading copies outside the scrolling list. Initial loading, drag commits, overflow expansion/collapse, and grouping/ordering/filter switches settle immediately.
|
||||
|
||||
### Persistence
|
||||
|
||||
Workspace KV, Remote values, and Client snapshots store archive and pin membership as Session id arrays, without object wrappers or pin timestamps. Missing collections default to empty; object-form pin entries are not accepted. Pinning and restoration do not change the Workspace domain version or Session-log format, and neither operation edits Session logs.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep the settings-page archived list.** Two homes for one state; the sidebar filter shows archived rows in the browsing context they came from, so the package is removed outright.
|
||||
|
||||
**A restore action without an archived-row filter.** Hidden rows cannot expose actions. The filter makes those rows reachable, so restoration can live with the Session list instead of a separate settings page.
|
||||
|
||||
**A separate restore delta or an existence check on unarchive.** A second frame type duplicates the complete-set response and requires another merge path. An existence probe would turn removal of an orphan archive id into an error even though removal cannot introduce an unresolved id. Restoration therefore shares the archive-set protocol and omits the probe.
|
||||
|
||||
**Render archive ids whose Session summaries are missing.** Such entries could explain orphan state, but they cannot provide the normal conversation row. The sidebar displays loaded summaries; direct restoration remains available through the API, and orphan management remains outside this browser.
|
||||
|
||||
**A separately editable pin sequence.** This duplicates ordering authority and requires another drag write path. The global pin array only seeds missing pinned members; established positions belong to the complete Session sequence.
|
||||
|
||||
**Restore pre-pin or pre-archive positions.** Keeping Pin separate from manual positions would restore pre-pin slots, but Pin is an intentional reorder. A separate restoration record adds a product guarantee that this interaction does not require; hiding an archived row already preserves its existing position.
|
||||
|
||||
**Rank pins by their pin timestamp.** This makes Last updated mean something other than Session activity. A newly pinned old Session need not lead the pinned partition in that mode; Manual shows the Pin action's first position.
|
||||
|
||||
**Persist every derived or filtered list.** Rendering and visibility changes are not ordering commands. An incomplete or filtered projection cannot replace the complete saved sequence.
|
||||
|
||||
**Animate every list pass.** Revealing hidden rows, committing a drag, or switching the filter moves rows wholesale and reads as exaggerated motion; those passes apply instantly while pin jumps, archive fades, and neighboring-row movement retain their animations.
|
||||
|
||||
## Consequences
|
||||
|
||||
The sidebar owns archived visibility end to end and settings loses a package. The pin set is registry-global, so pins survive grouping and ordering switches. Row animation owns no persistent state or dependency.
|
||||
|
||||
Filtering and reordering share one complete membership sequence, so hidden archives survive drags and filter switches. Older saved orders recover omitted members in memory and adopt the supplemented sequence on their next Session-order write. Host pin/archive membership semantics remain unchanged. The browser gives up pin-time promotion in Last updated and automatic restoration of pre-pin positions.
|
||||
|
||||
An archived id without a loaded Session summary has no visible row or sidebar restore action, even though the API can remove it. This UI limit does not change the registry's idempotent restoration behavior.
|
||||
|
||||
Unit suites cover supplementation, complete-sequence dragging, Pin completion after intervening changes, New Session priority, archived navigation guards, filters, toast lifetime, and animation lifecycle. Workspace and Controller tests cover pin/archive exclusivity, identity-only persistence and snapshots, idempotent no-op restoration, missing-Session restoration without persistence probes, restart recovery, and stale unary replies superseded by newer requests or pushed state. The keyless sidebar restore scenario verifies the row action and reload durability.
|
||||
|
||||
## Related
|
||||
|
||||
- [Session archive (registry-global set)](../../archived/feature/2026-07-31-session-archive-global-set.md) — the frozen record of the archive set and its follow increment.
|
||||
@@ -0,0 +1,79 @@
|
||||
# Agent Note: Session pinning and the sidebar archived filter
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-18-session-pin-and-sidebar-archive.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
侧边栏会话列表无法把重要会话固定在顶部,已归档会话只能通过远离列表的设置页(`ui-settings-unarchive-sessions`)访问。用户归档一个会话之后就再也看不到它了。
|
||||
|
||||
显隐筛选可以隐藏会话,但不改变它的 Workspace 归属。把筛选后的行保存成所属列表的完整顺序,会丢失隐藏项的位置。单独维护可编辑的置顶顺序,也会让拖拽依赖另一份序列,而不是用户正在编辑的会话顺序。
|
||||
|
||||
## Decision
|
||||
|
||||
`dsh-workspace` registry 将全局置顶与归档集合存为会话 id 数组(`pinnedSessionIds` 和 `archivedSessionIds`),并提供持久化的 `pinSession`/`unpinSession` 操作。置顶数组把最近置顶的 id 放在前面。置顶与归档互斥:归档会在同一次持久化写入中移除该会话的置顶,置顶已归档会话则以 `WorkspaceArchivedSessionPinError` 失败。
|
||||
|
||||
### 完整会话顺序
|
||||
|
||||
每份浏览器本地排序记录包含一条完整会话序列,包括置顶和归档成员。每份序列受 Workspace 成员关系约束,全局置顶与归档集合不会把会话补进无关的 Workspace。分组、Ungrouped 和平铺视图使用同一套规则。
|
||||
|
||||
- 已有成员保留位置。缺失的置顶成员按置顶数组中的顺序补到头部。其他缺失成员按会话 `updatedAt` 降序追加,缺失的归档成员放在最后。补齐不穿插已有位置,也不重复加入同一个 id。
|
||||
- 补齐是内存派生,不自动迁移存储。一次会话顺序写入保存补齐后的完整序列,包括隐藏的归档行。单纯切换筛选不重写会话位置。
|
||||
- 置顶成功后,会话被标记为置顶,同时移到所属完整序列的首位。完成时使用当前保存的位置与最新 Workspace 成员关系,保留请求等待期间完成的重排序。取消置顶只移除标记,不恢复更早的位置。置顶行与普通行的拖拽都修改同一条完整序列,显示下标不会覆盖其中的隐藏成员。
|
||||
- 当前空白新会话排在两个分区之前,其余会话再按置顶区、非置顶区排列。手动模式下,两区分别保持完整序列中的相对顺序。最近更新模式下,两区都严格按 `updatedAt` 降序排序,以会话 id 打破平局。置顶记录只包含会话身份,不包含时间戳。
|
||||
- 会话同时命中两个集合时,归档优先。归档会清除 Host 的置顶标记,但保留会话在所属序列中的位置。归档筛选只改变可见性和禁用行的展示,不改变成员关系或排序。
|
||||
|
||||
既有的[模式切换与临时新会话规则](../simplification/2026-09-10-derived-workspace-recency.zh.md)保持不变:拖拽选择手动模式,最近更新丢弃先前的手动布局,进入手动模式记录当前时间顺序,当前选中的空白会话保留临时首位。显式置顶更新保存的位置,但不切换所选模式;时间视图仍忽略这些位置。
|
||||
|
||||
### 取消归档
|
||||
|
||||
`WorkspaceRegistry.unarchiveSession` 与归档及其他注册表写入共用串行操作链,从全局归档集合移除一个 id。id 不在集合中时是幂等空操作,不产生持久写入或通知。恢复保留 Workspace 成员关系与 Session 日志,不恢复置顶,也不改变保存的顺序。
|
||||
|
||||
取消归档不探测 Session 是否存在:移除 id 不会引入未知引用,因此即使 Session 已不存在,也能在不列举持久化记录的情况下移除归档项。Remote 返回完整归档集合,既有 `archived` 增量把同一状态传给其他 Client。Client 只在一元响应仍属于最新归档集合请求时安装它;更新的请求、follow 增量或替换基线会覆盖旧响应的资格。写入失败不改变集合,侧边栏记录拒绝原因并保留原行。
|
||||
|
||||
### 归档显隐与行动画
|
||||
|
||||
侧边栏在 `ui-workspace` 中派生可见行:
|
||||
|
||||
- 已归档行保留其记账位置,置灰渲染且不可打开。视图选项菜单持有 `ArchivedFilter`(`default` 隐藏 / `show` / `only`),同时作用于列表与搜索;设置页的归档列表连同其包一并删除。
|
||||
- 归档动作弹出携带撤销与筛选已归档操作的 toast。父组件重渲染不会延长其生命周期,完全淡出后不会保留可点击操作。
|
||||
- 行动画由私有 React 组件使用浏览器原生位移与透明度动画实现。组件在结构性提交前后记录行位置,不使用观察器或轮询;被移除的行在滚动列表外留下不可交互的淡出副本。初始加载、拖拽提交、展开/收起其余会话,以及分组/排序/筛选切换直接完成。
|
||||
|
||||
### 持久化
|
||||
|
||||
Workspace KV、Remote 值与 Client 快照都用会话 id 数组存储归档和置顶成员,不包含对象包装或置顶时间戳。缺失集合默认为空,不接受对象形式的置顶条目。置顶与恢复不改变 Workspace 领域版本或 Session 日志格式,也不编辑 Session 日志。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**保留设置页的归档列表。** 同一状态出现两个入口;侧边栏筛选让已归档行显示在它们原本所在的浏览上下文中,因此该包被整体移除。
|
||||
|
||||
**只提供恢复操作,不提供已归档筛选。** 隐藏行无法提供操作。筛选让这些行可以被访问,因此恢复可以与 Session 列表放在一起,而不需要独立设置页。
|
||||
|
||||
**独立恢复增量,或在取消归档时检查存在性。** 新增帧类型会重复完整集合响应,并增加合并路径。存在性探测会把移除孤立归档 id 变成错误,尽管移除不可能引入未解析 id。因此恢复复用归档集合协议,不执行该探测。
|
||||
|
||||
**显示缺少 Session 摘要的归档 id。** 这些条目可以解释孤立状态,但不能提供普通对话行。侧边栏显示已加载摘要,API 仍支持直接恢复;孤立项管理不属于当前浏览器。
|
||||
|
||||
**单独维护可编辑的置顶序列。** 这会产生两个排序来源,也需要另一条拖拽写入路径。全局置顶数组只为缺失置顶成员提供初始位置,已有位置由完整会话序列管理。
|
||||
|
||||
**恢复置顶或归档之前的位置。** 让置顶独立于手动位置可以恢复置顶前的位置,但置顶本身就是一次有意的重排序。另存恢复记录增加了这套交互不需要的产品保证;隐藏归档行本身已经保留其已有位置。
|
||||
|
||||
**用置顶时间给置顶行排名。** 这会让最近更新不再严格表达会话活动。在该模式下,新置顶的旧会话不必排在置顶区首位;手动模式才显示置顶操作调整后的首位。
|
||||
|
||||
**保存每次派生或筛选后的列表。** 渲染和显隐变化不是排序命令。不完整或经过筛选的投影不能覆盖完整的已保存序列。
|
||||
|
||||
**为每次列表变化播放动画。** 显示隐藏行、提交拖拽或切换筛选会整体移动行,观感夸张;这些变化即时应用,而置顶跳动、归档淡出和相邻行补位仍保留动画。
|
||||
|
||||
## Consequences
|
||||
|
||||
侧边栏端到端拥有已归档可见性,设置页少了一个包。置顶集合是 registry 全局的,因此置顶在分组与排序切换后仍然保留。行动画不持有持久状态,也不引入依赖。
|
||||
|
||||
筛选与重排序共用一条完整成员序列,隐藏的归档项不会因拖拽或切换筛选丢失。旧排序遗漏的成员在内存中补齐,并在下一次会话顺序写入时随完整序列保存。Host 的置顶与归档成员语义保持不变。浏览器不再用置顶时间提升最近更新模式下的位置,也不自动恢复置顶前的位置。
|
||||
|
||||
归档 id 没有已加载 Session 摘要时,不会产生可见行或侧边栏恢复操作,尽管 API 仍能移除它。这个 UI 限制不改变注册表的幂等恢复行为。
|
||||
|
||||
单元测试覆盖补齐、完整序列拖拽、期间发生其他变更后的 Pin 完成、新会话优先级、归档导航保护、筛选、toast 生命周期与行动画生命周期。Workspace 和 Controller 测试覆盖置顶与归档互斥、只含身份的持久化及快照、幂等空操作恢复、缺失 Session 时不探测持久化的恢复、重启恢复,以及被新请求或推送状态取代的旧一元响应。无密钥的侧边栏恢复场景验证行操作与重载后的持久性。
|
||||
|
||||
## 相关
|
||||
|
||||
- [Session 归档(注册表全局集合)](../../archived/feature/2026-07-31-session-archive-global-set.md) — 归档集合及其 follow 增量的冻结记录。
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-09-10-derived-workspace-recency.md
|
||||
2026-09-10-derived-workspace-recency.md: e8f304110c89bf563164dd24125beeb36e7644a6
|
||||
2026-09-10-derived-workspace-recency.zh.md: 60f2da36c277e77d367f152aa8a3159ff68d909c
|
||||
2026-09-10-derived-workspace-recency.md: b967e332f234d6d120bb82c444e4b05818b85103
|
||||
2026-09-10-derived-workspace-recency.zh.md: 87c2bb9f2648baa613ded32cda5b8673a77c5f56
|
||||
@@ -12,7 +12,7 @@ An editable activity-promoted list can disagree with its displayed timestamps wi
|
||||
|
||||
Last updated is a pure projection of current Session summaries: ordinary rows sort by descending `updatedAt`, with Session id as the tie-break. The selected blank New Session precedes ordinary rows until its first prompt. Grouped, Ungrouped, and flat views use the same policy. Reloads and delayed summaries require no observed-timestamp history or promotion events in the view store.
|
||||
|
||||
Only Manual uses browser-persisted Session display order. A Session drag snapshots every active account, applies the move to its browser-local account, and selects Manual atomically. Entering Manual also freezes every active account from the current chronological ordering. Returning to Last updated discards the manual layout; entering Manual again starts from the current timestamps. Reconciliation retains saved members that remain in the Workspace account, removes departed members, and appends newly known members at the end, newest first when several arrive together. New members without summaries wait for the summary, while previously saved slots survive a temporarily missing summary. Workspace membership and Workspace-group order remain Host-owned; Workspace-group drags still call the Host reorder operation.
|
||||
Manual reads browser-persisted Session display order. A Session drag snapshots every active account, applies the move to its browser-local account, and selects Manual atomically. Entering Manual also freezes every active account from the current chronological ordering. Returning to Last updated discards the manual layout; entering Manual again starts from the current timestamps. The [Session pinning and archive rules](../feature/2026-09-18-session-pin-and-sidebar-archive.md) own missing-member supplementation and keep archive visibility out of saved membership. New members without summaries wait for the summary, while previously saved slots survive a temporarily missing summary. Workspace membership and Workspace-group order remain Host-owned; Workspace-group drags still call the Host reorder operation.
|
||||
|
||||
The selected blank New Session is a display invariant applied after either base order: it remains first and cannot start a drag. Manual persists the observed blank position even while the Workspace stream reconnects, preserving every other saved member until the complete baseline permits reconciliation. Waiting to record that position would lose it if the first prompt arrives before the baseline. Its first prompt removes the blank state. Manual then keeps the existing first slot and allows dragging; Last updated projects it from the prompt timestamp. The ordering subscription and reconciliation remain mounted when the sidebar is collapsed or search replaces the list body.
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ Status: implemented
|
||||
|
||||
最近更新是当前 Session 摘要的纯投影:普通行按 `updatedAt` 降序排列,时间相同时按 Session id 排序。当前选中的空白新会话在首条提示词落地前排在普通行之前。分组、Ungrouped 和单列表视图采用相同策略。重新加载和摘要迟到不需要视图存储中的已观察时间历史或提升事件。
|
||||
|
||||
只有手动排序使用浏览器持久化的 Session 显示顺序。拖拽 Session 会一次性记录所有有效记账、在对应的浏览器本地记账中应用移动,并原子地选中手动排序。直接进入手动排序同样会从当前时间顺序冻结所有有效记账。返回最近更新会丢弃手动布局;再次进入手动排序时,从当前时间戳确定的顺序开始。对账会保留仍属于 Workspace 记账的已保存成员、移除已经离开的成员,并把新发现的成员追加到末尾;多条同时到达时按最近更新时间降序排列。尚无摘要的新成员会等待摘要,而已经保存的位置在摘要暂时缺失时仍会保留。Workspace 成员关系和 Workspace 分组顺序仍由 Host 拥有;拖拽 Workspace 分组仍会调用 Host 重排序操作。
|
||||
手动排序读取浏览器持久化的 Session 显示顺序。拖拽 Session 会一次性记录所有有效记账、在对应的浏览器本地记账中应用移动,并原子地选中手动排序。直接进入手动排序同样会从当前时间顺序冻结所有有效记账。返回最近更新会丢弃手动布局;再次进入手动排序时,从当前时间戳确定的顺序开始。[会话置顶与归档规则](../feature/2026-09-18-session-pin-and-sidebar-archive.zh.md)说明缺失成员补齐,并让归档显隐不影响保存的成员关系。尚无摘要的新成员会等待摘要,而已经保存的位置在摘要暂时缺失时仍会保留。Workspace 成员关系和 Workspace 分组顺序仍由 Host 拥有;拖拽 Workspace 分组仍会调用 Host 重排序操作。
|
||||
|
||||
当前选中的空白新会话是在两种基础顺序之后应用的显示不变式:它保持首位且无法发起拖拽。即使 Workspace 流正在重连,手动排序也会持久化已观察到的空白行位置,并保留其他所有已保存成员,直到完整基线允许对账。若等待基线后才记录该位置,首条提示词先到达时就会丢失它。首条提示词会移除空白状态;此后,手动排序保留原有首位并允许拖拽,最近更新则按提示词时间戳派生其位置。侧边栏折叠或搜索替代列表主体时,排序订阅和对账仍保持挂载。
|
||||
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.md
|
||||
2026-09-04-session-open-performance-gate.md: eaf7c6045bca12329d81cf9c968fb191229b0add
|
||||
2026-09-04-session-open-performance-gate.zh.md: e9bf13460aafbd962a1d15b7a7d240f0b4be6b4e
|
||||
2026-09-04-session-open-performance-gate.md: 1b2b0aabb79aceb0ac515a1d90ba3e2ba042535c
|
||||
2026-09-04-session-open-performance-gate.zh.md: 9ad56796aac6310977aedfff0f9188e46cbbf3d7
|
||||
@@ -78,6 +78,22 @@ The calibrated source budgets are:
|
||||
| Client-fold delta scaling | 2.5× | 3.125× |
|
||||
| Constrained old space | — | 128 MB |
|
||||
|
||||
### Indexed traversal of stored JSON arrays
|
||||
|
||||
The JSONL backend freezes decoded event graphs before memoization. Its native decoder and fixed migration catalog produce acyclic JSON graphs whose arrays contain only indexed values. Numeric array traversal preserves freezing of every nested object and array while avoiding string-key enumeration across the compact stream's 125,000 text entries and 124,600 time deltas. Object traversal still preserves own `__proto__` and `constructor` data. No admission, relationship validation, decoding, or freezing moves beyond the measured open endpoint, and a shallow `Object.isFrozen()` result never skips descendant traversal.
|
||||
|
||||
On an Apple M4 Pro with Node 24.19.0, an open-only CPU profile attributes about 4.2 ms to complete event freezing. Separate fresh-process V4 runs give these results; the final column restores and rebuilds the exact original traversal as a negative control:
|
||||
|
||||
| Reopen measurement | Original traversal | Indexed traversal | Original restored |
|
||||
|---|---|---|---|
|
||||
| `open` samples (ms) | 22.2, 22.4, 22.8, 22.6, 22.9 | 17.7, 18.4, 17.4, 17.8, 17.6 | 21.7, 22.4, 22.2, 21.7, 21.6 |
|
||||
| Median `open` (ms) | 22.6 | 17.7 | 21.7 |
|
||||
| Median first history (ms) | 35.0 | 30.6 | 33.6 |
|
||||
| Median Agent resume (ms) | 30.5 | 26.8 | 29.8 |
|
||||
| Agent retained heap (MB) | 4.5 | 4.5 | 4.5 |
|
||||
|
||||
Each run passes all 17 Session cases, including the 128 MB completion checks. The original implementation remains below 63 ms on this local machine; the restored traversal demonstrates the removed cost, while the hosted benchmark remains the required budget verdict. The workload, time limits, memory limits, cold-process setup, and measurement endpoints are unchanged. The JSONL owner tests cover nested opaque JSON, rejected mutation, and immutable event sharing through historical V3 and current reads. These measurements establish a reopen improvement; they do not establish a consistent first-open improvement or hosted-runner timing.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Check out the historical commit and compare it on every CI run.** Rejected because a historical checkout requires a separate install, and old and current revisions can assign work to different API phases, adding runtime, dependency, and interface drift. A fixed workload with static budgets calibrated against positive and negative controls is easier to reproduce and review.
|
||||
|
||||
@@ -78,6 +78,22 @@ PR #3640 的标准双 CPU 运行 (run 34023970384, job 101461539961)使用 Node
|
||||
| Client fold delta 缩放比 | 2.5× | 3.125× |
|
||||
| 受限 old space | — | 128 MB |
|
||||
|
||||
### 按索引遍历持久化 JSON 数组
|
||||
|
||||
JSONL backend 在 memo 化前冻结解码后的 event graph。原生 decoder 与固定 migration catalog 产出无环 JSON graph,其中数组只包含索引值。按数字索引遍历数组仍会冻结每个嵌套对象和数组,同时避免枚举 compact stream 中 125,000 个文本条目和 124,600 个时间差值的字符串键。对象遍历继续保留自有的 `__proto__` 和 `constructor` 数据。Admission、关系校验、解码与冻结都仍在被测 open 终点前完成;浅层 `Object.isFrozen()` 结果不会跳过后代遍历。
|
||||
|
||||
在 Apple M4 Pro、Node 24.19.0 上,仅覆盖 open 的 CPU profile 将约 4.2 ms 归因于完整 event 冻结。独立的全新进程 V4 运行得到以下结果;末列恢复并重新构建完全相同的原始遍历,作为负对照:
|
||||
|
||||
| Reopen 测量项 | 原始遍历 | 索引遍历 | 恢复原始遍历 |
|
||||
|---|---|---|---|
|
||||
| `open` 样本(ms) | 22.2, 22.4, 22.8, 22.6, 22.9 | 17.7, 18.4, 17.4, 17.8, 17.6 | 21.7, 22.4, 22.2, 21.7, 21.6 |
|
||||
| `open` 中位数(ms) | 22.6 | 17.7 | 21.7 |
|
||||
| 首屏历史中位数(ms) | 35.0 | 30.6 | 33.6 |
|
||||
| Agent resume 中位数(ms) | 30.5 | 26.8 | 29.8 |
|
||||
| Agent 保留堆(MB) | 4.5 | 4.5 | 4.5 |
|
||||
|
||||
每次运行的全部 17 个 Session 用例均通过,包括 128 MB 完成性检查。原始实现在本机仍低于 63 ms;恢复原始遍历展示了被移除的成本,而托管 benchmark 仍负责给出必须满足的预算结果。工作负载、时间上限、内存上限、冷进程准备与测量终点保持不变。JSONL owner 测试覆盖历史 V3 和当前读取中的嵌套不透明 JSON、修改拒绝及不可变 event 共享。这些测量确认了 reopen 改善,但没有确认一致的 first-open 改善或托管运行器耗时。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**每次 CI checkout 历史提交并做相对比较。** 拒绝:历史 checkout 需要独立安装,旧版与当前版还可能把工作放在不同 API 阶段,增加时间、依赖和接口漂移。固定 workload 与经正反例校准的静态预算更容易复现和评审。
|
||||
|
||||
@@ -88,6 +88,7 @@ interface CapturedFixture {
|
||||
readonly value: {
|
||||
readonly items: readonly WorkspaceView[]
|
||||
readonly archivedSessionIds: readonly string[]
|
||||
readonly pinnedSessionIds: readonly string[]
|
||||
}
|
||||
}
|
||||
readonly control: ControlBaseline
|
||||
@@ -185,6 +186,7 @@ export function createAssembledRemote(options: AssembledRemoteOptions = {}): Ass
|
||||
value: {
|
||||
items: structuredClone(workspaces),
|
||||
archivedSessionIds: structuredClone(fixture.workspace.value.archivedSessionIds),
|
||||
pinnedSessionIds: structuredClone(fixture.workspace.value.pinnedSessionIds),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -83,7 +83,7 @@ async function openSeed(page: Page): Promise<void> {
|
||||
// Search collapsed into a header action; expand it before filling.
|
||||
const searchButton = page.getByRole('button', { name: 'Search sessions' })
|
||||
if (await searchButton.getAttribute('aria-expanded') !== 'true') await searchButton.click()
|
||||
const search = page.getByRole('textbox', { name: 'Search sessions...', exact: true })
|
||||
const search = page.getByRole('textbox', { name: 'Search session names', exact: true })
|
||||
await search.fill(FIXTURE.markers.user(1))
|
||||
const results = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
|
||||
await results.first().waitFor({ timeout: 60_000 })
|
||||
|
||||
@@ -292,7 +292,7 @@ async function openSeed(page: Page, fixture: ChatScrollFixture, tailMarker?: str
|
||||
// Search collapsed into a header action; expand it before filling.
|
||||
const searchButton = page.getByRole('button', { name: 'Search sessions' })
|
||||
if (await searchButton.getAttribute('aria-expanded') !== 'true') await searchButton.click()
|
||||
const search = page.getByRole('textbox', { name: 'Search sessions...', exact: true })
|
||||
const search = page.getByRole('textbox', { name: 'Search session names', exact: true })
|
||||
// Cold summaries initially show the temporary workspace basename, so the
|
||||
// persisted first-prompt marker is the stable user-facing identity. The
|
||||
// query itself triggers lazy content-index reconciliation; no transient
|
||||
|
||||
@@ -192,7 +192,7 @@ async function openSeededSession(page: Page): Promise<void> {
|
||||
// Search collapsed into a header action; expand it before filling.
|
||||
const searchButton = page.getByRole('button', { name: 'Search sessions' })
|
||||
if (await searchButton.getAttribute('aria-expanded') !== 'true') await searchButton.click()
|
||||
const search = page.getByRole('textbox', { name: 'Search sessions...', exact: true })
|
||||
const search = page.getByRole('textbox', { name: 'Search session names', exact: true })
|
||||
await search.fill(FIXTURE.markers.user(1))
|
||||
const results = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
|
||||
const deadline = Date.now() + 60_000
|
||||
|
||||
@@ -169,7 +169,11 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action }) +
|
||||
'# Markdown smoke', '', 'Rendered from the workspace.', '',
|
||||
...Array.from({ length: (PAGE_LINES - 4) / 2 }, (_, index) => [`Paragraph ${index + 1}: ${'visible prefix '.repeat(20)}`, '']).flat(),
|
||||
'# Markdown tail',
|
||||
'', '',
|
||||
'', `.replaceAll('\\', '/')}>)`,
|
||||
'', '![reference image][local-image]', '', '[local-image]: preview-images/local%20image.png',
|
||||
].join('\n')
|
||||
await mkdir(join(cwd, 'preview-images'))
|
||||
const codeLines = [
|
||||
...Array.from({ length: PAGE_LINES }, (_, index) => index === 0 ? 'const prefix = "CODE_PREFIX";' : `// prefix line ${index + 1}`),
|
||||
'const tail = "CODE_TAIL";',
|
||||
@@ -204,6 +208,7 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action }) +
|
||||
writeFile(join(cwd, 'local.css'), '#local-result { color: rgb(12, 34, 56); }'),
|
||||
writeFile(outsideScript, 'document.getElementById("outside-result").textContent="OUTSIDE_JS_OK";'),
|
||||
writeFile(join(cwd, 'tiny.png'), TINY_PNG),
|
||||
writeFile(join(cwd, 'preview-images', 'local image.png'), TINY_PNG),
|
||||
writeFile(join(cwd, 'large.svg'), [
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="1600" viewBox="0 0 1200 1600">',
|
||||
'<script>parent.document.documentElement.setAttribute("data-image-preview-escape","true")</script>',
|
||||
@@ -282,6 +287,15 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action }) +
|
||||
expect(await preview.getByRole('heading', { name: heading, exact: true }).count()).toBe(1)
|
||||
expect(await preview.getByText('Rendered from the workspace.', { exact: true }).count()).toBe(1)
|
||||
const tailHeading = await markdownTail.innerText()
|
||||
const markdownImages: string[] = []
|
||||
for (const alt of ['relative image', 'absolute image', 'reference image']) {
|
||||
const image = preview.getByRole('img', { name: alt, exact: true })
|
||||
await image.scrollIntoViewIfNeeded()
|
||||
await expect.poll(() => image.evaluate((node: HTMLImageElement) => node.complete && node.naturalWidth > 0)).toBe(true)
|
||||
const source = new URL(await image.getAttribute('src') ?? '')
|
||||
expect(source.pathname).toBe('/api/file')
|
||||
markdownImages.push(alt)
|
||||
}
|
||||
await preview.getByRole('heading', { name: heading, exact: true }).scrollIntoViewIfNeeded()
|
||||
await successShot(page, 'markdown')
|
||||
const markdownTab = column.locator('[data-dockkit-tab]').filter({ has: page.getByText('smoke.md', { exact: true }) })
|
||||
@@ -314,6 +328,7 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action }) +
|
||||
'## Markdown', '',
|
||||
`- Heading: ${heading}`,
|
||||
`- Tail loaded by scrolling: ${tailHeading}`,
|
||||
`- Loaded images: ${markdownImages.join(' | ')}`,
|
||||
`- Viewers: ${markdownViewers.join(' -> ')}`,
|
||||
`- Same tab: ${String(await markdownTab.getAttribute('data-dockkit-tab') === markdownTabId)}`,
|
||||
].join('\n'))
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
- button "模型"
|
||||
- button "内置插件"
|
||||
- button "Agent 预设"
|
||||
- button "已归档会话"
|
||||
- button "打开配置文件"
|
||||
- button "关闭"
|
||||
- heading "Agent 预设" [level=2]
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
- button "模型"
|
||||
- button "内置插件"
|
||||
- button "Agent 预设"
|
||||
- button "已归档会话"
|
||||
- button "打开配置文件"
|
||||
- button "关闭"
|
||||
- heading "Agent 预设" [level=2]
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
- button "模型"
|
||||
- button "内置插件"
|
||||
- button "Agent 预设"
|
||||
- button "已归档会话"
|
||||
- button "打开配置文件"
|
||||
- button "关闭"
|
||||
- heading "Agent 预设" [level=2]
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
- button "模型"
|
||||
- button "内置插件"
|
||||
- button "Agent 预设"
|
||||
- button "已归档会话"
|
||||
- button "打开配置文件"
|
||||
- button "关闭"
|
||||
- heading "模型" [level=2]
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
- tree "Sessions":
|
||||
- treeitem "{{workspace}}" [expanded]
|
||||
- treeitem "Review deepseek-ai/deepseek-harness#314 Session actions for Review deepseek-ai/deepseek-harness#314" [selected]:
|
||||
- treeitem "Review deepseek-ai/deepseek-harness#314 Session actions for Review deepseek-ai/deepseek-harness#314 Archive session Pin session" [selected]:
|
||||
- text: Review deepseek-ai/deepseek-harness#314
|
||||
- button "Session actions for Review deepseek-ai/deepseek-harness#314"
|
||||
- button "Archive session"
|
||||
- button "Pin session"
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
- tree "Sessions":
|
||||
- treeitem "{{workspace}}" [expanded]
|
||||
- treeitem "Review deepseek-ai/deepseek-harness#314 Session actions for Review deepseek-ai/deepseek-harness#314" [selected]:
|
||||
- treeitem "Review deepseek-ai/deepseek-harness#314 Session actions for Review deepseek-ai/deepseek-harness#314 Archive session Pin session" [selected]:
|
||||
- text: Review deepseek-ai/deepseek-harness#314
|
||||
- button "Session actions for Review deepseek-ai/deepseek-harness#314"
|
||||
- button "Archive session"
|
||||
- button "Pin session"
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
- button "模型"
|
||||
- button "内置插件"
|
||||
- button "Agent 预设"
|
||||
- button "已归档会话"
|
||||
- button "打开配置文件"
|
||||
- button "关闭"
|
||||
- heading "模型" [level=2]
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
- button "模型"
|
||||
- button "内置插件"
|
||||
- button "Agent 预设"
|
||||
- button "已归档会话"
|
||||
- button "打开配置文件"
|
||||
- button "关闭"
|
||||
- heading "模型" [level=2]
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
- button "模型"
|
||||
- button "内置插件"
|
||||
- button "Agent 预设"
|
||||
- button "已归档会话"
|
||||
- button "打开配置文件"
|
||||
- button "关闭"
|
||||
- heading "模型" [level=2]
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
- button "模型"
|
||||
- button "内置插件"
|
||||
- button "Agent 预设"
|
||||
- button "已归档会话"
|
||||
- button "打开配置文件"
|
||||
- button "关闭"
|
||||
- heading "模型" [level=2]
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
- button "模型"
|
||||
- button "内置插件"
|
||||
- button "Agent 预设"
|
||||
- button "已归档会话"
|
||||
- button "打开配置文件"
|
||||
- button "关闭"
|
||||
- heading "模型" [level=2]
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
- button "模型"
|
||||
- button "内置插件"
|
||||
- button "Agent 预设"
|
||||
- button "已归档会话"
|
||||
- button "打开配置文件"
|
||||
- button "关闭"
|
||||
- heading "模型" [level=2]
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
- button "模型"
|
||||
- button "内置插件"
|
||||
- button "Agent 预设"
|
||||
- button "已归档会话"
|
||||
- button "打开配置文件"
|
||||
- button "关闭"
|
||||
- heading "模型" [level=2]
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
- button "模型"
|
||||
- button "内置插件"
|
||||
- button "Agent 预设"
|
||||
- button "已归档会话"
|
||||
- button "打开配置文件"
|
||||
- button "关闭"
|
||||
- heading "模型" [level=2]
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
- button "模型"
|
||||
- button "内置插件"
|
||||
- button "Agent 预设"
|
||||
- button "已归档会话"
|
||||
- button "打开配置文件"
|
||||
- button "关闭"
|
||||
- heading "模型" [level=2]
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
- button "Models"
|
||||
- button "Built-in plugins"
|
||||
- button "Agent presets"
|
||||
- button "Archived sessions"
|
||||
- button "Open configuration file"
|
||||
- button "Close"
|
||||
- text: Permission Choose the default permission mode for new sessions
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
- button "模型"
|
||||
- button "内置插件"
|
||||
- button "Agent 预设"
|
||||
- button "已归档会话"
|
||||
- button "打开配置文件"
|
||||
- button "关闭"
|
||||
- text: 权限 选择新会话的默认权限模式
|
||||
|
||||
@@ -7,3 +7,7 @@
|
||||
- text: Order by
|
||||
- menuitem "Manual"
|
||||
- menuitem "Last updated"
|
||||
- separator
|
||||
- text: Filter sessions
|
||||
- menuitem "Show archived"
|
||||
- menuitem "Archived only"
|
||||
+2
-1
@@ -253,7 +253,8 @@
|
||||
"updatedAt": "2026-09-13T03:02:55.884Z"
|
||||
}
|
||||
],
|
||||
"archivedSessionIds": []
|
||||
"archivedSessionIds": [],
|
||||
"pinnedSessionIds": []
|
||||
}
|
||||
},
|
||||
"control": {
|
||||
|
||||
@@ -241,9 +241,10 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
|
||||
() => branchButtons.evaluateAll(buttons => buttons.map(button => button.getAttribute('aria-disabled'))),
|
||||
{ timeout: 5_000 },
|
||||
).toEqual(['true', null, null])
|
||||
await branchButtons.first().focus()
|
||||
await expect.poll(() => page.getByRole('tooltip').textContent(), { timeout: 5_000 })
|
||||
.toBe('Available only on the last message of a completed turn')
|
||||
await branchButtons.first().press('Shift+Tab')
|
||||
await page.keyboard.press('Tab')
|
||||
await expect.poll(() => page.getByRole('tooltip').allTextContents(), { timeout: 5_000 })
|
||||
.toEqual(['Available only on the last message of a completed turn'])
|
||||
await expect.poll(() => page.getByRole('button', { name: 'Edit' }).count(), { timeout: 5_000 }).toBe(0)
|
||||
}, 60_000)
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ async function ensureSeedOpen(page: Page): Promise<void> {
|
||||
// Search is a collapsed header action; expand it so the input is actionable.
|
||||
const searchButton = page.getByRole('button', { name: 'Search sessions' })
|
||||
if (await searchButton.getAttribute('aria-expanded') !== 'true') await searchButton.click()
|
||||
const search = page.getByPlaceholder('Search sessions', { exact: false })
|
||||
const search = page.getByPlaceholder('Search session names', { exact: false })
|
||||
if (await chat.count() === 0) {
|
||||
await search.fill('WATERFALL')
|
||||
const result = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
|
||||
@@ -188,7 +188,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
|
||||
// Search is a collapsed header action; expand it so the input is actionable.
|
||||
const searchButton = page.getByRole('button', { name: 'Search sessions' })
|
||||
if (await searchButton.getAttribute('aria-expanded') !== 'true') await searchButton.click()
|
||||
const search = page.getByPlaceholder('Search sessions', { exact: false })
|
||||
const search = page.getByPlaceholder('Search session names', { exact: false })
|
||||
// The cold row has not been opened, so only the persisted log can satisfy
|
||||
// this query. First search lazily reconciles the SQLite content index.
|
||||
await search.fill('zzzqx-no-such-session')
|
||||
|
||||
@@ -72,7 +72,7 @@ describe.skipIf(MODE === 'record' || !HAS_PWSH)('web e2e: pwsh calls use the bas
|
||||
// collapsed header action; expand it so the input is actionable.
|
||||
const searchButton = page.getByRole('button', { name: 'Search sessions' })
|
||||
if (await searchButton.getAttribute('aria-expanded') !== 'true') await searchButton.click()
|
||||
const search = page.getByPlaceholder('Search sessions', { exact: false })
|
||||
const search = page.getByPlaceholder('Search session names', { exact: false })
|
||||
await search.fill('Run a PowerShell command')
|
||||
const result = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
|
||||
await expect.poll(() => result.count(), { timeout: 15_000 }).toBe(1)
|
||||
|
||||
@@ -53,7 +53,7 @@ describe('web e2e: rail search click survives its own document-level bubble', ()
|
||||
|
||||
const wideSearch = page.getByRole('button', { name: 'Search sessions' })
|
||||
await expect.poll(async () => wideSearch.getAttribute('aria-expanded'), { timeout: 10_000 }).toBe('true')
|
||||
const input = page.getByPlaceholder('Search sessions...')
|
||||
const input = page.getByPlaceholder('Search session names')
|
||||
await expect.poll(
|
||||
async () => input.evaluate(el => document.activeElement === el),
|
||||
{ timeout: FOCUS_SETTLE_MS + 10_000 },
|
||||
|
||||
@@ -686,7 +686,7 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => {
|
||||
expect(await catalogRow.getByRole('img', { name: ACTIVE_SCHEDULE_LABEL }).count()).toBe(1)
|
||||
|
||||
await page.getByRole('button', { name: 'Search sessions' }).click()
|
||||
const search = page.getByPlaceholder('Search sessions', { exact: false })
|
||||
const search = page.getByPlaceholder('Search session names', { exact: false })
|
||||
await search.fill(CATALOG_TITLE)
|
||||
const result = page.getByRole('tree', { name: 'Search results' })
|
||||
.getByRole('treeitem', { name: new RegExp(CATALOG_TITLE) })
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
// Web e2e scenario: the archived-session Settings page restores a Session
|
||||
// through the real wire, from its row menu through the archive set, the page,
|
||||
// and the reload that rebuilds state from the host baseline. Zero model calls:
|
||||
// both verbs are host RPCs and the page reads already-loaded Session summaries,
|
||||
// so no replay fixture mounts and a stray model stream fails loud.
|
||||
// Archive and restore through sidebar row menus and the archived filter,
|
||||
// then reload from the Host baseline. This flow makes no model calls.
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { Browser, Locator, Page } from 'playwright'
|
||||
@@ -19,7 +16,7 @@ import { newEnglishPage, saveFailureShot } from './support.ts'
|
||||
const SEED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v3.jsonl', import.meta.url))
|
||||
const SEED_ID = 'session-unarchive-web-e2e'
|
||||
|
||||
describe('web e2e: archived sessions are restored from the Settings page', () => {
|
||||
describe('web e2e: archived sessions are restored from the sidebar filter', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
@@ -76,7 +73,7 @@ describe('web e2e: archived sessions are restored from the Settings page', () =>
|
||||
await scaffold?.close()
|
||||
})
|
||||
|
||||
it('archives the seed, unarchives it from Settings, and keeps it restored across reload', async () => {
|
||||
it('archives the seed, restores it through the sidebar filter, and keeps it restored across reload', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-session-unarchive'))
|
||||
// Select the only visible Session, then give it a user-owned title: the
|
||||
// locator binds to the seed's own copy on both sides of the round trip.
|
||||
@@ -104,46 +101,18 @@ describe('web e2e: archived sessions are restored from the Settings page', () =>
|
||||
expect((await scaffold.ctx.sessionPersistence.list()).map(snapshot => snapshot.header.id))
|
||||
.toContain(SessionId(SEED_ID))
|
||||
|
||||
// The Settings page lists the archived Session behind its own search box.
|
||||
await page.getByRole('button', { name: 'Settings', exact: true }).click()
|
||||
const dialog = page.getByRole('dialog', { name: 'Settings' })
|
||||
await dialog.waitFor({ timeout: 10_000 })
|
||||
await dialog.getByRole('button', { name: 'Archived sessions' }).click()
|
||||
const search = dialog.getByRole('searchbox', { name: 'Search archived sessions' })
|
||||
await search.waitFor({ timeout: 10_000 })
|
||||
const unarchiveRow = dialog.getByRole('button', { name: `Unarchive ${title}` })
|
||||
await expect.poll(() => unarchiveRow.count(), { timeout: 10_000 }).toBe(1)
|
||||
expect(await dialog.getByText(title, { exact: true }).count()).toBe(1)
|
||||
expect(await dialog.getByText(/^Ungrouped · /).count()).toBe(1)
|
||||
expect(await dialog.getByText('No archived sessions.', { exact: true }).count()).toBe(0)
|
||||
// The search box filters these rows in both directions.
|
||||
await search.fill('zzz-no-such-archived-session')
|
||||
await expect.poll(
|
||||
() => dialog.getByText('No matching sessions.', { exact: true }).count(),
|
||||
{ timeout: 5_000 },
|
||||
).toBe(1)
|
||||
expect(await unarchiveRow.count()).toBe(0)
|
||||
await search.fill(title)
|
||||
await expect.poll(() => unarchiveRow.count(), { timeout: 5_000 }).toBe(1)
|
||||
expect(await dialog.getByText('No matching sessions.', { exact: true }).count()).toBe(0)
|
||||
await search.fill('')
|
||||
await expect.poll(() => unarchiveRow.count(), { timeout: 5_000 }).toBe(1)
|
||||
|
||||
// Unarchive: the durable set empties (the row's own RPC), the page row
|
||||
// leaves with the empty status, and the Session row is back in the sidebar.
|
||||
await unarchiveRow.click()
|
||||
await page.getByRole('button', { name: 'View options' }).click()
|
||||
await page.getByRole('menuitem', { name: 'Show archived', exact: true }).click()
|
||||
await ungroupedSection()
|
||||
await expect.poll(() => sessionRow.count(), { timeout: 10_000 }).toBe(1)
|
||||
await clickHoverAction(sessionRow, `Session actions for ${title}`)
|
||||
await page.getByRole('menuitem', { name: 'Unarchive session' }).click()
|
||||
await expect.poll(
|
||||
() => [...scaffold.ctx.workspaceRegistry.archivedSessionIds],
|
||||
{ timeout: 10_000 },
|
||||
).toEqual([])
|
||||
await expect.poll(() => unarchiveRow.count(), { timeout: 10_000 }).toBe(0)
|
||||
expect(await dialog.getByText(title, { exact: true }).count()).toBe(0)
|
||||
await expect.poll(
|
||||
() => dialog.getByText('No archived sessions.', { exact: true }).count(),
|
||||
{ timeout: 10_000 },
|
||||
).toBe(1)
|
||||
await dialog.getByRole('button', { name: 'Close' }).last().click()
|
||||
await dialog.waitFor({ state: 'hidden', timeout: 10_000 })
|
||||
await page.getByRole('button', { name: 'View options' }).click()
|
||||
await page.getByRole('menuitem', { name: 'Show archived', exact: true }).click()
|
||||
await ungroupedSection()
|
||||
await expect.poll(() => sessionRow.count(), { timeout: 15_000 }).toBe(1)
|
||||
|
||||
|
||||
@@ -595,7 +595,8 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
|
||||
() => page.getByRole('tree', { name: 'Sessions' }).getByRole('treeitem').count(),
|
||||
{ timeout: 15_000 },
|
||||
).toBe(3)
|
||||
expect(await page.getByText('Ungrouped', { exact: true }).count()).toBe(0)
|
||||
expect(await page.getByRole('tree', { name: 'Sessions' })
|
||||
.getByRole('treeitem', { name: 'Ungrouped', exact: true }).count()).toBe(0)
|
||||
const hierarchy = page.getByRole('navigation', { name: 'Session hierarchy' })
|
||||
await expect.poll(() => hierarchy.getByRole('button').count()).toBe(1)
|
||||
await compareOrRefreshGolden(
|
||||
|
||||
@@ -71,7 +71,7 @@ async function openSeed(page: Page): Promise<void> {
|
||||
// Search collapsed into a header action; expand it before filling.
|
||||
const searchButton = page.getByRole('button', { name: 'Search sessions' })
|
||||
if (await searchButton.getAttribute('aria-expanded') !== 'true') await searchButton.click()
|
||||
const search = page.getByRole('textbox', { name: 'Search sessions...', exact: true })
|
||||
const search = page.getByRole('textbox', { name: 'Search session names', exact: true })
|
||||
await search.fill(FIXTURE.markers.user(1))
|
||||
const result = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
|
||||
await expect.poll(() => result.count(), { timeout: 60_000 }).toBe(1)
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/config-catalog.md
|
||||
config-catalog.md: 118cf3ca1322ce03256a0fa0dc01f73a8f2bc00d
|
||||
config-catalog.zh.md: 972bdb9198feeea42b43269a92fe597fd8a07c1e
|
||||
config-catalog.md: 239da8f903a0031340deff551293ab96dd4333f0
|
||||
config-catalog.zh.md: e6279390fac6a81146e2ecebda56c7395415df30
|
||||
@@ -3818,7 +3818,6 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
|
||||
- `@deepseek-ai/dsh-client-ui-settings-models` ([`packages/client/ui-settings-models/src/index.ts`](../packages/client/ui-settings-models/src/index.ts))
|
||||
- `@deepseek-ai/dsh-client-ui-settings-plugin-inventory` ([`packages/client/ui-settings-plugin-inventory/src/index.ts`](../packages/client/ui-settings-plugin-inventory/src/index.ts))
|
||||
- `@deepseek-ai/dsh-client-ui-settings-plugins` ([`packages/client/ui-settings-plugins/src/index.ts`](../packages/client/ui-settings-plugins/src/index.ts))
|
||||
- `@deepseek-ai/dsh-client-ui-settings-unarchive-sessions` ([`packages/client/ui-settings-unarchive-sessions/src/index.ts`](../packages/client/ui-settings-unarchive-sessions/src/index.ts))
|
||||
- `@deepseek-ai/dsh-client-ui-sidebar` ([`packages/client/ui-sidebar/src/index.ts`](../packages/client/ui-sidebar/src/index.ts))
|
||||
- `@deepseek-ai/dsh-client-ui-sidebar-browser` ([`packages/client/ui-sidebar-browser/src/index.ts`](../packages/client/ui-sidebar-browser/src/index.ts))
|
||||
- `@deepseek-ai/dsh-client-ui-sidebar-files` ([`packages/client/ui-sidebar-files/src/index.ts`](../packages/client/ui-sidebar-files/src/index.ts))
|
||||
|
||||
@@ -3820,7 +3820,6 @@ export interface Config {
|
||||
- `@deepseek-ai/dsh-client-ui-settings-models`([`packages/client/ui-settings-models/src/index.ts`](../packages/client/ui-settings-models/src/index.ts))
|
||||
- `@deepseek-ai/dsh-client-ui-settings-plugin-inventory`([`packages/client/ui-settings-plugin-inventory/src/index.ts`](../packages/client/ui-settings-plugin-inventory/src/index.ts))
|
||||
- `@deepseek-ai/dsh-client-ui-settings-plugins`([`packages/client/ui-settings-plugins/src/index.ts`](../packages/client/ui-settings-plugins/src/index.ts))
|
||||
- `@deepseek-ai/dsh-client-ui-settings-unarchive-sessions`([`packages/client/ui-settings-unarchive-sessions/src/index.ts`](../packages/client/ui-settings-unarchive-sessions/src/index.ts))
|
||||
- `@deepseek-ai/dsh-client-ui-sidebar`([`packages/client/ui-sidebar/src/index.ts`](../packages/client/ui-sidebar/src/index.ts))
|
||||
- `@deepseek-ai/dsh-client-ui-sidebar-browser`([`packages/client/ui-sidebar-browser/src/index.ts`](../packages/client/ui-sidebar-browser/src/index.ts))
|
||||
- `@deepseek-ai/dsh-client-ui-sidebar-files`([`packages/client/ui-sidebar-files/src/index.ts`](../packages/client/ui-sidebar-files/src/index.ts))
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/cookbook/adding-a-session-format-version.md
|
||||
adding-a-session-format-version.md: 4ea2f28d3e4164302bb23cad2a69b21f7720a299
|
||||
adding-a-session-format-version.zh.md: 4944ef2dc04028d383f355f25f048533afce60ed
|
||||
adding-a-session-format-version.md: d859f554e8942a5aa5ed1d1ce0a5cb2be2847a5c
|
||||
adding-a-session-format-version.zh.md: 53c3e4b5fded929fa6061f14a243a39713763bcc
|
||||
@@ -25,7 +25,7 @@ Let N be the accepted or published version in [version/status](../session-format
|
||||
|
||||
Use a shared `release/*` integration base for N+1. The base change adds the writer, codec, catalog wiring, identity migration, and verification. Create each independent child branch from that base and target its PR at the release branch, not another independent child’s branch. Each child adds its structural transformation, validators, consumers, and tests to the same adjacent migration package. Do not allocate extra versions just to represent review order. Merge reviewed children into the release branch through PRs, then validate the combined result before release. Honor release-branch force-push and deletion protections; do not force-sync it.
|
||||
|
||||
Released codecs and accepted migration meanings remain stable. An unaccepted N→N+1 transition may combine coordinated breaking changes. Once accepted, preserve that transition: later compatible evolution uses new same-version records, while new breaking changes require the next edge and their own header increase.
|
||||
Released codecs and accepted target-format meanings remain stable. The N→N+1 converter may receive fixes or support additional historical cases after N+1 ships, without a version bump, if its output remains compatible with N+1. A converter fix affects subsequent conversions, not existing N+1 successors. Document changes to previously accepted mappings and how existing outputs remain supported. Changing the established target representation or interpretation incompatibly requires the next version.
|
||||
|
||||
Use disposable, isolated Harness homes while N+1 remains open for integration. An interim N+1 file already has the target writer version, so a later edit to N→N+1 will not migrate that file again. Re-run from unchanged historical input in a fresh test home; never repair this by rewriting a committed generation or reusing a real user's home.
|
||||
|
||||
@@ -57,6 +57,12 @@ Treat the inherited cut as a logical event count, not a physical row count. Expo
|
||||
|
||||
Define the new edge's event admission and transformation rules explicitly. The [V2-to-V3 source audit](../../packages/session/session-format-v2-to-v3/README.md#source-audit) and [alpha V0→V1 rule](../../.agents/notes/implemented/architecture/2026-08-31-alpha-historical-unknown-event-refusal.md) own the policies of those released edges, not the new edge. Do not generalize either to every edge. A change to structure or event positions requires classifying source events, payload members, and references, and explicitly deciding whether opaque data can remain valid. [Equal-version retention](../../.agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.md) alone does not prove a structural transformation safe. Validate target semantics and give each newly accepted case a rejecting counterexample; never widen older edges to hide an unsupported transformation.
|
||||
|
||||
Use native writer output as the conversion oracle: replay the same recorded LLM messages, tool outputs, and other inputs through the old and target writers. Compare migrated old output with raw target output before any format-normalizing helper; mask only documented volatile fields. For V4, ordinary tool results become flat tool-role messages with ordinary content blocks. A theoretically permitted old structure alone does not justify a new core content type.
|
||||
|
||||
Alpha converters may explicitly refuse historical cases without an implemented, evidenced mapping, including otherwise valid logs. Keep the source unchanged and publish no partial successor. Prefer real corpora and reproducible writers when extending support; inspect user corpora read-only and create sanitized regression fixtures.
|
||||
|
||||
Document only the identifier and field mappings needed by the target format in the edge README, with a reason for each. Do not add namespaces to direct source kinds or ordinary metadata solely because they are extensible. Preserve opaque nested data. Alpha converters may refuse unexpected source fields that would acquire new target meanings rather than invent an interpretation.
|
||||
|
||||
Maintain the new edge's README as its single complete transformation and admission catalog, following the [V2-to-V3 specification](../../packages/session/session-format-v2-to-v3/README.md#v2-to-v3-specification). Enumerate every converted header, event, message slot, and payload field; exact rename or collision rules; preserved ids, coordinates, references, and inherited cuts; external prerequisites; refusal and opaque-data policies; and unsupported or deferred behavior. Separate historical source conversion from native target admission, and state which codec, restorer, installed validator, and recovery policy performs each check. Record any validation that is deliberately narrower than the declared schema. Update this catalog in the same change as its code; a generated schema inventory or PR description does not replace it.
|
||||
|
||||
Prove strict restoration through `sessionFormatCatalog.createRestore(header, { recovery: 'strict', validation: 'current' })`, feeding rows in order and calling `finish()`. This exercises physical decoding, the complete chain, and installed current Session validation. Production's recoverable/transformed policy is not a replacement for strict fixture and publication verification. Preserve documented historical validation exceptions rather than claiming stricter source validation than the edge actually performs.
|
||||
@@ -109,6 +115,20 @@ pnpm run lint
|
||||
git diff --check
|
||||
```
|
||||
|
||||
<a id="final-v3-vocabulary"></a>
|
||||
### Final V3 event vocabulary before V4 publication
|
||||
|
||||
While the published format remains V3 and the integration writer is V4, master can still add valid V3 event types. After each master integration and before the first V4 publication, compare the migration-owned `RELEASED_V3_EVENT_TYPES` with the latest V3-writer commit used by that refresh. Capture its full immutable id while the locally verified `origin/master` still writes V3:
|
||||
|
||||
```sh
|
||||
V3_SOURCE_REF="$(git rev-parse --verify 'origin/master^{commit}')"
|
||||
pnpm run verify-v3-event-vocabulary --source-ref "$V3_SOURCE_REF"
|
||||
```
|
||||
|
||||
The verifier reads only that local commit and requires exact event-name equality. It rejects missing V3 names, extra copied names, and a source writer other than V3. In particular, V4-only `developer/message` must not enter the frozen V3 set. Review each difference against the V3 payload, required conversions, target admission, and consumers; adding the name alone does not establish a complete migration. Add the corresponding migration regression before accepting a new source event.
|
||||
|
||||
This command does not fetch or establish remote freshness. The release operator must verify that the selected commit is the latest V3 writer included in the integration; an old remote-tracking ref or passing result for an older pin is insufficient. Record the checked id with release validation. If master already writes V4, reuse the recorded final V3 commit rather than resolving current master. After V4 ships, keep the V3 vocabulary tied to that historical source; later V4 event additions do not update it. Regular static CI has no dependency on a mutable master ref.
|
||||
|
||||
<a id="dev-note"></a>
|
||||
## Dev Note
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
|
||||
为 N+1 使用共享的 `release/*` 集成基线。基线变更添加写入器、codec、catalog 接线、恒等迁移与验证。从该基线创建各个独立子分支,并将其 PR(Pull Request)的目标设为发布分支,而非另一个独立子分支。每个子分支在同一个相邻迁移包内添加自身的结构变换、校验器、消费方和测试。不要只为表示评审顺序而分配额外版本。通过 PR 将评审后的子分支合入发布分支,并在发布前验证组合结果。遵守发布分支的强制推送与删除保护;不要强制同步该分支。
|
||||
|
||||
已发布 codec 与已接受迁移的含义保持稳定。尚未接受的 N→N+1 转换可以合并协同的破坏性变更。接受后须保留该转换:后续兼容演进使用新的同版本记录,新的破坏性变更则要求下一条迁移边及自身的头版本递增。
|
||||
已发布 codec 和已接受的目标格式含义保持稳定。N+1 发布后,N→N+1 转换器仍可修复或增加历史场景支持;只要输出仍兼容 N+1,就不必提高版本。转换器修复只影响之后的转换,不会重新处理已有 N+1 后继文件。记录对原先已接纳映射的修改,以及已有输出如何继续得到支持。不兼容地改变既定目标表示或解释语义需要下一版本。
|
||||
|
||||
N+1 尚处于开放集成阶段时,应使用可丢弃、相互隔离的 Harness home。中间版本产生的 N+1 文件已标为目标写入器版本,因此后续对 N→N+1 的修改不会再次迁移该文件。请在全新测试 home 中从未变更的历史输入重新运行;绝不通过改写已提交代际或复用真实用户 home 来修复这个问题。
|
||||
|
||||
@@ -57,6 +57,12 @@ pnpm run gen-session-format-catalog
|
||||
|
||||
显式定义新迁移边的事件准入与变换规则。[V2 到 V3 源审计](../../packages/session/session-format-v2-to-v3/README.zh.md#source-audit)和 [Alpha V0→V1 规则](../../.agents/notes/implemented/architecture/2026-08-31-alpha-historical-unknown-event-refusal.zh.md)分别负责对应已发布迁移边的策略,而非新迁移边的策略。不要将任一策略推广到所有迁移边。结构或事件位置变化时,必须分类源事件、载荷成员与引用,并显式判断不透明数据能否保持有效。[同版本保留](../../.agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.zh.md)本身不能证明结构变换安全。校验目标语义,并为每个新增可接受案例提供一个被拒绝的反例;绝不放宽旧迁移边来掩盖不受支持的转换。
|
||||
|
||||
以原生 writer 输出作为转换对照:用相同录制的 LLM 消息、工具输出和其他输入回放旧 writer 与目标 writer。任何格式归一化 helper 运行前,比较迁移后的旧输出与目标原始输出,只屏蔽已说明的易变字段。V4 的普通工具结果变为包含普通内容块的平铺 tool-role 消息。旧类型理论上允许某种结构,本身不足以支持新增核心内容类型。
|
||||
|
||||
Alpha 转换器可以明确拒绝尚无已实现、有证据映射的历史场景,包括原本有效的日志。源文件保持不变,且不发布部分 successor。扩展支持时优先使用真实语料和可复现 writer;只读检查用户语料,并创建脱敏回归 fixture。
|
||||
|
||||
在迁移边 README 中只记录目标格式所需的标识符与字段映射,并逐项说明原因。不要仅因可扩展就给直接 source kind 或普通 metadata 添加命名空间。保留不透明嵌套数据。Alpha 转换器可以拒绝在目标格式中会获得新语义的意外源字段,而不是为其虚构解释。
|
||||
|
||||
参照 [V2 到 V3 规范](../../packages/session/session-format-v2-to-v3/README.zh.md#v2-to-v3-specification),将新迁移边的 README 维护为唯一完整的转换与接纳目录。枚举每个被转换的 header、事件、消息槽和载荷字段,精确重命名或冲突规则,保留的 id、坐标、引用与继承截点,外部前置证据,拒绝与不透明数据策略,以及不支持或延后的行为。将历史源转换与原生目标接纳分开,说明每项检查由哪个 codec、restorer、已安装校验器和恢复策略执行。记录刻意窄于声明 schema 的校验范围。代码变更必须同时更新此目录;生成的 schema 库存或 PR 描述不能替代它。
|
||||
|
||||
通过 `sessionFormatCatalog.createRestore(header, { recovery: 'strict', validation: 'current' })` 验证严格恢复,按顺序传入各行并调用 `finish()`。这会执行物理解码、完整迁移链与已安装当前 Session 校验。生产环境的 recoverable/transformed 策略不能替代 fixture(测试前置数据)和发布验证所需的严格校验。保留已记录的历史校验例外,不要宣称源校验比迁移边实际执行的更严格。
|
||||
@@ -109,6 +115,20 @@ pnpm run lint
|
||||
git diff --check
|
||||
```
|
||||
|
||||
<a id="final-v3-vocabulary"></a>
|
||||
### 发布 V4 前确认最终 V3 事件词汇
|
||||
|
||||
当已发布格式仍为 V3、集成写入方为 V4 时,master 仍可能新增有效的 V3 事件类型。每次集成 master 后及首次发布 V4 前,都要把迁移所有的 `RELEASED_V3_EVENT_TYPES` 与该次刷新使用的最新 V3 写入方提交比较。在已于本地核验的 `origin/master` 仍写入 V3 时,记录其完整、不可变的提交 id:
|
||||
|
||||
```sh
|
||||
V3_SOURCE_REF="$(git rev-parse --verify 'origin/master^{commit}')"
|
||||
pnpm run verify-v3-event-vocabulary --source-ref "$V3_SOURCE_REF"
|
||||
```
|
||||
|
||||
校验器只读取该本地提交,并要求事件名完全一致。它拒绝遗漏的 V3 名称、额外复制的名称,以及不写入 V3 的源提交。尤其不能把 V4 专有的 `developer/message` 放入冻结的 V3 集合。逐项核对 V3 payload、必要转换、目标接纳及消费方;只添加名称不能证明迁移完整。接受新源事件前要补上相应迁移回归。
|
||||
|
||||
此命令不会 fetch,也不能证明远端新鲜度。发布操作者必须确认所选提交是此次集成包含的最新 V3 写入方;陈旧的远端跟踪 ref 或旧 pin 的通过结果均不足。把核验过的 id 记入发布验证。如果 master 已写入 V4,应复用已记录的最终 V3 提交,而不再解析当前 master。V4 发布后,V3 词汇仍绑定该历史源码;后续 V4 事件新增不更新它。常规静态 CI 不依赖可变的 master ref。
|
||||
|
||||
<a id="dev-note"></a>
|
||||
## 开发备注
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/module-graph.md
|
||||
module-graph.md: 531e97991ded62406d7e68c7cc3f5286c9764af8
|
||||
module-graph.zh.md: fd757d1bdc42c18357a2319619e910ee215d1ea2
|
||||
module-graph.md: 5471a7a6160dec338f5b1e542cd14483f1325f13
|
||||
module-graph.zh.md: 9214a63680f28ee70830ca0ed23f33204e3cb116
|
||||
@@ -180,7 +180,6 @@ flowchart TD
|
||||
pkg_client_ui_settings_models["client-ui-settings-models"]
|
||||
pkg_client_ui_settings_plugin_inventory["client-ui-settings-plugin-inventory"]
|
||||
pkg_client_ui_settings_plugins["client-ui-settings-plugins"]
|
||||
pkg_client_ui_settings_unarchive_sessions["client-ui-settings-unarchive-sessions"]
|
||||
pkg_client_ui_sidebar["client-ui-sidebar"]
|
||||
pkg_client_ui_sidebar_browser["client-ui-sidebar-browser"]
|
||||
pkg_client_ui_sidebar_documentpreview["client-ui-sidebar-documentpreview"]
|
||||
@@ -1363,7 +1362,6 @@ flowchart TD
|
||||
| [`client-ui-settings-models`](../packages/client/ui-settings-models) | `client` | — |
|
||||
| [`client-ui-settings-plugin-inventory`](../packages/client/ui-settings-plugin-inventory) | `client` | — |
|
||||
| [`client-ui-settings-plugins`](../packages/client/ui-settings-plugins) | `client` | — |
|
||||
| [`client-ui-settings-unarchive-sessions`](../packages/client/ui-settings-unarchive-sessions) | `client` | — |
|
||||
| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | — |
|
||||
| [`client-ui-sidebar-browser`](../packages/client/ui-sidebar-browser) | `client` | — |
|
||||
| [`client-ui-sidebar-documentpreview`](../packages/client/ui-sidebar-documentpreview) | `client` | — |
|
||||
|
||||
@@ -182,7 +182,6 @@ flowchart TD
|
||||
pkg_client_ui_settings_models["client-ui-settings-models"]
|
||||
pkg_client_ui_settings_plugin_inventory["client-ui-settings-plugin-inventory"]
|
||||
pkg_client_ui_settings_plugins["client-ui-settings-plugins"]
|
||||
pkg_client_ui_settings_unarchive_sessions["client-ui-settings-unarchive-sessions"]
|
||||
pkg_client_ui_sidebar["client-ui-sidebar"]
|
||||
pkg_client_ui_sidebar_browser["client-ui-sidebar-browser"]
|
||||
pkg_client_ui_sidebar_documentpreview["client-ui-sidebar-documentpreview"]
|
||||
@@ -1365,7 +1364,6 @@ flowchart TD
|
||||
| [`client-ui-settings-models`](../packages/client/ui-settings-models) | `client` | — |
|
||||
| [`client-ui-settings-plugin-inventory`](../packages/client/ui-settings-plugin-inventory) | `client` | — |
|
||||
| [`client-ui-settings-plugins`](../packages/client/ui-settings-plugins) | `client` | — |
|
||||
| [`client-ui-settings-unarchive-sessions`](../packages/client/ui-settings-unarchive-sessions) | `client` | — |
|
||||
| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | — |
|
||||
| [`client-ui-sidebar-browser`](../packages/client/ui-sidebar-browser) | `client` | — |
|
||||
| [`client-ui-sidebar-documentpreview`](../packages/client/ui-sidebar-documentpreview) | `client` | — |
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/session-format-status.md
|
||||
session-format-status.md: 7ca2c4b23e6b8f186098d3114ec25d4b17c351a2
|
||||
session-format-status.zh.md: 4a6b73a8263d78735bd553b51d3ce01152fc652b
|
||||
session-format-status.md: 9df313da2fad277254f464a8bdf6a6c8b541aab1
|
||||
session-format-status.zh.md: 31e1710632b07a1f64a5bc528bf0827c5af47af9
|
||||
@@ -36,6 +36,8 @@ V4 has an accepted compatibility baseline in the [checkpoint](persistence-change
|
||||
|
||||
Finalization does not freeze every future V4 addition and does not assert publication. The release record below retains the independently verified published version. Ordinary comments, aliases, source locations, and implementation fixes preserving the accepted meaning do not change this baseline.
|
||||
|
||||
Before the first V4 publication, every integration of a newer V3-writing master must pass the [explicit V3 vocabulary check](cookbook/adding-a-session-format-version.md#final-v3-vocabulary) against the recorded local source commit. Verify the source pin’s freshness and review new event payload conversions before updating the migration-owned set. After publication, the final V3 vocabulary remains historical and independent of current V4 additions.
|
||||
|
||||
<a id="release-record"></a>
|
||||
## Release record
|
||||
|
||||
|
||||
@@ -36,6 +36,8 @@ V4 的已接受兼容性基线保存在[检查点](persistence-changes/finalized
|
||||
|
||||
定稿不冻结 V4 之后的每项新增,也不表示已发布。下方发布记录保留独立验证的已发布版本。普通注释、别名、源码位置,以及保留已接受含义的实现修复,不改变该基线。
|
||||
|
||||
首次发布 V4 前,每次集成仍写入 V3 的较新 master 都必须对已记录的本地源提交执行[显式 V3 词汇校验](cookbook/adding-a-session-format-version.zh.md#final-v3-vocabulary)。更新迁移所有的集合前,要核验源 pin 的新鲜度并审查新事件 payload 的转换。发布后,最终 V3 词汇保持为历史定义,与当前 V4 新增事件无关。
|
||||
|
||||
<a id="release-record"></a>
|
||||
## 发布记录
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/subsystems/web-client.md
|
||||
web-client.md: 59bf663864b367382aedb1352ee3ca38f3e651ba
|
||||
web-client.zh.md: f38ef5e2c9d2258be765d8a2bb400751d72948c8
|
||||
web-client.md: 6bcf9bf84ed0b049b801c38d2426074d784e8de6
|
||||
web-client.zh.md: 9c697964a0f92bc646f5ca305fc3372bebcdc902
|
||||
@@ -47,7 +47,7 @@ The durable event path opens `follow()`, whose first frame contains the current
|
||||
|
||||
### Workspaces
|
||||
|
||||
[`api/workspace-controller`](../../packages/api/workspace-controller/README.md) keeps Workspace mutation policy and the authoritative follow feed on the Host. `ClientWorkspaceModel` owns the browser rows, order, archived Session ids, command echoes, and stream/unary race resolution. Every stream generation starts with a complete baseline followed by `upsert`, `remove`, `order`, and `archived` increments; reconnect replaces the model from the new baseline. `WorkspaceController` exposes that model as `ctx.workspaces`, while `ui-workspace` contributes `useWorkspaces` and navigation callbacks to the UI. The archived Session ids filter every grouping surface and feed the archived-sessions Settings page, which joins the set with the loaded Session summaries and offers one Unarchive action per row; a restore calls the `workspace.unarchiveSession` Remote, and the complete returned set reaches every Client through the `archived` increment.
|
||||
[`api/workspace-controller`](../../packages/api/workspace-controller/README.md) keeps Workspace mutation policy and the authoritative follow feed on the Host. `ClientWorkspaceModel` owns browser rows, Workspace order, archived and pinned Session id arrays, command echoes, and stream/unary race resolution. Every stream generation starts with a complete baseline followed by `upsert`, `remove`, `order`, `archived`, and `pinned` increments; reconnect replaces the model from the new baseline. `WorkspaceController` exposes that model as `ctx.workspaces`, while `ui-workspace` contributes `useWorkspaces` and navigation callbacks. The sidebar's `ArchivedFilter` controls default-hidden, shown, or archived-only rows in lists and search. Archived rows retain their ordering slots, render grayed, and cannot open until restored through the row or search-result Unarchive action. Restoration calls `workspace.unarchiveSession`, and its complete archive set reaches Clients through the unary response and `archived` increment. Session display order stays browser-local and includes hidden archives; pinning moves a Session within that full order, while unpinning does not restore its earlier position.
|
||||
|
||||
This pairing is not a second source of business truth. Host controllers decide durable state and mutation outcomes; Client models maintain the latest usable local projection, preserve object identity where useful to rendering, and encode how delayed responses and replacement baselines merge.
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ Connection 拥有 request correlation、`/api` carrier、trust check、精确 Fe
|
||||
|
||||
### Workspaces
|
||||
|
||||
[`api/workspace-controller`](../../packages/api/workspace-controller/README.zh.md)把 Workspace mutation policy 与权威 follow feed 留在 Host。`ClientWorkspaceModel` 拥有浏览器侧 row、order、archived Session id、command echo,以及 stream/unary 竞态合并。每代 stream 先给出完整 baseline,再给出 `upsert`、`remove`、`order` 和 `archived` increment;重连时以新 baseline 替换 model。`WorkspaceController` 把该 model 作为 `ctx.workspaces` 公开,而 `ui-workspace` 向 UI 提供 `useWorkspaces` 与 navigation callback。archived Session id 过滤每一个分组视图,并驱动「已归档会话」设置页;该页把该集合与已加载的 Session summary 合并,为每行提供一个取消归档操作。恢复会调用 `workspace.unarchiveSession` Remote,返回的完整集合则经 `archived` increment 到达每个 Client。
|
||||
[`api/workspace-controller`](../../packages/api/workspace-controller/README.zh.md)把 Workspace 变更策略与权威 follow feed 留在 Host。`ClientWorkspaceModel` 拥有浏览器行、Workspace 顺序、归档与置顶 Session id 数组、命令回显,以及流与一元调用的竞态合并。每代 stream 先给出完整 baseline,再给出 `upsert`、`remove`、`order`、`archived` 和 `pinned` increment;重连时以新 baseline 替换 model。`WorkspaceController` 把该 model 作为 `ctx.workspaces` 公开,`ui-workspace` 则提供 `useWorkspaces` 与导航回调。侧边栏的 `ArchivedFilter` 控制列表与搜索中的默认隐藏、显示已归档或仅显示已归档。归档行保留排序位置、置灰展示,必须通过行菜单或搜索结果的取消归档操作恢复后才能打开。恢复调用 `workspace.unarchiveSession`,完整归档集合通过一元响应及 `archived` increment 到达 Client。Session 显示顺序保存在浏览器本地,包含隐藏归档项;置顶在该完整顺序内移动 Session,取消置顶不恢复先前位置。
|
||||
|
||||
这种配对不会产生第二份业务真相。Host controller 决定持久状态与 mutation outcome;Client model 维护最新可用的本地 projection,在有利于渲染时保持 object identity,并明确 delayed response 与 replacement baseline 的合并规则。
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/subsystems/workspace.md
|
||||
workspace.md: 8ecf54a2860ab5d442c4856f6efd03900de1ce9b
|
||||
workspace.zh.md: dd4aed5f271aff0256115b03c85ec8b50ad0c502
|
||||
workspace.md: 9fe5f2bf4c62ba7d4f878b7b2f34490f43ca0b81
|
||||
workspace.zh.md: 63681e1a459c81a327654cfafa1f5cb2f231d6c8
|
||||
@@ -121,6 +121,10 @@ Ownership truth is the record's ordered `sessionIds`, never derived from session
|
||||
|
||||
Sessions get their cwd at create time from whoever creates them, not from this registry — the API gateway resolves a new session's cwd from the chosen workspace's `path` (falling back to an explicit or default cwd), creates the session so the cwd lands in its immutable [`SessionHeader`](persistence.md#sessionheader--metadata-beside-the-log), then calls `attachSession`, which re-validates that stored header cwd against the workspace path. On the first successful start, the registry bootstraps history from persisted headers alone (`id`, `cwd`, `createdAt` — never event bodies), grouping sessions with a valid canonical cwd into per-directory workspaces, newest first; the initialized marker is written last so an interrupted bootstrap resumes safely. The bootstrap is one-time: cwd-less legacy sessions stay Ungrouped, and sessions created afterwards join a workspace only through `attachSession`.
|
||||
|
||||
## Session pinning
|
||||
|
||||
The controller's [transport types](../../packages/api/workspace-controller/src/types.ts) define `WorkspacePinSessionRequest` and `WorkspaceUnpinSessionRequest`, each carrying one `sessionId`. Both operations return `WorkspacePinValue`: the complete `pinnedSessionIds` array of Session ids, most recently pinned first. Pinning requires a known, unarchived Session; unpinning an id that is not pinned succeeds without changing the set. Archiving removes the Session's pin in the same durable write, and unarchiving does not restore it.
|
||||
|
||||
## Consumers
|
||||
|
||||
[`dsh-workspace-controller`](../../packages/api/workspace-controller) serves workspace CRUD to GUI clients over `ctx.workspaceRegistry`, and [`dsh-session-controller`](../../packages/api/session-controller) performs the create-session-then-attach flow above. [dsh-agent-instructions](../../packages/context/agent-instructions) is **not** a consumer despite the name: it discovers AGENTS.md-style instruction files under an agent's own cwd and never touches `ctx.workspaceRegistry` — the shared word refers to the user's working directory, not to this registry's entities.
|
||||
@@ -339,6 +343,20 @@ Host service backing the generated `ctx.remote.workspace` namespace.
|
||||
*/
|
||||
@Remote('unarchiveSession') unarchiveSession(request: WorkspaceUnarchiveSessionRequest): Promise<WorkspaceArchiveValue>
|
||||
|
||||
/**
|
||||
* Surface one known unarchived Session ahead of unpinned Sessions.
|
||||
* @param request - Session identity to pin.
|
||||
* @returns the complete resulting pin set, most recently pinned first.
|
||||
*/
|
||||
@Remote('pinSession') pinSession(request: WorkspacePinSessionRequest): Promise<WorkspacePinValue>
|
||||
|
||||
/**
|
||||
* Remove one Session's pin without changing its saved Session order.
|
||||
* @param request - Session identity to unpin.
|
||||
* @returns the complete resulting pin set, most recently pinned first.
|
||||
*/
|
||||
@Remote('unpinSession') unpinSession(request: WorkspaceUnpinSessionRequest): Promise<WorkspacePinValue>
|
||||
|
||||
/**
|
||||
* Stream a complete Workspace baseline followed by ordered increments.
|
||||
* @param signal - generation cancellation.
|
||||
@@ -485,7 +503,9 @@ insertBefore(id: WorkspaceId, beforeId?: WorkspaceId): Promise<readonly Workspac
|
||||
/**
|
||||
* Archive one session durably. The session must exist (live or in session
|
||||
* persistence); its workspace accounting — or lack of one — is irrelevant.
|
||||
* An already archived id resolves without writing.
|
||||
* Archiving drops the session's pin in the same durable write (pinning and
|
||||
* archival are mutually exclusive). An already archived id resolves without
|
||||
* writing.
|
||||
* @param sessionId - The session to archive.
|
||||
* @returns resolution after durability.
|
||||
*/
|
||||
@@ -503,6 +523,25 @@ archiveSession(sessionId: SessionId): Promise<void>
|
||||
*/
|
||||
unarchiveSession(sessionId: SessionId): Promise<void>
|
||||
|
||||
/**
|
||||
* Pin one session durably, prepending it to the registry-global pin set.
|
||||
* The session must exist (live or in session persistence) and must not be
|
||||
* archived. An already pinned id resolves without writing or reordering.
|
||||
* @param sessionId - The session to pin.
|
||||
* @returns resolution after durability.
|
||||
*/
|
||||
pinSession(sessionId: SessionId): Promise<void>
|
||||
|
||||
/**
|
||||
* Unpin one session durably by dropping it from the registry-global pin
|
||||
* set. Unpinning runs no session-existence check because removing an id
|
||||
* cannot introduce an unknown one, so an entry whose session is gone still
|
||||
* resolves. An id that is not pinned resolves without writing.
|
||||
* @param sessionId - The session to unpin.
|
||||
* @returns resolution after durability.
|
||||
*/
|
||||
unpinSession(sessionId: SessionId): Promise<void>
|
||||
|
||||
/**
|
||||
* Resolve by canonical directory path without creating or mutating a
|
||||
* workspace. A missing path rejects during `realpath`; an existing unowned
|
||||
|
||||
@@ -121,6 +121,10 @@ interface Workspace {
|
||||
|
||||
会话的 cwd 在创建时由创建者赋予,而不是由本注册表赋予——API 网关从所选工作区的 `path` 解析新会话的 cwd(回退到显式或默认 cwd),先创建会话使 cwd 落入其不可变的 [`SessionHeader`](persistence.zh.md#sessionheader--metadata-beside-the-log),再调用 `attachSession`,后者会把已存储的 header cwd 与工作区路径重新校验一遍。首次成功启动时,注册表仅凭已持久化的 header(`id`、`cwd`、`createdAt`——绝不读事件正文)引导历史:把规范 cwd 有效的会话按目录分组为工作区,最新的排在最前;「已初始化」标记最后写入,因此被中断的引导可以安全续跑。引导只发生这一次:没有 cwd 的历史遗留会话保持 Ungrouped,此后创建的会话只能通过 `attachSession` 加入工作区。
|
||||
|
||||
## 会话置顶
|
||||
|
||||
控制器的[传输类型](../../packages/api/workspace-controller/src/types.ts)定义了 `WorkspacePinSessionRequest` 和 `WorkspaceUnpinSessionRequest`,两者都携带一个 `sessionId`。两个操作都返回 `WorkspacePinValue`:完整的会话 id 数组 `pinnedSessionIds`,最近置顶的会话排在前面。置顶要求会话已知且未归档;对未置顶的 id 取消置顶会成功,且不改变集合。归档在同一次持久化写入中移除该会话的置顶,取消归档不会恢复置顶。
|
||||
|
||||
## 消费方
|
||||
|
||||
[`dsh-workspace-controller`](../../packages/api/workspace-controller) 经 `ctx.workspaceRegistry` 向 GUI 客户端提供工作区 CRUD,[`dsh-session-controller`](../../packages/api/session-controller) 执行上文「先建会话再 attach」的流程。[dsh-agent-instructions](../../packages/context/agent-instructions) 尽管名字如此,却**不是**消费方:它在 agent 自己的 cwd 下发现 AGENTS.md 风格的指令文件,从不触碰 `ctx.workspaceRegistry`——两者共用的这个词指的是用户的工作目录,而非本注册表的实体。
|
||||
@@ -339,6 +343,20 @@ Host service backing the generated `ctx.remote.workspace` namespace.
|
||||
*/
|
||||
@Remote('unarchiveSession') unarchiveSession(request: WorkspaceUnarchiveSessionRequest): Promise<WorkspaceArchiveValue>
|
||||
|
||||
/**
|
||||
* Surface one known unarchived Session ahead of unpinned Sessions.
|
||||
* @param request - Session identity to pin.
|
||||
* @returns the complete resulting pin set, most recently pinned first.
|
||||
*/
|
||||
@Remote('pinSession') pinSession(request: WorkspacePinSessionRequest): Promise<WorkspacePinValue>
|
||||
|
||||
/**
|
||||
* Remove one Session's pin without changing its saved Session order.
|
||||
* @param request - Session identity to unpin.
|
||||
* @returns the complete resulting pin set, most recently pinned first.
|
||||
*/
|
||||
@Remote('unpinSession') unpinSession(request: WorkspaceUnpinSessionRequest): Promise<WorkspacePinValue>
|
||||
|
||||
/**
|
||||
* Stream a complete Workspace baseline followed by ordered increments.
|
||||
* @param signal - generation cancellation.
|
||||
@@ -485,7 +503,9 @@ insertBefore(id: WorkspaceId, beforeId?: WorkspaceId): Promise<readonly Workspac
|
||||
/**
|
||||
* Archive one session durably. The session must exist (live or in session
|
||||
* persistence); its workspace accounting — or lack of one — is irrelevant.
|
||||
* An already archived id resolves without writing.
|
||||
* Archiving drops the session's pin in the same durable write (pinning and
|
||||
* archival are mutually exclusive). An already archived id resolves without
|
||||
* writing.
|
||||
* @param sessionId - The session to archive.
|
||||
* @returns resolution after durability.
|
||||
*/
|
||||
@@ -503,6 +523,25 @@ archiveSession(sessionId: SessionId): Promise<void>
|
||||
*/
|
||||
unarchiveSession(sessionId: SessionId): Promise<void>
|
||||
|
||||
/**
|
||||
* Pin one session durably, prepending it to the registry-global pin set.
|
||||
* The session must exist (live or in session persistence) and must not be
|
||||
* archived. An already pinned id resolves without writing or reordering.
|
||||
* @param sessionId - The session to pin.
|
||||
* @returns resolution after durability.
|
||||
*/
|
||||
pinSession(sessionId: SessionId): Promise<void>
|
||||
|
||||
/**
|
||||
* Unpin one session durably by dropping it from the registry-global pin
|
||||
* set. Unpinning runs no session-existence check because removing an id
|
||||
* cannot introduce an unknown one, so an entry whose session is gone still
|
||||
* resolves. An id that is not pinned resolves without writing.
|
||||
* @param sessionId - The session to unpin.
|
||||
* @returns resolution after durability.
|
||||
*/
|
||||
unpinSession(sessionId: SessionId): Promise<void>
|
||||
|
||||
/**
|
||||
* Resolve by canonical directory path without creating or mutating a
|
||||
* workspace. A missing path rejects during `realpath`; an existing unowned
|
||||
|
||||
@@ -171,6 +171,7 @@
|
||||
"verify-persistence-releases": "tsx scripts/persistence-releases.ts",
|
||||
"verify-persistence-formats": "tsx scripts/persistence-formats.ts",
|
||||
"gen-session-format-catalog": "tsx scripts/gen-session-format-catalog.ts",
|
||||
"verify-v3-event-vocabulary": "tsx scripts/verify-v3-event-vocabulary.ts",
|
||||
"verify-session-format-catalog": "tsx scripts/gen-session-format-catalog.ts --check",
|
||||
"gen-third-party-notices": "tsx scripts/gen-third-party-notices.ts",
|
||||
"verify-third-party-notices": "tsx scripts/gen-third-party-notices.ts --check",
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/api/workspace-controller/README.md
|
||||
README.md: ccf07bfb61ad005d516928ad27145148a62ddf19
|
||||
README.zh.md: b96f49e92696cef2475458f4b51c764063aa32f9
|
||||
README.md: 46556b0105bb5186cf27b56faee4127b358eac9b
|
||||
README.zh.md: 9d210c10486a71772cf6c0a10480b734fa992441
|
||||
@@ -22,9 +22,9 @@ English | [中文](README.zh.md)
|
||||
<a id="use-this-package"></a>
|
||||
## Use this package
|
||||
|
||||
The Host controller serializes mutations whose correctness depends on current registry state and throws `RemoteError` with a stable `workspace/*` or `directory-picker/*` code for expected failures. Its `follow()` stream synchronously attaches to durable Workspace changes, emits one complete baseline first, then emits ordered `upsert`, `remove`, `order`, and `archived` increments. A reconnect starts another generation with a replacement baseline, so consumers do not depend on receiving every increment while disconnected.
|
||||
The Host controller serializes mutations whose correctness depends on current registry state and throws `RemoteError` with a stable error code for expected failures. Its `follow()` stream synchronously attaches to durable Workspace changes, emits one complete baseline first, then emits ordered `upsert`, `remove`, `order`, `archived`, and `pinned` increments. Archive and pin sets are Session id arrays, with the most recently pinned id first in the pin array. A reconnect starts another generation with a replacement baseline, so consumers do not depend on receiving every increment while disconnected.
|
||||
|
||||
The Client entry provides `ClientWorkspaceModel` and `createWorkspaceStateStream()`. The model owns Workspace rows, registry order, archived Session ids, unary mutation echoes, and stream/unary race resolution. A newer Host row wins by `updatedAt`; a committed stream order outranks an older unary response; a removed Workspace id cannot be resurrected by delayed data. The package exposes framework-neutral snapshots and subscriptions, leaving navigation policy and React hooks to the UI owner.
|
||||
The Client entry provides `ClientWorkspaceModel` and `createWorkspaceStateStream()`. The model owns Workspace rows, registry order, archived and pinned Session identities, unary mutation echoes, and stream/unary race resolution. A newer Host row wins by `updatedAt`; a committed stream order outranks an older unary response; a removed Workspace id cannot be resurrected by delayed data. Pin snapshots change only when their identities or order change. The package exposes framework-neutral snapshots and subscriptions, leaving navigation policy and React hooks to the UI owner.
|
||||
|
||||
-----
|
||||
|
||||
|
||||
@@ -22,9 +22,9 @@ kind: "package-reference"
|
||||
<a id="use-this-package"></a>
|
||||
## 使用本包
|
||||
|
||||
Host 控制器会串行执行正确性取决于当前注册表状态的变更,并为预期失败抛出带有稳定 `workspace/*` 或 `directory-picker/*` 错误码的 `RemoteError`。它的 `follow()` 流会同步订阅持久 Workspace 变更,先发出一份完整 baseline,再按顺序发出 `upsert`、`remove`、`order` 和 `archived` 增量。重连会以替换 baseline 开始新一代,因此消费方不依赖收到断线期间的每个增量。
|
||||
Host 控制器会串行执行正确性取决于当前注册表状态的变更,并为预期失败抛出带有稳定错误码的 `RemoteError`。它的 `follow()` 流会同步订阅持久 Workspace 变更,先发出一份完整 baseline,再按顺序发出 `upsert`、`remove`、`order`、`archived` 和 `pinned` 增量。归档与置顶集合都是会话 id 数组,置顶数组把最近置顶的 id 放在前面。重连会以替换 baseline 开始新一代,因此消费方不依赖收到断线期间的每个增量。
|
||||
|
||||
Client 入口提供 `ClientWorkspaceModel` 和 `createWorkspaceStateStream()`。该模型拥有 Workspace 行、registry 顺序、已归档 Session id、一元变更回显,以及流与一元调用的竞态处理。较新的 Host 行按 `updatedAt` 获胜;已提交的流顺序优先于较旧的一元响应;已经移除的 Workspace id 不会被延迟数据复活。该包公开与框架无关的快照和订阅,把导航策略与 React 钩子留给 UI owner。
|
||||
Client 入口提供 `ClientWorkspaceModel` 和 `createWorkspaceStateStream()`。该模型拥有 Workspace 行、registry 顺序、归档与置顶会话身份、一元变更回显,以及流与一元调用的竞态处理。较新的 Host 行按 `updatedAt` 获胜;已提交的流顺序优先于较旧的一元响应;已经移除的 Workspace id 不会被延迟数据复活。置顶快照仅在会话身份或顺序变化时更新。该包公开与框架无关的快照和订阅,把导航策略与 React 钩子留给 UI owner。
|
||||
|
||||
-----
|
||||
|
||||
|
||||
@@ -107,6 +107,9 @@ function acceptIncrement(accept: WorkspaceFollowSink, frame: WorkspaceFollowIncr
|
||||
case 'archived':
|
||||
accept.replaceArchived(frame.archivedSessionIds)
|
||||
return
|
||||
case 'pinned':
|
||||
accept.replacePinned(frame.pinnedSessionIds)
|
||||
return
|
||||
/* v8 ignore next -- the generated Remote codec validates this closed union */
|
||||
default:
|
||||
return assertNever(frame)
|
||||
|
||||
@@ -13,7 +13,10 @@ import type {
|
||||
WorkspaceDeleteValue,
|
||||
WorkspaceInsertSessionBeforeRequest,
|
||||
WorkspaceOrderValue,
|
||||
WorkspacePinSessionRequest,
|
||||
WorkspacePinValue,
|
||||
WorkspaceUnarchiveSessionRequest,
|
||||
WorkspaceUnpinSessionRequest,
|
||||
WorkspaceValue,
|
||||
WorkspaceId,
|
||||
WorkspaceView,
|
||||
@@ -30,6 +33,8 @@ export interface WorkspaceSnapshot {
|
||||
readonly items: readonly WorkspaceView[]
|
||||
/** Complete registry-global archive set in Host order. */
|
||||
readonly archivedSessionIds: WorkspaceArchiveValue['archivedSessionIds']
|
||||
/** Complete registry-global pin set, most recently pinned first. */
|
||||
readonly pinnedSessionIds: WorkspacePinValue['pinnedSessionIds']
|
||||
readonly state: 'idle' | 'loading' | 'error'
|
||||
readonly phase: WorkspaceListPhase
|
||||
readonly error: RemoteFailure | null
|
||||
@@ -47,6 +52,8 @@ export interface WorkspaceFollowSink {
|
||||
replaceOrder(workspaceIds: readonly WorkspaceId[]): void
|
||||
/** Replace the complete archived Session set. */
|
||||
replaceArchived(sessionIds: WorkspaceArchiveValue['archivedSessionIds']): void
|
||||
/** Replace the complete pinned Session set. */
|
||||
replacePinned(pinnedSessionIds: WorkspacePinValue['pinnedSessionIds']): void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -55,6 +62,7 @@ export interface WorkspaceFollowSink {
|
||||
export class ClientWorkspaceModel implements WorkspaceFollowSink {
|
||||
private items: readonly WorkspaceView[] = []
|
||||
private archivedSessionIds: WorkspaceArchiveValue['archivedSessionIds'] = []
|
||||
private pinnedSessionIds: WorkspacePinValue['pinnedSessionIds'] = []
|
||||
private state: WorkspaceSnapshot['state'] = 'loading'
|
||||
private phase: WorkspaceListPhase = 'pending'
|
||||
private error: RemoteFailure | null = null
|
||||
@@ -66,6 +74,8 @@ export class ClientWorkspaceModel implements WorkspaceFollowSink {
|
||||
private committedOrder: WorkspaceId[] = []
|
||||
/** Latest archive-set request; a later request or a pushed set supersedes it. */
|
||||
private archiveRequestSeq = 0
|
||||
/** Latest pin-set request; a later request or a pushed set supersedes it. */
|
||||
private pinRequestSeq = 0
|
||||
/** Host Workspace ids are never reused, so delayed data cannot resurrect a removed row. */
|
||||
private readonly removedIds = new Set<WorkspaceId>()
|
||||
private readonly listeners = new Set<() => void>()
|
||||
@@ -173,6 +183,9 @@ export class ClientWorkspaceModel implements WorkspaceFollowSink {
|
||||
const result = await this.remote.archiveSession({ sessionId })
|
||||
if (result.ok && requestSeq === this.archiveRequestSeq) {
|
||||
this.installArchived(result.value.archivedSessionIds)
|
||||
// The Host drops an archived session's pin in the same durable write;
|
||||
// mirror that locally so no frame shows the row both archived and pinned.
|
||||
this.installPinned(this.pinnedSessionIds.filter(id => id !== sessionId))
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -194,6 +207,40 @@ export class ClientWorkspaceModel implements WorkspaceFollowSink {
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Pin one Session and install the returned complete pin set.
|
||||
* A reply superseded by a later pin request or a pushed set installs nothing.
|
||||
* @param sessionId - Session to pin.
|
||||
* @returns generated Remote result.
|
||||
*/
|
||||
async pinSession(
|
||||
sessionId: WorkspacePinSessionRequest['sessionId'],
|
||||
): Promise<RemoteResult<WorkspacePinValue>> {
|
||||
const requestSeq = ++this.pinRequestSeq
|
||||
const result = await this.remote.pinSession({ sessionId })
|
||||
if (result.ok && requestSeq === this.pinRequestSeq) {
|
||||
this.installPinned(result.value.pinnedSessionIds)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Unpin one Session and install the returned complete pin set.
|
||||
* A reply superseded by a later pin request or a pushed set installs nothing.
|
||||
* @param sessionId - Session to unpin.
|
||||
* @returns generated Remote result.
|
||||
*/
|
||||
async unpinSession(
|
||||
sessionId: WorkspaceUnpinSessionRequest['sessionId'],
|
||||
): Promise<RemoteResult<WorkspacePinValue>> {
|
||||
const requestSeq = ++this.pinRequestSeq
|
||||
const result = await this.remote.unpinSession({ sessionId })
|
||||
if (result.ok && requestSeq === this.pinRequestSeq) {
|
||||
this.installPinned(result.value.pinnedSessionIds)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the projection from one complete stream-generation baseline.
|
||||
* @param baseline - complete Workspace and archive projection.
|
||||
@@ -201,8 +248,10 @@ export class ClientWorkspaceModel implements WorkspaceFollowSink {
|
||||
replaceBaseline(baseline: WorkspaceBaseline): void {
|
||||
this.orderFrameGeneration++
|
||||
this.archiveRequestSeq++
|
||||
this.pinRequestSeq++
|
||||
this.installViews(baseline.items)
|
||||
this.installArchived(baseline.archivedSessionIds)
|
||||
this.installPinned(baseline.pinnedSessionIds)
|
||||
this.state = 'idle'
|
||||
this.phase = 'ready'
|
||||
this.error = null
|
||||
@@ -234,6 +283,15 @@ export class ClientWorkspaceModel implements WorkspaceFollowSink {
|
||||
this.installArchived(archivedSessionIds)
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the pinned Session set from the current follow generation.
|
||||
* @param pinnedSessionIds - complete Host-confirmed pin set, most recently pinned first.
|
||||
*/
|
||||
replacePinned(pinnedSessionIds: WorkspacePinValue['pinnedSessionIds']): void {
|
||||
this.pinRequestSeq++
|
||||
this.installPinned(pinnedSessionIds)
|
||||
}
|
||||
|
||||
/** Keep the last complete projection visible while a lost carrier reconnects. */
|
||||
handleCarrierFailure(): void {
|
||||
this.state = 'loading'
|
||||
@@ -275,6 +333,7 @@ export class ClientWorkspaceModel implements WorkspaceFollowSink {
|
||||
return {
|
||||
items: this.items,
|
||||
archivedSessionIds: this.archivedSessionIds,
|
||||
pinnedSessionIds: this.pinnedSessionIds,
|
||||
state: this.state,
|
||||
phase: this.phase,
|
||||
error: this.error,
|
||||
@@ -288,6 +347,13 @@ export class ClientWorkspaceModel implements WorkspaceFollowSink {
|
||||
this.invalidate()
|
||||
}
|
||||
|
||||
private installPinned(pinnedSessionIds: WorkspacePinValue['pinnedSessionIds']): void {
|
||||
if (pinnedSessionIds.length === this.pinnedSessionIds.length
|
||||
&& pinnedSessionIds.every((id, index) => id === this.pinnedSessionIds[index])) return
|
||||
this.pinnedSessionIds = [...pinnedSessionIds]
|
||||
this.invalidate()
|
||||
}
|
||||
|
||||
private installOrder(workspaceIds: readonly WorkspaceId[], committed = false): void {
|
||||
if (committed) this.committedOrder = [...workspaceIds]
|
||||
const rank = new Map(workspaceIds.map((id, index) => [id, index]))
|
||||
|
||||
@@ -67,6 +67,16 @@ export interface IWorkspaces {
|
||||
* @param sessionId - Session to unarchive.
|
||||
*/
|
||||
unarchiveSession(sessionId: SessionId): Promise<void>
|
||||
/**
|
||||
* Pin a Session ahead of unpinned Sessions on Workspace grouping surfaces.
|
||||
* @param sessionId - Session to pin.
|
||||
*/
|
||||
pinSession(sessionId: SessionId): Promise<void>
|
||||
/**
|
||||
* Remove a Session's pin without changing its saved Session order.
|
||||
* @param sessionId - Session to unpin.
|
||||
*/
|
||||
unpinSession(sessionId: SessionId): Promise<void>
|
||||
/**
|
||||
* Move a Session within one Workspace account.
|
||||
* @param workspaceId - owning Workspace.
|
||||
@@ -126,6 +136,16 @@ export class WorkspaceController extends Service implements IWorkspaces {
|
||||
if (!result.ok) throw commandError('session unarchive', result.error)
|
||||
}
|
||||
|
||||
async pinSession(sessionId: SessionId): Promise<void> {
|
||||
const result = await this.model.pinSession(sessionId)
|
||||
if (!result.ok) throw commandError('session pin', result.error)
|
||||
}
|
||||
|
||||
async unpinSession(sessionId: SessionId): Promise<void> {
|
||||
const result = await this.model.unpinSession(sessionId)
|
||||
if (!result.ok) throw commandError('session unpin', result.error)
|
||||
}
|
||||
|
||||
async insertSessionBefore(
|
||||
workspaceId: WorkspaceId,
|
||||
sessionId: SessionId,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { Workspace } from '@deepseek-ai/dsh-workspace'
|
||||
import {
|
||||
WorkspaceArchivedSessionPinError,
|
||||
WorkspaceId,
|
||||
WorkspaceMoveInvalidError,
|
||||
WorkspaceOrderInvalidError,
|
||||
@@ -20,8 +21,11 @@ import type {
|
||||
WorkspaceInsertBeforeRequest,
|
||||
WorkspaceInsertSessionBeforeRequest,
|
||||
WorkspaceOrderValue,
|
||||
WorkspacePinSessionRequest,
|
||||
WorkspacePinValue,
|
||||
WorkspaceRenameRequest,
|
||||
WorkspaceUnarchiveSessionRequest,
|
||||
WorkspaceUnpinSessionRequest,
|
||||
WorkspaceValue,
|
||||
} from './types.ts'
|
||||
|
||||
@@ -173,6 +177,38 @@ export class WorkspaceCommands {
|
||||
return { archivedSessionIds: [...this.ctx.workspaceRegistry.archivedSessionIds] }
|
||||
}
|
||||
|
||||
/**
|
||||
* Add one known unarchived Session to the registry-global pin set.
|
||||
* @param request - Session identity to pin.
|
||||
* @returns the complete resulting pin set, most recently pinned first.
|
||||
*/
|
||||
async pinSession(request: WorkspacePinSessionRequest): Promise<WorkspacePinValue> {
|
||||
try {
|
||||
await this.ctx.workspaceRegistry.pinSession(request.sessionId)
|
||||
} catch (error) {
|
||||
if (error instanceof WorkspaceUnknownSessionError) {
|
||||
throw new RemoteError('session/not-found', error.message, { sessionId: request.sessionId }, { cause: error })
|
||||
}
|
||||
if (error instanceof WorkspaceArchivedSessionPinError) {
|
||||
throw new RemoteError('gateway/bad-request', error.message, {}, { cause: error })
|
||||
}
|
||||
throw error
|
||||
}
|
||||
return { pinnedSessionIds: [...this.ctx.workspaceRegistry.pinnedSessionIds] }
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop one Session from the registry-global pin set. An id that is not
|
||||
* pinned is not an error: the call is idempotent, so a lost race with
|
||||
* another surface resolves as a no-op.
|
||||
* @param request - Session identity to unpin.
|
||||
* @returns the complete resulting pin set, most recently pinned first.
|
||||
*/
|
||||
async unpinSession(request: WorkspaceUnpinSessionRequest): Promise<WorkspacePinValue> {
|
||||
await this.ctx.workspaceRegistry.unpinSession(request.sessionId)
|
||||
return { pinnedSessionIds: [...this.ctx.workspaceRegistry.pinnedSessionIds] }
|
||||
}
|
||||
|
||||
private requireWorkspace(workspaceId: WorkspaceId): Workspace {
|
||||
const workspace = this.ctx.workspaceRegistry.get(WorkspaceId(workspaceId))
|
||||
if (workspace === undefined) throw workspaceNotFound(workspaceId)
|
||||
|
||||
@@ -49,6 +49,7 @@ export class WorkspaceFeed {
|
||||
private knownIds: Set<string>
|
||||
private order: readonly string[]
|
||||
private archived: readonly string[]
|
||||
private pinned: readonly string[]
|
||||
|
||||
/** @param ctx - Host context containing the authoritative Workspace registry. */
|
||||
constructor(private readonly ctx: Context) {
|
||||
@@ -56,6 +57,7 @@ export class WorkspaceFeed {
|
||||
this.knownIds = new Set(baseline.map(workspace => String(workspace.id)))
|
||||
this.order = baseline.map(workspace => String(workspace.id))
|
||||
this.archived = ctx.workspaceRegistry.archivedSessionIds.map(String)
|
||||
this.pinned = ctx.workspaceRegistry.pinnedSessionIds.map(String)
|
||||
ctx.on('domain/changed', (change: DomainChanged) => { this.changed(change) })
|
||||
ctx.effect(() => () => {
|
||||
for (const follower of this.followers) follower.close()
|
||||
@@ -65,12 +67,13 @@ export class WorkspaceFeed {
|
||||
|
||||
/**
|
||||
* Read the complete current projection synchronously.
|
||||
* @returns all active Workspaces and archived Session identities.
|
||||
* @returns all active Workspaces plus archived and pinned Session identities.
|
||||
*/
|
||||
baseline(): WorkspaceBaseline {
|
||||
return {
|
||||
items: this.ctx.workspaceRegistry.list().map(workspaceView),
|
||||
archivedSessionIds: [...this.ctx.workspaceRegistry.archivedSessionIds],
|
||||
pinnedSessionIds: [...this.ctx.workspaceRegistry.pinnedSessionIds],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +118,11 @@ export class WorkspaceFeed {
|
||||
this.archived = nextArchived
|
||||
this.publish({ type: 'archived', archivedSessionIds: [...state.archivedSessionIds] })
|
||||
}
|
||||
const nextPinned = state.pinnedSessionIds.map(String)
|
||||
if (!sameStrings(this.pinned, nextPinned)) {
|
||||
this.pinned = nextPinned
|
||||
this.publish({ type: 'pinned', pinnedSessionIds: [...state.pinnedSessionIds] })
|
||||
}
|
||||
return
|
||||
}
|
||||
if (change.table !== 'workspaces') return
|
||||
|
||||
@@ -16,8 +16,11 @@ import type {
|
||||
WorkspaceInsertBeforeRequest,
|
||||
WorkspaceInsertSessionBeforeRequest,
|
||||
WorkspaceOrderValue,
|
||||
WorkspacePinSessionRequest,
|
||||
WorkspacePinValue,
|
||||
WorkspaceRenameRequest,
|
||||
WorkspaceUnarchiveSessionRequest,
|
||||
WorkspaceUnpinSessionRequest,
|
||||
WorkspaceValue,
|
||||
} from './types.ts'
|
||||
|
||||
@@ -120,6 +123,26 @@ export class WorkspaceController extends TypertRemoteService {
|
||||
return this.commands.unarchiveSession(request)
|
||||
}
|
||||
|
||||
/**
|
||||
* Surface one known unarchived Session ahead of unpinned Sessions.
|
||||
* @param request - Session identity to pin.
|
||||
* @returns the complete resulting pin set, most recently pinned first.
|
||||
*/
|
||||
@Remote('pinSession')
|
||||
pinSession(request: WorkspacePinSessionRequest): Promise<WorkspacePinValue> {
|
||||
return this.commands.pinSession(request)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove one Session's pin without changing its saved Session order.
|
||||
* @param request - Session identity to unpin.
|
||||
* @returns the complete resulting pin set, most recently pinned first.
|
||||
*/
|
||||
@Remote('unpinSession')
|
||||
unpinSession(request: WorkspaceUnpinSessionRequest): Promise<WorkspacePinValue> {
|
||||
return this.commands.unpinSession(request)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream a complete Workspace baseline followed by ordered increments.
|
||||
* @param signal - generation cancellation.
|
||||
|
||||
@@ -114,10 +114,27 @@ export interface WorkspaceArchiveValue {
|
||||
readonly archivedSessionIds: readonly SessionId[]
|
||||
}
|
||||
|
||||
/** Session requested for pinning ahead of unpinned Sessions on grouping surfaces. */
|
||||
export interface WorkspacePinSessionRequest {
|
||||
readonly sessionId: SessionId
|
||||
}
|
||||
|
||||
/** Session requested for removal from the pin set. */
|
||||
export interface WorkspaceUnpinSessionRequest {
|
||||
readonly sessionId: SessionId
|
||||
}
|
||||
|
||||
/** Complete pinned Session set after a mutation, most recently pinned first. */
|
||||
export interface WorkspacePinValue {
|
||||
readonly pinnedSessionIds: readonly SessionId[]
|
||||
}
|
||||
|
||||
/** Complete reconnect baseline for Workspace browser state. */
|
||||
export interface WorkspaceBaseline {
|
||||
readonly items: readonly WorkspaceView[]
|
||||
readonly archivedSessionIds: readonly SessionId[]
|
||||
/** Registry-global pin set, most recently pinned first. */
|
||||
readonly pinnedSessionIds: readonly SessionId[]
|
||||
}
|
||||
|
||||
/** One ordered Workspace change after a generation's baseline. */
|
||||
@@ -126,6 +143,7 @@ export type WorkspaceFollowIncrement =
|
||||
| { readonly type: 'remove'; readonly workspaceId: WorkspaceId }
|
||||
| { readonly type: 'order'; readonly workspaceIds: readonly WorkspaceId[] }
|
||||
| { readonly type: 'archived'; readonly archivedSessionIds: readonly SessionId[] }
|
||||
| { readonly type: 'pinned'; readonly pinnedSessionIds: readonly SessionId[] }
|
||||
|
||||
/** Workspace state stream; every generation starts with exactly one baseline. */
|
||||
export type WorkspaceFollowFrame =
|
||||
|
||||
@@ -13,8 +13,11 @@ import type {
|
||||
WorkspaceInsertBeforeRequest,
|
||||
WorkspaceInsertSessionBeforeRequest,
|
||||
WorkspaceOrderValue,
|
||||
WorkspacePinSessionRequest,
|
||||
WorkspacePinValue,
|
||||
WorkspaceRenameRequest,
|
||||
WorkspaceUnarchiveSessionRequest,
|
||||
WorkspaceUnpinSessionRequest,
|
||||
WorkspaceValue,
|
||||
WorkspaceId,
|
||||
WorkspaceView,
|
||||
@@ -89,6 +92,14 @@ class FakeWorkspaceRemote implements WorkspaceRemote {
|
||||
request: WorkspaceUnarchiveSessionRequest,
|
||||
) => Promise<RemoteResult<WorkspaceArchiveValue>> = request =>
|
||||
Promise.resolve(remoteOk({ archivedSessionIds: [request.sessionId] }))
|
||||
onPinSession: (
|
||||
request: WorkspacePinSessionRequest,
|
||||
) => Promise<RemoteResult<WorkspacePinValue>> = request =>
|
||||
Promise.resolve(remoteOk({ pinnedSessionIds: [request.sessionId] }))
|
||||
onUnpinSession: (
|
||||
_request: WorkspaceUnpinSessionRequest,
|
||||
) => Promise<RemoteResult<WorkspacePinValue>> = () =>
|
||||
Promise.resolve(remoteOk({ pinnedSessionIds: [] }))
|
||||
|
||||
create(request: WorkspaceCreateRequest): Promise<RemoteResult<WorkspaceCreateValue>> {
|
||||
this.record('create', request)
|
||||
@@ -125,6 +136,16 @@ class FakeWorkspaceRemote implements WorkspaceRemote {
|
||||
return this.onUnarchiveSession(request)
|
||||
}
|
||||
|
||||
pinSession(request: WorkspacePinSessionRequest): Promise<RemoteResult<WorkspacePinValue>> {
|
||||
this.record('pinSession', request)
|
||||
return this.onPinSession(request)
|
||||
}
|
||||
|
||||
unpinSession(request: WorkspaceUnpinSessionRequest): Promise<RemoteResult<WorkspacePinValue>> {
|
||||
this.record('unpinSession', request)
|
||||
return this.onUnpinSession(request)
|
||||
}
|
||||
|
||||
async *follow(_signal?: AbortSignal): AsyncGenerator<WorkspaceFollowFrame> {}
|
||||
|
||||
private record(method: string, request: unknown): void {
|
||||
@@ -140,8 +161,9 @@ function baseline(
|
||||
model: ClientWorkspaceModel,
|
||||
items: readonly WorkspaceView[] = [],
|
||||
archivedSessionIds: readonly SessionId[] = [],
|
||||
pinnedSessionIds: readonly SessionId[] = [],
|
||||
): void {
|
||||
model.replaceBaseline({ items, archivedSessionIds })
|
||||
model.replaceBaseline({ items, archivedSessionIds, pinnedSessionIds })
|
||||
}
|
||||
|
||||
describe('ClientWorkspaceModel', () => {
|
||||
@@ -393,6 +415,83 @@ describe('ClientWorkspaceModel', () => {
|
||||
expect(model.getSnapshot().archivedSessionIds).toEqual(['second'])
|
||||
})
|
||||
|
||||
it('applies pin mutation echoes and leaves failed results unchanged', async () => {
|
||||
const remote = new FakeWorkspaceRemote()
|
||||
const model = modelFor(remote)
|
||||
baseline(model, [], [], [sid('kept')])
|
||||
|
||||
remote.onPinSession = () => Promise.resolve(workspaceError(
|
||||
new RemoteError('session/not-found', 'missing', { sessionId: sid('missing') }),
|
||||
))
|
||||
await expect(model.pinSession(sid('missing'))).resolves.toMatchObject({ ok: false })
|
||||
expect(model.getSnapshot().pinnedSessionIds).toEqual(['kept'])
|
||||
remote.onPinSession = request => Promise.resolve(remoteOk({
|
||||
pinnedSessionIds: [request.sessionId, sid('kept')],
|
||||
}))
|
||||
await expect(model.pinSession(sid('fresh'))).resolves.toMatchObject({ ok: true })
|
||||
expect(model.getSnapshot().pinnedSessionIds).toEqual(['fresh', 'kept'])
|
||||
expect(remote.calls).toContainEqual({ method: 'pinSession', request: { sessionId: 'fresh' } })
|
||||
|
||||
remote.onUnpinSession = () => Promise.resolve(workspaceError(
|
||||
new RemoteError('gateway/internal', 'wire down', {}),
|
||||
))
|
||||
await expect(model.unpinSession(sid('fresh'))).resolves.toMatchObject({ ok: false })
|
||||
expect(model.getSnapshot().pinnedSessionIds).toEqual(['fresh', 'kept'])
|
||||
remote.onUnpinSession = () => Promise.resolve(remoteOk({ pinnedSessionIds: [sid('kept')] }))
|
||||
await expect(model.unpinSession(sid('fresh'))).resolves.toMatchObject({ ok: true })
|
||||
expect(model.getSnapshot().pinnedSessionIds).toEqual(['kept'])
|
||||
expect(remote.calls).toContainEqual({ method: 'unpinSession', request: { sessionId: 'fresh' } })
|
||||
|
||||
const before = model.getSnapshot()
|
||||
model.replacePinned([sid('kept')])
|
||||
expect(model.getSnapshot()).toBe(before)
|
||||
model.replacePinned([sid('other')])
|
||||
expect(model.getSnapshot()).not.toBe(before)
|
||||
expect(model.getSnapshot().pinnedSessionIds).toEqual([sid('other')])
|
||||
})
|
||||
|
||||
it('keeps the latest pin reply when overlapping requests settle out of order', async () => {
|
||||
const remote = new FakeWorkspaceRemote()
|
||||
const model = modelFor(remote)
|
||||
const firstGate = deferred<RemoteResult<WorkspacePinValue>>()
|
||||
const secondGate = deferred<RemoteResult<WorkspacePinValue>>()
|
||||
let request = 0
|
||||
remote.onPinSession = () => request++ === 0 ? firstGate.promise : secondGate.promise
|
||||
|
||||
const first = model.pinSession(sid('first'))
|
||||
const second = model.pinSession(sid('second'))
|
||||
secondGate.resolve(remoteOk({ pinnedSessionIds: [sid('second'), sid('first')] }))
|
||||
await expect(second).resolves.toMatchObject({ ok: true })
|
||||
expect(model.getSnapshot().pinnedSessionIds).toEqual(['second', 'first'])
|
||||
firstGate.resolve(remoteOk({ pinnedSessionIds: [sid('first')] }))
|
||||
await expect(first).resolves.toMatchObject({ ok: true })
|
||||
expect(model.getSnapshot().pinnedSessionIds).toEqual(['second', 'first'])
|
||||
})
|
||||
|
||||
it('keeps a pushed pin set when an unpin reply lands later', async () => {
|
||||
const remote = new FakeWorkspaceRemote()
|
||||
const model = modelFor(remote)
|
||||
baseline(model, [], [], [sid('first')])
|
||||
const gate = deferred<RemoteResult<WorkspacePinValue>>()
|
||||
remote.onUnpinSession = () => gate.promise
|
||||
|
||||
const pending = model.unpinSession(sid('first'))
|
||||
model.replacePinned([sid('first'), sid('second')])
|
||||
gate.resolve(remoteOk({ pinnedSessionIds: [] }))
|
||||
await expect(pending).resolves.toMatchObject({ ok: true })
|
||||
expect(model.getSnapshot().pinnedSessionIds).toEqual(['first', 'second'])
|
||||
})
|
||||
|
||||
it('mirrors the Host pin drop locally when an archive echo lands', async () => {
|
||||
const remote = new FakeWorkspaceRemote()
|
||||
const model = modelFor(remote)
|
||||
baseline(model, [], [], [sid('pinned'), sid('kept')])
|
||||
|
||||
await expect(model.archiveSession(sid('pinned'))).resolves.toMatchObject({ ok: true })
|
||||
expect(model.getSnapshot().archivedSessionIds).toEqual(['pinned'])
|
||||
expect(model.getSnapshot().pinnedSessionIds).toEqual(['kept'])
|
||||
})
|
||||
|
||||
it('keeps the newest row and places Workspaces missing from partial orders last', async () => {
|
||||
const model = modelFor()
|
||||
baseline(model, [
|
||||
|
||||
@@ -19,8 +19,11 @@ import type {
|
||||
WorkspaceInsertBeforeRequest,
|
||||
WorkspaceInsertSessionBeforeRequest,
|
||||
WorkspaceOrderValue,
|
||||
WorkspacePinSessionRequest,
|
||||
WorkspacePinValue,
|
||||
WorkspaceRenameRequest,
|
||||
WorkspaceUnarchiveSessionRequest,
|
||||
WorkspaceUnpinSessionRequest,
|
||||
WorkspaceValue,
|
||||
WorkspaceView,
|
||||
} from '../../src/types.ts'
|
||||
@@ -59,12 +62,15 @@ export function workspace(id: string, overrides: Partial<WorkspaceView> = {}): W
|
||||
}
|
||||
|
||||
/**
|
||||
* A baseline frame holding the named Workspaces and no archived Sessions.
|
||||
* A baseline frame holding the named Workspaces and no archived or pinned Sessions.
|
||||
* @param ids - Workspace ids in registry order.
|
||||
* @returns the frame.
|
||||
*/
|
||||
export function baseline(...ids: readonly string[]): WorkspaceBaselineFrame {
|
||||
return { type: 'baseline', value: { items: ids.map(id => workspace(id)), archivedSessionIds: [] } }
|
||||
return {
|
||||
type: 'baseline',
|
||||
value: { items: ids.map(id => workspace(id)), archivedSessionIds: [], pinnedSessionIds: [] },
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -98,5 +104,7 @@ export const workspaceWorld: RemoteTable = {
|
||||
}),
|
||||
'workspace/archiveSession': (request: WorkspaceArchiveSessionRequest): RemoteResult<WorkspaceArchiveValue> => ok({ archivedSessionIds: [request.sessionId] }),
|
||||
'workspace/unarchiveSession': (_request: WorkspaceUnarchiveSessionRequest): RemoteResult<WorkspaceArchiveValue> => ok({ archivedSessionIds: [] }),
|
||||
'workspace/pinSession': (request: WorkspacePinSessionRequest): RemoteResult<WorkspacePinValue> => ok({ pinnedSessionIds: [request.sessionId] }),
|
||||
'workspace/unpinSession': (_request: WorkspaceUnpinSessionRequest): RemoteResult<WorkspacePinValue> => ok({ pinnedSessionIds: [] }),
|
||||
},
|
||||
}
|
||||
@@ -58,6 +58,7 @@ function accepts(overrides: Partial<WorkspaceFollowSink> = {}): WorkspaceFollowS
|
||||
removeView: ignore,
|
||||
replaceOrder: ignore,
|
||||
replaceArchived: ignore,
|
||||
replacePinned: ignore,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
@@ -153,6 +154,7 @@ describe('Workspace state stream', () => {
|
||||
{ type: 'remove', workspaceId: view.workspaceId },
|
||||
{ type: 'order', workspaceIds: [view.workspaceId] },
|
||||
{ type: 'archived', archivedSessionIds: [sid('session-one')] },
|
||||
{ type: 'pinned', pinnedSessionIds: [sid('session-two')] },
|
||||
]
|
||||
mock.stream(FOLLOW, openStream([opening, ...increments]))
|
||||
const replaceBaseline = vi.fn<WorkspaceFollowSink['replaceBaseline']>()
|
||||
@@ -160,20 +162,22 @@ describe('Workspace state stream', () => {
|
||||
const removeView = vi.fn<WorkspaceFollowSink['removeView']>()
|
||||
const replaceOrder = vi.fn<WorkspaceFollowSink['replaceOrder']>()
|
||||
const replaceArchived = vi.fn<WorkspaceFollowSink['replaceArchived']>()
|
||||
const replacePinned = vi.fn<WorkspaceFollowSink['replacePinned']>()
|
||||
const stream = createWorkspaceStateStream(remote, {
|
||||
accept: accepts({ replaceBaseline, upsertView, removeView, replaceOrder, replaceArchived }),
|
||||
accept: accepts({ replaceBaseline, upsertView, removeView, replaceOrder, replaceArchived, replacePinned }),
|
||||
failed: vi.fn(),
|
||||
})
|
||||
|
||||
stream.start()
|
||||
stream.start()
|
||||
await vi.waitFor(() => { expect(replaceArchived).toHaveBeenCalledOnce() })
|
||||
await vi.waitFor(() => { expect(replacePinned).toHaveBeenCalledOnce() })
|
||||
|
||||
expect(replaceBaseline).toHaveBeenCalledWith(opening.value)
|
||||
expect(upsertView).toHaveBeenCalledWith(view)
|
||||
expect(removeView).toHaveBeenCalledWith(view.workspaceId)
|
||||
expect(replaceOrder).toHaveBeenCalledWith([view.workspaceId])
|
||||
expect(replaceArchived).toHaveBeenCalledWith(['session-one'])
|
||||
expect(replacePinned).toHaveBeenCalledWith(['session-two'])
|
||||
await stream.dispose()
|
||||
expect(streamStates(mock)).toEqual(['cancelled'])
|
||||
})
|
||||
@@ -309,7 +313,7 @@ describe('WorkspaceController', () => {
|
||||
it('publishes the model source and exposes successful Workspace commands', async ({ mock, start }) => {
|
||||
const { remote, client } = await gatewayClient(mock, start)
|
||||
const model = new ClientWorkspaceModel(remote.workspace)
|
||||
model.replaceBaseline({ items: [workspace('one')], archivedSessionIds: [] })
|
||||
model.replaceBaseline({ items: [workspace('one')], archivedSessionIds: [], pinnedSessionIds: [] })
|
||||
const controller = new WorkspaceController(client.ctx, model)
|
||||
|
||||
expect(controller.list).toBe(model)
|
||||
@@ -322,6 +326,8 @@ describe('WorkspaceController', () => {
|
||||
})
|
||||
await expect(controller.archiveSession(sid('session'))).resolves.toBeUndefined()
|
||||
await expect(controller.unarchiveSession(sid('session'))).resolves.toBeUndefined()
|
||||
await expect(controller.pinSession(sid('session'))).resolves.toBeUndefined()
|
||||
await expect(controller.unpinSession(sid('session'))).resolves.toBeUndefined()
|
||||
await expect(controller.delete(wid('one'))).resolves.toBeUndefined()
|
||||
// Each command crosses the wire as one positional request object.
|
||||
expect(mock.log.requests('workspace/create')).toEqual([{ path: '/work/created' }])
|
||||
@@ -330,6 +336,8 @@ describe('WorkspaceController', () => {
|
||||
expect(mock.log.requests('workspace/insertSessionBefore')).toEqual([{ workspaceId: 'one', sessionId: 'session' }])
|
||||
expect(mock.log.requests('workspace/archiveSession')).toEqual([{ sessionId: 'session' }])
|
||||
expect(mock.log.requests('workspace/unarchiveSession')).toEqual([{ sessionId: 'session' }])
|
||||
expect(mock.log.requests('workspace/pinSession')).toEqual([{ sessionId: 'session' }])
|
||||
expect(mock.log.requests('workspace/unpinSession')).toEqual([{ sessionId: 'session' }])
|
||||
expect(mock.log.requests('workspace/delete')).toEqual([{ workspaceId: 'one' }])
|
||||
})
|
||||
|
||||
@@ -356,6 +364,12 @@ describe('WorkspaceController', () => {
|
||||
mock.remote.workspace.unarchiveSession.mockResolvedValueOnce(err(missingSession))
|
||||
await expect(controller.unarchiveSession(sid('session')))
|
||||
.rejects.toThrow('workspace session unarchive failed: session/not-found: missing session')
|
||||
mock.remote.workspace.pinSession.mockResolvedValueOnce(err(missingSession))
|
||||
await expect(controller.pinSession(sid('session')))
|
||||
.rejects.toThrow('workspace session pin failed: session/not-found: missing session')
|
||||
mock.remote.workspace.unpinSession.mockResolvedValueOnce(err(missingSession))
|
||||
await expect(controller.unpinSession(sid('session')))
|
||||
.rejects.toThrow('workspace session unpin failed: session/not-found: missing session')
|
||||
mock.remote.workspace.insertSessionBefore.mockResolvedValueOnce(err(new RemoteError(
|
||||
'workspace/move-invalid', 'invalid move', { workspaceId: wid('missing'), sessionId: sid('session') },
|
||||
)))
|
||||
|
||||
@@ -156,6 +156,16 @@ describe('WorkspaceController commands', () => {
|
||||
vi.spyOn(ctx.workspaceRegistry, 'unarchiveSession').mockRejectedValueOnce(unarchiveFailure)
|
||||
await expect(controller.unarchiveSession({ sessionId: SessionId('session') }))
|
||||
.rejects.toBe(unarchiveFailure)
|
||||
|
||||
const pinFailure = new Error('pin storage failed')
|
||||
vi.spyOn(ctx.workspaceRegistry, 'pinSession').mockRejectedValueOnce(pinFailure)
|
||||
await expect(controller.pinSession({ sessionId: SessionId('session') }))
|
||||
.rejects.toBe(pinFailure)
|
||||
|
||||
const unpinFailure = new Error('unpin storage failed')
|
||||
vi.spyOn(ctx.workspaceRegistry, 'unpinSession').mockRejectedValueOnce(unpinFailure)
|
||||
await expect(controller.unpinSession({ sessionId: SessionId('session') }))
|
||||
.rejects.toBe(unpinFailure)
|
||||
})
|
||||
|
||||
it('resolves queued Workspace identities when their operation starts', async () => {
|
||||
@@ -232,6 +242,33 @@ describe('WorkspaceController commands', () => {
|
||||
await expect(controller.unarchiveSession({ sessionId: session.id }))
|
||||
.resolves.toEqual({ archivedSessionIds: [] })
|
||||
})
|
||||
|
||||
it('pins only known unarchived Sessions and unpins idempotently', async () => {
|
||||
const { controller, ctx, root } = await harness()
|
||||
const created = await controller.create({ path: stageDir(root, 'pins') })
|
||||
const session = ctx.sessions.create(SessionId('pin-me'), {
|
||||
meta: { cwd: created.workspace.path },
|
||||
})
|
||||
|
||||
await expect(controller.pinSession({ sessionId: session.id }))
|
||||
.resolves.toEqual({ pinnedSessionIds: [session.id] })
|
||||
await expect(controller.pinSession({ sessionId: SessionId('unknown') }))
|
||||
.rejects.toMatchObject({ code: 'session/not-found' })
|
||||
|
||||
// Pinning an archived Session is a caller error, not a missing session.
|
||||
const archived = ctx.sessions.create(SessionId('stored'), {
|
||||
meta: { cwd: created.workspace.path },
|
||||
})
|
||||
await controller.archiveSession({ sessionId: archived.id })
|
||||
await expect(controller.pinSession({ sessionId: archived.id }))
|
||||
.rejects.toMatchObject({ code: 'gateway/bad-request' })
|
||||
|
||||
await expect(controller.unpinSession({ sessionId: session.id }))
|
||||
.resolves.toEqual({ pinnedSessionIds: [] })
|
||||
// Unpin is idempotent: an id that is not pinned is not an error.
|
||||
await expect(controller.unpinSession({ sessionId: session.id }))
|
||||
.resolves.toEqual({ pinnedSessionIds: [] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('WorkspaceController follow', () => {
|
||||
@@ -253,18 +290,38 @@ describe('WorkspaceController follow', () => {
|
||||
initialized: true,
|
||||
workspaceIds: ['missing'],
|
||||
archivedSessionIds: [],
|
||||
pinnedSessionIds: [],
|
||||
},
|
||||
})
|
||||
}).toThrow('references missing Workspace "missing"')
|
||||
})
|
||||
|
||||
it('starts a fresh feed with existing pins and follows their removal', async () => {
|
||||
const { controller, ctx, root } = await harness()
|
||||
const session = ctx.sessions.create(SessionId('already-pinned'), { meta: { cwd: root } })
|
||||
await controller.pinSession({ sessionId: session.id })
|
||||
const feed = new WorkspaceFeed(ctx)
|
||||
const abort = new AbortController()
|
||||
const iterator = feed.follow(abort.signal)[Symbol.asyncIterator]()
|
||||
try {
|
||||
await expect(nextFrame(iterator)).resolves.toMatchObject({
|
||||
type: 'baseline', value: { pinnedSessionIds: [session.id] },
|
||||
})
|
||||
await controller.unpinSession({ sessionId: session.id })
|
||||
await expect(nextFrame(iterator)).resolves.toEqual({ type: 'pinned', pinnedSessionIds: [] })
|
||||
} finally {
|
||||
abort.abort()
|
||||
await iterator.return?.()
|
||||
}
|
||||
})
|
||||
|
||||
it('starts with a complete baseline and emits committed increments in domain order', async () => {
|
||||
const { controller, ctx, root } = await harness()
|
||||
const abort = new AbortController()
|
||||
const iterator = controller.follow(abort.signal)[Symbol.asyncIterator]()
|
||||
await expect(nextFrame(iterator)).resolves.toEqual({
|
||||
type: 'baseline',
|
||||
value: { items: [], archivedSessionIds: [] },
|
||||
value: { items: [], archivedSessionIds: [], pinnedSessionIds: [] },
|
||||
})
|
||||
|
||||
const first = await controller.create({ path: stageDir(root, 'first') })
|
||||
@@ -307,6 +364,15 @@ describe('WorkspaceController follow', () => {
|
||||
await expect(nextFrame(iterator)).resolves.toEqual({
|
||||
type: 'archived', archivedSessionIds: [],
|
||||
})
|
||||
await controller.pinSession({ sessionId: session.id })
|
||||
await expect(nextFrame(iterator)).resolves.toEqual({
|
||||
type: 'pinned', pinnedSessionIds: [session.id],
|
||||
})
|
||||
// Unpin rides the same complete-set increment: no new frame type.
|
||||
await controller.unpinSession({ sessionId: session.id })
|
||||
await expect(nextFrame(iterator)).resolves.toEqual({
|
||||
type: 'pinned', pinnedSessionIds: [],
|
||||
})
|
||||
await controller.delete({ workspaceId: second.workspace.workspaceId })
|
||||
await expect(nextFrame(iterator)).resolves.toEqual({
|
||||
type: 'order', workspaceIds: [first.workspace.workspaceId],
|
||||
|
||||
@@ -263,9 +263,6 @@
|
||||
- id: ui-settings-plugin-inventory
|
||||
name: '@deepseek-ai/dsh-client-ui-settings-plugin-inventory'
|
||||
|
||||
- id: ui-settings-unarchive-sessions
|
||||
name: '@deepseek-ai/dsh-client-ui-settings-unarchive-sessions'
|
||||
|
||||
- id: ui-conversation
|
||||
name: '@deepseek-ai/dsh-client-ui-conversation'
|
||||
|
||||
|
||||
@@ -83,7 +83,6 @@
|
||||
"@deepseek-ai/dsh-client-ui-plugin-manager": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-settings-plugin-inventory": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-settings-plugins": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-settings-unarchive-sessions": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-sidebar": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-sidebar-browser": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-sidebar-files": "workspace:^",
|
||||
|
||||
@@ -25,7 +25,7 @@ function emptySessions() {
|
||||
}
|
||||
function emptyWorkspaces() {
|
||||
const store = createSnapshotStore<WorkspaceSnapshot>({
|
||||
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
|
||||
items: [], archivedSessionIds: [], pinnedSessionIds: [], state: 'idle', phase: 'ready', error: null,
|
||||
})
|
||||
return bindSnapshotSelector(store)
|
||||
}
|
||||
|
||||
@@ -212,7 +212,7 @@ function emptySessions() {
|
||||
|
||||
function emptyWorkspaces() {
|
||||
const store = createSnapshotStore<WorkspaceSnapshot>({
|
||||
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
|
||||
items: [], archivedSessionIds: [], pinnedSessionIds: [], state: 'idle', phase: 'ready', error: null,
|
||||
})
|
||||
return bindSnapshotSelector(store)
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ function emptySessions() {
|
||||
|
||||
function emptyWorkspaces() {
|
||||
return bindSnapshotSelector(createSnapshotStore<WorkspaceSnapshot>({
|
||||
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
|
||||
items: [], archivedSessionIds: [], pinnedSessionIds: [], state: 'idle', phase: 'ready', error: null,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ function emptySessions() {
|
||||
|
||||
function emptyWorkspaces() {
|
||||
return bindSnapshotSelector(createSnapshotStore<WorkspaceSnapshot>({
|
||||
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
|
||||
items: [], archivedSessionIds: [], pinnedSessionIds: [], state: 'idle', phase: 'ready', error: null,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
Loaded 100 of 256 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user