mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-10-26 16:55:08 +00:00
feat(session): catalogue experimental plugin records
This commit is contained in:
1 parent
3a221c87e3
commit
dd302f718b
30 files changed
+781
-58
No files matched your search
+4
-4
@@ -9,11 +9,11 @@
|
||||
en: d2df1eb3602af45d
|
||||
zh: fd162b1c214f1494
|
||||
/agent-note-retain-ignorable-session-events-for-external-plugins/decision:
|
||||
en: 7c8943a5cdfb42f5
|
||||
zh: e73b1cf5f5c4074d
|
||||
en: 1bf1cc3675ef0dbd
|
||||
zh: f716038fad00711f
|
||||
/agent-note-retain-ignorable-session-events-for-external-plugins/alternatives-considered:
|
||||
en: 2e510735b754be73
|
||||
zh: f5a783d9c9e7b683
|
||||
en: f17e0fadb751ec55
|
||||
zh: fa54b1bf42afdb76
|
||||
/agent-note-retain-ignorable-session-events-for-external-plugins/consequences:
|
||||
en: b14df3e7f7489748
|
||||
zh: ac227743cf5c11f7
|
||||
+5
-1
@@ -18,6 +18,8 @@ The field is removable only after a replacement supports the current third-party
|
||||
|
||||
`appendPluginRecord()` in `@deepseek-ai/dsh-session` is the one first-party writer of the marker. It appends ignorable records whose type is in the `plugin:` namespace, which the V3-to-V4 edge already assigns to unknown ignorable V3 events, and only production source under `packages/experimental/` may call it; the `verify-plugin-record-callers` gate enforces that, and the persistence catalog generator rejects a `SessionEventMap` member in the namespace. Experimental packages keep state there that their owners can lose: a format migration retains records on a best-effort basis, so a release package declares its events instead.
|
||||
|
||||
Experimental packages declare record names and payload types in the separate `PluginRecordMap`. The map types writes and supplies a compact current-source section in the persistence catalog, making plugin-owned persistence discoverable without expanding its payload schemas or recording released type history. It does not add names to `KNOWN_SESSION_EVENT_TYPES` or change stored-event admission. The reader returns unknown payloads for owner validation, including records whose declarations have changed or disappeared.
|
||||
|
||||
Historical format migration is deliberately stricter in the alpha implementation. The v0-to-v1 edge refuses every unknown v0 type, including an ignorable one, because an opaque payload may contain references that a format edge cannot validate. The [alpha historical-event decision](2026-08-31-alpha-historical-unknown-event-refusal.md) owns that bounded exception; equal-version append and reload continue to follow this note.
|
||||
|
||||
## Alternatives considered
|
||||
@@ -28,7 +30,9 @@ Historical format migration is deliberately stricter in the alpha implementation
|
||||
|
||||
**Treat every repository-external event as ignorable.** Rejected because a reader cannot infer that an unknown durable event is informational. An external event may change later reconstruction or plugin-owned state.
|
||||
|
||||
**Declare a `SessionEventMap` member for each experimental package's state.** Rejected because each member is required-on-read and enters the persistence catalog and its type history: a build without the experimental package refuses the Session, and removing the package leaves a released type behind.
|
||||
**Declare a `SessionEventMap` member for each experimental package's state.** Rejected because each member is required-on-read and enters the released-schema inventory and type history: a build without the experimental package refuses the Session, and removing the package leaves a released type behind.
|
||||
|
||||
**Omit plugin records from the persistence catalog.** Rejected because maintainers need to find in-repository persistence writers. Current-source declarations provide that inventory while their payloads retain best-effort compatibility; a full schema history would impose the released-event obligations that these records deliberately avoid.
|
||||
|
||||
**Let any package call the record writer.** Rejected because records survive a format migration only on a best-effort basis, and a release package must not keep state that an upgrade can drop.
|
||||
|
||||
|
||||
+5
-1
@@ -18,6 +18,8 @@ Status: implemented
|
||||
|
||||
`@deepseek-ai/dsh-session` 中的 `appendPluginRecord()` 是该标记唯一的第一方写入方。它追加类型位于 `plugin:` 命名空间的 ignorable 记录(V3-to-V4 迁移边已把未知的 ignorable V3 事件放入该命名空间),且只有 `packages/experimental/` 下的生产源码可以调用它;`verify-plugin-record-callers` 门禁强制这一点,持久化目录生成器也拒绝该命名空间中的 `SessionEventMap` 成员。实验性包在这里保存其所有者可以丢失的状态:格式迁移以尽力而为的方式保留记录,因此正式发布的包改为声明自己的事件。
|
||||
|
||||
实验性包在独立的 `PluginRecordMap` 中声明记录名称与 payload 类型。该映射约束写入类型,并为持久化目录提供精简的当前源码小节,让维护者能够找到插件自有的持久化数据,而无须展开其 payload schema 或记录已发布类型历史。它不向 `KNOWN_SESSION_EVENT_TYPES` 添加名称,也不改变已存事件的准入。读取方返回未知类型的 payload,由所属包校验,其中也包括声明已变更或消失的记录。
|
||||
|
||||
Alpha 实现中的历史格式迁移有意更严格。v0-to-v1 迁移边会拒绝每个未知 v0 类型,包括 ignorable 类型,因为不透明 payload 可能包含格式迁移边无法校验的引用。[Alpha 历史事件决策](2026-08-31-alpha-historical-unknown-event-refusal.zh.md)定义该有限例外;同版本 append 与 reload 继续遵循本记录。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
@@ -28,7 +30,9 @@ Alpha 实现中的历史格式迁移有意更严格。v0-to-v1 迁移边会拒
|
||||
|
||||
**把所有仓库外事件都视为可忽略。** 不予采用,因为读取器无法推断未知持久事件是否属于信息性事件。外部事件可能改变后续重建或插件自有状态。
|
||||
|
||||
**为每个实验性包的状态声明 `SessionEventMap` 成员。** 不予采用,因为每个成员都是读取必需的,并会进入持久化目录及其类型历史:没有该实验性包的构建会拒绝该会话,移除该包后还会留下一个已发布的类型。
|
||||
**为每个实验性包的状态声明 `SessionEventMap` 成员。** 不予采用,因为每个成员都是读取必需的,并会进入已发布 schema 清单及类型历史:没有该实验性包的构建会拒绝该会话,移除该包后还会留下一个已发布的类型。
|
||||
|
||||
**不在持久化目录中列出插件记录。** 不予采用,因为维护者需要找到仓库内的持久化写入方。当前源码声明提供这份清单,而 payload 仍然只有尽力而为的兼容性;完整的 schema 历史会施加这些记录刻意不承担的已发布事件义务。
|
||||
|
||||
**允许任何包调用记录写入方。** 不予采用,因为记录只能以尽力而为的方式经受格式迁移,而正式发布的包不得保存升级可能丢弃的状态。
|
||||
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
en: 77c9c26d94fb33ca
|
||||
zh: 313c54c45cfc7dbc
|
||||
/cookbook-adding-a-session-log-format-version/3-implement-per-artifact-stages-and-validation:
|
||||
en: f5271fb57a6a6d83
|
||||
zh: b646903d1c39ca7c
|
||||
en: d7741b19c2d4daa8
|
||||
zh: edc2971edc696008
|
||||
/cookbook-adding-a-session-log-format-version/4-update-current-version-consumers:
|
||||
en: a285126945d02c12
|
||||
zh: e039c77cf7866ad2
|
||||
|
||||
@@ -58,7 +58,7 @@ 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.
|
||||
|
||||
Plugin records that `appendPluginRecord()` writes are ignorable events in the `plugin:` namespace, which experimental packages read back by type. Carry them forward under their names when the target format can represent them, and state in the edge README which records it drops otherwise; the [ignorable events decision](../../.agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.md) keeps them on a best-effort basis.
|
||||
Plugin records that `appendPluginRecord()` writes are ignorable events in the `plugin:` namespace, which experimental packages read back by type. Carry them forward under their names when the target format can represent them, and state in the edge README which records it drops otherwise; the [ignorable events decision](../../.agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.md) keeps them on a best-effort basis. Apply this policy even when a stored record has no declaration in the current-source catalog.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ 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)本身不能证明结构变换安全。校验目标语义,并为每个新增可接受案例提供一个被拒绝的反例;绝不放宽旧迁移边来掩盖不受支持的转换。
|
||||
|
||||
`appendPluginRecord()` 写入的插件记录是 `plugin:` 命名空间中的 ignorable 事件,实验性包按类型把它们读回。目标格式能够表示它们时,以原名称继续携带;否则在迁移边 README 中说明它丢弃哪些记录。[ignorable 事件决策](../../.agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.zh.md)以尽力而为的方式保留这些记录。
|
||||
`appendPluginRecord()` 写入的插件记录是 `plugin:` 命名空间中的 ignorable 事件,实验性包按类型把它们读回。目标格式能够表示它们时,以原名称继续携带;否则在迁移边 README 中说明它丢弃哪些记录。[ignorable 事件决策](../../.agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.zh.md)以尽力而为的方式保留这些记录。即使已存记录在当前源码目录中没有声明,也适用这一策略。
|
||||
|
||||
以原生 writer 输出作为转换对照:用相同录制的 LLM 消息、工具输出和其他输入回放旧 writer 与目标 writer。任何格式归一化 helper 运行前,比较迁移后的旧输出与目标原始输出,只屏蔽已说明的易变字段。V4 的普通工具结果变为包含普通内容块的平铺 tool-role 消息。旧类型理论上允许某种结构,本身不足以支持新增核心内容类型。
|
||||
|
||||
|
||||
@@ -61,10 +61,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:263`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
|
||||
| `schedule/changed` | `emit` | [`packages/schedule/schedule/src/types.ts:478`](../packages/schedule/schedule/src/types.ts) | [`schedule`](../packages/schedule/schedule) (`emit`) | `remotes` |
|
||||
| `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:44`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
|
||||
| `session/created` | `emit` | [`packages/core/session/src/index.ts:55`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`permission-presets`](../packages/interaction/permission-presets), [`schedule`](../packages/schedule/schedule), `server`, `session-controller`, [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry) |
|
||||
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:64`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) |
|
||||
| `session/event` | `emit` | [`packages/core/session/src/index.ts:75`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `agent-team`, `claude-code-mods`, [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`loader-smoke`](../packages/test-support/loader-smoke), `product-analytics`, `server`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`user-questions`](../packages/interaction/user-questions), [`workspace-changes`](../packages/deliverables/workspace-changes) |
|
||||
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:83`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) |
|
||||
| `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`permission-presets`](../packages/interaction/permission-presets), [`schedule`](../packages/schedule/schedule), `server`, `session-controller`, [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry) |
|
||||
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:63`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) |
|
||||
| `session/event` | `emit` | [`packages/core/session/src/index.ts:74`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `agent-team`, `claude-code-mods`, [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`loader-smoke`](../packages/test-support/loader-smoke), `product-analytics`, `server`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`user-questions`](../packages/interaction/user-questions), [`workspace-changes`](../packages/deliverables/workspace-changes) |
|
||||
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:82`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) |
|
||||
| `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:75`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`emit`) | `remotes` |
|
||||
| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:296`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - |
|
||||
| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:172`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server` |
|
||||
|
||||
@@ -63,10 +63,10 @@
|
||||
| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:263`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
|
||||
| `schedule/changed` | `emit` | [`packages/schedule/schedule/src/types.ts:478`](../packages/schedule/schedule/src/types.ts) | [`schedule`](../packages/schedule/schedule) (`emit`) | `remotes` |
|
||||
| `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:44`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
|
||||
| `session/created` | `emit` | [`packages/core/session/src/index.ts:55`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`permission-presets`](../packages/interaction/permission-presets), [`schedule`](../packages/schedule/schedule), `server`, `session-controller`, [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry) |
|
||||
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:64`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) |
|
||||
| `session/event` | `emit` | [`packages/core/session/src/index.ts:75`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `agent-team`, `claude-code-mods`, [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`loader-smoke`](../packages/test-support/loader-smoke), `product-analytics`, `server`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`user-questions`](../packages/interaction/user-questions), [`workspace-changes`](../packages/deliverables/workspace-changes) |
|
||||
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:83`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) |
|
||||
| `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`permission-presets`](../packages/interaction/permission-presets), [`schedule`](../packages/schedule/schedule), `server`, `session-controller`, [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry) |
|
||||
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:63`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) |
|
||||
| `session/event` | `emit` | [`packages/core/session/src/index.ts:74`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `agent-team`, `claude-code-mods`, [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`loader-smoke`](../packages/test-support/loader-smoke), `product-analytics`, `server`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`user-questions`](../packages/interaction/user-questions), [`workspace-changes`](../packages/deliverables/workspace-changes) |
|
||||
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:82`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) |
|
||||
| `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:75`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`emit`) | `remotes` |
|
||||
| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:296`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - |
|
||||
| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:172`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server` |
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
en: a11f106978c9dce7
|
||||
zh: a11f106978c9dce7
|
||||
/session-persistence-event-catalog:
|
||||
en: e7c80d1de4d328b4
|
||||
zh: d9f368c27fd9f998
|
||||
en: f7cbb5a4b1274855
|
||||
zh: 5661d5180fc83054
|
||||
/session-persistence-event-catalog/persistence-type-fingerprints:
|
||||
en: 33dd28b1c768cd8b
|
||||
zh: 7256442baf2faed1
|
||||
@@ -290,6 +290,9 @@
|
||||
/session-persistence-event-catalog/events/workspace/workspace-changes-log-only:
|
||||
en: ee6b592f9e2193a7
|
||||
zh: 7a7a69ae52aa714a
|
||||
/session-persistence-event-catalog/experimental-plugin-records:
|
||||
en: 154c163feab69117
|
||||
zh: b43c46a6c953f63e
|
||||
/session-persistence-event-catalog/resolved-persistence-types:
|
||||
en: 3493ecf0993e5397
|
||||
zh: ec95688f593a4ed4
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
# Session Persistence Event Catalog
|
||||
|
||||
Every repository-declared durable Session event appears here with its source declaration and resolved types. The catalog covers the logical and physical headers, event envelopes, and every plugin declaration merge. See [Session](subsystems/session.md) for replay and [persistence](subsystems/persistence.md) for storage.
|
||||
Every repository-declared durable Session event appears here with its source declaration and resolved types. The catalog covers the logical and physical headers, event envelopes, every SessionEventMap declaration merge, and a separate inventory of experimental plugin records. See [Session](subsystems/session.md) for replay and [persistence](subsystems/persistence.md) for storage.
|
||||
|
||||
Run `pnpm run gen-persistence-catalog` to regenerate both catalog languages, their pairing record, the known-event module, and the machine schema inventory. `pnpm run verify-persistence-catalog` checks all generated files. Declaration fences preserve source JSDoc and type references; resolved definitions expose their transitive structure.
|
||||
|
||||
@@ -1281,6 +1281,12 @@ Source: [`packages/web/web-search-deepseek/src/provider.ts:82`](../packages/web/
|
||||
|
||||
Source: [`packages/deliverables/workspace-changes/src/types.ts:106`](../packages/deliverables/workspace-changes/src/types.ts)
|
||||
|
||||
## Experimental plugin records
|
||||
|
||||
These current-source PluginRecordMap declarations describe ignorable, log-only records owned by experimental packages. Payload annotations link to their owning source without expanding referenced types. Records stay outside the known-event set, persistence schema fingerprints, and compatibility history; format migrations retain them on a best-effort basis.
|
||||
|
||||
No production package currently declares a plugin record.
|
||||
|
||||
## Resolved persistence types
|
||||
|
||||
Each definition appears once. References preserve sharing and recursion; the digest beside a definition includes its complete reachable structure. Source names and locations identify its declarations but are excluded from its digest.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
[English](persistence-catalog.md) | 中文
|
||||
|
||||
本目录列出仓库声明的每个持久化 Session 事件及其源码声明和解析类型,覆盖逻辑与物理 header、事件信封以及各插件的声明合并。回放规则参见 [Session](subsystems/session.zh.md),存储规则参见[持久化](subsystems/persistence.zh.md)。
|
||||
本目录列出仓库声明的每个持久化 Session 事件及其源码声明和解析类型,覆盖逻辑与物理 header、事件信封、所有 SessionEventMap 声明合并,以及单独列出的实验性插件记录。回放规则参见 [Session](subsystems/session.zh.md),存储规则参见[持久化](subsystems/persistence.zh.md)。
|
||||
|
||||
运行 `pnpm run gen-persistence-catalog` 可重新生成目录的两种语言、配对记录、已知事件模块和机器 schema 目录。`pnpm run verify-persistence-catalog` 检查所有生成文件。声明围栏保留源码 JSDoc 和类型引用;解析后的定义展开其传递引用结构。
|
||||
|
||||
@@ -1283,6 +1283,12 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
|
||||
|
||||
来源:[`packages/deliverables/workspace-changes/src/types.ts:106`](../packages/deliverables/workspace-changes/src/types.ts)
|
||||
|
||||
## 实验性插件记录
|
||||
|
||||
这些当前源码中的 PluginRecordMap 声明描述实验性包拥有的 ignorable、仅日志记录。载荷类型注解链接到所属源码,不展开引用类型。记录不进入已知事件集合、持久化 schema 指纹或兼容性历史;格式迁移以尽力而为的方式保留它们。
|
||||
|
||||
当前没有生产包声明插件记录。
|
||||
|
||||
## 已解析的持久化类型
|
||||
|
||||
每个类型定义仅列出一次。引用保留共享和递归关系;定义旁的摘要涵盖其完整可达结构。源码名称和位置标识声明来源,但不参与摘要计算。
|
||||
|
||||
@@ -60,8 +60,8 @@
|
||||
en: 0a014a755b75449f
|
||||
zh: 9d59d74490a1bcb2
|
||||
/sessions/plugin-contributed-log-only-events/plugin-records:
|
||||
en: fbdff72f8b2f3194
|
||||
zh: 7a7745b0edb75ed6
|
||||
en: 0cfce3844b6c554f
|
||||
zh: 7acf342c8e552a7c
|
||||
/sessions/durability-contract:
|
||||
en: 62e3e4fbcba292a3
|
||||
zh: cb6dcaf531621e85
|
||||
|
||||
@@ -754,7 +754,7 @@ The hook bridges' `hook/invoked` / `hook/result` pairs (from `@deepseek-ai/dsh-h
|
||||
|
||||
### Plugin records
|
||||
|
||||
Experimental packages keep plugin-owned state in plugin records instead of declaring events: `appendPluginRecord()` appends an event marked `ignorable: true` whose type is in the `plugin:` namespace, and `pluginRecordOf()` reads one back. The [package README](../../packages/core/session/README.md#write-experimental-plugin-records) owns the caller restriction, retention, and read path.
|
||||
Experimental packages declare plugin-owned state in `PluginRecordMap`: `appendPluginRecord()` uses each declaration to type its name and payload, and appends an event marked `ignorable: true` in the `plugin:` namespace. `pluginRecordOf()` reads stored records with unknown payloads regardless of current declarations. The [package README](../../packages/core/session/README.md#write-experimental-plugin-records) owns the caller restriction, current-source catalog, retention, and read path.
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
@@ -766,6 +766,16 @@ Experimental packages keep plugin-owned state in plugin records instead of decla
|
||||
type PluginRecordType = `plugin:${string}`
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
* Payloads written by experimental packages, keyed by their `plugin:` record
|
||||
* names. Packages augment this map to type writes and enter the current plugin
|
||||
* record catalog; these declarations do not enter {@link SessionEventMap} or
|
||||
* released persistence schemas. Stored records still require owner validation.
|
||||
*/
|
||||
interface PluginRecordMap {}
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
* One committed experimental plugin record, read from the log by
|
||||
|
||||
@@ -758,7 +758,7 @@ interface TurnEndReasonMap {
|
||||
|
||||
### 插件记录
|
||||
|
||||
实验性包把插件自有的状态保存为插件记录,而不是声明事件:`appendPluginRecord()` 追加一条带 `ignorable: true` 标记、类型位于 `plugin:` 命名空间的事件,`pluginRecordOf()` 把它读回。调用方限制、保留方式与读取路径见[包 README](../../packages/core/session/README.zh.md#write-experimental-plugin-records)。
|
||||
实验性包在 `PluginRecordMap` 中声明插件自有的状态:`appendPluginRecord()` 根据每项声明约束名称与 payload 类型,并在 `plugin:` 命名空间中追加一条带 `ignorable: true` 标记的事件。`pluginRecordOf()` 读取已存记录,其 payload 为未知类型,不受当前声明约束。调用方限制、当前源码目录、保留方式与读取路径见[包 README](../../packages/core/session/README.zh.md#write-experimental-plugin-records)。
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
@@ -770,6 +770,16 @@ interface TurnEndReasonMap {
|
||||
type PluginRecordType = `plugin:${string}`
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
* Payloads written by experimental packages, keyed by their `plugin:` record
|
||||
* names. Packages augment this map to type writes and enter the current plugin
|
||||
* record catalog; these declarations do not enter {@link SessionEventMap} or
|
||||
* released persistence schemas. Stored records still require owner validation.
|
||||
*/
|
||||
interface PluginRecordMap {}
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
* One committed experimental plugin record, read from the log by
|
||||
|
||||
@@ -27,8 +27,8 @@
|
||||
en: c4bf3103b9762b6c
|
||||
zh: 369c734bb20de2ea
|
||||
/deepseek-ai-dsh-session/use-this-package/write-experimental-plugin-records:
|
||||
en: e2ee607edd3f567b
|
||||
zh: 8aac3277adfd72cc
|
||||
en: 2a90965fca937d4a
|
||||
zh: 89966ab7961fa629
|
||||
/deepseek-ai-dsh-session/use-this-package/fork-a-session:
|
||||
en: 1c40d17a786059d3
|
||||
zh: 81320ce118977c65
|
||||
|
||||
@@ -67,9 +67,13 @@ Session log positions use two numeric types. `SessionSeq` identifies an existing
|
||||
|
||||
### Write experimental plugin records
|
||||
|
||||
`appendPluginRecord(session, type, data)` appends one plugin record for a package under `packages/experimental/`; the `verify-plugin-record-callers` gate rejects any other production caller in this repository. Plugins outside this repository must not call it either, although no gate can check them. A record is an event marked `ignorable: true` whose type is `plugin:` followed by lowercase slash-separated segments, such as `plugin:pi-extensions/entry`. No `SessionEventMap` member uses that namespace, so the persistence catalog does not list records, and a build that does not recognize one retains and skips it on read. A record never enters the model-visible surface. Resume and fork carry it with the rest of the log, and a Session format migration keeps it on a best-effort basis.
|
||||
`appendPluginRecord(session, type, data)` appends one plugin record for a package under `packages/experimental/`; the `verify-plugin-record-callers` check rejects any other production caller in this repository. Plugins outside this repository must not call it either, although no repository check can inspect them.
|
||||
|
||||
`pluginRecordOf(event)` returns an event as a record, or `undefined` for any other event; a `ctx.sessionProjections` unit that passes each event to it rebuilds plugin state on resume. The V3-to-V4 format edge also renames each unknown ignorable V3 event into the `plugin:` namespace, so the owning package names its records under its own package name and validates `data` before use: a restored record carries whatever JSON an earlier build or a migrated event holds. The [ignorable events decision](../../../.agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.md) records why records are restricted to experimental packages.
|
||||
Declare each record in `PluginRecordMap` through `@deepseek-ai/dsh-session/types`, with a description and an explicit payload type annotation in the owning experimental package's `src/`. Names use `plugin:<owner>/<record>`, where `owner` is the suffix of the package name `@deepseek-ai/dsh-experimental-<owner>`; for example, `plugin:pi-extensions/entry`. TypeScript checks each name and payload against its declaration; the writer's runtime JSON snapshot rejects values that cannot be preserved losslessly. Put extension-defined or other dynamic names inside the payload of a declared record.
|
||||
|
||||
The [persistence catalog](../../../docs/persistence-catalog.md) lists current plugin-record declarations with their owners, descriptions, payload type annotations, and source files. These declarations remain separate from `SessionEventMap`, expanded persistence schemas, released type history, and `KNOWN_SESSION_EVENT_TYPES`. Records carry `ignorable: true`: a build that does not recognize one retains and skips it on read. Records never enter the model-visible surface. Resume and fork carry them with the rest of the log; Session format migration keeps them on a best-effort basis.
|
||||
|
||||
`pluginRecordOf(event)` returns an event as a record, or `undefined` for any other event; a `ctx.sessionProjections` unit that passes each event to it rebuilds plugin state on resume. Its `data` remains `unknown` even when the current map declares the name. The owner validates it before use because earlier builds can write different payloads, and the V3-to-V4 format edge can rename an unknown ignorable V3 event into the same `plugin:` namespace. Removing or renaming a declaration does not discard stored records. The [ignorable events decision](../../../.agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.md) owns the compatibility policy.
|
||||
|
||||
### Fork a session
|
||||
|
||||
|
||||
@@ -67,9 +67,13 @@ session.deriveMessages() // the derived model history
|
||||
|
||||
### 写入实验性插件记录
|
||||
|
||||
`appendPluginRecord(session, type, data)` 为 `packages/experimental/` 下的包追加一条插件记录;`verify-plugin-record-callers` 门禁拒绝本仓库中任何其他生产调用方。本仓库之外的插件同样不得调用它,尽管没有门禁能检查它们。记录是带有 `ignorable: true` 标记的事件,其类型为 `plugin:` 后接以斜杠分隔的小写段,例如 `plugin:pi-extensions/entry`。没有任何 `SessionEventMap` 成员使用该命名空间,因此持久化目录不列出记录;不认识某条记录的构建在读取时保留并跳过它。记录从不进入模型可见的 surface。resume 与 fork 随日志其余部分一起携带它,Session 格式迁移以尽力而为的方式保留它。
|
||||
`appendPluginRecord(session, type, data)` 为 `packages/experimental/` 下的包追加一条插件记录;`verify-plugin-record-callers` 检查拒绝本仓库中任何其他生产调用方。本仓库之外的插件同样不得调用它,尽管仓库检查无法检查它们。
|
||||
|
||||
`pluginRecordOf(event)` 把事件作为记录返回,对其他任何事件返回 `undefined`;把每个事件都交给它的 `ctx.sessionProjections` 单元在 resume 时重建插件状态。V3-to-V4 格式迁移边也会把每个未知的 ignorable V3 事件改名放入 `plugin:` 命名空间,因此所属包以自己的包名命名其记录,并在使用 `data` 之前先校验:恢复出的记录携带的是早先构建写入或迁移事件带来的任意 JSON。[ignorable 事件决策](../../../.agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.zh.md)记录了为何只有实验性包可以写入记录。
|
||||
在所属实验性包的 `src/` 中,通过 `@deepseek-ai/dsh-session/types` 的 `PluginRecordMap` 为每条记录声明描述与显式 payload 类型注解。名称使用 `plugin:<owner>/<record>`,其中 `owner` 是包名 `@deepseek-ai/dsh-experimental-<owner>` 的后缀;例如 `plugin:pi-extensions/entry`。TypeScript 按声明检查每个名称与 payload;写入方运行时的 JSON 快照拒绝无法无损保留的值。扩展自定义名称或其他动态名称放在已声明记录的 payload 中。
|
||||
|
||||
[持久化目录](../../../docs/persistence-catalog.zh.md)列出当前插件记录声明及其所属包、描述、payload 类型注解与源文件。这些声明与 `SessionEventMap`、展开的持久化 schema、已发布类型历史及 `KNOWN_SESSION_EVENT_TYPES` 分开。记录带有 `ignorable: true`:不认识某条记录的构建在读取时保留并跳过它。记录从不进入模型可见的 surface。resume 与 fork 随日志其余部分一起携带它们;Session 格式迁移以尽力而为的方式保留它们。
|
||||
|
||||
`pluginRecordOf(event)` 把事件作为记录返回,对其他任何事件返回 `undefined`;把每个事件都交给它的 `ctx.sessionProjections` 单元在 resume 时重建插件状态。即使当前映射声明了该名称,其 `data` 仍为 `unknown`。所属包在使用前校验数据,因为早先构建可能写入不同的 payload,V3-to-V4 格式迁移边也可能把未知的 ignorable V3 事件改名放入同一个 `plugin:` 命名空间。删除或重命名声明不会丢弃已存记录。[ignorable 事件决策](../../../.agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.zh.md)定义兼容性策略。
|
||||
|
||||
### 派生会话的 fork
|
||||
|
||||
|
||||
@@ -10,13 +10,12 @@ import { Context, Service } from '@deepseek-ai/cordis'
|
||||
import { isAbsolute } from 'node:path'
|
||||
import { brandString } from '@deepseek-ai/dsh-brand'
|
||||
import { assertNever, deepFreeze, snapshotJsonValue } from '@deepseek-ai/dsh-util-values'
|
||||
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
||||
import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope'
|
||||
import type { Scoped } from '@deepseek-ai/dsh-scope'
|
||||
import type { Message } from '@deepseek-ai/dsh-llm'
|
||||
import { SESSION_FORMAT_VERSION, SessionLogOffset, SessionSeq } from './types.ts'
|
||||
import type { TypertLookup } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import type { CreateSessionOptions, EpochHeader, PluginRecord, PluginRecordType, PrepareSessionOptions, RequestContext, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SessionId, SessionSeedEventState, SurfaceIntent, SurfaceEventType } from './types.ts'
|
||||
import type { CreateSessionOptions, EpochHeader, PluginRecord, PluginRecordMap, PluginRecordType, PrepareSessionOptions, RequestContext, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SessionId, SessionSeedEventState, SurfaceIntent, SurfaceEventType } from './types.ts'
|
||||
import { SurfaceManager, validateSessionEventData, validateSurfaceMetadata } from './surface.ts'
|
||||
import type { SessionSurface, SessionMessageProjection } from './surface.ts'
|
||||
import { foldRequestHeader } from './request-header.ts'
|
||||
@@ -464,13 +463,13 @@ function assertPluginRecordEvent(value: unknown): asserts value is SessionEvent
|
||||
}
|
||||
|
||||
/** Module-private access to {@link Session}'s record commit, assigned once by its static block. */
|
||||
let commitPluginRecord: (session: Session, type: PluginRecordType, data: JsonValue) => SessionSeq
|
||||
let commitPluginRecord: (session: Session, type: PluginRecordType, data: unknown) => SessionSeq
|
||||
|
||||
/**
|
||||
* Append one experimental plugin record: an event marked `ignorable` whose
|
||||
* type, in the `plugin:` namespace, is not a {@link SessionEventMap} member.
|
||||
* The generated persistence catalog therefore does not list it. A reader that
|
||||
* does not recognize the type retains and skips the record, the record never
|
||||
* type is declared in {@link PluginRecordMap}, outside {@link SessionEventMap}.
|
||||
* The plugin record catalog lists it separately from released event schemas.
|
||||
* A reader that does not recognize the type retains and skips the record, which never
|
||||
* enters the model-visible surface, and fork and resume carry it with the rest
|
||||
* of the log. A Session format migration retains records on a best-effort
|
||||
* basis, so a record holds plugin-owned state that its owner can lose without
|
||||
@@ -481,15 +480,20 @@ let commitPluginRecord: (session: Session, type: PluginRecordType, data: JsonVal
|
||||
* production caller in this repository. Read records back with
|
||||
* {@link pluginRecordOf}.
|
||||
* @param session - the Session whose log receives the record.
|
||||
* @param type - `plugin:` followed by slash-separated segments of lowercase
|
||||
* letters, digits, `.`, `_`, and `-`, each starting with a letter or digit.
|
||||
* @param type - declared record name: `plugin:` followed by slash-separated
|
||||
* segments of lowercase letters, digits, `.`, `_`, and `-`, each starting
|
||||
* with a letter or digit.
|
||||
* @param data - JSON payload, snapshotted before it enters the log.
|
||||
* @returns the sequence number of the committed record.
|
||||
* @throws when `type` is outside that grammar, when `data` is not losslessly
|
||||
* JSON-serializable, or when the call reenters another append's publication;
|
||||
* a rejected record does not change the log.
|
||||
*/
|
||||
export function appendPluginRecord(session: Session, type: PluginRecordType, data: JsonValue): SessionSeq {
|
||||
export function appendPluginRecord<K extends Extract<keyof PluginRecordMap, PluginRecordType>>(
|
||||
session: Session,
|
||||
type: K,
|
||||
data: NoInfer<PluginRecordMap[K]>,
|
||||
): SessionSeq {
|
||||
return commitPluginRecord(session, type, data)
|
||||
}
|
||||
|
||||
@@ -832,7 +836,7 @@ export class Session {
|
||||
* @param data - JSON payload, snapshotted before it enters the log.
|
||||
* @returns the record's sequence number.
|
||||
*/
|
||||
#appendRecord(type: PluginRecordType, data: JsonValue): SessionSeq {
|
||||
#appendRecord(type: PluginRecordType, data: unknown): SessionSeq {
|
||||
const dataSnapshot = snapshotJsonValue(data)
|
||||
if (dataSnapshot === undefined) {
|
||||
throw new Error(`plugin record "${type}" carries non-JSON-serializable data`)
|
||||
|
||||
@@ -523,6 +523,14 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
|
||||
*/
|
||||
export type PluginRecordType = `plugin:${string}`
|
||||
|
||||
/**
|
||||
* Payloads written by experimental packages, keyed by their `plugin:` record
|
||||
* names. Packages augment this map to type writes and enter the current plugin
|
||||
* record catalog; these declarations do not enter {@link SessionEventMap} or
|
||||
* released persistence schemas. Stored records still require owner validation.
|
||||
*/
|
||||
export interface PluginRecordMap {}
|
||||
|
||||
/**
|
||||
* One committed experimental plugin record, read from the log by
|
||||
* `pluginRecordOf`. The record's owner validates `data` before use, because a
|
||||
|
||||
@@ -10,7 +10,8 @@ import SessionStore, {
|
||||
SessionLogOffset,
|
||||
SessionSeq,
|
||||
} from '@deepseek-ai/dsh-session'
|
||||
import type { PluginRecordType, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { PluginRequestFixture } from './plugin-records.type-test.ts'
|
||||
|
||||
/** One user turn opening, so a record lands between model-visible events. */
|
||||
function openTurn(session: Session): void {
|
||||
@@ -53,6 +54,24 @@ describe('plugin records', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('snapshots an interface-typed request with readonly messages', () => {
|
||||
const session = Session.create(SessionId('typed-request'))
|
||||
const request: PluginRequestFixture = {
|
||||
config: { provider: 'fixture', model: 'fixture' },
|
||||
messages: [{ role: 'user', content: [{ type: 'text', text: 'Translate this fragment.' }] }],
|
||||
}
|
||||
const seq = appendPluginRecord(session, 'plugin:test/request', request)
|
||||
request.config.model = 'changed'
|
||||
|
||||
const event = session.eventAt(seq)
|
||||
expect(event?.data).toEqual({
|
||||
config: { provider: 'fixture', model: 'fixture' },
|
||||
messages: [{ role: 'user', content: [{ type: 'text', text: 'Translate this fragment.' }] }],
|
||||
})
|
||||
expect(Object.isFrozen(event?.data)).toBe(true)
|
||||
expect(session.deriveMessages()).toEqual([])
|
||||
})
|
||||
|
||||
it('does not read a known event, even one marked ignorable', () => {
|
||||
const events: SessionEvent[] = [
|
||||
{ type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } },
|
||||
@@ -75,7 +94,10 @@ describe('plugin records', () => {
|
||||
const session = Session.create(SessionId('grammar'))
|
||||
const name: string = type
|
||||
|
||||
expect(() => appendPluginRecord(session, name as PluginRecordType, {}))
|
||||
expect(() => {
|
||||
// @ts-expect-error -- runtime callers can supply names outside the declared map.
|
||||
appendPluginRecord(session, name, {})
|
||||
})
|
||||
.toThrow(`plugin record type "${type}" must be "plugin:" followed by lowercase slash-separated segments`)
|
||||
expect(session.seq).toBe(0)
|
||||
})
|
||||
@@ -83,8 +105,8 @@ describe('plugin records', () => {
|
||||
it('rejects data that is not losslessly JSON-serializable without changing the log', () => {
|
||||
const session = Session.create(SessionId('lossy'))
|
||||
|
||||
expect(() => appendPluginRecord(session, 'plugin:test/state', Number.NaN))
|
||||
.toThrow('plugin record "plugin:test/state" carries non-JSON-serializable data')
|
||||
expect(() => appendPluginRecord(session, 'plugin:test/number', Number.NaN))
|
||||
.toThrow('plugin record "plugin:test/number" carries non-JSON-serializable data')
|
||||
expect(session.seq).toBe(0)
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/** Compile-time calls to the public plugin-record writer; excluded from production declarations. */
|
||||
import { appendPluginRecord, pluginRecordOf } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { LlmCallConfig, RequestUserInput } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
/** An independent request with domain interfaces and immutable message arrays. */
|
||||
export interface PluginRequestFixture {
|
||||
readonly config: LlmCallConfig
|
||||
readonly messages: readonly RequestUserInput[]
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-session/types' {
|
||||
interface PluginRecordMap {
|
||||
'plugin:test/state': { count: number }
|
||||
'plugin:test/echo': Record<string, never>
|
||||
'plugin:test/number': number
|
||||
'plugin:test/request': PluginRequestFixture
|
||||
'plugin:test.bridge/entry_1': readonly (string | number)[]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Typecheck accepted payloads and rejected record names without executing writes.
|
||||
* @param session - target Session.
|
||||
* @param request - typed independent model request.
|
||||
*/
|
||||
export function checkPluginRecordWriteTypes(session: Session, request: PluginRequestFixture): void {
|
||||
appendPluginRecord(session, 'plugin:test/request', request)
|
||||
appendPluginRecord(session, 'plugin:test.bridge/entry_1', ['a', 2] as const)
|
||||
// @ts-expect-error -- an undeclared record name has no writable payload type.
|
||||
appendPluginRecord(session, 'plugin:test/undeclared', request)
|
||||
// @ts-expect-error -- the payload must match the selected record's declaration.
|
||||
appendPluginRecord(session, 'plugin:test/state', request)
|
||||
// @ts-expect-error -- another declared payload must not widen the selected key.
|
||||
appendPluginRecord(session, 'plugin:test/state', 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Require payload validation even when a stored record has a declared name.
|
||||
* @param event - committed or restored event.
|
||||
*/
|
||||
export function checkPluginRecordReadTypes(event: SessionEvent): void {
|
||||
const record = pluginRecordOf(event)
|
||||
if (record?.type !== 'plugin:test/request') return
|
||||
// @ts-expect-error -- a known name does not validate a stored payload.
|
||||
const request: PluginRequestFixture = record.data
|
||||
void request
|
||||
}
|
||||
@@ -16,6 +16,12 @@ import { appendPluginRecord, pluginRecordOf, SessionId } from '@deepseek-ai/dsh-
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionPersistence } from '../src/index.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-session/types' {
|
||||
interface PluginRecordMap {
|
||||
'plugin:persistence-test/state': { count: number }
|
||||
}
|
||||
}
|
||||
|
||||
/** One mounted backend under a session store, plus same-storage remount support. */
|
||||
export interface LiveWriteBackend {
|
||||
/** Context with SessionStore and the persistence backend mounted. */
|
||||
@@ -90,7 +96,7 @@ export function runLiveWritePathContract(
|
||||
const session = ctx.sessions.create(SessionId('plugin-record'))
|
||||
const handle = await ctx.sessionPersistence.create(session.header)
|
||||
session.append('turn/start', { turn: 1 })
|
||||
appendPluginRecord(session, 'plugin:test/state', { count: 1 })
|
||||
appendPluginRecord(session, 'plugin:persistence-test/state', { count: 1 })
|
||||
await expect(ctx.sessions.flush(session)).resolves.toBe(true)
|
||||
await handle.close()
|
||||
await ctx.fiber.dispose()
|
||||
@@ -99,11 +105,11 @@ export function runLiveWritePathContract(
|
||||
const events = await readAll(restarted.sessionPersistence, session.id)
|
||||
expect(events.map(event => [event.type, event.ignorable])).toEqual([
|
||||
['turn/start', undefined],
|
||||
['plugin:test/state', true],
|
||||
['plugin:persistence-test/state', true],
|
||||
])
|
||||
expect(events.map(pluginRecordOf)).toEqual([
|
||||
undefined,
|
||||
{ type: 'plugin:test/state', seq: 1, time: events[1]?.time, data: { count: 1 } },
|
||||
{ type: 'plugin:persistence-test/state', seq: 1, time: events[1]?.time, data: { count: 1 } },
|
||||
])
|
||||
await restarted.fiber.dispose()
|
||||
})
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import { extractPersistenceSchema } from './persistence-schema.ts'
|
||||
import { persistenceCatalogText, type PersistenceCatalogLocale } from './persistence-catalog-text.ts'
|
||||
import { renderPersistencePair, type PersistenceArtifact } from './persistence-artifacts.ts'
|
||||
import { collectPluginRecords, type PluginRecordEntry } from './plugin-record-catalog.ts'
|
||||
|
||||
export { annotateSurface, collectEventEnvelopeTypes, collectLogEvents, collectSurfaceEventTypes } from './persistence-catalog-source.ts'
|
||||
export type { AnnotatedLogEventEntry, EventEnvelopeTypeEntry, LogEventEntry } from './persistence-catalog-source.ts'
|
||||
@@ -81,12 +82,32 @@ function renderEvent(e: AnnotatedLogEventEntry, locale: PersistenceCatalogLocale
|
||||
return out
|
||||
}
|
||||
|
||||
/** Escape a source annotation as inline table code, including template literals and unions. */
|
||||
function recordCode(value: string): string {
|
||||
return `<code>${value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>')
|
||||
.replaceAll('|', '|').replaceAll('`', '`').replace(/\r?\n/gu, '<br>')}</code>`
|
||||
}
|
||||
|
||||
/** Render the current plugin record inventory without expanding payload types. */
|
||||
function renderPluginRecords(records: readonly PluginRecordEntry[], locale: PersistenceCatalogLocale): string[] {
|
||||
const text = persistenceCatalogText[locale]
|
||||
const lines = [`## ${text.pluginRecords}`, '', text.pluginRecordsIntro, '']
|
||||
if (records.length === 0) return [...lines, text.pluginRecordsEmpty, '']
|
||||
lines.push(text.pluginRecordColumns, '|---|---|---|---|')
|
||||
for (const record of [...records].sort((left, right) => left.owner.localeCompare(right.owner) || left.name.localeCompare(right.name))) {
|
||||
const doc = record.doc.replaceAll('|', '\\|').replace(/\s*\n\s*/gu, ' ')
|
||||
lines.push(`| \`${record.name}\` | \`${record.owner}\` | ${doc} | ${recordCode(record.payload)} · [\`${record.source}\`](../${record.source}) |`)
|
||||
}
|
||||
return [...lines, '']
|
||||
}
|
||||
|
||||
/** Render the full catalog (pure, deterministic given the collected inputs). */
|
||||
export function render(
|
||||
events: AnnotatedLogEventEntry[],
|
||||
envelopeTypes: EventEnvelopeTypeEntry[],
|
||||
schema?: PersistenceSchemaInventory,
|
||||
locale: PersistenceCatalogLocale = 'en',
|
||||
pluginRecords: readonly PluginRecordEntry[] = [],
|
||||
): string {
|
||||
const text = persistenceCatalogText[locale]
|
||||
const lines: string[] = [
|
||||
@@ -121,6 +142,7 @@ export function render(
|
||||
lines.push(...renderEvent(e, locale))
|
||||
}
|
||||
}
|
||||
lines.push(...renderPluginRecords(pluginRecords, locale))
|
||||
if (schema) lines.push(renderPersistenceSchemaDefinitions(schema, locale, undefined, 2, 'current'))
|
||||
return lines.join('\n')
|
||||
}
|
||||
@@ -175,8 +197,9 @@ export function renderKnownEventTypes(events: AnnotatedLogEventEntry[]): string
|
||||
export function persistenceCatalogArtifacts(scanRoot: string, schema: PersistenceSchemaInventory): PersistenceArtifact[] {
|
||||
const events = annotateSurface(collectLogEvents(scanRoot), collectSurfaceEventTypes(scanRoot))
|
||||
const envelope = collectEventEnvelopeTypes(scanRoot)
|
||||
const pluginRecords = collectPluginRecords(scanRoot)
|
||||
return [
|
||||
...renderPersistencePair(scanRoot, OUT, render(events, envelope, schema), render(events, envelope, schema, 'zh')),
|
||||
...renderPersistencePair(scanRoot, OUT, render(events, envelope, schema, 'en', pluginRecords), render(events, envelope, schema, 'zh', pluginRecords)),
|
||||
{ path: OUT_RUNTIME_TYPES, content: renderKnownEventTypes(events) },
|
||||
{ path: OUT_SCHEMA, content: `${JSON.stringify(persistenceSchemaSnapshot(schema), null, 2)}\n` },
|
||||
]
|
||||
|
||||
@@ -5,10 +5,14 @@ export type PersistenceCatalogLocale = 'en' | 'zh'
|
||||
|
||||
const english = {
|
||||
title: 'Session Persistence Event Catalog',
|
||||
intro: 'Every repository-declared durable Session event appears here with its source declaration and resolved types. The catalog covers the logical and physical headers, event envelopes, and every plugin declaration merge. See [Session](subsystems/session.md) for replay and [persistence](subsystems/persistence.md) for storage.',
|
||||
intro: 'Every repository-declared durable Session event appears here with its source declaration and resolved types. The catalog covers the logical and physical headers, event envelopes, every SessionEventMap declaration merge, and a separate inventory of experimental plugin records. See [Session](subsystems/session.md) for replay and [persistence](subsystems/persistence.md) for storage.',
|
||||
generation: 'Run `pnpm run gen-persistence-catalog` to regenerate both catalog languages, their pairing record, the known-event module, and the machine schema inventory. `pnpm run verify-persistence-catalog` checks all generated files. Declaration fences preserve source JSDoc and type references; resolved definitions expose their transitive structure.',
|
||||
envelopeIntro: 'The envelope carries `type`, `seq`, `time`, `data`, optional `ignorable`, and conditional `surfaceOp` / `sourceEventSeqs`. A **surface** event produces model history; a **log-only** event does not. The inventory covers this repository; external plugin types require their own declarations and are outside this catalog.',
|
||||
envelope: 'Event envelope', events: 'Events', sources: 'Sources: ', source: 'Source: ', types: 'Types: ',
|
||||
pluginRecords: 'Experimental plugin records',
|
||||
pluginRecordsIntro: 'These current-source PluginRecordMap declarations describe ignorable, log-only records owned by experimental packages. Payload annotations link to their owning source without expanding referenced types. Records stay outside the known-event set, persistence schema fingerprints, and compatibility history; format migrations retain them on a best-effort basis.',
|
||||
pluginRecordsEmpty: 'No production package currently declares a plugin record.',
|
||||
pluginRecordColumns: '| Record | Owner | Purpose | Payload and source |',
|
||||
fingerprints: 'Persistence type fingerprints',
|
||||
fingerprintsIntro: 'The [machine inventory](persistence-schema.json) contains every reachable normalized type and its SHA-256 digest. Root digests include referenced types. Comments, source locations, alias names, erased brands, readonly markers, and harmless field, union, or intersection reordering do not affect these fingerprints. Tuple order, property names, value types, and optionality do. Catalog text and source locations can still produce a diff when digests stay unchanged.',
|
||||
historyIntro: 'The [format references](persistence-changes/historical-formats/README.md) cover every historical Session format. The [change records](persistence-changes/README.md) acknowledge exact transitions using snapshots kept in this tree. Follow the [review workflow](cookbook/reviewing-persistence-type-changes.md) to classify a change and record it. These checks cover declared type structure; opaque payload contents and behavior without type changes are outside their scope.',
|
||||
@@ -26,10 +30,14 @@ const english = {
|
||||
|
||||
const chinese: Record<keyof typeof english, string> = {
|
||||
title: '会话持久化事件目录',
|
||||
intro: '本目录列出仓库声明的每个持久化 Session 事件及其源码声明和解析类型,覆盖逻辑与物理 header、事件信封以及各插件的声明合并。回放规则参见 [Session](subsystems/session.zh.md),存储规则参见[持久化](subsystems/persistence.zh.md)。',
|
||||
intro: '本目录列出仓库声明的每个持久化 Session 事件及其源码声明和解析类型,覆盖逻辑与物理 header、事件信封、所有 SessionEventMap 声明合并,以及单独列出的实验性插件记录。回放规则参见 [Session](subsystems/session.zh.md),存储规则参见[持久化](subsystems/persistence.zh.md)。',
|
||||
generation: '运行 `pnpm run gen-persistence-catalog` 可重新生成目录的两种语言、配对记录、已知事件模块和机器 schema 目录。`pnpm run verify-persistence-catalog` 检查所有生成文件。声明围栏保留源码 JSDoc 和类型引用;解析后的定义展开其传递引用结构。',
|
||||
envelopeIntro: '信封包含 `type`、`seq`、`time`、`data`、可选的 `ignorable` 以及条件字段 `surfaceOp` / `sourceEventSeqs`。**surface** 事件产生模型历史,**log-only** 事件不产生模型历史。目录覆盖本仓库;外部插件类型需要独立声明,不属于本目录。',
|
||||
envelope: '事件信封', events: '事件', sources: '来源:', source: '来源:', types: '类型:',
|
||||
pluginRecords: '实验性插件记录',
|
||||
pluginRecordsIntro: '这些当前源码中的 PluginRecordMap 声明描述实验性包拥有的 ignorable、仅日志记录。载荷类型注解链接到所属源码,不展开引用类型。记录不进入已知事件集合、持久化 schema 指纹或兼容性历史;格式迁移以尽力而为的方式保留它们。',
|
||||
pluginRecordsEmpty: '当前没有生产包声明插件记录。',
|
||||
pluginRecordColumns: '| 记录 | 所属包 | 用途 | 载荷与源码 |',
|
||||
fingerprints: '持久化类型指纹',
|
||||
fingerprintsIntro: '[机器可读目录](persistence-schema.json)包含所有可达的规范化类型及其 SHA-256 摘要。根类型的摘要涵盖引用类型。注释、源码位置、别名、擦除的品牌标记、readonly 标记以及无语义变化的字段、联合类型或交叉类型重排不影响摘要;元组顺序、属性名称、值类型和可选性会影响摘要。摘要不变时,目录文本和源码位置仍可能产生 diff。',
|
||||
historyIntro: '[格式参考](persistence-changes/historical-formats/README.zh.md)覆盖每个历史 Session 格式。[变更记录](persistence-changes/README.zh.md)通过保存在本源码树中的快照确认精确的类型转换。按照[评审流程](cookbook/reviewing-persistence-type-changes.zh.md)分类并记录变更。这些检查覆盖已声明的类型结构;不透明载荷的内部内容和未改变类型的行为变更不在检查范围内。',
|
||||
|
||||
@@ -811,6 +811,7 @@ describe('persistence changes current-tree commands', () => {
|
||||
paths: { '@deepseek-ai/dsh-session/types': ['./packages/core/session/src/types.ts'] },
|
||||
} }))
|
||||
const source = [
|
||||
'/** Experimental plugin records. */', 'export interface PluginRecordMap {}',
|
||||
'/** Stored header. */', 'export interface SessionHeader { version: 3; id: string }',
|
||||
'/** Stored event payloads. */', 'export interface SessionEventMap {', '/** Saved value. */',
|
||||
"'example/value': { value: string }", '}', '/** Surface event names. */', "export type SurfaceEventType = 'example/value'",
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
/** Plugin record inventory rejects incomplete declarations without creating compatibility roots. */
|
||||
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { collectPluginRecords } from './plugin-record-catalog.ts'
|
||||
import { annotateSurface, collectLogEvents, render, renderKnownEventTypes } from './gen-persistence-catalog.ts'
|
||||
import { extractPersistenceSchema } from './persistence-schema.ts'
|
||||
|
||||
const roots: string[] = []
|
||||
const OWNER = 'packages/core/session/src/types.ts'
|
||||
const RECORDS = 'packages/experimental/bridge/src/records.ts'
|
||||
const OWNER_SOURCE = '/** Experimental records declared by their owning packages. */\nexport interface PluginRecordMap {}\n'
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true, maxRetries: 3 })
|
||||
})
|
||||
|
||||
function put(root: string, file: string, source: string): void {
|
||||
const path = join(root, file)
|
||||
mkdirSync(dirname(path), { recursive: true })
|
||||
writeFileSync(path, source)
|
||||
}
|
||||
|
||||
function fixture(files: Record<string, string> = {}): string {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-plugin-record-catalog-'))
|
||||
roots.push(root)
|
||||
for (const [file, source] of Object.entries({
|
||||
'packages/core/session/package.json': '{"name":"@deepseek-ai/dsh-session"}',
|
||||
'packages/experimental/bridge/package.json': '{"name":"@deepseek-ai/dsh-experimental-bridge"}',
|
||||
[OWNER]: OWNER_SOURCE,
|
||||
...files,
|
||||
})) put(root, file, source)
|
||||
return root
|
||||
}
|
||||
|
||||
function augmentation(members: string, heritage = ''): string {
|
||||
return `export {}\ndeclare module '@deepseek-ai/dsh-session/types' {\n interface PluginRecordMap ${heritage}{\n${members}\n }\n}\n`
|
||||
}
|
||||
|
||||
const ENTRY = " /** Retains a bridge entry. */\n 'plugin:bridge/entry': SavedEntry"
|
||||
|
||||
describe('plugin record declarations', () => {
|
||||
it('accepts an empty inventory only when the owning empty map exists', () => {
|
||||
expect(collectPluginRecords(fixture())).toEqual([])
|
||||
expect(() => collectPluginRecords(fixture({ [OWNER]: 'export {}\n' })))
|
||||
.toThrow('missing exported empty PluginRecordMap')
|
||||
})
|
||||
|
||||
it('collects owner, purpose, unexpanded payload annotation and a stable source file', () => {
|
||||
const root = fixture({ [RECORDS]: augmentation(ENTRY) })
|
||||
const records = collectPluginRecords(root)
|
||||
expect(records).toEqual([{
|
||||
name: 'plugin:bridge/entry', owner: '@deepseek-ai/dsh-experimental-bridge', payload: 'SavedEntry',
|
||||
doc: 'Retains a bridge entry.', source: RECORDS,
|
||||
}])
|
||||
put(root, RECORDS, `\n\n${augmentation(ENTRY)}`)
|
||||
expect(collectPluginRecords(root)).toEqual(records)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['optional property', "'plugin:bridge/entry'?: SavedEntry", /required string-literal property/],
|
||||
['index signature', '[name: string]: SavedEntry', /required string-literal property/],
|
||||
['computed name', "['plugin:bridge/entry']: SavedEntry", /required string-literal property/],
|
||||
['identifier name', 'entry: SavedEntry', /required string-literal property/],
|
||||
['method', "'plugin:bridge/entry'(): SavedEntry", /required string-literal property/],
|
||||
['missing type', "'plugin:bridge/entry'", /explicit payload type/],
|
||||
['another owner', "'plugin:other/entry': SavedEntry", /must use 'plugin:bridge\/<name>'/],
|
||||
['missing suffix', "'plugin:bridge': SavedEntry", /must use 'plugin:bridge\/<name>'/],
|
||||
['invalid grammar', "'plugin:bridge/Entry': SavedEntry", /lowercase slash-separated segments/],
|
||||
])('rejects a declaration with %s', (_name, member, error) => {
|
||||
const root = fixture({ [RECORDS]: augmentation(`/** Entry. */\n${member}`) })
|
||||
expect(() => collectPluginRecords(root)).toThrow(error)
|
||||
})
|
||||
|
||||
it('rejects missing prose, Cordis mode tags, inheritance and duplicate names', () => {
|
||||
for (const [source, error] of [
|
||||
[augmentation("'plugin:bridge/entry': SavedEntry"), /has no description prose/],
|
||||
[augmentation("/** Entry.\n * @mode emit\n */\n'plugin:bridge/entry': SavedEntry"), /must not carry a Cordis @mode/],
|
||||
[augmentation(ENTRY, 'extends Other '), /without extends or type parameters/],
|
||||
[augmentation(`${ENTRY}\n${ENTRY}`), /already declared/],
|
||||
] as const) expect(() => collectPluginRecords(fixture({ [RECORDS]: source }))).toThrow(error)
|
||||
})
|
||||
|
||||
it('requires one empty exported map in the session package', () => {
|
||||
for (const [source, error] of [
|
||||
['interface PluginRecordMap {}\n', /must export/],
|
||||
[`${OWNER_SOURCE}${OWNER_SOURCE}`, /duplicates the owning declaration/],
|
||||
[OWNER_SOURCE.replace('{}', "{ 'plugin:session/entry': string }"), /must remain empty/],
|
||||
[OWNER_SOURCE.replace('Map {}', 'Map<T> {}'), /without extends or type parameters/],
|
||||
] as const) expect(() => collectPluginRecords(fixture({ [OWNER]: source }))).toThrow(error)
|
||||
expect(() => collectPluginRecords(fixture({ [RECORDS]: OWNER_SOURCE }))).toThrow('is outside @deepseek-ai/dsh-session')
|
||||
})
|
||||
|
||||
it('finds and rejects record declarations outside experimental production source', () => {
|
||||
const root = fixture({
|
||||
'packages/core/bridge/package.json': '{"name":"@deepseek-ai/dsh-experimental-bridge"}',
|
||||
'packages/core/bridge/src/records.ts': augmentation(ENTRY),
|
||||
})
|
||||
expect(() => collectPluginRecords(root)).toThrow('must belong to an experimental')
|
||||
})
|
||||
|
||||
it('requires the types module augmentation and verifiable package ownership', () => {
|
||||
expect(() => collectPluginRecords(fixture({
|
||||
[RECORDS]: augmentation(ENTRY).replace("dsh-session/types'", "dsh-session'"),
|
||||
}))).toThrow("must augment declare module '@deepseek-ai/dsh-session/types'")
|
||||
for (const manifest of ['{}', 'broken json', '{"name":"@external/bridge"}']) {
|
||||
expect(() => collectPluginRecords(fixture({
|
||||
[RECORDS]: augmentation(ENTRY), 'packages/experimental/bridge/package.json': manifest,
|
||||
}))).toThrow(/ownership|must belong/)
|
||||
}
|
||||
})
|
||||
|
||||
it('omits tests and handles escaped declaration names through syntax discovery', () => {
|
||||
const root = fixture({
|
||||
'packages/experimental/bridge/tests/fixture.ts': augmentation(ENTRY),
|
||||
[RECORDS]: augmentation(ENTRY).replace('PluginRecordMap', '\\u0050luginRecordMap'),
|
||||
})
|
||||
expect(collectPluginRecords(root).map(record => record.name)).toEqual(['plugin:bridge/entry'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('plugin record catalogue output', () => {
|
||||
it('renders honest empty states in both languages', () => {
|
||||
expect(render([], [])).toContain('No production package currently declares a plugin record.')
|
||||
expect(render([], [], undefined, 'zh')).toContain('当前没有生产包声明插件记录。')
|
||||
})
|
||||
|
||||
it('renders compact, sorted rows with escaped annotations and no line-number churn', () => {
|
||||
const records = collectPluginRecords(fixture({ [RECORDS]: augmentation(`
|
||||
/** Retains state | metadata. */
|
||||
'plugin:bridge/state': { value: string | null; marker: 'a b' }
|
||||
${ENTRY}
|
||||
`) }))
|
||||
const output = render([], [], undefined, 'en', [...records].reverse())
|
||||
expect(output).toContain('| `plugin:bridge/entry` | `@deepseek-ai/dsh-experimental-bridge` | Retains a bridge entry. | <code>SavedEntry</code>')
|
||||
expect(output).toContain('Retains state \\| metadata.')
|
||||
expect(output).toContain("<code>{ value: string | null; marker: 'a b'; }</code>")
|
||||
expect(output).toContain(`[\`${RECORDS}\`](../${RECORDS})`)
|
||||
expect(output.indexOf('| `plugin:bridge/entry`')).toBeLessThan(output.indexOf('| `plugin:bridge/state`'))
|
||||
expect(output).not.toMatch(/records\.ts:\d/u)
|
||||
})
|
||||
|
||||
it('keeps record declarations and payload changes out of persisted schemas and runtime names', () => {
|
||||
const root = fixture({
|
||||
'tsconfig.host.json': JSON.stringify({ compilerOptions: {
|
||||
target: 'es2024', module: 'esnext', moduleResolution: 'bundler', strict: true, types: [],
|
||||
paths: { '@deepseek-ai/dsh-session/types': ['./packages/core/session/src/types.ts'] },
|
||||
}, include: ['packages/**/src/**/*.ts'] }),
|
||||
[OWNER]: `${OWNER_SOURCE}
|
||||
export interface SessionHeader { version: 4; id: string; createdAt: number }
|
||||
export interface SessionEventMap {
|
||||
/** One ordinary log event. */
|
||||
'test/record': { value: string }
|
||||
}
|
||||
export type SurfaceEventType = never
|
||||
export type SessionEvent<T extends keyof SessionEventMap = keyof SessionEventMap> = {
|
||||
[K in keyof SessionEventMap]: { type: K; seq: number; time: number; data: SessionEventMap[K] }
|
||||
}[T]
|
||||
`,
|
||||
'packages/session/session-persistence-jsonl/src/format.ts': "interface HeaderLine {type: 'session'; version: number; id: string; delegationDepth: number}\nexport {}\n",
|
||||
})
|
||||
const schema = extractPersistenceSchema(root)
|
||||
const known = renderKnownEventTypes(annotateSurface(collectLogEvents(root), []))
|
||||
const empty = render([], [], undefined, 'en', collectPluginRecords(root))
|
||||
put(root, RECORDS, `interface SavedEntry { value: string }\n${augmentation(ENTRY)}`)
|
||||
const declared = render([], [], undefined, 'en', collectPluginRecords(root))
|
||||
expect(declared).not.toBe(empty)
|
||||
expect(extractPersistenceSchema(root)).toEqual(schema)
|
||||
put(root, RECORDS, `interface SavedEntry { nested: { count: number } }\n${augmentation(ENTRY)}`)
|
||||
expect(render([], [], undefined, 'en', collectPluginRecords(root))).toBe(declared)
|
||||
expect(extractPersistenceSchema(root)).toEqual(schema)
|
||||
expect(renderKnownEventTypes(annotateSurface(collectLogEvents(root), []))).toBe(known)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,132 @@
|
||||
/** Discover package-owned experimental Session records without adding persistence schema roots. */
|
||||
|
||||
import { globSync, readFileSync } from 'node:fs'
|
||||
import { resolve, sep } from 'node:path'
|
||||
import ts from 'typescript'
|
||||
import { parseJsDoc, pointer, rawJsDoc, reportViolations } from './jsdoc.ts'
|
||||
|
||||
const SESSION_PACKAGE = '@deepseek-ai/dsh-session'
|
||||
const SESSION_TYPES_MODULE = `${SESSION_PACKAGE}/types`
|
||||
const PACKAGE_PREFIX = '@deepseek-ai/dsh-experimental-'
|
||||
const RECORD_NAME = /^plugin:[a-z0-9][a-z0-9._-]*(?:\/[a-z0-9][a-z0-9._-]*)+$/u
|
||||
const printer = ts.createPrinter({ removeComments: true })
|
||||
|
||||
/** One current-source plugin record declaration, independent of compatibility history. */
|
||||
export interface PluginRecordEntry {
|
||||
/** Literal stored record name. */
|
||||
readonly name: string
|
||||
/** Owning workspace package's npm name. */
|
||||
readonly owner: string
|
||||
/** Authored payload annotation, without resolving referenced types. */
|
||||
readonly payload: string
|
||||
/** Declaration JSDoc description. */
|
||||
readonly doc: string
|
||||
/** Repository-relative declaration file, without a line number. */
|
||||
readonly source: string
|
||||
}
|
||||
|
||||
/** Read package ownership from the source file's workspace manifest. */
|
||||
function packageName(source: string, root: string): string | undefined {
|
||||
const manifest: unknown = JSON.parse(readFileSync(resolve(root, source.split('/').slice(0, 3).join('/'), 'package.json'), 'utf8'))
|
||||
return typeof manifest === 'object' && manifest !== null && 'name' in manifest && typeof manifest.name === 'string'
|
||||
? manifest.name : undefined
|
||||
}
|
||||
|
||||
/** Print compact type syntax while preserving whitespace within literal values. */
|
||||
function payloadText(type: ts.TypeNode, source: ts.SourceFile): string {
|
||||
const singleLine = (node: ts.Node): void => {
|
||||
ts.setEmitFlags(node, ts.EmitFlags.SingleLine)
|
||||
ts.forEachChild(node, singleLine)
|
||||
}
|
||||
singleLine(type)
|
||||
return printer.printNode(ts.EmitHint.Unspecified, type, source).trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect documented `PluginRecordMap` augmentations from production package source.
|
||||
* @param scanRoot - repository root containing the owning empty map and package manifests.
|
||||
* @returns unique record declarations sorted by owner and name, with unexpanded payload annotations.
|
||||
* @throws when ownership, namespace, declaration syntax, or description requirements fail.
|
||||
*/
|
||||
export function collectPluginRecords(scanRoot: string): PluginRecordEntry[] {
|
||||
const entries: PluginRecordEntry[] = []
|
||||
const violations: string[] = []
|
||||
const seen = new Map<string, string>()
|
||||
let owningDeclaration: string | undefined
|
||||
const files = globSync('packages/*/*/src/**/*.{ts,tsx,mts,cts}', { cwd: scanRoot })
|
||||
.map(file => file.split(sep).join('/')).sort()
|
||||
for (const file of files) {
|
||||
const text = readFileSync(resolve(scanRoot, file), 'utf8')
|
||||
const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true)
|
||||
const visit = (node: ts.Node): void => {
|
||||
if (!ts.isInterfaceDeclaration(node) || node.name.text !== 'PluginRecordMap') {
|
||||
ts.forEachChild(node, visit)
|
||||
return
|
||||
}
|
||||
const where = `PluginRecordMap (${pointer(file, source, node)})`
|
||||
let owner: string | undefined
|
||||
try {
|
||||
owner = packageName(file, scanRoot)
|
||||
} catch (error: unknown) {
|
||||
violations.push(`${where} has no readable package ownership: ${String(error)}`)
|
||||
return
|
||||
}
|
||||
if (node.heritageClauses?.length || node.typeParameters?.length) {
|
||||
violations.push(`${where} must declare record properties directly, without extends or type parameters.`)
|
||||
}
|
||||
if (ts.isSourceFile(node.parent)) {
|
||||
if (!file.startsWith('packages/core/session/src/') || owner !== SESSION_PACKAGE) {
|
||||
violations.push(`${where} is outside ${SESSION_PACKAGE}; contribute records through declare module '${SESSION_TYPES_MODULE}'.`)
|
||||
} else if (!node.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword)) {
|
||||
violations.push(`${where} must export the owning empty interface.`)
|
||||
} else if (owningDeclaration !== undefined) {
|
||||
violations.push(`${where} duplicates the owning declaration at ${owningDeclaration}.`)
|
||||
} else {
|
||||
owningDeclaration = pointer(file, source, node)
|
||||
}
|
||||
if (node.members.length > 0) violations.push(`${where} must remain empty; experimental packages own record declarations.`)
|
||||
return
|
||||
}
|
||||
const container = node.parent
|
||||
const module = ts.isModuleBlock(container) ? container.parent : undefined
|
||||
if (module === undefined || !ts.isModuleDeclaration(module) || !ts.isStringLiteral(module.name)
|
||||
|| module.name.text !== SESSION_TYPES_MODULE || !ts.isSourceFile(module.parent)) {
|
||||
violations.push(`${where} must augment declare module '${SESSION_TYPES_MODULE}'.`)
|
||||
return
|
||||
}
|
||||
if (!file.startsWith('packages/experimental/') || owner === undefined || !owner.startsWith(PACKAGE_PREFIX)) {
|
||||
violations.push(`${where} must belong to an experimental ${PACKAGE_PREFIX}<name> package.`)
|
||||
return
|
||||
}
|
||||
const prefix = `plugin:${owner.slice(PACKAGE_PREFIX.length)}/`
|
||||
for (const member of node.members) {
|
||||
const location = pointer(file, source, member)
|
||||
if (!ts.isPropertySignature(member) || member.type === undefined || member.questionToken !== undefined
|
||||
|| !ts.isStringLiteral(member.name)) {
|
||||
violations.push(`plugin record at ${location} must be a required string-literal property with an explicit payload type.`)
|
||||
continue
|
||||
}
|
||||
const name = member.name.text
|
||||
if (!RECORD_NAME.test(name) || !name.startsWith(prefix)) {
|
||||
violations.push(`plugin record '${name}' (${location}) must use '${prefix}<name>' with lowercase slash-separated segments.`)
|
||||
continue
|
||||
}
|
||||
const prior = seen.get(name)
|
||||
if (prior !== undefined) {
|
||||
violations.push(`plugin record '${name}' (${location}) is already declared at ${prior}.`)
|
||||
continue
|
||||
}
|
||||
seen.set(name, location)
|
||||
const { doc, hasMode } = parseJsDoc(rawJsDoc(text, member))
|
||||
if (doc === '') violations.push(`plugin record '${name}' (${location}) has no description prose.`)
|
||||
if (hasMode) violations.push(`plugin record '${name}' (${location}) must not carry a Cordis @mode tag.`)
|
||||
const payload = payloadText(member.type, source)
|
||||
entries.push({ name, owner, payload, doc, source: file })
|
||||
}
|
||||
}
|
||||
visit(source)
|
||||
}
|
||||
if (owningDeclaration === undefined) violations.push(`missing exported empty PluginRecordMap in ${SESSION_PACKAGE}.`)
|
||||
reportViolations('plugin-record-catalog', violations)
|
||||
return entries.sort((left, right) => left.owner.localeCompare(right.owner) || left.name.localeCompare(right.name))
|
||||
}
|
||||
@@ -629,6 +629,11 @@
|
||||
"symbol": "PluginRecordType",
|
||||
"source": "packages/core/session/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/session.md",
|
||||
"symbol": "PluginRecordMap",
|
||||
"source": "packages/core/session/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/session.md",
|
||||
"symbol": "PluginRecord",
|
||||
|
||||
@@ -4,6 +4,7 @@ import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
checkDeclaredPluginRecordWrites,
|
||||
checkPluginRecordCallers,
|
||||
findPluginRecordReferences,
|
||||
isAdmittedCaller,
|
||||
@@ -34,6 +35,33 @@ const OWNER_SOURCE = 'export function appendPluginRecord(session, type, data) {
|
||||
const EXPERIMENTAL = 'packages/experimental/bridge/src/index.ts'
|
||||
const RELEASE = 'packages/core/agent/src/index.ts'
|
||||
|
||||
/** A source-only compiler fixture whose production map declares one bridge record. */
|
||||
function declaredRepository(source: string, extra: Record<string, string> = {}): string {
|
||||
return repository({
|
||||
'tsconfig.host.json': JSON.stringify({
|
||||
compilerOptions: { module: 'NodeNext', moduleResolution: 'NodeNext', strict: true, noEmit: true,
|
||||
paths: { '@deepseek-ai/dsh-session': ['./packages/core/session/src/index.ts'],
|
||||
'@deepseek-ai/dsh-session/types': ['./packages/core/session/src/types.ts'] } },
|
||||
include: ['packages/**/*.ts'],
|
||||
}),
|
||||
'packages/core/session/package.json': JSON.stringify({ name: '@deepseek-ai/dsh-session' }),
|
||||
'packages/experimental/bridge/package.json': JSON.stringify({ name: '@deepseek-ai/dsh-experimental-bridge' }),
|
||||
'packages/core/session/src/types.ts': 'export interface PluginRecordMap {}\n',
|
||||
[OWNER_FILE]: "import type { PluginRecordMap } from './types.ts'\nexport function appendPluginRecord<K extends keyof PluginRecordMap>(session: object, type: K, data: NoInfer<PluginRecordMap[K]>): number { return 0 }\n",
|
||||
'packages/experimental/bridge/src/types.ts': [
|
||||
'export interface Entry { value: number }',
|
||||
"declare module '@deepseek-ai/dsh-session/types' {",
|
||||
' interface PluginRecordMap {',
|
||||
' /** Stores the bridge state. */',
|
||||
" 'plugin:bridge/state': Entry",
|
||||
' }',
|
||||
'}',
|
||||
].join('\n'),
|
||||
[EXPERIMENTAL]: source,
|
||||
...extra,
|
||||
})
|
||||
}
|
||||
|
||||
/** A minimal corpus: the owner, one experimental file, and one release file with the given source. */
|
||||
function corpus(release: string, extra: Record<string, string> = {}): Map<string, string> {
|
||||
return new Map(Object.entries({
|
||||
@@ -164,4 +192,133 @@ describe('verify-plugin-record-callers', () => {
|
||||
'scripts/tool.mjs',
|
||||
])
|
||||
})
|
||||
|
||||
it('accepts declared names through renamed imports, inferred aliases, and namespace calls', () => {
|
||||
const root = declaredRepository([
|
||||
"import { appendPluginRecord as write } from '@deepseek-ai/dsh-session'",
|
||||
"import * as session from '@deepseek-ai/dsh-session'",
|
||||
'const alias = write',
|
||||
"write({}, 'plugin:bridge/state', { value: 1 })",
|
||||
"alias({}, 'plugin:bridge/state', { value: 2 })",
|
||||
"session.appendPluginRecord({}, 'plugin:bridge/state', { value: 3 })",
|
||||
].join('\n'))
|
||||
expect(checkDeclaredPluginRecordWrites(root, readRepositorySources(root))).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects undeclared literal names, partially declared unions, and dynamic strings', () => {
|
||||
const root = declaredRepository([
|
||||
"import { appendPluginRecord as write } from '@deepseek-ai/dsh-session'",
|
||||
"declare const union: 'plugin:bridge/state' | 'plugin:bridge/missing'",
|
||||
'declare const dynamic: string',
|
||||
"write({}, 'plugin:bridge/missing', {})",
|
||||
'write({}, union, {})',
|
||||
'write({}, dynamic, {})',
|
||||
].join('\n'))
|
||||
const errors = checkDeclaredPluginRecordWrites(root, readRepositorySources(root))
|
||||
expect(errors.map(error => error.line)).toEqual([4, 5, 6])
|
||||
expect(errors.map(error => error.reason)).toEqual([
|
||||
expect.stringContaining('not assignable'),
|
||||
expect.stringContaining('not assignable'),
|
||||
expect.stringContaining('not assignable'),
|
||||
])
|
||||
})
|
||||
|
||||
it('does not authorize a production write with a test-only declaration', () => {
|
||||
const root = declaredRepository([
|
||||
"import { appendPluginRecord } from '@deepseek-ai/dsh-session'",
|
||||
"import type { PluginRecordMap } from '@deepseek-ai/dsh-session/types'",
|
||||
"const key = 'plugin:bridge/test-only' as const",
|
||||
'appendPluginRecord({}, key, {})',
|
||||
].join('\n'), {
|
||||
'packages/experimental/bridge/tests/records.spec.ts': [
|
||||
'export {}',
|
||||
"declare module '@deepseek-ai/dsh-session/types' {",
|
||||
" interface PluginRecordMap { 'plugin:bridge/test-only': object }",
|
||||
'}',
|
||||
].join('\n'),
|
||||
})
|
||||
expect(checkDeclaredPluginRecordWrites(root, readRepositorySources(root))).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('accepts finite generic keys and ignores test writes and unrelated function names', () => {
|
||||
const root = declaredRepository([
|
||||
"import { appendPluginRecord } from '@deepseek-ai/dsh-session'",
|
||||
"function write<K extends 'plugin:bridge/state'>(key: K): void { appendPluginRecord({}, key, { value: 1 }) }",
|
||||
'function other(type: string): void {}',
|
||||
"other('plugin:bridge/missing')",
|
||||
].join('\n'), {
|
||||
'packages/experimental/bridge/tests/records.spec.ts': "import { appendPluginRecord } from '@deepseek-ai/dsh-session'\nappendPluginRecord({}, 'plugin:test/state', {})\n",
|
||||
})
|
||||
expect(checkDeclaredPluginRecordWrites(root, readRepositorySources(root))).toEqual([])
|
||||
})
|
||||
|
||||
it('checks an empty production caller set without loading a compiler project', () => {
|
||||
const root = declaredRepository('export {}\n')
|
||||
rmSync(join(root, 'tsconfig.host.json'))
|
||||
expect(checkDeclaredPluginRecordWrites(root, readRepositorySources(root))).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects a test declaration imported transitively by production', () => {
|
||||
const root = declaredRepository([
|
||||
"import { appendPluginRecord } from '@deepseek-ai/dsh-session'",
|
||||
"import type { Fixture } from '../tests/fixture.ts'",
|
||||
'declare const data: Fixture',
|
||||
"appendPluginRecord({}, 'plugin:bridge/test-only', data)",
|
||||
].join('\n'), {
|
||||
'packages/experimental/bridge/tests/fixture.ts': [
|
||||
'export interface Fixture { value: number }',
|
||||
"declare module '@deepseek-ai/dsh-session/types' {",
|
||||
" interface PluginRecordMap { 'plugin:bridge/test-only': Fixture }",
|
||||
'}',
|
||||
].join('\n'),
|
||||
})
|
||||
const errors = checkDeclaredPluginRecordWrites(root, readRepositorySources(root))
|
||||
expect(errors).toHaveLength(1)
|
||||
expect(errors[0]?.reason).toContain('visible to production but has no production catalogue declaration')
|
||||
})
|
||||
|
||||
it('keeps compiler faces separate and rejects unconfigured writer source', () => {
|
||||
const compilerOptions = { module: 'NodeNext', moduleResolution: 'NodeNext', strict: true, noEmit: true,
|
||||
paths: { '@deepseek-ai/dsh-session': ['./packages/core/session/src/index.ts'],
|
||||
'@deepseek-ai/dsh-session/types': ['./packages/core/session/src/types.ts'] } }
|
||||
const root = declaredRepository('export {}\n', {
|
||||
'tsconfig.host.json': JSON.stringify({ compilerOptions, include: ['packages/core/**/src/**/*.ts'] }),
|
||||
'tsconfig.client.json': JSON.stringify({ compilerOptions, include: ['packages/**/src/**/*.ts'] }),
|
||||
'packages/experimental/bridge/src/client.ts': "import { appendPluginRecord } from '@deepseek-ai/dsh-session'\nappendPluginRecord({}, 'plugin:bridge/state', { value: 1 })\n",
|
||||
'packages/experimental/bridge/tsdown.config.ts': 'const broken: number = "config is not source"\n',
|
||||
'packages/experimental/bridge/examples/hook.js': 'export const hook = value => value\n',
|
||||
})
|
||||
expect(checkDeclaredPluginRecordWrites(root, readRepositorySources(root))).toEqual([])
|
||||
writeFileSync(join(root, 'packages/experimental/bridge/examples/write.ts'),
|
||||
"import { appendPluginRecord } from '@deepseek-ai/dsh-session'\nappendPluginRecord({}, 'plugin:bridge/state', { value: 1 })\n")
|
||||
const errors = checkDeclaredPluginRecordWrites(root, readRepositorySources(root))
|
||||
expect(errors).toHaveLength(1)
|
||||
expect(errors[0]?.reason).toContain('must be included in a Host or Client compiler face')
|
||||
})
|
||||
|
||||
it('checks call, apply, and bound arguments without rejecting declared forwarding', () => {
|
||||
const root = declaredRepository([
|
||||
"import { appendPluginRecord as write } from '@deepseek-ai/dsh-session'",
|
||||
"write.call(undefined, {}, 'plugin:bridge/missing', {})",
|
||||
"write.apply(undefined, [{}, 'plugin:bridge/missing', {}])",
|
||||
"const missing = [{}, 'plugin:bridge/missing', {}] as const",
|
||||
'write.apply(undefined, [...missing])',
|
||||
"const bound = write.bind(undefined, {}, 'plugin:bridge/missing', {})",
|
||||
'bound()',
|
||||
"write.call(undefined, {}, 'plugin:bridge/state', { value: 1 })",
|
||||
"write.apply(undefined, [{}, 'plugin:bridge/state', { value: 1 }])",
|
||||
"const valid = [{}, 'plugin:bridge/state', { value: 1 }] as const",
|
||||
'write.apply(undefined, [...valid])',
|
||||
"const validBound = write.bind(undefined, {}, 'plugin:bridge/state', { value: 1 })",
|
||||
'validBound()',
|
||||
'const partial = write.bind(undefined, {})',
|
||||
"partial('plugin:bridge/state', { value: 1 })",
|
||||
"partial('plugin:bridge/missing', {})",
|
||||
'write(...valid)',
|
||||
].join('\n'))
|
||||
const errors = checkDeclaredPluginRecordWrites(root, readRepositorySources(root))
|
||||
expect([...new Set(errors.map(error => error.line))]).toEqual([2, 3, 5, 6, 16])
|
||||
expect([...new Set(errors.filter(error => error.reason?.includes('plugin:bridge/missing'))
|
||||
.map(error => error.line))]).toEqual([2, 3, 5, 6, 16])
|
||||
})
|
||||
})
|
||||
@@ -2,7 +2,7 @@
|
||||
* Verify that only experimental packages call `appendPluginRecord` from production source.
|
||||
*
|
||||
* `appendPluginRecord` in `@deepseek-ai/dsh-session` appends an ignorable `plugin:` record that the
|
||||
* persistence catalog does not list and that a Session format migration keeps only on a best-effort
|
||||
* persistence catalogue lists separately and that a Session format migration keeps only on a best-effort
|
||||
* basis. Experimental packages may rely on that; a release package must declare its events instead.
|
||||
* The owning module and test files, which exercise the operation, are exempt.
|
||||
*
|
||||
@@ -14,12 +14,16 @@
|
||||
* it still names the function, and that reference is. A file is parsed when its text, or its text
|
||||
* with its escapes unescaped, contains the name, so a spelling that escapes part of it (`\u0061`,
|
||||
* `\x61`, `\u{61}`, `\141`, `\a`, a line continuation) is still found.
|
||||
* Production writes must name a record in the production-only `PluginRecordMap` inventory;
|
||||
* test declarations cannot authorize a production write.
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { relative, resolve } from 'node:path'
|
||||
import ts from 'typescript'
|
||||
import { collectPluginRecords } from './plugin-record-catalog.ts'
|
||||
import { faceConfigs } from './ts-project.ts'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
|
||||
@@ -78,6 +82,8 @@ export interface PluginRecordCaller {
|
||||
readonly line: number
|
||||
/** The trimmed source line. */
|
||||
readonly text: string
|
||||
/** Why a production write lacks a catalogue declaration, when applicable. */
|
||||
readonly reason?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -161,6 +167,81 @@ export function checkPluginRecordCallers(files: ReadonlyMap<string, string>): Pl
|
||||
.flatMap(file => findPluginRecordReferences(file, files.get(file) ?? ''))
|
||||
}
|
||||
|
||||
/**
|
||||
* Check production write keys against the generated catalogue's source inventory.
|
||||
* The compiler reads production sources only, so test map augmentations cannot widen the writer.
|
||||
* @param repoRoot - checkout holding the compiler face and record declarations.
|
||||
* @param files - tracked and unignored repository sources.
|
||||
* @returns compiler errors in experimental production consumers, including undeclared write keys.
|
||||
*/
|
||||
export function checkDeclaredPluginRecordWrites(
|
||||
repoRoot: string,
|
||||
files: ReadonlyMap<string, string>,
|
||||
): PluginRecordCaller[] {
|
||||
const declared = new Set(collectPluginRecords(repoRoot).map(record => record.name))
|
||||
const candidates = [...files.keys()].filter(file => file.startsWith(EXPERIMENTAL_PREFIX) && !isTestFile(file)
|
||||
&& findPluginRecordReferences(file, files.get(file) ?? '').length > 0)
|
||||
if (candidates.length === 0) return []
|
||||
const remaining = new Set(candidates)
|
||||
const violations: PluginRecordCaller[] = []
|
||||
for (const face of ['host', 'client'] as const) {
|
||||
if (remaining.size === 0) break
|
||||
const configs = faceConfigs(repoRoot, face)
|
||||
const roots = new Set<string>()
|
||||
for (const config of configs.byPath.values()) {
|
||||
for (const file of config.fileNames) {
|
||||
if (!isTestFile(relative(repoRoot, file).replaceAll('\\', '/'))) roots.add(file)
|
||||
}
|
||||
}
|
||||
const admitted = candidates.filter(file => roots.has(resolve(repoRoot, file)))
|
||||
if (admitted.length === 0) continue
|
||||
for (const file of admitted) remaining.delete(file)
|
||||
const consumers = new Set(admitted.map(file => file.split('/').slice(0, 3).join('/')))
|
||||
const program = ts.createProgram([...roots], {
|
||||
...configs.root.options,
|
||||
noEmit: true,
|
||||
composite: false,
|
||||
incremental: false,
|
||||
})
|
||||
const owner = program.getSourceFile(resolve(repoRoot, 'packages/core/session/src/types.ts'))
|
||||
const map = owner?.statements.find((node): node is ts.InterfaceDeclaration =>
|
||||
ts.isInterfaceDeclaration(node) && node.name.text === 'PluginRecordMap')
|
||||
if (map === undefined) throw new Error('production compiler did not load the owning PluginRecordMap')
|
||||
for (const member of program.getTypeChecker().getTypeAtLocation(map).getProperties()) {
|
||||
if (declared.has(member.name)) continue
|
||||
const declaration = member.declarations?.[0]
|
||||
if (declaration === undefined) throw new Error(`plugin record has no source declaration: ${member.name}`)
|
||||
const source = declaration.getSourceFile()
|
||||
violations.push({
|
||||
file: relative(repoRoot, source.fileName).replaceAll('\\', '/'),
|
||||
line: source.getLineAndCharacterOfPosition(declaration.getStart(source)).line + 1,
|
||||
text: declaration.getText(source),
|
||||
reason: `record ${member.name} is visible to production but has no production catalogue declaration`,
|
||||
})
|
||||
}
|
||||
for (const file of roots) {
|
||||
const local = relative(repoRoot, file).replaceAll('\\', '/')
|
||||
if (!consumers.has(local.split('/').slice(0, 3).join('/'))) continue
|
||||
const source = program.getSourceFile(file)
|
||||
if (source === undefined) throw new Error(`plugin record caller source was not loaded: ${local}`)
|
||||
for (const diagnostic of program.getSemanticDiagnostics(source)) {
|
||||
if (diagnostic.category !== ts.DiagnosticCategory.Error) continue
|
||||
const line = source.getLineAndCharacterOfPosition(diagnostic.start ?? 0).line
|
||||
violations.push({
|
||||
file: local,
|
||||
line: line + 1,
|
||||
text: source.text.split(/\r?\n/u)[line]?.trim() ?? '',
|
||||
reason: ts.flattenDiagnosticMessageText(diagnostic.messageText, ' '),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const file of remaining) {
|
||||
violations.push({ file, line: 1, text: '', reason: 'production record writers must be included in a Host or Client compiler face' })
|
||||
}
|
||||
return violations
|
||||
}
|
||||
|
||||
/**
|
||||
* Read every tracked or unignored source file outside `vendor/`.
|
||||
* @param repoRoot - the repository root; defaults to this checkout.
|
||||
@@ -187,13 +268,14 @@ export function readRepositorySources(repoRoot: string = root): Map<string, stri
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const callers = checkPluginRecordCallers(readRepositorySources())
|
||||
const files = readRepositorySources()
|
||||
const callers = [...checkPluginRecordCallers(files), ...checkDeclaredPluginRecordWrites(root, files)]
|
||||
if (callers.length === 0) {
|
||||
console.log(`verify-plugin-record-callers: ${RESTRICTED_NAME} has no production caller outside ${EXPERIMENTAL_PREFIX}.`)
|
||||
console.log('verify-plugin-record-callers: production callers are experimental and record keys are catalogued.')
|
||||
return
|
||||
}
|
||||
console.error(`verify-plugin-record-callers: only ${EXPERIMENTAL_PREFIX} source may call ${RESTRICTED_NAME}.\n`)
|
||||
for (const caller of callers) console.error(` ${caller.file}:${String(caller.line)} ${caller.text}`)
|
||||
console.error('verify-plugin-record-callers: invalid production record callers.\n')
|
||||
for (const caller of callers) console.error(` ${caller.file}:${String(caller.line)} ${caller.text}${caller.reason === undefined ? '' : ` — ${caller.reason}`}`)
|
||||
console.error('\nA release package declares its events in SessionEventMap instead of writing plugin records.')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user