SQLite 会话库
Agent CLI 的持久状态都在一个 SQLite 文件里:为什么用 Node 内置的 node:sqlite,库在哪、有哪些表,22 条只追加的迁移怎样按校验和记账,多进程并发与事务,故障注入,-c 与 --resume 的查询,以及恢复时怎样把消息和 part 重建成模型历史与读文件状态。
ZCode 的 Agent 运行时把会话事件流放在内存里(apps/zcode-cli/packages/bootstrap/src/app/create-app.ts:730 默认创建的是 createInMemorySessionEventStore()),进程退出后还要留着的东西——会话、消息与 part、输入账本、Todo、目标、检查点记录、用量——都写进同一个 SQLite 文件。读写它的是 SqliteSessionStore:一个类同时实现 SessionStorePort、InputHistoryStorePort、LocalSettingStorePort、ScriptWorkflowStorePort、UsageStorePort 五个端口(apps/zcode-cli/packages/adapters/src/storage/session-store/sqlite-session-store.ts:225),动态工作流的 journal 端口由 workflowJournalStore() 另外交出(sqlite-session-store.ts:996)。主端口定义在 apps/zcode-cli/packages/contracts/src/interfaces/session-store.port.ts:1089,45 个方法里有 22 个带问号,旧宿主可以不实现。
代码在 apps/zcode-cli/packages/adapters/src/storage,34 个非测试文件、8907 行,其中 session-store/ 占 7115 行。事件怎样投影成消息与 part 落库,见会话事件流与持久化投影;本篇讲库本身、迁移与恢复。
| 位置 | 职责 |
|---|---|
session-store/sqlite-session-store.ts | 打开连接、触发迁移,把端口方法分派给仓储;分叉、共享上下文导入等多表事务也在这里 |
session-store/migration-runner.ts、migrations.ts、migrations/ | 迁移账本与 22 条迁移 |
session-store/repositories/ | 按表读写:sessions、messages、session-entries、session-inputs、todos、usage、dwf-journal* 等 |
session-store/codecs.ts、rows.ts、json.ts | 行结构与 JSON 编解码,读取时剥掉旧版字段 |
session-target.ts | 目标(Goal)表 |
fs-fault-injection.ts | 测试用的文件系统故障注入 |
index.ts | 另外导出工具结果文件存储 NodeToolArtifactStore |
怎么用
库文件默认在 ~/.zcode/cli/db/db.sqlite(apps/zcode-cli/packages/adapters/src/storage/session-store/paths.ts:7,配置默认值见 apps/zcode-cli/packages/contracts/src/config/index.ts:303),NOTICE 第三节写的也是这个位置(NOTICE.md:58)。要换位置,改配置键 storage.sessionDbPath,或设环境变量 ZCODE_SESSION_DB_PATH(ZCODE_SESSION_DB 同义,apps/zcode-cli/packages/adapters/src/config/env-config.adapter.ts:29);相对路径按进程工作目录解析(apps/zcode-cli/packages/bootstrap/src/app/session-store.ts:104)。库开着 WAL,旁边会多出 -wal、-shm 两个文件,桌面端资源管理器把三者都归为“会话存储”并标成不可清理(packages/services/src/storage/domain/storageCatalog.ts:26、storageCatalog.ts:56)。
接着之前的会话往下做,有这几个入口:
| 入口 | 行为 |
|---|---|
zcode -c(--continue) | 取当前目录最近更新的一个根会话;没有就报 No resumable session found for 加目录(apps/zcode-cli/packages/cli/src/resume.ts:16) |
zcode --resume <sessionId> | 直接用给定 ID,存不存在要到真正恢复时才查(src/resume.ts:12) |
| 两者同时给 | 报 --resume and --continue cannot be used together. 后退出(apps/zcode-cli/packages/cli/src/run.ts:368) |
TUI 里的 /resume | 弹出当前目录最近 50 个根会话的选择列表(apps/zcode-cli/packages/cli/src/tui-command-data.ts:45、apps/zcode-cli/packages/cli/src/command-center/create.ts:333) |
TUI 里的 /resume <id>、/continue | 按 ID 恢复,或恢复最近一个(create.ts:345) |
-p 无头模式 | 走同一套解析,在解析出的会话里接着跑这次提示词(apps/zcode-cli/packages/cli/src/prompt-command.ts:165) |
“根会话”指 parent_id 为空的会话。两个列表查询都带 roots: true(apps/zcode-cli/packages/bootstrap/src/sessions.ts:21、apps/zcode-cli/packages/bootstrap/src/sessions.ts:45),SQL 是按 time_updated desc, id desc 排序、默认排除已归档(apps/zcode-cli/packages/adapters/src/storage/session-store/repositories/sessions.ts:217、repositories/sessions.ts:228)。分叉出的子会话都记了 parent_id(见下一篇),所以它们既不在 /resume 列表里,也不会被 -c 选中,只能用 --resume 加 ID 打开。
为什么是 node:sqlite
整个仓库没有一处文字交代为什么选 Node 内置的 node:sqlite,但代码里能读出几条理由:
- 没有原生依赖。仓库根与
apps/zcode-cli的两份pnpm-lock.yaml里都找不到 sqlite 字样,不需要 better-sqlite3 这类按 Node ABI 与平台编译的原生模块。从打包脚本推断,这能省掉单文件可执行(SEA)的一块麻烦:TUI 用到的 koffi 就得按目标平台单独挑出koffi.node塞进包里(apps/zcode-cli/packages/cli/scripts/sea-tui-assets.mjs:294)。 - 同步 API。
DatabaseSync的每个调用都是同步的,仓储函数虽然标着async,函数体里并不等待 I/O。动态工作流的 journal 端口干脆设计成同步接口,注释说它“正好贴合 node:sqlite 的 DatabaseSync”(apps/zcode-cli/packages/adapters/src/storage/session-store/repositories/dwf-journal.ts:4)。 - 运行时已经锁定。CLI 固定在 Node 24.14.0(
apps/zcode-cli/package.json:47、mise.toml:2),桌面端服务层的任务索引库也用node:sqlite,还特意用createRequire加载,防止打包器把它改写成 npm 上的 sqlite 包(packages/services/src/session/tasksDatabase/startup.ts:5)。
代价是 Node 24 里这个模块仍会打印 ExperimentalWarning: SQLite is an experimental feature,CLI 在 stderr 边界把这一行吞掉(apps/zcode-cli/packages/cli/src/runtime-warnings.ts:8)。
打开连接时先把忙等超时交给驱动,再跑迁移(sqlite-session-store.ts:243):
try {
ensureParentDir(this.dbPath);
maybeThrowStorageFsFault({ operation: "sqliteOpen", path: this.dbPath });
// 多个本地或远程 Agent 会共享同一个 session DB;timeout 必须在执行首条
// PRAGMA 前生效,否则并发启动会在 migration prelude 直接抛 database is locked。
this.db = new DatabaseSync(this.dbPath, { timeout: startupLockTimeoutMs });
} catch (error) {
// ...
}
try {
if (startupToken !== deferredStartup)
runSqliteSessionMigrations(this.db, this.dbPath, startupLockTimeoutMs);startupLockTimeoutMs 默认 5000 毫秒(apps/zcode-cli/packages/adapters/src/storage/session-store/migration-runner.ts:13)。构造函数走同步迁移;应用启动走 SqliteSessionStore.openStartup,用一个模块私有的 symbol 跳过构造期迁移,再跑异步版本,保证“所有 Repo/业务只可能拿到 COMMIT 后的连接”(sqlite-session-store.ts:276)。
有哪些表
业务表都由迁移创建,一共 22 张,另有迁移运行器自己建的账本 schema_migration。核心几张的关系:
逐张列出来(“建表行”指 apps/zcode-cli/packages/adapters/src/storage/session-store/migrations.ts 里对应 create table 语句的行号):
| 表 | 建表行 | 用途 | 关键列 |
|---|---|---|---|
session | 14 | 会话主记录 | project_id、workspace_id、parent_id、directory、title 与 title_source、revert(对话回退游标)、permission、task_type、trace_id、time_archived |
message | 41 | 消息;除 ID、时间和序号外,整条消息以 JSON 存在 data | session_id、sequence |
part | 52 | 消息的组成部分:文本、推理、工具调用、文件、时间线等,同样整条存 JSON | message_id、session_id、sequence |
session_entry | 77 | 会话级的类型化条目:模型选择、执行状态、检查点、命令幂等事实等 | type、data |
session_input | 691 | 输入账本:立即开始、引导、排队三种输入的持久生命周期,0017、0018 各重建一次 | delivery、status、admitted_sequence、promoted_message_id |
todo | 64 | Todo 列表 | 主键 (session_id, position) |
session_target | 166 | 目标、Token 预算与活跃运行记账,0005 重建 | objective、status、token_budget、tokens_used |
input_history | 97 | 输入框历史,全库最多保留 100 条 | project_id、text、kind、attachments |
local_setting | 116 | 按作用域存的本地设置,如项目权限规则与模式 | (scope, scope_id, namespace, key) |
permission | 90 | 旧的项目权限表,只作读取回退 | project_id、data |
model_usage、turn_usage、tool_usage | 396、448、481 | 模型请求、回合、工具调用的用量与耗时事实,保留 30 天 | trace_id、turn_id、各类 Token 数 |
workflow_* 四张与 session_task_link | 234 起 | 旧的脚本工作流 | 运行、活动、事件与父子会话链接 |
dwf_run、dwf_actor、dwf_node、dwf_event | 831 起 | 动态工作流 journal,不设外键 | 见动态工作流(三) |
session_entry 的 type 是开放字符串,端口里登记了七种常量,比如 runtime/model_selection、runtime/execution_state、runtime/workspace_checkpoint(session-store.port.ts:802),分叉与共享上下文导入另外用到 v4/command_fact、v4/shared_context_import 等类型。两个数字的出处:输入历史的 100 条在 apps/zcode-cli/packages/adapters/src/storage/session-store/repositories/input-history.ts:12,删除时不分项目;用量的 30 天在 apps/zcode-cli/packages/adapters/src/storage/session-store/repositories/usage.ts:17,每次写用量都会顺手清一遍过期行(usage.ts:163、usage.ts:335)。
迁移:22 条,只追加
迁移注册表 SQLITE_MIGRATIONS(apps/zcode-cli/packages/adapters/src/storage/session-store/migrations.ts:9)是一个 { appVersion, id, sql } 数组,编号从 0001_base_session_store 到 0022_backfilled_session_reasoning,appVersion 从 0.2.0 一路到 0.16.5。执行在一个 begin immediate 事务里完成(migration-runner.ts:159):
yield* acquire(() => db.exec("begin immediate"));
transactionStarted = true;
db.exec(`create table if not exists schema_migration (
id text primary key, checksum text not null, app_version text, time_applied integer not null
)`);
// ...
for (const migration of SQLITE_MIGRATIONS) {
try {
const checksum = migrationChecksum(migration.sql);
const applied = readAppliedMigration(db, migration.id);
if (applied) {
ensureMigrationChecksum(migration.id, applied.checksum, checksum, dbPath);
completed++;
continue;
}
// ...
db.exec(migration.sql);
if (migrationFacts) migrationFacts.executedCount++;
db.prepare(
"insert into schema_migration (id, checksum, app_version, time_applied) values (?, ?, ?, ?)",
).run(migration.id, checksum, migration.appVersion, Date.now());
completed++;
} catch (error) {
throw normalizeMigrationError(error, dbPath, migration.id);
}
}几个要点:
- 账本与校验和。校验和是 SQL 去掉首尾空白后的 sha256(
migration-runner.ts:296)。已执行的迁移每次启动都重新核对,对不上就以checksum_mismatch拒绝启动,报错原文是“Historical migrations are immutable; add a new migration instead.”(migration-runner.ts:308)。 - 拿锁之后再读账本。进事务前先把库切到 WAL、打开外键(
migration-runner.ts:137、migration-runner.ts:141),再做一次只读预检,判断这次是none、initialize还是upgrade,只用于展示(migration-runner.ts:318);真正要跑哪些,以拿到写锁后的账本为准。两个进程同时启动,后到的那个等锁、读账本、发现都已提交,就什么也不做。失败则整体回滚,一条都不留(migration-runner.ts:206)。 - 只重试 SQLITE_BUSY。等锁时退避间隔从 10 毫秒翻倍到最多 200 毫秒(
migration-runner.ts:16),SQLITE_LOCKED不重试(migration-runner.ts:122)。同步版本总共只等 5 秒;异步版本把驱动自身的忙等压到 25 毫秒、让出事件循环,总预算一小时,结束后恢复 5 秒(migration-runner.ts:45、migration-runner.ts:66、migration-runner.ts:92)。每个阶段(checking、waiting_for_lock、migrating、committing、ready、failed)都会回调进度:普通启动写进启动日志(apps/zcode-cli/packages/bootstrap/src/app/session-store.ts:84),协议与存储准备模式则以控制帧报给桌面端,见文末。
migrations/ 目录里只有 0020 到 0022 三个文件:0001 到 0019 都以内联 SQL 写在 migrations.ts 里,后三条是改写 JSON 字段的数据迁移,SQL 很长,0020 与 0022 还要用 TypeScript 拼出来(比如 0020 把旧的模型身份字段改写成 modelSelection,并把 builtin: 前缀的 provider 映射成新 ID,apps/zcode-cli/packages/adapters/src/storage/session-store/migrations/0020-provider-model-selection.ts:16),于是各自成文件;文件头强调“冻结的数据迁移只生成 SQL,checksum 覆盖最终 SQL”(0020-provider-model-selection.ts:1)。编号为什么恰好从 0020 接着排,0019 的注释给了答案(migrations.ts:803):
// 本条是 beta 前把开发期 0019–0030 十二条迁移**压成的单一基线**:四张表一次建齐、形状即
// 0030 之后的终态。中间态(三次为放宽 CHECK 的整表重建、0028 的删列改名)只存在于
// 内部预览库里,收敛办法是删掉四张 dwf_* 表并清掉 schema_migration 里的 dwf 记账行,
// 下次启动由本条重建。beta 之后本条
// 不可再改:runner 按 checksum 记账,历史迁移只能追加。
//
// 只为占住 0019 这个槽位,让
// staging 后续迁移从 0020 起编号,功能分支合回时 ledger 不会撞号。四张表在功能落地前闲置无害。也就是说,动态工作流在开发期用掉了 0019 到 0030 十二个号,发布前压成一条 0019,占住这个槽位,staging 分支上的后续迁移因此从 0020 起编。迁移里还能看到新旧二进制并存的痕迹:0015 的注释记录了一次实测,本机有 1690 行 message、5933 行 part 的 sequence 为空,“主要嫌疑是旧版本二进制并存写同一 DB”,于是补了两个 AFTER INSERT 触发器兜底(migrations.ts:598、migrations.ts:656)。
并发与事务
同一个库会被多个进程同时打开,注释原话是“多个本地或远程 Agent 会共享同一个 session DB”(sqlite-session-store.ts:246)。协调全靠 SQLite 自己:WAL 允许并发读,写锁由 busy_timeout 等待。在这个前提下,写路径有几条规矩:
- 单条语句靠自动提交,多行写入用
begin immediate。Todo 整表替换(apps/zcode-cli/packages/adapters/src/storage/session-store/repositories/todos.ts:30)、输入历史插入并截断、输入账本的批量改写与“提升”、分叉提交、完全访问授权提交、用量清理都各开一个立即写事务。完全访问的提交还特意说明事务内不await,“取消检查和提交之间没有异步重入窗口”(apps/zcode-cli/packages/adapters/src/storage/session-store/repositories/permission-full-access.ts:5)。 - 序号在插入语句里算。消息与 part 的
sequence用子查询取同一会话(或同一消息)的最大值加一,冲突更新时保留原序号,只有跨会话改绑才换新序号;注释解释了为什么不能用coalesce:会把历史里为空的行在任何一次重存时挪到队尾(apps/zcode-cli/packages/adapters/src/storage/session-store/repositories/messages.ts:35)。读取时按sequence is null, sequence, time_created, rowid排序(messages.ts:230)。 - 时间只增不减。每存一条消息或 part 都会“触碰”会话的
time_updated,写法是max(time_updated, ?)(repositories/sessions.ts:341),这就是-c和/resume列表的排序依据;路径自愈这类维护写入也用旧值做比较交换,避免把并发写进来的新标题、权限覆盖掉(repositories/sessions.ts:285)。 - 给回滚留快照。旧版读取器会无条件读
user.model、toModel.providerID之类的字段,新代码写入时补上最小的旧对象,冲突更新时保留旧快照(messages.ts:16、messages.ts:46)。换句话说,库要能被上一个版本的二进制继续打开。 - 幂等靠事实行。分叉等命令在父会话里写一条
v4/command_fact条目,ID 形如v4_command_fact:child:加父会话 ID 和命令 ID;重复提交时先查这一行,存在就直接返回已创建的子会话(sqlite-session-store.ts:336、sqlite-session-store.ts:395)。输入账本也是同一个思路:admitted之后要么在同一事务里和用户消息一起“提升”为promoted,要么收口为cancelled、discarded、failed,迟到的丢弃不能把已提升的记录改回去(apps/zcode-cli/packages/adapters/src/storage/session-store/repositories/session-inputs.ts:1、session-inputs.ts:323)。
故障注入
存储层的关键操作之前都有一道 maybeThrowStorageFsFault,规则来自环境变量 ZCODE_E2E_FS_FAULTS,是一个 JSON 数组(apps/zcode-cli/packages/adapters/src/storage/fs-fault-injection.ts:1)。每条规则指定错误码、要拦的操作(mkdir、writeFile、appendFile、rename、rm、sqliteOpen、sqliteRun 或 any)、路径匹配条件,以及最多命中几次,缺省 1 次(fs-fault-injection.ts:4、fs-fault-injection.ts:98)。它只在 ZCODE_ENV=test 或同时设了 ZCODE_E2E_FS_FAULTS_ALLOW=1 时生效,否则规则被忽略(fs-fault-injection.ts:219)。
在会话库里,打开数据库对应 sqliteOpen,大部分写方法开头的 throwBeforeWrite() 对应 sqliteRun(sqlite-session-store.ts:300);日志写盘和文件系统适配器也接了同一套钩子(apps/zcode-cli/packages/adapters/src/logging/index.ts:132、apps/zcode-cli/packages/adapters/src/fs/index.ts:123)。不过并不是每个写方法都过这道闸,目标(Goal)与用量的写入就没有调用它(sqlite-session-store.ts:742、sqlite-session-store.ts:838)。
另有两个只给测试用的构造选项:forkCommitFaultAt 让分叉事务在子会话、消息、目标、条目、输入、命令事实写完后或提交前的任一阶段抛错,用来验证全有或全无;startupLockTimeoutMs 调短启动锁等待(apps/zcode-cli/packages/adapters/src/storage/session-store/options.ts:12)。
恢复:从行到内存历史
--resume 或 -c 只是定出一个会话 ID,再以 resume: true 创建应用。这样启动时恢复是惰性的:等到第一次真实执行(提交提示词、启动工作流等)的边界才调用 resumeFromStore(create-app.ts:502、create-app.ts:514);TUI 里的 /resume 换上新 App 后则立即显式调用(create.ts:346)。进 core 之前,bootstrap 先读 runtime/model_selection 条目恢复模型选择(create-app.ts:484)。core 里的流程(apps/zcode-cli/packages/core/src/runtime/methods/resume.ts:59):
几处细节:会话已归档也算找不到(methods/resume.ts:73);环境信息取第一条带 contextSnapshot 的用户消息里存下的那份,而不是当前机器的(methods/resume.ts:114);账本里还处于 admitted 的输入一律以 session_resumed 为由丢弃,“重启不保留队列”(apps/zcode-cli/packages/core/src/runtime/methods/steering.ts:1346);恢复出的目标会以附件形式提醒模型,并叮嘱计划或清单做完不等于目标完成(methods/resume.ts:443)。
消息历史。hydrateMessageHistoryFromSession(apps/zcode-cli/packages/core/src/agent/session-history-hydrator.ts:54)先用 activeSessionMessages 裁出活跃分支:先按回退游标取分支,再从分支里最后一个压缩边界往后取(session-history-hydrator.ts:240);没有游标的旧数据保持先压缩、后分支的老顺序(session-history-hydrator.ts:211)。然后逐条回放。用户消息里的合成提醒还原成附件,文本附件还原成 prompt_attachment 提醒,图片、视频放到文字之后,与实时路径的顺序一致(session-history-hydrator.ts:271);尚未附着的共享上下文不进历史(session-history-hydrator.ts:84)。助手消息还原文本、推理和工具调用,工具结果按状态分三种(session-history-hydrator.ts:149):
for (const part of toolParts) {
const providerToolName = providerToolNameFromPart(part);
if (part.state.status === "completed") {
// ...
const content = projectedMediaContent ?? part.state.output;
input.history.addToolResult(part.callID, providerToolName, content, true);
continue;
}
if (part.state.status === "error") {
const persistedModelContent = part.state.metadata?.modelContent;
// 实时链路使用 ToolExecutionResult.modelContent,但旧恢复逻辑只重放
// 面向 UI / 日志的 state.error;优先使用持久化字符串并兼容旧 session。
input.history.addToolResult(
part.callID,
providerToolName,
typeof persistedModelContent === "string" ? persistedModelContent : part.state.error,
false,
);
continue;
}
interruptedToolCount++;
input.history.addToolResult(part.callID, providerToolName, INTERRUPTED_TOOL_RESULT, false);
}进程死在工具执行中途的调用,会被补上一条 [Tool execution was interrupted before resume] 作为结果(session-history-hydrator.ts:45),保证历史里每个工具调用都有一条配对的结果。
文件 part。filePartToContentBlock(apps/zcode-cli/packages/core/src/agent/file-part-hydration.ts:8)先看 part 自己的 URL 是不是有效的 data URL,不是就按 zcode-artifact:// 地址从 artifact 存储读回(file-part-hydration.ts:113)。读到了,图片、视频、PDF 分别还原成对应的内容块;只有这三类会还原成二进制输入,注释提到曾因把白名单放宽到所有非文本类型,导致音频在冷恢复后意外变成 provider 文件输入(file-part-hydration.ts:32)。文本类型用存下的预览文字,其余一律退化成 [Attached <mime>: <名字>] 的占位文字。完成的工具结果若带附件,还要按持久化的内容布局重排,布局缺失或损坏就退回旧的文字输出(session-history-hydrator.ts:161)。
读文件状态。“先读后写”约束依赖运行时记住读过哪些文件(见读、写、改、搜),重启后这张表也要重建。hydrateReadFileStateFromSession(apps/zcode-cli/packages/core/src/agent/read-file-state-hydrator.ts:28)先清空,再扫活跃分支里已完成的 Read、Write、Edit 工具 part,只恢复带结构化元数据(修改时间、修订号、大小、内容)的完整读取;带偏移或行数限制的局部读取跨恢复一律不认(read-file-state-hydrator.ts:90、read-file-state-hydrator.ts:170)。它也从不去读当前磁盘来“补全”,理由写在注释里:外部手动保存会被误认成 Agent 已读(read-file-state-hydrator.ts:125)。结果里的 skippedUnreadableEditCount 在当前代码里初始化为 0 之后再没有递增过(read-file-state-hydrator.ts:50)。
给人看的“转录”是另一条投影:TUI 恢复后显示的历史来自 projectSessionTranscript,它跳过摘要消息和只给模型看的用户消息(apps/zcode-cli/packages/bootstrap/src/session-transcript.ts:59),与上面喂给模型的历史不是同一份。
存储准备模式
--prepare-storage(apps/zcode-cli/packages/cli/src/arguments.ts:77)目前只有桌面端调用。桌面 Host 启动时有一道数据库启动门:先在 Worker 线程里迁移自己的任务索引库 tasks-index.sqlite,再对每个候选工作目录,在 Worker 里跑同一个 CLI bundle 的 app-server --stdio --prepare-storage --cwd <目录>,最后才启动服务(packages/desktop/src/host/hostDatabaseStartup.ts:46、packages/desktop/src/host/storagePreparationProcesses.ts:131)。按目录逐个准备,是因为相对路径的 sessionDbPath 要按各自的工作目录解析;同一次准备里真实路径相同的库只迁移一次(storagePreparationProcesses.ts:171)。这个模式下 CLI 不改进程名、不装协议生命周期、不准备 SEA 运行时工具和 provider 环境(apps/zcode-cli/packages/cli/src/main.ts:19),bootstrap 也只做存储(apps/zcode-cli/packages/bootstrap/src/zcode-protocol-entrypoint.ts:85)。
stdout 上是一问一答的控制帧(apps/zcode-cli/packages/bootstrap/src/zcode-protocol/storage-startup.ts:105):
try {
await write({ method: "startup/storagePath", params: { path: options.dbPath } });
const reuse = await acknowledgement;
clearTimeout(timer!);
lines.close();
// reuse 仅由同一次 Host 准备的成功路径集合授予;不打开连接,也不写永久跳过标记。
if (!reuse) {
store = await openProtocolStartupStorage(options);
store.close();
store = undefined;
}
await write({ method: "startup/storagePrepared", params: {} });先报库路径,等 Host 回一行 startup/storagePathReady(Host 要先给这个路径的磁盘占用采个基线,hostDatabaseStartup.ts:30),30 秒收不到就以 startup_status_timeout 失败(storage-startup.ts:78);随后迁移,每个阶段都以 startup/storageState 帧报告,最后发 startup/storagePrepared。失败原因被归成 12 种错误码,比如 storage_full、corrupt、lock_timeout、checksum_mismatch,跨进程只传错误码和迁移 ID,不带 SQL 或文件内容(packages/shared/src/database-startup.ts:3、database-startup.ts:43);其中损坏、校验和不符、传输中断等几种不给手动重试,只能退出重开(database-startup.ts:164)。桌面端这道门的界面与服务启动见桌面应用。
最后一个细节印证了“打开即迁移”:apps/zcode-cli/scripts/shadow-replay.mjs 要拿真实库做冷恢复对账,默认先把库连同 -wal、-shm 复制到临时目录再打开,因为“store 打开时会跑迁移”(apps/zcode-cli/scripts/shadow-replay.mjs:6);调试界面读库则用只读连接(apps/zcode-cli/packages/debug/server/sources.ts:75)。
下一篇:检查点、回退与分叉——Write 与 Edit 留下的文件快照怎样撤销,“回退对话”为什么只移游标不删消息,分叉又复制了哪些行。