Skip to content

DeepSeek Harness 中的存储是分层的:后端拥有一种介质,域层加上schema 与变更事件,而会话持久化是独立的、事件溯源式的日志。枢纽本身不做任何 I/O。官方设计说明在 docs/persistence-catalog.md(生成的事件目录)与 docs/subsystems/persistence.md

角色注册为 / ctx key
packages/storage/storageKV 枢纽 + 命名后端注册表ctx.storage
packages/storage/storage-domainschema 校验、发出变更事件的 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-sqliteSQLite 支撑的会话日志实现它
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-sqliteSQLite 查询/交叉引用索引插件
packages/util/home-paths所有用户数据的存放位置ctx.dshHomePath

KV 枢纽

ctx.storagepackages/storage/storage/src/index.ts)是一个持有 BackendRegistry 与已挂载形态表Service。后端是暴露可选facets(目前为 kv)的介质属主:

ts
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()putRecorddeleteRecordsetGlobal。值在这一层是不透明 JSON——没有 schema、没有事件。写顺序明确是调用方的职责;单元只保证每个单次调用在 resolve 后原子且持久

枢纽在 StorageForms key(声明合并)下挂载数据形态。storage-domain 合并 domain,因此 ctx.storage.domain 能解析它。

域层

packages/storage/storage-domain 是类型化 KV 域(ctx.storageDomain)的唯一实现,也是应用代码实际接触的东西。其 Config 决定路由:backend 是默认,routes 按域名覆盖——命名了未注册后端的路由在打开时响亮失败

ts
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 的拆分:插件 Configschemastery,spec 内的记录 schema 用 zodsrc/spec.ts 记录了这一理由)。持久化 spec 示例:message_feedbackpackages/feedback/message-feedback/src/spec.ts)与 session_projcachepackages/session/session-projection-cache/src/spec.ts)。

后端:JSON vs SQLite

storage-json

packages/storage/storage-json 注册为后端 json。在配置的 root 下每个单元一个人类可读文件——可读性正是它存在的理由。每次写都原子地重发整个文件(src/atomic.ts):

json
{
  "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.sessionPersistencepackages/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(预发布,不承诺兼容性)。每个事件都符合持久化目录中的信封:

ts
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/messageassistant/messagetool/resultSurfaceEventType——它们可以携带 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 可挂载的读模型从持久化日志派生,并折叠进共享的 SessionProjectionMappackages/session/session-projection/src/types.ts)——一个可合并扩展的 interface … {},域包通过声明合并声明自己的 key。值是线上 JSON 的整体值;如何渲染是 UI 槽系统的职责。

  • session-statspackages/session/session-stats)贡献 sessionStats key:{ 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 与活跃单元状态版本不匹配的行在读时被丢弃,绑定身份(createdAtcwd、…)把一行围栏到一次会话生命周期,因此复用的 id 或被替换的持久化根无法给无关日志播种。随附 shell 配置:writeEveryEvents: 200writeIntervalMs: 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.jsonsession_projcache.json),经 storage-json root: dshHomePath('storages')
$DSH_HOME/attachments/v1/内容寻址的本地附件对象(见"身份、附件与反馈"页)
$DSH_HOME/.anonymous-user-id每 home 的匿名用户 id
$DSH_HOME/profiles/<name>/profile 目录:package.jsoncordis.patch.ymlpnpm-workspace.yaml
$DSH_HOME/profiles/node_modules/为树外插件维护的扁平符号链接回退

延伸阅读

  • 身份、附件与反馈——持久化 sidecar 域(message_feedback)与内容寻址附件。
  • 宿主平台——在 dshHomePath('storages') 挂载 storage-json 的宿主组合。
  • 会话查询与日志导出——构建在共享持久化之上的 SQLite 跨会话查询索引。
  • 架构一览——存储枢纽背后的能力接缝哲学。
  • 源码:packages/storage/storage/src/{index,registry,backend}.tspackages/storage/storage-domain/src/{index,domain}.tspackages/storage/storage-json/src/{index,unit,format}.tspackages/storage/storage-sqlite/src/{index,schema}.ts
  • 日志与投影:packages/session/session-persistence/src/index.tspackages/session/session-persistence-jsonl/src/format.tspackages/session/session-projection-cache/src/spec.tsdocs/persistence-catalog.md