第 06 章 SQLite 持久化契约与数据模型
本章是"第 2 部分 · 状态与持久化"的开篇。第 01 章把"状态版本化、升级受护"列为七项可检验属性中的第 5 条,源代码锚点落在
package.json的openclaw.schemaVersions。本章要把它拆开:OpenClaw 用哪些库装状态?版本号写在哪里?谁在什么时候检查 schema、完整性和索引?为什么事后不再重复检查? 以及最容易被忽略的一点 ------ 一个"per-agent 库"的设计到底解决了什么问题。本章以当前快照
2026.10.1的真实源码与一等文档为准:package.json、根AGENTS.md、docs/reference/database-schemas.md及其子页(layout.md/versioning.md/integrity-and-recovery.md/worker-access.md),以及src/state/**、src/infra/sqlite-*.ts的实现。凡涉及具体数值(schema 版本、超时、表名、列名),均以源码为准;不确定处以"以源码为准"标注。
6.1 两个数字,两本账
6.1.1 package.json 里的版本声明
仓库根 package.json 的顶部有一段专属 OpenClaw 元数据:
json
"openclaw": {
"updateAdmissionProtocol": 1,
"schemaVersions": {
"state": 20,
"agent": 24
}
}
这三个数字各有含义:
state: 20------ 共享状态库(shared state database)的 schema 版本。agent: 24------ per-agent 数据库的 schema 版本。它与state不是同一个库,这是本章的主线之一。updateAdmissionProtocol: 1------ "更新准入协议"也有版本号,"升级是否被允许"本身是被建模的一等公民(第 22 章展开)。
package.json 声明的是"对外发布契约"的版本。而在 TypeScript 侧,这两个数字有各自的唯一常量定义:
ts
// src/state/openclaw-state-db-contract.ts
export const OPENCLAW_STATE_SCHEMA_VERSION = 20;
// src/state/openclaw-agent-db-contract.ts
export const OPENCLAW_AGENT_SCHEMA_VERSION = 24;
src/state/openclaw-state-db-contract.ts 在第 23 行把版本号连同它的语义注释固定下来 ------ 注释本身是一份"为什么涨版本"的活文档:
ts
// v20 fences possibly delivered cron completions across restart recovery.
// v19 preserves original channel-owner authorization across recovery.
// v18 binds shared GitHub publication to its original requesting authority.
// v17 records one-use prepared worker capacity and node workspace ownership.
// ...
export const OPENCLAW_STATE_SCHEMA_VERSION = 20;
src/state/openclaw-agent-db-contract.ts 同样用注释列出一路演进:
ts
// v24 separates keyed cold session snapshots from hot entry facts without rewriting transcripts.
// v23 compacts payloads and replaces deployed v22 lazy FTS ownership without rewriting FTS content.
// v22 introduced exact FTS row ownership with nullable completeness and lazy repair.
// ...
export const OPENCLAW_AGENT_SCHEMA_VERSION = 24;
export const AGENT_STORAGE_SCHEMA_VERSION = 23;
export const TRANSCRIPT_FTS_ROW_SCHEMA_VERSION = 22;
export const AGENT_MEDIA_SCHEMA_VERSION = 17;
export const CANONICAL_SESSION_VALIDATION_SCHEMA_VERSION = 21;
注意末尾那四个常量:它们不是"另一个库的版本",而是"同一本 agent 库内,某个子契约从第几版开始成立"的水位线 。比如 CANONICAL_SESSION_VALIDATION_SCHEMA_VERSION = 21 表示"canonical-session 校验(pending 表 + 失效触发器)从 schema 21 起是必需的"。后面的 chapter 会看到,agent 库的迁移代码大量以这些水位线做版本闸门。
6.1.2 为什么版本号要写两遍,还要 CI 守住
同一件事有两处声明,就会漂移。OpenClaw 的应对是一个 CI 脚本 ------ scripts/check-native-state-schema-version.mjs:
js
const SWIFT_CONTRACT_PATH =
"apps/shared/OpenClawKit/Sources/OpenClawNativeState/OpenClawNativeStateSQLite.swift";
const TYPESCRIPT_CONTRACT_PATH = "src/state/openclaw-state-db-contract.ts";
它分别从 Swift 契约(private static let maximumSupportedSchemaVersion: Int64 = <n>)与 TypeScript 契约(export const OPENCLAW_STATE_SCHEMA_VERSION = <n>;)里各抽取一次版本号,若不相等就直接让构建失败:
js
if (swiftVersion !== typescriptVersion) {
throw new Error(
`Native state schema version drift: Swift supports ${swiftVersion}, TypeScript owns ${typescriptVersion}`,
);
}
这解释了一个容易被忽视的事实:原生应用(macOS/iOS 的 OpenClawKit)也会直接读写共享状态库 。于是"schema 版本"不再是后端内部约定,而是一份跨语言契约,必须有单一事实源与漂移守卫。docs/reference/database-schemas.md 对此的表述是:
CI runs
scripts/check-native-state-schema-version.mjs, which fails the build when the Swift and TypeScript state-database contracts declare different schema versions.
6.1.3 三类库,不该混淆
在进入细节前,先把"库"这个词澄清。仓库里至少有三类持久化对象容易被混为一谈:
| 名称 | 路径(默认) | 角色 | 版本常量 |
|---|---|---|---|
| 共享状态库 | ~/.openclaw/state/openclaw.sqlite |
控制平面:配置状态、注册表、审批、插件状态、共享运行时状态 | OPENCLAW_STATE_SCHEMA_VERSION = 20 |
| per-agent 库 | ~/.openclaw/agents/<agentId>/agent/openclaw-agent.sqlite |
数据平面:会话、transcript、记忆索引、鉴权状态、agent 局部运行时状态 | OPENCLAW_AGENT_SCHEMA_VERSION = 24 |
| 隔离存储 | ~/.openclaw/state/openclaw-quarantine.sqlite |
完整性/隔离判决(在被隔离的库损坏时仍要存活) | 独立(以源码为准) |
来源:src/state/openclaw-state-db.paths.ts、src/state/openclaw-agent-db.paths.ts、docs/reference/database-schemas/layout.md。
这里要特别点出一个同名陷阱 :src/storage/** 目录里的 locations.ts、provider.ts、encryption.ts 不是 SQLite 状态库,而是"可插拔存储位置(storage locations)" ------ 面向用户产物的对象存储(文件系统、bucket、加密、marker)。它是一个独立的 owner ,与本章的 src/state/** 是两条不同的责任线。读代码时看到 src/storage 不要想当然认为它在管 openclaw.sqlite;SQLite 状态库的家在 src/state/** 与 src/infra/sqlite-*.ts。
6.2 存储目录与 state-directory 锁
6.2.1 路径解析:单点、可覆盖、Windows 感知
共享状态库的路径由 src/state/openclaw-state-db.paths.ts 统一解析:
ts
/** Resolve the directory that contains the shared state SQLite file. */
export function resolveOpenClawStateSqliteDir(env: NodeJS.ProcessEnv = process.env): string {
return path.join(resolveStateDir(env), "state");
}
/** Resolve the shared state SQLite file path. */
export function resolveOpenClawStateSqlitePath(env: NodeJS.ProcessEnv = process.env): string {
return path.join(resolveStateDir(env), "state", "openclaw.sqlite");
}
调用方可以在 OpenClawStateDatabaseOptions 里传 env 或显式 path 覆盖,resolveDatabasePath() 负责 path.resolve 归一化:
ts
export function resolveDatabasePath(options: OpenClawStateDatabaseOptions = {}): string {
return path.resolve(options.path ?? resolveOpenClawStateSqlitePath(options.env ?? process.env));
}
per-agent 库的路径则由 src/state/openclaw-agent-db.paths.ts 解析,并按 agentId + stateDir 做有界 memo:
ts
const resolved = path.resolve(stateDir, "agents", agentId, "agent", "openclaw-agent.sqlite");
注意 agentSqlitePaths 是一个上限 256 条、FIFO 淘汰的缓存,注释写得很清楚 ------ "只有解析结果被 memo;root 变了就落到新条目" ("Only its resolved output is memoized; a changed root selects a new entry.")。这类"缓存派生自 owner、并带显式失效生命周期"的写法,正是根 AGENTS.md "一处责任一个所有者"里对缓存的要求。
src/state/openclaw-agent-db.paths.ts 还承担一个安全职责:incognito(隐身)库路径的哨兵与防碰撞。incognito 会话不落盘,用一个保留文件名做"进程内内存库"的 sentinel:
ts
export const INCOGNITO_AGENT_SQLITE_BASENAME = "incognito-openclaw-agent.sqlite";
如果这个保留路径真的存在一个文件 ,就抛 IncognitoAgentDatabasePathCollisionError,拒绝把它误当普通库使用。这是"默认配置要产生一个可理解的结果,且避免静默失败"的典型落地。
6.2.2 state-directory 锁:谁拥有这份状态
"一个 Gateway = 一个信任域"(第 01 章)在存储层的对应物是:一份 state 目录同一时刻只能有一个 owner 在写 。这由 src/state/openclaw-state-lease.ts 的 host-owned 租约实现,文件头一行注释点题:
ts
// Host-owned SQLite leases serialize trusted work across processes.
它提供两种入口:withOpenClawStateLease(native)与 withOpenClawStateLeaseAsync(worker 内)。租约的持久化、续约、所有权校验都收敛在 openclaw-state-lease-storage.ts(prepareLeaseDatabase / renewOpenClawStateLease / verifyOpenClawStateLeaseOwnership / releaseOpenClawStateLease)。
这里有一个关键设计点:"发布"与"拥有"是两件事 。openclaw-state-db.ts 的 reconcileOpenClawStateSchemaPublication() 注释直说了:
Publication holds the Gateway lifecycle fence: the owning Gateway can publish, and a later writable open can publish when no Gateway owns the state directory.
也就是说,schema 版本号的"正式发布"必须由 state 目录的当前 owner 来做;其它进程只能"看着不动"。这条规则会在 6.4 节的"延迟发布"里再次出现。
docs/reference/database-schemas/integrity-and-recovery.md 还给出了并发冲突的用户可见语义:state-lifecycle 通常在启动/一次写/维护结束后自行释放,而 gateway-lifecycle 与 state-handles 会在 Gateway 存活期间一直被持有。因此"state database is busy"往往不是 bug,而是owner 仍在线的证据。
6.3 三个角色:admission、migration owner、runtime
本章最重要的一句话,出自 docs/reference/database-schemas.md:
Schema-version, integrity, canonical-index, and table-existence checks belong to open/admission and the migration owner after migrations; runtime paths must carry admitted schema facts with the handle, never re-query them , and use fresh
PRAGMA data_versionprobes to observe foreign commits on the next unpinned read while preserving active SQLite snapshots.
拆成三个角色:
- admission(准入) ------ 在打开数据库、允许运行时使用之前,一次性完成版本、完整性、canonical 索引、表存在性检查;通过后把"已准入事实"(admitted facts)绑在 handle 上。
- migration owner(迁移所有者) ------ 当发现旧版本时,在一个同步事务内做前向迁移(forward-only),迁移后再做一次权威校验,最后写入新的版本元数据。
- runtime(运行时) ------ 只消费 handle 上携带的已准入事实,不再重复查询 ;用
PRAGMA data_version探针观察外部提交,只在必要时失效缓存。
下面这张图勾勒了三者与检查时机的对应关系:
6.3.1 admission:integrity_check / quick_check / foreign_key_check
完整性校验的实现集中在 src/infra/sqlite-integrity.ts。核心是 assertSqliteIntegrity():
ts
export function assertSqliteIntegrity(
database: DatabaseSync,
databaseLabel: string,
check: SqliteCheckPragma = "integrity_check",
tables?: SqliteIntegrityTableCheck[],
timing?: SqliteIntegrityCheckTiming,
): SqliteIntegrityChecks {
if (tables) {
// 逐表:PRAGMA integrity_check('table') 或 quick_check('table') + 外键检查
for (const entry of tables) {
runSqliteCheck(database, databaseLabel, entry.check, entry.table);
runSqliteForeignKeyCheck(database, databaseLabel, entry.table);
}
return { integrityCheck: "ok" };
}
const integrityCheck = runSqliteCheck(database, databaseLabel, check);
runSqliteForeignKeyCheck(database, databaseLabel);
return { integrityCheck };
}
两个细节值得记住:
PRAGMA必须用直接语法,而不是表值函数。 代码注释解释了原因:一个真实的 schema 对象可能"遮蔽(shadow)"表值 pragma 的名字,让损坏的库看起来干净 ------ 所以这里坚持database.prepare('PRAGMA foreign_key_check...'),并用setReadBigInts(true)读取。- 外键检查有一个"可修复的定向拒绝" 。
runSqliteForeignKeyCheck()把task_delivery_state.task_id → task_runs.task_id (CASCADE)这一种孤儿关系单独识别为SqliteRepairableForeignKeyError:
ts
export class SqliteRepairableForeignKeyError extends Error {
readonly repair: Readonly<{
kind: "task-delivery-orphans";
relation: "task_delivery_state.task_id";
parentTable: "task_runs";
orphanCount: number;
}>;
// ... "Run openclaw doctor --fix to migrate this legacy state before retrying."
}
注释强调这是从旧版本继承下来的精确 孤儿关系,只有更新准入或带围栏的 v19 迁移所有者才能接受这种"类型化拒绝",运行时不能把它当成"权限"。这体现了"分类(classification)与授权(permission)分离"的设计纪律。
docs/reference/database-schemas/integrity-and-recovery.md 进一步给出了 agent 库准入的检查策略表:
| 时机 | 检查 |
|---|---|
| 每次 open | 校验 schema_meta 表与 primary 元数据行 |
| 可写 agent open 与 Gateway readiness | 在既无当前运行时证明、也无干净同版本重启回执时,跑逐表完整性 + 外键检查 |
| 同进程 agent 重开 | 复用当前文件绑定的运行时证明,不再做完整性/quick 检查;只复查 owner、版本、schema、canonical 索引 |
| 干净同版本 agent 重启 | 复查 owner/版本/schema/索引;在 Gateway 监听后排队一次子进程 quick_check + 外键检查 |
| 迁移前 | 全量 integrity、外键、role、schema、索引扫描 |
| Doctor、备份校验、compaction | 在接受或重写数据库前跑全量扫描 |
其中"当前 schema 的可写 agent 准入"这一段源码级描述是:
For current-schema writable agent admission, the gate runs
quick_checkontranscript_eventsand per-tableintegrity_checkon every other discovered table, including shadow tables andsqlite_schema.
也就是说 transcript_events 用更轻的 quick_check,其余表用更重的逐表 integrity_check ------ 因为 transcript 是最大的表,全量 integrity_check 代价高;quick_check 检查结构但不做索引条目交叉校验。这是一种明确的、以成本换覆盖率的取舍(后文"不变量与取舍"会回收它)。
此外还有一个"定向确认"函数 confirmSqliteFileIntegrity():它最多重试 3 次,每次读取文件代际(device/inode/generation),确保确认过程中的文件没有被替换(A→B→A 轮换会让结论无效),才给出终局判决。它把"文件身份稳定"作为完整性结论成立的前置条件。
6.3.2 为什么 admission 只检查一次:三层缓存
"只检查一次"不是一句口号,而是三层机制叠加的结果。
第一层:schema cookie 失效缓存(共享状态库)
src/state/openclaw-state-db-existing-schema.ts 用一个 WeakMap<DatabaseSync, { cookie }> 记住"这个连接校验过的 schema cookie",只有当 cookie 变化时才重新跑校验:
ts
const validatedSchemas = new WeakMap<DatabaseSync, { cookie: number; unregister: () => void }>();
export function assertExistingOpenClawStateRuntimeSchema(database, pathname): void {
const schemaCookie = runSqliteDeferredTransactionSync(database, () => {
assertExistingOpenClawStateRuntimeMetadata(database, pathname);
const currentCookie = readSqliteSchemaCookie(database);
const cached = validatedSchemas.get(database);
if (cached?.cookie !== currentCookie) {
cached?.unregister();
validatedSchemas.delete(database);
assertSqliteIntegrity(database, pathname); // 只有 cookie 变了才跑完整性
const readTable = createSqliteTableContractReader(database);
assertCurrentStateRuntimeSchema(database, pathname, readTable);
assertNoLegacyStateRuntimeRepair(database, pathname);
}
return currentCookie;
});
// ...
}
关键是 readSqliteSchemaCookie(PRAGMA schema_version):DDL 会推高 cookie,纯数据写入不会。因此"每次读都重查 schema"被替换为"cookie 未变就跳过"。
第二层:admitted facts + PRAGMA data_version 探针
src/infra/sqlite-schema-facts.ts 把"已准入事实"实体化为 SqliteSchemaFacts:
ts
export type SqliteSchemaFacts = {
readonly revision: number;
readonly userVersion: number;
readonly schemaVersion: number;
readonly tables: ReadonlySet<string>;
readonly tableSql: ReadonlyMap<string, string | null>;
};
只有显式 admitSqliteSchema(database) 的连接才会携带这些事实("Only database admission opts a connection into retained schema facts.")。运行时读路径通过 getAdmittedSqliteSchemaFacts() 取用;它内部调用 readSqliteCacheDataVersion() 做新鲜度探针:
ts
export function readSqliteCacheDataVersion(database: DatabaseSync): number {
// ...
const dataVersion = readSqliteDataVersion(database); // PRAGMA data_version:总是新鲜执行
if (owner) {
if (owner.dataVersion !== dataVersion) {
const facts = owner.facts;
// Data commits preserve schema-derived caches; compare both markers in one snapshot.
const unchanged =
facts &&
runSqlitePinnedReadSnapshotSync(database, (schemaVersion) => {
const userVersion = executeWithCachedStatement(database, "PRAGMA user_version", [], (s) => s.get());
return facts.schemaVersion === schemaVersion && facts.userVersion === userVersion?.user_version;
});
if (!unchanged) {
invalidate(owner);
}
owner.dataVersion = dataVersion;
}
}
return dataVersion;
}
这就是权威文档那句"use fresh PRAGMA data_version probes to observe foreign commits "的源码落点:外部进程提交数据后,本连接在下一次未固定的读上会观察到 data_version 变化;OpenClaw 在一个 pinned snapshot 内 同时比较 schema_version 与 user_version ------ 若两者都没变,就保留已准入事实(纯数据提交不触发表/列扫描),只有版本变化才失效。这正是"数据提交便宜、schema 变化才昂贵"的成本模型。
第三层:scope 级发布(跨连接)
sqlite-schema-facts.ts 还用一个以物理路径身份为 key 的全局 scopes,让同一物理库的多个连接在某个连接做了 DDL 后同步失效:
ts
const scopes = resolveGlobalSingleton(Symbol.for("openclaw.sqliteSchemaScopes"), () => {
const byIdentity = new Map<string, SchemaScope>();
// ...
});
publishSchemaChange() 会推进 scope.revision,任何持有旧 scopeRevision 的连接在下一次 getAdmittedSqliteSchemaFacts() 时会被 invalidate(owner)。此外 trackSchemaChanges() 会把连接上的 exec / prepare 做插桩 ,用正则识别 CREATE/ALTER/DROP/REINDEX/VACUUM 与 PRAGMA user_version/schema_version/...(changesSchema()),在事务边界上正确发布或失效 ------ 注释说明这是"保守匹配",宁可误报(多失效一次)也不漏报。
为什么必须"只查一次"? 因为完整性与 schema 扫描是全表级 I/O 。docs 用 baseline 数字量化了代价:一个 267.5 MiB 的库在无 sidecar 时给到 635 秒前台预算;一个 2 GiB 的库给到 2,860 秒。把这些扫描放进每次读,等于让热路径背上整库 I/O。所以设计把成本前移到 admission 一次 ,之后靠 O(1) 的 data_version 探针守护正确性。这就是权威文档那句"Existing per-call checks are legacy and must be migrated when touched"的分量所在。
6.3.3 migration owner:一个同步事务、前向-only
迁移的入口在 src/state/openclaw-state-db-schema-runtime.ts 的 ensureOpenClawStateRuntimeSchema()。它的结构非常清楚:
ts
export function ensureOpenClawStateRuntimeSchema(db, pathname, env, initialization, busyTimeoutMs, initializeNativeOnly) {
if (isExistingOpenClawStateSchema(pathname, db)) {
assertExistingOpenClawStateRuntimeSchema(db, pathname); // 既有 schema:只做准入
assertOpenClawStateWriteAllowed({ database: db, databasePath: pathname, env });
return [];
}
try {
if (isOpenClawStateSchemaFastPathEligible(db, pathname)) { // 已经是当前版本:快路径
assertOpenClawStateWriteAllowed({ database: db, databasePath: pathname, env });
return [];
}
} catch (error) { /* 保留诊断 */ }
return withStateDatabaseSchemaMaintenance({ databasePath: pathname, busyTimeoutMs }, () => {
const now = Date.now();
const applied = runStateSchemaMigrationTransaction(db, pathname, () => {
// 1. 权限 + 版本断言
assertOpenClawStateWriteAllowed(...);
assertSupportedStateSchemaVersion(db, pathname);
// 2. 已是当前版本:只做 canonical 索引修复 + 加列收敛
if (previousVersion === OPENCLAW_STATE_SCHEMA_VERSION) {
assertNoLegacyStateRuntimeRepair(db, pathname);
const indexes = verifyAndRepairCanonicalSqliteIndexes(db, pathname, OPENCLAW_STATE_SCHEMA_SQL, {...});
ensureAdditiveStateColumns(db, "runtime");
assertCurrentStateRuntimeSchema(db, pathname);
writeCurrentStateSchemaMetadata(db, now);
// ...
}
// 3. 旧版本:内容变换 → 退休旧表 → 版本化迁移 → canonical DDL → STRICT 迁移 → 索引修复
openClawStateMigrationAssertions.get(previousVersion)?.(db, { pathname });
assertSqliteIntegrity(db, pathname);
dropLegacyStateTables(db);
const changes = retirements.runRetiredStateTableMigrations(db, previousVersion);
// ... migrateSingletonStateFoldInV12 / migrateWorkerPlacementExecutionModeSchema / 路径迁移 ...
ensureAdditiveStateColumns(db, "repair");
for (const migration of versionedStateMigrations) {
if (migration.migrate(db, previousVersion)) changes.push(migration.applied);
}
executeCanonicalStateSchema(db, {...});
// ... STRICT 迁移 ...
repairCanonicalSqliteIndexes(db, pathname, OPENCLAW_STATE_SCHEMA_SQL, { verifyPhysicalIntegrity: false });
writeCurrentStateSchemaMetadata(db, now);
assertOpenClawStateDatabaseForMaintenance(db, { pathname });
return changes;
}, { busyTimeoutMs, databaseLabel: pathname, operationLabel: "state.schema.ensure" });
// ...
});
}
从这段代码能提炼出迁移 owner 的几条硬约束:
- 一次性、原子 :所有迁移步骤在
runStateSchemaMigrationTransaction()的一个IMMEDIATE同步事务内完成;中途失败整体回滚。根AGENTS.md的规则 ------ "Write transactions are synchronous: plan async work first, reread authoritative rows, then write; no Promise orawaitin transaction callbacks" ------ 在这里被严格遵守。 - 前向-only :只把旧库升到当前版本;老 build 遇到比自己新的库会拒绝(见 6.4 节),而不是降级重写。
- 版本化迁移是可插拔的数组 :
versionedStateMigrations(定义在src/state/openclaw-state-db-maintenance.ts)是"版本闸门 + 内容变换"的列表,每一项自带migrate(db, previousVersion) => boolean(是否真的改了)与applied(人类可读的变更摘要):
ts
export const versionedStateMigrations: ReadonlyArray<{
migrate: (db: DatabaseSync, previousVersion: number) => boolean;
applied: string;
}> = [
{ migrate: migrateJsonCanonicalWideRowsV13, applied: "Consolidated shared state tables (v13)" },
{ migrate: migrateCronCreatorNamespaces, applied: "Qualified historical cron creator attribution as unknown (v14)" },
{ migrate: migrateConversationBindingTargets, applied: "Removed redundant conversation binding target projections (v15)" },
{ migrate: migrateSkillWorkshopDirectoryOwnership, applied: "Moved Skill Workshop ownership to per-agent directories (v16)" },
{ migrate: migratePreparedWorkerOwnership, applied: "Recorded prepared worker ownership and one-use lifecycle (v17)" },
{ migrate: migrateGitHubPublicationRequesterAuthority, applied: "Added original requester authority to GitHub publication receipts (v18)" },
{ migrate: migrateCronDeliveryAttemptState, applied: "Recorded cron completion delivery attempt uncertainty (v20)" },
];
- 迁移后的权威校验 :写完元数据后立刻
assertOpenClawStateDatabaseForMaintenance()复验,确保"迁移结果 = 声称的结果",而不是"迁移没抛错就算成功"。 - 迁移前有权限重检 :
assertOpenClawStateWriteAllowed()在进入事务前与事务内都被调用 ------ 对应根AGENTS.md"Privileged actions need current owner-held authority: revalidate after awaited work and right before side effects."
迁移与 Doctor 的分工 :运行时的 ensureOpenClawStateRuntimeSchema 只做"schema 收敛 "(converge schema)。历史上的行数据修复 (backfill、legacy payload 字段、退休表的导入)归 openclaw doctor --fix。src/state/openclaw-state-db.ts 里的 repairOpenClawStateDatabaseSchema() / repairOpenClawStateDatabaseIndexesForDoctor() / repairOpenClawStateDatabaseReadabilityForDoctor() 三个函数对应 Doctor 的 doctor / indexes / readability 三种 scope,全部包在 withStateDatabaseSchemaMaintenance() 里。业务代码里那句注释把边界说得很清楚:
ts
const stateDbLog = createSubsystemLogger("state/db");
/** Runtime converges schema; historical row repair belongs to explicit Doctor maintenance. */
openclaw-state-db-schema-runtime.ts 里甚至有一句注释直接说明自动准备与物理打开者的区别:"Automatic preparation enters without the physical opener's integrity preflight." 也就是说,自动准备路径(例如 Gateway 启动后的自动迁移)不带 物理打开者的完整性预检 ------ 完整性预检是 assertSqliteIntegrity(db, pathname) 在迁移事务内单独做的,两者不重复。
6.3.4 runtime:消费已准入事实
运行时(agent loop、渠道、Control UI 后端等)拿到的 handle 已经携带 admitted facts。openclaw-state-db.ts 的缓存复用分支体现了这一点:
ts
const cached = options.database ?? stateDbCache.getCachedOpenClawStateDatabase(pathname);
if (cached && (options.database || cached.db.isOpen)) {
// ...
assertStateDatabaseSchemaAdmission(cached); // 只对"既有 schema"做一次重新准入断言
assertOpenClawStateWriteAllowed({ database: cached.db, databasePath: pathname, env, schemaReady: ... });
observeOpenClawDatabaseMaintenanceResource(cached.db);
return cached; // 直接复用,不重新扫描
}
assertStateDatabaseSchemaAdmission() 的注释也点明它只服务"existing shared-state schema"(由 Swift 原生写入的库),走的是前面那套 cookie 缓存:
ts
function assertStateDatabaseSchemaAdmission(database: OpenClawStateDatabase): void {
if (isExistingOpenClawStateSchema(database.path, database.db)) {
// 校验 physical database 身份,并断言 existing runtime schema
assertExistingOpenClawStateRuntimeSchema(database.db, database.path);
}
}
docs/reference/database-schemas/worker-access.md 把运行时的职责表述为一句提纲:
The Gateway main thread owns live projections, caches, and caller authority; it awaits prepared facts and installs committed results.
6.4 版本契约:user_version + schema_meta + 内容版本
6.4.1 版本记录在两处
docs/reference/database-schemas/versioning.md 开宗明义:
Each database records its published schema in two places:
PRAGMA user_versionis the SQLite schema version.- The primary
schema_metarow recordsrole,agent_id,schema_version, andapp_version.
schema_meta 的建表语句在 src/state/openclaw-state-schema.sql 中:
sql
CREATE TABLE IF NOT EXISTS schema_meta (
meta_key TEXT NOT NULL PRIMARY KEY,
role TEXT NOT NULL,
schema_version INTEGER NOT NULL,
agent_id TEXT,
app_version TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
) STRICT;
写元数据的函数是 src/state/openclaw-state-db-maintenance.ts 的 writeCurrentStateSchemaMetadata():先 PRAGMA user_version = <n>,再用 Kysely 对 schema_meta 做 ON CONFLICT(meta_key) DO UPDATE。其中有一个非常讲究的 where 子句:
ts
.onConflict((conflict) =>
conflict.column("meta_key").doUpdateSet({
role: "global", schema_version: schemaVersion, agent_id: null, app_version: VERSION, updated_at: now,
})
// updated_at tracks schema metadata changes; unconditional bumps dirty every
// open and defeat no-change backup detection.
.where((eb) =>
eb.or([
eb("schema_meta.schema_version", "!=", schemaVersion),
eb("schema_meta.app_version", "is not", VERSION),
eb("schema_meta.role", "!=", "global"),
]),
),
)
为什么要条件更新?因为无条件推高 updated_at 会让"每次 open 都脏",从而破坏"无变化即跳过备份"的探测 。这是一条从备份/更新系统倒推回来的契约 ------ schema 元数据只在真正变化时才更新。
role 的取值还有硬约束。assertOpenClawStateDatabaseOwner() 明确要求共享状态库的 role 必须是 global:
ts
if (metadata?.role !== "global") {
const role = typeof metadata?.role === "string" ? metadata.role : "missing";
throw new SqliteSchemaMismatchError(
`OpenClaw state database ${options.pathname} has schema role ${role}; expected global. Run openclaw doctor --fix to inspect and repair its ownership.`,
);
}
per-agent 库则要求 role 与 agent_id 匹配对应 agent(见 src/state/openclaw-agent-db-schema-helpers.ts 的 assertOpenClawAgentCurrentRuntimeSchema())。"共享库只能由 global 拥有、agent 库只能由对应 agent 拥有",是把"一处责任一个所有者"写进磁盘的两条断言。
6.4.2 新版本拒绝:状态 78,且不再重启
如果运行中的 build 比磁盘上的库旧,必须拒绝启动。src/infra/sqlite-user-version.ts 的 createNewerSqliteSchemaVersionError() 生成一条给操作者看的错误,并且同时报出"拒绝它的 build 身份 + 安装根":
ts
return new SqliteSchemaVersionError(
"This OpenClaw build cannot open your existing data.\n" +
`${databaseLabel} ${pathname} uses newer schema version ${schemaVersion}; this build supports ${supportedVersion}.\n` +
`Refused by ${describeRunningOpenClawBuild()}.\n` +
`Use a build that supports schema ${schemaVersion} or newer with this state directory. To use an older build, restore your pre-update backup created with openclaw backup create.\n` +
`See ${OPENCLAW_DATABASE_SCHEMA_DOCS_URL}.`,
);
docs 补充了进程级行为:Gateway 启动遇到更新的库会 以状态码 78 退出 (避免 systemd 反复重启),macOS 上还会驻停 LaunchAgent 以停止 KeepAlive 重试。并且明确要求"Action on the install root, not the version " ------ 因为一个版本字符串横跨多个 commit 与 schema 形态,两个都自称 2026.7.2 的安装可能对同一个库判断不同。
6.4.3 内容版本与"延迟发布"
这里有个非常巧妙的机制,用于跨越"更新过程中新旧 updater 并存"的窗口。src/state/openclaw-state-db-schema-version.ts 定义了内容版本键:
ts
// Read-only clients need schema admission without loading updater publication policy.
export const CONTENT_VERSION_KEY = "state.schema.contentVersion";
以及读内容版本的函数 ------ 注意它取的是 max(published, contentVersion):
ts
/** Content and its marker commit together, even while older readers retain their version floor. */
export function readStateSchemaContentVersion(db: DatabaseSync): number {
const schema = getAdmittedSqliteSchemaFacts(db);
return readContentVersion(db, schema?.userVersion ?? readSqliteUserVersion(db));
}
// readContentVersion 返回 Math.max(published, contentVersion)
背后的思路(权威文档 versioning.md 的 "Schema bumps and older updaters" 一节):2026.9.2 引入了更新账本(update ledger),但会在跑完目标版本的 Doctor 后用旧代码重新打开 数据库。为了让这个 updater 能跑完,共享状态库的 runner 采用"先应用迁移内容,后发布版本号":
- runner 把"已应用的内容版本"记在
config_machine_state的state.schema.contentVersion键上。 - 发布被延迟期间,
PRAGMA user_version与schema_meta.schema_version仍保持旧值。 - 新代码用内容版本工作;旧 updater 读到旧版本号也不会拒绝。
- 发布条件与 2026.9.2 那批 run 行的状态挂钩(终态行
finished_at_ms至少 5 分钟;运行中行updated_at_ms超过 30 分钟视为遗弃)。发布必须在一个同步写事务内重读内容 marker 与所有受影响行,再推进两个已发布版本号。
这套机制完美示范了"延迟发布"如何在同一份磁盘上同时满足"新代码看到新内容"与"旧代码不被新版本号拒绝"。它也是根 AGENTS.md 那句"Updates always work"在存储层的落地之一。
6.5 数据模型:共享库装什么,agent 库装什么
6.5.1 共享状态库(state/openclaw.sqlite)
docs/reference/database-schemas/layout.md 给出的定位是:
Shared configuration state, registries, approvals, plugin state, and shared runtime state.
src/state/openclaw-state-schema.sql 是它的canonical schema (约 600+ 行的 SQL),通过 src/state/openclaw-state-schema.ts 读取:
ts
export const OPENCLAW_STATE_SCHEMA_SQL = process
.getBuiltinModule("node:fs")
.readFileSync(fileURLToPath(new URL("./openclaw-state-schema.sql", import.meta.url)), "utf8");
注释特别说明:这是用原生 builtin 读文件 ,以免无关的 node:fs mock 替换这个源输入;同时"生产构建会替换这个模块,使打包后的数据库打开不再需要资源文件"。
共享库中可辨认的表族(均以源码为准):
- 元数据 :
schema_meta、config_machine_state(键值型单例状态,含state.schema.contentVersion)、config_revision_keys。 - 设备/配对 :
device_pairing_pending(列含request_id、public_key、roles_json、scopes_json、silent、is_repair...)、device_pair_setup_completions、device_pairing_join_codes。 - cron / 任务历史 :
cron_jobs、cron_job_scratch、cron_run_receipts、cron_job_runtime_authorities、cron_run_trigger_state_retirements;以及退休但保留 的task_runs/task_delivery_state/flow_runs(Tasks/TaskFlow 运行时已移除,但行保留、不动)。 - 更新 :
update_runs(更新账本,见 6.5.3)。 - 会议 transcript :
meeting_transcript_sessions/meeting_transcript_utterances/meeting_transcript_summaries(三张STRICT表)。 - 审批与权限 :
operator_approvals、operator_approval_standing_grants、operator_approval_standing_grant_generations、operator_approval_execution_identities。 - 插件状态 :
plugin_state_entries(键值型插件存储 + 过期 TTL)。 - 技能库 :
skill_library_entries/_revisions/_events/_uploads。 - app 发布 / 仓库工作区 :
github_publication_requests、github_repository_publication_requests、session_repository_workspaces、github_personal_publication_requests。 - worker / 环境 :
worker_environments、worker_session_placements、sandbox_registry_entries、worker_environment_session_attachments。 - 个人数据 :
user_profiles、user_profile_identities、user_preferences、user_model_accounts、web_push_subscriptions、web_push_approval_deliveries。 - 会话绑定 :
current_conversation_bindings、session_watch_cursors、subagent_runs。
6.5.2 "首用(first-use)"与"惰性可加(lazy additive)"表
非常值得注意的是 src/state/openclaw-state-db-contract.ts 中两组清单:
ts
// Privacy-sensitive feature tables remain absent even in fresh databases until
// their feature-local first write. The canonical SQL still owns their shape.
export const FIRST_USE_STATE_TABLES = [
"user_profile_identities", "local_workspace_projections", "update_runs",
"session_repository_workspaces", "github_repository_publication_requests",
// ... skill_library_* / github_personal_publication_requests
"cron_job_runtime_authorities", "cron_run_trigger_state_retirements",
"execution_identity_contexts", "mcp_oauth_pending_authorizations",
"node_worker_launch_containers", "node_worker_launch_cleanup",
"node_worker_launch_process_scopes", "node_worker_launches",
"node_worker_prepared_workspaces", "node_worker_turns",
"operator_approval_execution_identities", "operator_approval_standing_grants",
"operator_approval_standing_grant_generations", "web_push_approval_deliveries",
"execution_decision_facts", "execution_owner_lifecycle_bindings",
"outbound_message_execution_bindings", "outbound_message_progress",
] as const;
export const LAZY_ADDITIVE_STATE_TABLES = [
...FIRST_USE_STATE_TABLES, "agent_provenance", "cron_run_receipts",
"config_revision_keys", "secret_store_entries", "projects", "worktree_templates",
"user_preferences", "device_pair_setup_completions", "github_publication_requests",
"device_pairing_join_codes", "skill_workshop_proposal_events",
"skill_workshop_collection_reviews", "skill_workshop_proposal_rollbacks",
"skill_workshop_proposals", "worker_environment_ssh_fallback_ports",
"worker_session_placement_moves",
] as const;
为什么要区分?因为新增表可以"不涨 schema 版本" 。权威文档 versioning.md 的规则是:
Changes may stay at the same schema version only when downgraded readers remain safe. New tables qualify because older builds ignore them.
也就是说,只要"老 build 忽略它就能安全运行",一张新表就可以在同版本下增量安装。隐私敏感表 (如 user_profile_identities、update_runs)被进一步要求"在首次真实写入前,即便全新库也不创建 " ------ 这是隐私最小面原则:不使用的功能不该在磁盘上留痕。它们的形态仍由 canonical SQL 拥有 (OPENCLAW_STATE_SCHEMA_SQL 里始终有定义),只是"不主动实例化"。
这套"表清单驱动"的设计在 getOpenClawStateRuntimeSchema() 里被用来生成"本次 open 允许创建的运行时 schema":
ts
export function getOpenClawStateRuntimeSchema(options: {
includeVersionLazyAdditiveTables: boolean;
includeAgentDeletionJournal?: boolean;
}): string {
let schema = OPENCLAW_STATE_SCHEMA_SQL;
const omittedTables = [
...(includeVersionLazyAdditiveTables ? FIRST_USE_STATE_TABLES : LAZY_ADDITIVE_STATE_TABLES),
...(includeAgentDeletionJournal ? [] : DOCTOR_OWNED_STATE_TABLES),
];
// 从 canonical SQL 里把这些表的 CREATE 块与相应索引剔除
// ...
}
DOCTOR_OWNED_STATE_TABLES = ["agent_deletion_journal"] 表示这张表的缺失代表历史丢失,只有 Doctor 才能在既有状态上重建它 ------ 因此它不在运行时主动创建的集合里。
6.5.3 一张表看懂"同版本增量":update_runs
update_runs 是共享状态库里最"厚"的表之一,也是"同版本增量演进"的范本(列含义见 layout.md 的 "Update run ledger"):
- 主键
run_id是 UUID; - 保留创建/更新/完成时间、trigger、phase、status、reason、origin、target、before/after 版本、steps、verification facts、repair attempts、known downtime;
- 每个 JSON 列有 16 KiB 硬上限,带确定性截断与脱敏;
origin_json内嵌可选origin.admission(候选准入结论)与origin.driver(host/pid/startIdentity);- 最多保留 8 个 driver 身份,只对"确证已死"的身份做修剪。
权威文档反复强调:这些增量"no column, table, or schema version changes"。update_runs 本身在共享 schema 15 是"additive"的 ------ canonical schema 声明它,首次使用时在同一个写事务里 ensure。
这张表是"存储的是有界诊断摘要,不是原始日志或凭据 "的示例,也回应了根 AGENTS.md 关于日志/凭据不外泄的要求。
6.5.4 per-agent 库(agent/openclaw-agent.sqlite)
per-agent 库的定位是"数据平面"(layout.md):
Sessions, transcripts, memory indexes, auth state, conversation state, and agent-scoped runtime state.
其 canonical schema 在 src/state/openclaw-agent-schema.sql(由 src/state/openclaw-agent-schema.ts 导出 OPENCLAW_AGENT_SCHEMA_SQL)。从 src/state/openclaw-agent-db-schema-helpers.ts 与权威文档可辨认的表族:
- 会话节点/窗口 :
session_nodes(session_key主键、current_session_id、entry_json、entry_valid、snapshot_revision...)、session_windows、session_key_contract。 - transcript :
transcript_events(schema 23 起:事件 JSON 存为event_json TEXT或event_zstd BLOB,带字节计数与有界导航元数据)、session_transcript_active_events、session_transcript_fts_rows(FTS rowid→session/message 映射)、session_transcript_cold_archives(冷归档位置)。 - 入口快照 :
session_entry_snapshots(schema 24 新增;按 session key + field 键控,装sessionDiffBaseline/skillsSnapshot/systemPromptReport)。 - 反应/参与者 :
session_reactions(key =session_key+session_id+message_id+emoji+identity_id)、session_participants。 - 记忆索引:memory chunk/cache(嵌入为 little-endian Float64 BLOB;FTS 维护用稳定整数 chunk 身份)。
- 缓存 :
cache_entries(scope + key 的键值缓存;也承载会话进度快照等派生数据)。 - 鉴权 :auth profile store(
auth_profile_store)。 - 看板/协作:board / session-sharing 表族(v15 起进入 canonical)。
layout.md 里两段"同版本语义"的说明值得引用:
- 会话反应 (
session_reactions):"The table is not secret storage."(它不是机密存储),且"Reactions never modify transcript payloads."(反应永不 修改 transcript 载荷)------ 反应是边数据(side data),与 canonical transcript 解耦。 - 冷 transcript 归档 (
session_transcript_cold_archives):行只记录冷归档的位置、代际、哈希、计数与大小 ,真正的载荷在不可变的压缩 JSONL 文件(~/.openclaw/agents/<agentId>/sessions/cold/<sha256>.jsonl.zst)里;这些文件是权威历史("These files contain authoritative history.")。这是"用文件承载命名用户产物、用 SQLite 承载可索引元数据"的边界示例。
6.5.5 schema 24 的"热事实/快照"分离:一个数据模型改进的样板
用 agent schema 24 的改动展示数据模型是如何演进的。权威文档 agent-schema-history.md 的 "Session hot facts and snapshots" 写道:
Agent schema 24 keeps exact hot session facts in
session_nodes.entry_jsonand movessessionDiffBaseline,skillsSnapshot, andsystemPromptReportintosession_entry_snapshots, keyed by session key and field.
它解决的问题是"元数据读取被大载荷拖累 ":过去读会话元数据可能要把体积很大的 snapshot 一起读出来。v24 把"热事实"(列表/驻留投影只需要的小字段)留在 session_nodes.entry_json,把"冷快照"分离到 session_entry_snapshots:
- 元数据读取不加载 这些载荷;只有"完整 entry 消费者"才会在同一条 SQL 语句快照内一起取回热事实与所需快照列。
- 写入侧:entry writer 只在同一个事务 里写变更过的快照值;快照表触发器 推进节点的
snapshot_revision(含裸 SQL 变更);回滚同时恢复数据与 revision。 - "Cached hot facts are immutable; internal borrowers share them while public mutable readers receive detached values."(缓存的热事实不可变:内部借用者共享,公开可变读者拿到分离副本。)
注意这里没有"新建一个大表就完事" ------ 它同时改了三件事:canonical schema 形态、写入所有权(同事务)、并发可见性(revision 比较 + 触发器)。这正是根 AGENTS.md 迁移准则"迁移所有受影响的内部/打包调用者,删除被取代的代码"的样本。
6.6 为什么是 per-agent 库
现在正面回答本章最核心的设计问题:为什么不是一个库装所有 agent,而是每个 agent 一个库?
6.6.1 隔离与故障域
docs/concepts/multi-agent.md 明确说会话存储与鉴权档案都在 <agentDir>/openclaw-agent.sqlite,并强调"Never share the main agent's agentDir with the delegate."(绝不要把主 agent 的 agentDir 共享给委派 agent)。多 agent 隔离有几个直接收益:
- 鉴权隔离:每个 agent 有自己的 auth profile store。委派 agent 的本地 profile 可以覆盖"共享只读基线",但"OAuth refresh token 不会被克隆进二级 agent 库"。把凭据按 agent 切分,是"凭据有所有者(Secrets have owners)"的直接体现(第 09、20 章)。
- 故障域 :一个 agent 库损坏/降级,不影响其它 agent。
docs/reference/database-schemas/integrity-and-recovery.md的 "Startup on multi-agent hosts" 描述得很细:启动时的 agent 库检查每次最多两个 ,超时只把该 agent 标为 degraded,"its sessions remain unavailable",而 Gateway 继续服务健康 agent。健康 agent 不会被拖垮。 - 可移植性 :
docs/concepts/agent-workspace.md指出,需要迁移某 agent 的会话时,"copy~/.openclaw/agents/<agentId>/agent/openclaw-agent.sqlite" 即可 ------ 一个 agent 的状态是一个自包含单位。
6.6.2 版本与迁移粒度
如果所有 agent 共用一个库,那么"升级"就是一次全局 schema 迁移:所有 agent 的会话、transcript、记忆索引必须同时迁移、同时失败或同时成功。per-agent 库把迁移碎化成每库一次:
- 每个 agent 库可以独立地"稍后迁移"(启动时按并发上限排队,
docs提到每次最多两个)。 - 一个库迁移失败不回滚其它库;Doctor 可以针对单个 agent 库做保留式修复(
openclaw-agent-db-*系列的 repair/backup 语义)。 - 这是因为 agent schema 与 state schema 是两条独立的版本线(24 vs 20),它们的迁移节奏本就不同。
6.6.3 共享库里只放"注册表"
per-agent 库需要被共享状态库登记 ,否则 Gateway 不知道有哪些库要检查。这就是 src/state/openclaw-agent-db-registry.ts 与 OpenClawRegisteredAgentDatabase 的职责:
ts
/** Shared-state registry row describing an agent database seen by this process. */
export type OpenClawRegisteredAgentDatabase = {
agentId: string;
path: string;
schemaVersion: number;
lastSeenAt: number;
sizeBytes: number | null;
};
共享库还持有 agent_database_leases(agent 库租约)与 agent_deletion_journal(删除日志,Doctor-owned)。于是"每个 agent 一个库 "与"一个共享注册表 "配合,形成清晰的数据平面 / 控制平面分工:
6.6.4 agent 库的打开是"准入 + 完整性"流水线
per-agent 的打开比共享库更复杂,因为它要处理并发、租约、删除清理与创建声明。src/state/openclaw-agent-db-admission.ts 用一个"生成器驱动 + 完整性检查在 worker 内执行"的模式:
ts
// 在 Worker 上运行,使其同连接的完整性检查位于父进程 writer 之外
async function assertSqliteIntegrityInWorker(pathname, OPENCLAW_SQLITE_BUSY_TIMEOUT_MS, pending.controller.signal, undefined, step.value.timing, step.value.tables) { ... }
它的关键不变量:完整性检查在独立只读子进程/worker 里跑 ,从而把全库扫描从 Gateway 主线程搬走(integrityChild)。检查失败会被"抛回生成器"以驱动索引修复,而不是简单地关闭句柄。
同一文件里还贯彻了"授权在被 await 之后、副作用之前重检":
ts
function assertAgentDatabaseOperationCurrent(database, options, pending, assertCurrent?): void {
pending.controller.signal.throwIfAborted();
assertAgentDatabaseAdmitted(database.agentId, { env: options.env });
if (cache.databases.get(pending.path) !== database || !database.db.isOpen) {
throw new Error(`Agent database closed before its admitted operation: ${pending.path}`);
}
// Coalesced callers keep their own scope; admission cannot lend its cleanup authority.
assertAgentDeletionDatabaseCleanupAccess(database, options);
assertAgentCreationClaimAccess(database, options);
assertCurrent?.();
}
注意注释那句"admission cannot lend its cleanup authority"(准入不能把自己的清理权限借给合并的调用者)------ 这防止"一个调用者的权限被另一个调用者挪用"。
6.7 worker 访问纪律与约束脚本
6.7.1 "数据库访问跑在 worker 线程"
根 AGENTS.md 的规定:
Database access runs in worker threads (read-only worker scope, SQLite writer broker), never on the Gateway main thread, which only awaits results and installs published facts. Exceptions: boot admission, migrations, Doctor/CLI one-shots, lock primitives. Existing main-thread access is legacy: never add more; migrate what you touch.
docs/reference/database-schemas/worker-access.md 把例外说清楚:
Synchronous boot admission, migrations, Doctor/CLI one-shots, and lock/lease primitives are the limited exceptions. Existing synchronous runtime paths are migration debt, not a pattern to extend.
也就是说:启动准入、迁移、Doctor/CLI 一次性命令、锁/租约原语 是允许同步访问的四个例外;其余运行时访问应迁到 worker。这也是为什么 SQLite 写入在 agent 侧被称为"writer broker",在共享状态侧由 openclaw-state-worker-* / openclaw-state-read-worker.ts 一族承担。
6.7.2 三个约束脚本:以 CI 执法架构
OpenClaw 把这些纪律变成可执行的门禁 。相关脚本(package.json):
json
"check:database-first-legacy-stores": "node --import ./scripts/tsx.mjs scripts/check-database-first-legacy-stores.mts",
"check:database-worker-ratchet": "node --import ./scripts/tsx.mjs scripts/check-database-worker-ratchet.mts",
"db:kysely:check": "node scripts/run-vitest.mjs test/scripts/generate-kysely-types.test.ts && node scripts/generate-kysely-types.mts --verify",
"lint:kysely": "node --import ./scripts/tsx.mjs scripts/check-kysely-guardrails.mts",
"check:architecture": "... && pnpm db:kysely:check && pnpm lint:kysely && pnpm check:database-first-legacy-stores"
逐个看:
-
check:database-first-legacy-stores------ 文件头一行注释点明职责:"Guards database-first state ownership by blocking legacy store writes in runtime code ."(守卫"数据库优先"的状态所有权:在运行时代码里封禁 legacy store 写入 。)它用 TypeScript AST 遍历,追踪 fs 写入、JSON store、legacy path 等事实(undefinedFact/LegacyStoreViolation),把"新写 JSON/JSONL/sidecar 文件来存状态"这类做法挡在门外。这直接对应根AGENTS.md的存储纪律:State and caches use SQLite, not new JSON/JSONL/sidecar stores; files are for named user artifacts, imports/exports, attachments, logs, backups, or external-tool contracts.
-
check:database-worker-ratchet------ 一个棘轮(ratchet) :只许收紧不许放松。它用database-worker-inventory.mjs清点主线程 SQLite 调用(T1 层),与 Git base commit 对比,只允许 T1 调用数下降、不允许增长:tsconst { increased } = compareRatchetCounts(after, before); if (total(after) > total(before) && reportRatchetFailures([...], "Move SQL behind the owner's worker operation: docs/reference/database-schemas/worker-access.md\n" + ...)) { return 1; }失败信息直接给出修法:把 SQL 移到 owner 的 worker operation 后面 ;如果整个文件只在 worker 内执行,就命名为
*.worker.ts或加入workerModules(并要求 caller 证据)。棘轮的设计意图是:在无法一次性清债时,用"只降不升"的机械约束保证债务单调收敛。 -
db:kysely:check/lint:kysely------ 对应根AGENTS.md的"Kysely for ordinary SQLite; raw SQL only for schema, migrations, bootstrap, and justified primitives."。前者验证生成的 Kysely 类型与 schema 一致(generate-kysely-types.mts --verify),后者是 Kysely 护栏。代码里也因此能看到// sqlite-allow-raw -- ...这类显式标注(见openclaw-agent-db-schema-helpers.ts的session_key_contract/session_reactionsensure),把"裸露 SQL"限定在 schema/bootstrap 的合法例外内,并要求给出理由。 -
check-native-state-schema-version------ 前面 6.1.2 已述,守护 Swift 与 TS 的 schema 版本一致。
这些脚本本身就是架构的一部分。 只有当"纪律"能被 CI 机械执行时,"新写 sidecar JSON""在主线程加 SQL"才会在合并前被拦下,而不依赖人自觉。第 22 章会把"以 CI 执法架构"作为一项工程实践来总结,本章给出的是它在存储层最密集的一组实例。
6.8 不变量与取舍
综合上述源码与文档,本章提炼出的不变量:
| 编号 | 不变量 | 源码/文档依据 | 反例(被禁止的做法) |
|---|---|---|---|
| I-6.1 | schema 版本只有一个事实源:package.json 声明 + 各自唯一常量 |
package.json;openclaw-state-db-contract.ts;check-native-state-schema-version.mjs |
在别处硬编码版本号 |
| I-6.2 | 版本记录在两处且必须一致:PRAGMA user_version 与 schema_meta primary 行 |
writeCurrentStateSchemaMetadata();assertOpenClawStateDatabaseOwner() |
只改其一的"半发布" |
| I-6.3 | schema/完整性/索引检查只在 admission 与 migration owner 运行 | assertExistingOpenClawStateRuntimeSchema();ensureOpenClawStateRuntimeSchema() |
在读取热路径里 PRAGMA integrity_check |
| I-6.4 | runtime 携带已准入事实,用 PRAGMA data_version 探针守护新鲜度 |
sqlite-schema-facts.ts |
每次读都重扫 sqlite_schema |
| I-6.5 | 迁移前向-only、原子、单事务;新版本被旧 build 拒绝 | runStateSchemaMigrationTransaction();createNewerSqliteSchemaVersionError() |
用手写 SQL 降级重写 |
| I-6.6 | 共享库 role 必须是 global;agent 库 role/agent_id 必须匹配 |
assertOpenClawStateDatabaseOwner();assertOpenClawAgentCurrentRuntimeSchema() |
一个库被两个 agent 当自己库用 |
| I-6.7 | 新增表/裸可空列可不涨版本,但必须"老 reader 忽略即安全" | versioning.md;FIRST_USE_STATE_TABLES |
给既有表加带约束的列却声称同版本 |
| I-6.8 | 隐私敏感表在首次真实写入前不实例化 | FIRST_USE_STATE_TABLES 注释 |
全新安装即创建全部敏感表 |
| I-6.9 | 状态/缓存用 SQLite;文件只用于命名产物、导入导出、附件、日志、备份 | 根 AGENTS.md;check-database-first-legacy-stores.mts |
新写 JSON/JSONL/sidecar 存状态 |
| I-6.10 | 普通 SQLite 用 Kysely;裸 SQL 仅 schema/migration/bootstrap | 根 AGENTS.md;lint:kysely / db:kysely:check |
在业务查询里写裸 SQL 且无标注 |
| I-6.11 | 数据库访问在 worker;主线程只 await 结果并安装已发布事实 | 根 AGENTS.md;worker-access.md;check-database-worker-ratchet.mts |
在 Gateway 主线程新增同步查询 |
| I-6.12 | 一份 state 目录同一时刻只有一个写 owner(租约) | openclaw-state-lease.ts |
两个 Gateway 共享可变 agent 库 |
对应的取舍(被接受的边界):
- 完整性检查一次很贵,但值得。 前移全库扫描换来了热路径的 O(1) 探针;代价是冷开与首次准入会有可观的 I/O 与延迟(文档给出 635s / 2860s 级别的预算)。这是一个"把成本放在启动、把热路径做快"的明确选择。
quick_check而非全量integrity_check用于 transcript。 换来更快的启动;代价是不做 transcript 索引条目的交叉校验 ,可能漏掉索引级损坏(文档:"This checks transcript structure without cross-checking its index entries or uniqueness.")。为了补这个洞,Gateway 不再做定期全库扫描,而把全量校验交给操作者按需或计划内的 Doctor 维护。- per-agent 库带来隔离与粒度,代价是"多库管理复杂度"。 Gateway 启动要按并发上限逐库检查("at most two databases at a time");注册表、租约、删除日志都成了共享库的负担。这是"故障域隔离"换来的管理成本。
- "同版本增量"降低升级摩擦,代价是"同版本可以有不同形态"。 文档直言:"two databases at the same version can still have different shapes."。于是 OpenClaw 必须额外做 canonical 形态校验 (表定义、约束、索引、触发器、虚拟表、表选项),并把"缺失/漂移的 canonical 索引"在可写 open 时原子重建。数字相同不代表形态相同,这是一条需要时刻记住的规则。
- 延迟发布(内容版本)让跨 updater 升级可行,代价是"版本标记的语义变复杂"。
user_version/schema_meta.schema_version与state.schema.contentVersion可能暂时不一致,必须靠一套精确的发布条件(5 分钟 / 30 分钟窗口 + 租约围栏)兜底。这提高了实现复杂度,但换来"升级总能完成"。
6.9 与相邻子系统的边界
明确边界,才能读懂"为什么这段逻辑在这个模块"。
- 与 Gateway(第 02、07 章) :Gateway 主线程不管 SQL 。它负责持有活投影、缓存与 caller 权威,await 已准备好的事实并安装提交结果。共享库的 schema 检查在启动准入阶段完成,之后由 owner 发布;主线程只消费。跨进程的"谁拥有 state 目录"由
gateway-state-owner与 state 租约协作(openclaw-state-db.ts顶部导入withStateDatabaseColdAdmission)。 - 与 Agent loop / sessions(第 08、10 章) :agent loop 的读写落到 per-agent 库 ,通过
openclaw-agent-db-admission.ts的准入门进入。transcript 写入的"writer-claim 围栏"(第 01 章提到的 I-1)作用在 agent 库的写入所有权上,本章只交代"库级"的准入门,具体围栏留给第 08 章。 - 与 Doctor(第 07、22 章) :Doctor 是显式维护 的 owner,负责运行时不做的事 ------ 文件→SQLite 迁移、历史行修复、退休表导入、索引修复、备份校验、全量
integrity_check。它在共享库的migration_runs/migration_sources表里为每次迁移记回执。运行时与 Doctor 的边界是"收敛 schema vs 修复数据"。 - 与更新系统(第 22 章) :
openclaw update在升级前做 schema preflight ------ "refuses a package or source target whose declared schema support is older than an on-disk database"。更新账本update_runs、内容版本延迟发布、备份/回滚都属这条线。本章是它们在存储层的地基。 - 与原生应用(第 21 章) :Swift 的
OpenClawNativeStateSQLite.swift直接读写共享状态库,故有check-native-state-schema-version.mjs这个跨语言守卫。原生侧是共享库的一个平级 reader/writer,不是"只能读"。 - 与
src/storage/**(存储位置) :本章已澄清 ------ 那是另一条责任线(面向用户产物的可插拔对象存储),与 SQLite 状态库无关。二者共享"存储"这个词,但不共享 owner。
6.10 小结与下一章预告
本章把"状态版本化、升级受护"这条可检验属性拆到了源码级。要点回顾:
- 两个数字,两本账 :
package.json声明state: 20与agent: 24;TS 侧各有唯一常量OPENCLAW_STATE_SCHEMA_VERSION(src/state/openclaw-state-db-contract.ts)与OPENCLAW_AGENT_SCHEMA_VERSION(src/state/openclaw-agent-db-contract.ts),并有 CI 守卫 Swift/TS 版本漂移。 - 三类库 :共享状态库(控制平面)、per-agent 库(数据平面)、quarantine 库(隔离判决)。
src/storage/**是另一条责任线,勿混淆。 - 三个角色 :admission 一次性完成版本/完整性/索引/表存在性检查并把已准入事实绑在 handle 上 ;migration owner 在一个同步 IMMEDIATE 事务 里前向迁移并复验;runtime 不再重查 ,靠
PRAGMA data_version探针与 schema cookie / scope revision 守护正确性。为什么只查一次 ------ 因为全库扫描是 O(库大小) 的 I/O,必须前移。 - 版本契约 :
PRAGMA user_version+schema_meta双记录;新版本被旧 build 拒绝(状态 78);state.schema.contentVersion支持跨 updater 的延迟发布。 - 数据模型 :共享库装配置/注册表/审批/插件状态/共享运行时;agent 库装会话/transcript/记忆/鉴权。表分"首用"与"惰性可加"两类,新增表可不涨版本(前提是老 reader 忽略即安全),隐私敏感表在首次写入前不实例化。
- per-agent 库的理由:鉴权隔离、故障域隔离、迁移粒度、可移植性;共享库只放注册表与租约。
- 纪律由 CI 执法 :
check:database-first-legacy-stores(封禁 legacy store 写入)、check:database-worker-ratchet(主线程 SQL 只降不升的棘轮)、db:kysely:check/lint:kysely(Kysely 护栏)、check-native-state-schema-version(跨语言版本守卫)。
下一章(第 07 章) 进入 Gateway 与 node:sqlite。本章反复提到"准入在 Gateway 启动阶段完成、主线程只消费已发布事实",却把**"准入的宿主"**留白了。第 07 章要回答:Gateway 进程如何启动并持有共享状态库的准入?node:sqlite 内建模块与版本约束(engine 的 Node 24+/26+)如何支撑"worker 访问 + writer broker"?withStateDatabaseColdAdmission、worker read scope、以及"主线程只 await 结果"的落地形态,都将在那里展开 ------ 本章的"检查时机图"会在第 07 章长出"进程与线程"的骨架。
本章引用:package.json(openclaw.schemaVersions)、根 AGENTS.md、docs/reference/database-schemas.md 及子页 layout.md / versioning.md / integrity-and-recovery.md / worker-access.md / agent-schema-history.md / state-schema-history.md;源码 src/state/openclaw-state-db-contract.ts、openclaw-agent-db-contract.ts、openclaw-state-db.ts、openclaw-state-db.paths.ts、openclaw-state-db-existing-schema.ts、openclaw-state-db-fast-path.ts、openclaw-state-db-schema-version.ts、openclaw-state-db-schema-runtime.ts、openclaw-state-db-maintenance.ts、openclaw-state-schema.ts、openclaw-state-schema.sql、openclaw-state-schema-compatibility.ts、openclaw-agent-db.paths.ts、openclaw-agent-db-admission.ts、openclaw-agent-db-schema-helpers.ts、src/infra/sqlite-integrity.ts、sqlite-user-version.ts、sqlite-schema-facts.ts、src/state/openclaw-state-lease.ts;脚本 scripts/check-native-state-schema-version.mjs、check-database-first-legacy-stores.mts、check-database-worker-ratchet.mts。所有数值(schema 版本 20 / 24、OPENCLAW_SQLITE_BUSY_TIMEOUT_MS = 5000、表名与列名)以快照 2026.10.1 源码为准;无法从源码完全确证处以"以源码为准"标注。