fix(session): address review of plugin records

- Make the record commit an ES private method, so appendPluginRecord()
  is its only runtime entry.
- Document PluginRecordType and PluginRecord on the session subsystem
  page, and state in the README and JSDoc that the V3-to-V4 edge renames
  unknown ignorable V3 events into the plugin: namespace and that plugins
  outside this repository must not call the writer.
- The caller gate parses only files that contain the name or a \u
  escape, documents that wildcard re-exports are not reported, and its
  spec reads a temporary repository instead of this checkout.
- Cover Session.fromRestore in the record test and name SessionEventMap
  in the catalog generator's diagnostic.
This commit is contained in:
Tianyi Cui committed 2026-10-05 08:57:58 +08:00
1 parent ab1e7811b9
commit 1678f0f5f5
13 files changed
+161 -26

No files matched your search

+3
View File
@@ -59,6 +59,9 @@
/sessions/plugin-contributed-log-only-events:
en: 0a014a755b75449f
zh: 9d59d74490a1bcb2
/sessions/plugin-contributed-log-only-events/plugin-records:
en: fbdff72f8b2f3194
zh: 7a7745b0edb75ed6
/sessions/durability-contract:
en: 62e3e4fbcba292a3
zh: cb6dcaf531621e85
+33
View File
@@ -752,6 +752,39 @@ When several events in one plugin-owned family assemble into one Web Client Conv
The hook bridges' `hook/invoked` / `hook/result` pairs (from `@deepseek-ai/dsh-hook-protocol`) correlate by `handlerId`. `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, and `Stop` fire inside the loop's open turn, so their `hook/*` records are turn-enclosed by construction. `SessionStart` gets no `hook/*` record because it runs before turn 1; its context remains pending in the inbox until a waking delivery opens a turn.
### 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.
```ts type-equiv
/**
* Event type of an experimental plugin record: `plugin:` followed by
* slash-separated segments of lowercase letters, digits, `.`, `_`, and `-`,
* each starting with a letter or digit. A record type is never a
* {@link SessionEventMap} member.
*/
type PluginRecordType = `plugin:${string}`
```
```ts type-equiv
/**
* One committed experimental plugin record, read from the log by
* `pluginRecordOf`. The record's owner validates `data` before use, because a
* restored record carries whatever JSON an earlier build of its owner wrote,
* or a V3 event that a format migration renamed into the `plugin:` namespace held.
*/
interface PluginRecord {
/** The record type, chosen by the owning plugin. */
readonly type: PluginRecordType
/** The record's position in the Session log. */
readonly seq: SessionSeq
/** Unix epoch milliseconds at append. */
readonly time: number
/** The JSON payload as committed. */
readonly data: unknown
}
```
## Durability contract
What a persistence backend relies on: the durable log persists every event losslessly, and every Assistant attempt is one `assistant/message` or `assistant/attempt` whose embedded compact stream preserves the original timed chunks. `seq` stays contiguous across these settlements and all interleaved events. A backend may choose its own storage framing for an event batch as long as a handle's `read()` returns the exact appended events; current JSONL writes one row per event (see [persistence.md](persistence.md)). All `event.data` must be JSON-serializable; `Session.append` enforces this at the source (throwing on non-serializable data), so a bad event never enters the log and `session.snapshotEvents()` always equals what a backend can persist. Adding an event type that carries non-serializable data, corrupts core execution nesting, or violates its owner's declared relation is a breaking change to the on-disk format.
+33
View File
@@ -756,6 +756,39 @@ interface TurnEndReasonMap {
钩子桥接层的 `hook/invoked` / `hook/result` 对(来自 `@deepseek-ai/dsh-hook-protocol`)通过 `handlerId` 关联。`UserPromptSubmit`、`PreToolUse`、`PostToolUse` 与 `Stop` 在 loop 已打开的轮次内触发,因此其 `hook/*` 记录天然位于轮次之内。`SessionStart` 不生成 `hook/*` 记录,因为它在轮次 1 之前运行;其上下文会在 inbox 中保持待处理,直到唤醒交付打开一个轮次。
### 插件记录
实验性包把插件自有的状态保存为插件记录,而不是声明事件:`appendPluginRecord()` 追加一条带 `ignorable: true` 标记、类型位于 `plugin:` 命名空间的事件,`pluginRecordOf()` 把它读回。调用方限制、保留方式与读取路径见[包 README](../../packages/core/session/README.zh.md#write-experimental-plugin-records)。
```ts type-equiv
/**
* Event type of an experimental plugin record: `plugin:` followed by
* slash-separated segments of lowercase letters, digits, `.`, `_`, and `-`,
* each starting with a letter or digit. A record type is never a
* {@link SessionEventMap} member.
*/
type PluginRecordType = `plugin:${string}`
```
```ts type-equiv
/**
* One committed experimental plugin record, read from the log by
* `pluginRecordOf`. The record's owner validates `data` before use, because a
* restored record carries whatever JSON an earlier build of its owner wrote,
* or a V3 event that a format migration renamed into the `plugin:` namespace held.
*/
interface PluginRecord {
/** The record type, chosen by the owning plugin. */
readonly type: PluginRecordType
/** The record's position in the Session log. */
readonly seq: SessionSeq
/** Unix epoch milliseconds at append. */
readonly time: number
/** The JSON payload as committed. */
readonly data: unknown
}
```
## 持久性约定
持久化后端依赖的约定如下:持久日志无损保存每个事件,每个 Assistant attempt 都是一个 `assistant/message` 或 `assistant/attempt`,其嵌入式紧凑 stream 会保留原始带时间 chunk。`seq` 在这些 settlement 与所有交错事件之间保持连续。后端可以为事件批次选择自己的存储 framing,只要句柄的 `read()` 返回与追加时完全一致的事件即可;当前 JSONL 每个事件写一行(见 [persistence.md](persistence.zh.md))。所有 `event.data` 都必须可序列化为 JSON;`Session.append` 会从源头强制这一要求(遇到不可序列化数据时抛出),因此错误事件绝不会进入日志,`session.snapshotEvents()` 始终与后端可持久化的内容一致。新增会携带不可序列化数据、破坏核心执行嵌套或违反事件所有方声明关系的事件类型,都会构成磁盘格式的破坏性变更。
+4 -4
View File
@@ -24,11 +24,11 @@
en: aa5f1951ecf3ac0c
zh: 62a599ace8f2bf15
/deepseek-ai-dsh-session/use-this-package/read-the-log:
en: 2b29cfeac7a2baa8
zh: b535b04dcbc29d3d
en: c4bf3103b9762b6c
zh: 369c734bb20de2ea
/deepseek-ai-dsh-session/use-this-package/write-experimental-plugin-records:
en: 112ec5a859c20281
zh: 813986ed8b9d8477
en: e2ee607edd3f567b
zh: 8aac3277adfd72cc
/deepseek-ai-dsh-session/use-this-package/fork-a-session:
en: 1c40d17a786059d3
zh: 81320ce118977c65
+4 -2
View File
@@ -63,11 +63,13 @@ Append, seed/restore, and event adoption/snapshot reject any `header.system` and
Session log positions use two numeric types. `SessionSeq` identifies an existing event or inclusive event watermark; `SessionLogOffset` identifies a gap, prefix length, or read boundary and may equal the event count. `SessionSeqCursor` adds the `-1` “no event yet” value, while `OptionalSessionSeq` uses `null` when absence is data. The constructors validate non-negative safe integers, and the brands disappear at runtime, so durable JSON and wire values remain ordinary numbers.
<a id="write-experimental-plugin-records"></a>
### 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. 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` 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.
`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 owning package validates `data` before use, because a restored record carries whatever JSON an earlier build wrote. 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.
`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.
### Fork a session
+4 -2
View File
@@ -63,11 +63,13 @@ session.deriveMessages() // the derived model history
会话日志位置使用两种数字类型。`SessionSeq` 标识已有事件或包含端点的事件水位;`SessionLogOffset` 标识间隙、前缀长度或读取边界,并且可以等于事件数量。`SessionSeqCursor` 添加 `-1` 这个“尚无事件”值,`OptionalSessionSeq` 则在缺失本身属于数据时使用 `null`。构造函数会校验非负安全整数,brand 在运行时会被擦除,因此持久 JSON 与 wire 值仍是普通数字。
<a id="write-experimental-plugin-records"></a>
### 写入实验性插件记录
`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` 门禁拒绝本仓库中任何其他生产调用方。本仓库之外的插件同样不得调用它,尽管没有门禁能检查它们。记录是带有 `ignorable: true` 标记的事件,其类型为 `plugin:` 后接以斜杠分隔的小写段,例如 `plugin:pi-extensions/entry`。没有任何 `SessionEventMap` 成员使用该命名空间,因此持久化目录不列出记录;不认识某条记录的构建在读取时保留并跳过它。记录从不进入模型可见的 surface。resume 与 fork 随日志其余部分一起携带它,Session 格式迁移以尽力而为的方式保留它。
`pluginRecordOf(event)` 把事件作为记录返回,对其他任何事件返回 `undefined`;把每个事件都交给它的 `ctx.sessionProjections` 单元在 resume 时重建插件状态。所属包在使用 `data` 之前先校验,因为恢复出的记录携带的是早先构建写入的任意 JSON。[ignorable 事件决策](../../../.agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.zh.md)记录了为何只有实验性包可以写入记录。
`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)记录了为何只有实验性包可以写入记录。
### 派生会话的 fork
+5 -3
View File
@@ -494,7 +494,9 @@ export function appendPluginRecord(session: Session, type: PluginRecordType, dat
}
/**
* Read one committed event as a plugin record.
* Read one committed event as a plugin record. The V3-to-V4 format edge also
* renames each unknown ignorable V3 event into the `plugin:` namespace, so an
* owner names its records under its own package name and validates `data`.
* @param event - any committed Session event, live or restored.
* @returns the record, or undefined when the event is not an ignorable event
* whose type follows the plugin record grammar.
@@ -821,7 +823,7 @@ export class Session {
}
static {
commitPluginRecord = (session, type, data) => session.appendRecord(type, data)
commitPluginRecord = (session, type, data) => session.#appendRecord(type, data)
}
/**
@@ -830,7 +832,7 @@ export class Session {
* @param data - JSON payload, snapshotted before it enters the log.
* @returns the record's sequence number.
*/
private appendRecord(type: PluginRecordType, data: JsonValue): SessionSeq {
#appendRecord(type: PluginRecordType, data: JsonValue): SessionSeq {
const dataSnapshot = snapshotJsonValue(data)
if (dataSnapshot === undefined) {
throw new Error(`plugin record "${type}" carries non-JSON-serializable data`)
+2 -1
View File
@@ -526,7 +526,8 @@ export type PluginRecordType = `plugin:${string}`
/**
* One committed experimental plugin record, read from the log by
* `pluginRecordOf`. The record's owner validates `data` before use, because a
* restored record carries whatever JSON an earlier build of its owner wrote.
* restored record carries whatever JSON an earlier build of its owner wrote,
* or a V3 event that a format migration renamed into the `plugin:` namespace held.
*/
export interface PluginRecord {
/** The record type, chosen by the owning plugin. */
@@ -7,6 +7,7 @@ import SessionStore, {
pluginRecordOf,
Session,
SessionId,
SessionLogOffset,
SessionSeq,
} from '@deepseek-ai/dsh-session'
import type { PluginRecordType, SessionEvent } from '@deepseek-ai/dsh-session'
@@ -107,16 +108,17 @@ describe('plugin records', () => {
await ctx.fiber.dispose()
})
it('carries records through a restore and into a fork', () => {
it('carries records through a snapshot restore, a storage reopen, and a fork', () => {
const session = Session.create(SessionId('source'))
openTurn(session)
appendPluginRecord(session, 'plugin:test/state', { count: 1 })
const events = session.snapshotEvents()
const restored = Session.create(SessionId('restored'), events)
const reopened = Session.fromRestore(SessionId('source'), events, session.header, SessionLogOffset(0), 'shared-frozen')
const child = Session.create(SessionId('child'), buildForkSeed(events, SessionSeq(2)))
for (const copy of [restored, child]) {
for (const copy of [restored, reopened, child]) {
expect(pluginRecordOf(copy.snapshotEvents()[2] as SessionEvent))
.toEqual({ type: 'plugin:test/state', seq: 2, time: events[2]?.time, data: { count: 1 } })
expect(copy.deriveMessages()).toEqual(session.deriveMessages())
+1 -1
View File
@@ -186,7 +186,7 @@ export function collectLogEvents(scanRoot: string = root): LogEventEntry[] {
const name = member.name.text
const where = `log event '${name}' (${src})`
if (name.startsWith('plugin:')) {
violations.push(`${where} uses the plugin: namespace, which holds ignorable plugin records outside the event vocabulary; choose a scope/name event type.`)
violations.push(`${where} uses the plugin: namespace, which holds ignorable plugin records outside SessionEventMap; choose a scope/name event type.`)
continue
}
const prior = seen.get(name)
+10
View File
@@ -624,6 +624,16 @@
"symbol": "SessionEvent",
"source": "packages/core/session/src/types.ts"
},
{
"doc": "docs/subsystems/session.md",
"symbol": "PluginRecordType",
"source": "packages/core/session/src/types.ts"
},
{
"doc": "docs/subsystems/session.md",
"symbol": "PluginRecord",
"source": "packages/core/session/src/types.ts"
},
{
"doc": "docs/subsystems/session.md",
"symbol": "TurnEndCancelCause",
+46 -4
View File
@@ -1,4 +1,8 @@
import { describe, expect, it } from 'vitest'
import { execFileSync } from 'node:child_process'
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 {
checkPluginRecordCallers,
findPluginRecordReferences,
@@ -7,6 +11,24 @@ import {
readRepositorySources,
} from './verify-plugin-record-callers.ts'
const roots: string[] = []
afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
})
/** A temporary git repository with the given files written and none of them staged. */
function repository(files: Record<string, string>): string {
const root = mkdtempSync(join(tmpdir(), 'dsh-plugin-record-callers-'))
roots.push(root)
execFileSync('git', ['init', '--quiet'], { cwd: root, stdio: 'pipe' })
for (const [file, contents] of Object.entries(files)) {
mkdirSync(dirname(join(root, file)), { recursive: true })
writeFileSync(join(root, file), contents)
}
return root
}
const OWNER_SOURCE = 'export function appendPluginRecord(session, type, data) { return commit(session, type, data) }\n'
const EXPERIMENTAL = 'packages/experimental/bridge/src/index.ts'
const RELEASE = 'packages/core/agent/src/index.ts'
@@ -30,6 +52,7 @@ describe('verify-plugin-record-callers', () => {
['a destructured binding', 'const { appendPluginRecord: write } = S'],
['an element access', "S['appendPluginRecord'](session, 'plugin:a', {})"],
['a template element access', 'S[`appendPluginRecord`](session, `plugin:a`, {})'],
['an escaped identifier', 'S.\\u0061ppendPluginRecord(session, "plugin:a", {})'],
])('rejects %s in release package source', (_form, source) => {
expect(checkPluginRecordCallers(corpus(`${source}\n`))).toEqual([{ file: RELEASE, line: 1, text: source }])
})
@@ -43,12 +66,13 @@ describe('verify-plugin-record-callers', () => {
.toEqual(['packages/client/ui/src/view.tsx', 'scripts/tool.mjs'])
})
it('ignores comments, JSDoc links, longer strings, and other identifiers', () => {
it('ignores comments, JSDoc links, longer strings, other identifiers, and wildcard re-exports', () => {
const source = [
'// appendPluginRecord is restricted',
'/** Plugin state uses {@link appendPluginRecord} in experimental packages. */',
"const message = 'call appendPluginRecord only from experimental packages'",
'const appendPluginRecords = 1',
"export * from '@deepseek-ai/dsh-session'",
].join('\n')
expect(checkPluginRecordCallers(corpus(source))).toEqual([])
})
@@ -92,7 +116,25 @@ describe('verify-plugin-record-callers', () => {
}
})
it('passes on the repository', () => {
expect(checkPluginRecordCallers(readRepositorySources())).toEqual([])
it('reads tracked and unignored sources outside vendor, skipping other files and deleted ones', () => {
const root = repository({
'.gitignore': 'ignored/\n',
[OWNER_FILE]: OWNER_SOURCE,
'scripts/tool.mjs': 'export {}\n',
'ignored/scratch.ts': 'export {}\n',
'vendor/cordis/src/index.ts': 'export {}\n',
'docs/page.md': '# Page\n',
'packages/core/gone/src/index.ts': 'export {}\n',
})
execFileSync('git', ['add', '.'], { cwd: root, stdio: 'pipe' })
rmSync(join(root, 'packages/core/gone/src/index.ts'))
mkdirSync(join(root, 'packages/core/agent/src'), { recursive: true })
writeFileSync(join(root, 'packages/core/agent/src/new.ts'), 'export {}\n')
expect([...readRepositorySources(root).keys()].sort()).toEqual([
'packages/core/agent/src/new.ts',
OWNER_FILE,
'scripts/tool.mjs',
])
})
})
+12 -7
View File
@@ -6,10 +6,13 @@
* 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.
*
* Discovery is syntax-aware, as `scripts/AGENTS.md` requires: every identifier spelled
* `appendPluginRecord` counts — an import, re-export, call, namespace member, destructured binding,
* or alias source — and so does a string literal of exactly that name, which reaches the function by
* element access. Comments, including JSDoc links, and longer strings do not count.
* Discovery is syntax-aware: every identifier spelled `appendPluginRecord` counts — an import, a
* named re-export, a call, a namespace member, a destructured binding, or an alias source — and so
* does a string literal of exactly that name, which reaches the function by element access.
* Comments, including JSDoc links, and longer strings do not count. A wildcard re-export
* (`export * from`) names no identifier and is not reported; code that calls the function through
* it still names the function, and that reference is. Only files whose text contains the name or a
* `\u` escape are parsed, so an escaped spelling of the name is still found.
*/
import { execFileSync } from 'node:child_process'
@@ -79,6 +82,7 @@ function scriptKind(file: string): ts.ScriptKind {
* @returns one entry per referencing identifier or exact-name string literal, in source order.
*/
export function findPluginRecordReferences(file: string, source: string): PluginRecordCaller[] {
if (!source.includes(RESTRICTED_NAME) && !source.includes('\\u')) return []
const sourceFile = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, scriptKind(file))
const lines = source.split(/\r?\n/u)
const references: PluginRecordCaller[] = []
@@ -128,11 +132,12 @@ export function checkPluginRecordCallers(files: ReadonlyMap<string, string>): Pl
/**
* Read every tracked or unignored source file outside `vendor/`.
* @param repoRoot - the repository root; defaults to this checkout.
* @returns repository-relative path, in POSIX separators, to contents.
*/
export function readRepositorySources(): Map<string, string> {
export function readRepositorySources(repoRoot: string = root): Map<string, string> {
const listing = execFileSync('git', ['ls-files', '--cached', '--others', '--exclude-standard', '-z'], {
cwd: root,
cwd: repoRoot,
encoding: 'utf8',
maxBuffer: 64 * 1024 * 1024,
})
@@ -141,7 +146,7 @@ export function readRepositorySources(): Map<string, string> {
const file = entry.replaceAll('\\', '/')
if (file === '' || file.startsWith('vendor/') || !SOURCE_EXTENSION.test(file)) continue
try {
files.set(file, readFileSync(resolve(root, file), 'utf8'))
files.set(file, readFileSync(resolve(repoRoot, file), 'utf8'))
} catch (error: unknown) {
// A file deleted in the working tree is still listed until the deletion is staged.
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error