DeepSeek Harness 中的存储是分层的:后端拥有一种介质,域层加上schema 与变更事件,而会话持久化是独立的、事件溯源式的日志。枢纽本身不做任何 I/O。官方设计说明在 docs/persistence-catalog.md(生成的事件目录)与 docs/subsystems/persistence.md。
| 包 | 角色 | 注册为 / ctx key |
|---|---|---|
packages/storage/storage | KV 枢纽 + 命名后端注册表 | ctx.storage |
packages/storage/storage-domain | schema 校验、发出变更事件的 KV 域 | ctx.storageDomain,挂载 domain 形态 |
packages/storage/storage-json | 每个单元一个人类可读文件 | 后端 json |
packages/storage/storage-sqlite | 一个 DB 文件,每行一文档的表 | 后端 sqlite |
packages/session/session-persistence | 持久化会话日志服务定义 | ctx.sessionPersistence |
packages/session/session-persistence-jsonl | 每会话追加 JSONL 工件 | 实现它 |
packages/session/session-persistence-sqlite | SQLite 支撑的会话日志 | 实现它 |
packages/session/session-projection | 派生的每会话投影表 | 投影 key |
packages/session/session-projection-cache | 持久化投影检查点 | session_projcache 域 |
packages/session/session-stats | 整份日志的对话统计 | sessionStats key |
packages/session/session-checkpoint-policy | 语义持久性屏障 | 插件 session-checkpoint-policy |
packages/session-query/session-query-sqlite | SQLite 查询/交叉引用索引 | 插件 |
packages/util/home-paths | 所有用户数据的存放位置 | ctx.dshHomePath |
KV 枢纽
ctx.storage(packages/storage/storage/src/index.ts)是一个持有 BackendRegistry 与已挂载形态表的 Service。后端是暴露可选facets(目前为 kv)的介质属主:
interface StorageBackend {
readonly kv?: KvFacet
close(): Promise<void>
}
interface KvFacet {
open(descriptor: KvUnitDescriptor): Promise<KvUnit>
}KvUnit 按描述符({ name, version, tables, hasGlobal })打开,用 UNIT_NAME_RE = /^[a-z][a-z0-9_]*$/ 校验名称(作为文件名与 SQL 标识符都安全),并提供 loadAll()、putRecord、deleteRecord、setGlobal。值在这一层是不透明 JSON——没有 schema、没有事件。写顺序明确是调用方的职责;单元只保证每个单次调用在 resolve 后原子且持久。
枢纽在 StorageForms key(声明合并)下挂载数据形态。storage-domain 合并 domain,因此 ctx.storage.domain 能解析它。
域层
packages/storage/storage-domain 是类型化 KV 域(ctx.storageDomain)的唯一实现,也是应用代码实际接触的东西。其 Config 决定路由:backend 是默认,routes 按域名覆盖——命名了未注册后端的路由在打开时响亮失败。
export interface Config { backend: string; routes?: Record<string, string> }DomainFacility.open(spec) 校验名称空闲、路由到后端、要求其 kv facet、打开单元,然后用 zod 校验每条已存记录是否符合 spec(不匹配时报 invalid-record,带 table+key)。Domain<S> 提供类型化表句柄 KvTable<K,V>(get/entries/keys/size/put/delete/update)与一个 DomainGlobal。读操作从权威的内存映射同步进行;每次写都排入单一按域写链,先等待后端持久化,再改内存,然后发出 domain/changed({ domain, table, key, operation: 'put'|'deleted', value? })。被拒绝的后端写不会触碰内存。
spec 的拆分:插件 Config 用 schemastery,spec 内的记录 schema 用 zod(src/spec.ts 记录了这一理由)。持久化 spec 示例:message_feedback(packages/feedback/message-feedback/src/spec.ts)与 session_projcache(packages/session/session-projection-cache/src/spec.ts)。
后端:JSON vs SQLite
storage-json
packages/storage/storage-json 注册为后端 json。在配置的 root 下每个单元一个人类可读文件——可读性正是它存在的理由。每次写都原子地重发整个文件(src/atomic.ts):
{
"unit": { "name": "workspace", "version": 0 },
"global": null,
"tables": { "sessions": { "<id>": { /* record */ } } }
}root 刻意没有默认值(src/index.ts):process.cwd() 回退会把单元文件散落在进程启动的任意位置。随附 shell 把它设为 dshHomePath('storages')。
storage-sqlite
packages/storage/storage-sqlite 注册为后端 sqlite。一个数据库文件以每行一文档(key TEXT PRIMARY KEY, value TEXT … STRICT)方式承载每个被路由的单元,每个单元表对应一个物理 u_<unit>_<table> 表。Config = { path, journalMode? },journalMode 为 'wal'(默认)| 'delete' | 'truncate' | 'persist'——回滚日志模式是为 WAL 重内存文件失败的文件系统准备的。物理布局通过 PRAGMA user_version 钉在 STORAGE_SQLITE_SCHEMA_VERSION = 1;盖着过期戳的 DB 会被拒绝而非迁移(这个预发布格式没有迁移)。新文件以属主独占权限(0o600)创建,且支持默认为 :memory: 用于测试。
何时用哪个:想要每个单元一个操作者/读者可见、可 diff 的单文件时用 JSON(workspace、投影)。想要单一 DB、按行流式或按 seq 定位 readFrom 时用 SQLite。域路由表决定;两个后端实现同一个协商契约,因此一个域只需编辑 routes 就能换介质,spec 不动。
会话持久化(事件溯源)
会话日志不是 KV 记录——它是只追加的事件日志。ctx.sessionPersistence(packages/session/session-persistence/src/index.ts)是一个服务定义,契约如下:
create(meta)——注册一个SessionHeader(可把物理写入推迟到首次append,因此被遗弃的会话不留下任何痕迹);append(id, events)——连续批次,其首个seq必须等于已存的下一个 seq;拒绝非 JSON 可序列化数据;load(id)——返回平衡的日志,以turn/end结束,用合成的错误 closers 关闭被中断的最终 turn,只丢弃撕裂的最终记录;list()/listSnapshots()——不做完整解析的廉价 header/rev 列表;readFrom(id, fromSeq)——读模型的水位原语(SQLite 定位;JSONL 向前解析);prepare(id)——为 resume 重新水合未发布的SessionPreparation。
格式钉在 SESSION_FORMAT_VERSION = 0(预发布,不承诺兼容性)。每个事件都符合持久化目录中的信封:
export type SessionEvent<T extends SessionEventType = SessionEventType> = {
type: K; seq: number; time: number
data: SessionEventMap[K]
ignorable?: true
} & (K extends SurfaceEventType ? { sourceEventSeqs?: number[]; surfaceOp?: SurfaceOp } : {})只有 user/message、assistant/message、tool/result 是 SurfaceEventType——它们可以携带 surfaceOp('append' 或 { op:'replace'; start; end })并引用其来源 seq。读者遇到不带 ignorable 的未知类型必须拒绝,而不是静默丢弃该事件。
JSONL 后端
packages/session/session-persistence-jsonl 为每个会话写一个工件。路径布局(src/format.ts):
<root>/<projectKey(cwd) or _no-cwd>/<sessionId>/session(.jsonl | .jsonl.zstd)首条记录是不可变 header,标记为 { type: 'session', version, id, createdAt, cwd?, parentSession?, seedLength?, origin?, delegationDepth, agentPreset? };其后每行是一条 JSON-lines 事件。JsonlCompression = 'zstd' | 'none' 选择 .jsonl.zstd(经 zstd-*.ts 解码器打包的 Zstandard 帧)或明文。packChunks 把 delta-chunk 运行折叠成 text-chunks 存储行。
SQLite 后端
packages/session/session-persistence-sqlite 在单个 SQLite 文件中镜像同一契约(打包行,可按 seq 定位以支持 readFrom)。跨会话的 SQLite 查询/交叉引用索引在 packages/session-query/session-query-sqlite(即"会话查询与日志导出"界面);它与 storage-sqlite 共享打开/配置序列。
投影
ctx 可挂载的读模型从持久化日志派生,并折叠进共享的 SessionProjectionMap(packages/session/session-projection/src/types.ts)——一个可合并扩展的 interface … {},域包通过声明合并声明自己的 key。值是线上 JSON 的整体值;如何渲染是 UI 槽系统的职责。
session-stats(packages/session/session-stats)贡献sessionStatskey:{ turns, steps, llmMs, toolMs, ttftMs, ttftSteps, decodeMs, decodeTokens }——与客户端已分页多少历史无关的整份日志计数,字段名镜像客户端窗口折叠。session-projection-cache声明session_projcache域(默认storage-json,因此落在<root>/session_projcache.json,与workspace.json相邻)。每行是一个完整投影检查点{ key → { ver, seq, val } };ver与活跃单元状态版本不匹配的行在读时被丢弃,绑定身份(createdAt、cwd、…)把一行围栏到一次会话生命周期,因此复用的 id 或被替换的持久化根无法给无关日志播种。随附 shell 配置:writeEveryEvents: 200、writeIntervalMs: 5000。
检查点
packages/session/session-checkpoint-policy 在模型请求、顶层工具派发与已完成的 agent step 上安装语义持久性屏障。它包裹 llm/stream 链,使模型请求被延迟到"完整已记录请求前缀已持久"(在产出首个 chunk 前 ctx.sessions.flush(session));检查点被拒则阻止适配器派发。它在模型与工具副作用边界失败即关闭——下游适配器或工具体只在屏障之后被调用。
一切在磁盘上的位置
packages/util/home-paths 定义单一根的 Harness home:默认 ~/.dsh,可用 DSH_HOME 覆盖,由 resolveDshHome() 解析,作为 ctx.dshHomePath(...) 暴露给 Loader 的 !!js 表达式。本修订下的典型布局:
| home 下的路径 | 内容 |
|---|---|
$DSH_HOME/storages/ | JSON 存储域单元文件(如 workspace.json、session_projcache.json),经 storage-json root: dshHomePath('storages') |
$DSH_HOME/attachments/v1/ | 内容寻址的本地附件对象(见"身份、附件与反馈"页) |
$DSH_HOME/.anonymous-user-id | 每 home 的匿名用户 id |
$DSH_HOME/profiles/<name>/ | profile 目录:package.json、cordis.patch.yml、pnpm-workspace.yaml |
$DSH_HOME/profiles/node_modules/ | 为树外插件维护的扁平符号链接回退 |
延伸阅读
- 身份、附件与反馈——持久化 sidecar 域(
message_feedback)与内容寻址附件。 - 宿主平台——在
dshHomePath('storages')挂载storage-json的宿主组合。 - 会话查询与日志导出——构建在共享持久化之上的 SQLite 跨会话查询索引。
- 架构一览——存储枢纽背后的能力接缝哲学。
- 源码:
packages/storage/storage/src/{index,registry,backend}.ts、packages/storage/storage-domain/src/{index,domain}.ts、packages/storage/storage-json/src/{index,unit,format}.ts、packages/storage/storage-sqlite/src/{index,schema}.ts。 - 日志与投影:
packages/session/session-persistence/src/index.ts、packages/session/session-persistence-jsonl/src/format.ts、packages/session/session-projection-cache/src/spec.ts、docs/persistence-catalog.md。