PR #113 + #140:把“上传缓存”升格为正式 Storage 基建

figuretu/eyrie · f9499ca3..45982542 · 2026-07-12 · #113 已合并,#140 待合并 · 自包含,读完即弃

8 commits
69 文件
+4,058 / −1,080
53.4% 是测试代码
2 连续 PR

写法说明:本文按「逐跳走读」展开——每个机制都给真实代码片段(取自分支、经裁剪,青色斜体注释为解读所加,灰色斜体是源码原注释的保留或意译),每段代码标注所在文件。每条旅程结尾有一张「排查路标」:将来出问题时,症状对应去哪个文件看哪个函数。

1先说结论

这两个 PR 合起来做的事情,可以用一句话概括:Eyrie 不再把上传文件当成 session 目录里的临时缓存,而是把所有本地持久化明确分家,并为用户文件建立了一套有归属、可去重、可统计、可回收的正式存储组件。

#113 建骨架:统一 EYRIE_HOME 路径、JSONC 配置、typed KV、内容寻址 blob store、HTTP 上传下载、Agent 图片输入、tombstone 回收、启动 GC、tRPC 运维面和 renderer prefs。#140 不改这套大方向,而是重新按“基建代码”标准过了一遍,把写入与删除竞态、owner 归因、文件删除失败后的可恢复状态、权限、契约冗余和注释漂移逐层收紧。

读完后最该留下的三个判断:① bytes 和业务归属已经拆成 object/ref 两层;② 正常删除走 owner-scoped deref,事故恢复走 store-wide GC,两者不能混;③ SQLite 与文件系统没有跨资源事务,正确性来自操作顺序、串行 mutation lane 和可被 GC 识别的中间状态。

它不是一个孤立的 storage/ 目录。生产里只有一个 blob store 实例:HTTP route 往里写、AgentService 从中解析图片、maintenance 对它做 deref、StorageService 对它统计和 GC。组件是否成立,关键就在这四个消费者是否共享同一份状态。

2两个 PR 怎么接:#113 建城,#140 修城墙

PR #113 是功能与架构切换:旧的 services/upload-store.ts(session 目录 + 文件旁 sidecar)整体退场,新的 storage 子系统接管完整生命周期。PR #140 是 post-merge 复查:它没有再造模型,而是把第一版里“正常时能跑、异常时语义还不够硬”的部分收口。

#113 · 建立能力
新增 blob_objects / blob_refs 两表
上传、下载、turn、删除统一接同一个 blob store
新增 config / KV / paths / boot / usage
启动 GC + tombstone reaper 接管崩溃恢复
#140 · 明确边界
deref 只处理目标 owner;全局垃圾只交给 GC
文件先删、row 后删;不同调用者采用不同错误策略
最终 owner 检查改为同步临界区,封住 microtask 缝隙
权限、MIME、usage、config、KV、DTO 与测试收口

2.1称重:一半以上是测试,不代表主体很小

daemon tests
2,581 行 · 50.2%
storage core
926 行 · 18.0%
daemon wiring
877 行 · 17.1%
desktop prefs
386 行 · 7.5%
db schema
222 行 · 4.3%
api contract
130 行 · 2.5%
设计重心(要细读)可放心略过或快速扫
storage/blobs.ts:一致性、竞态、回收、权限Drizzle snapshot / journal:跟随 schema 生成
routes/blob.tsagent/service.ts:端到端消费bun.lock:只增加 jsonc-parser
maintenance-tick.tsblob-liveness.ts:生命周期大量 provider fixture:移除死字段 secretRef
config.tsboot.tskv.ts:启动不变量upload-store 的 535 行删除:理解 before 后无需逐行背

2.2#140 本身也不是一条直线

#140 值得按提交顺序看,因为中间方案被后续复查再次收紧。第一轮曾让 derefOwner 顺便扫描全 store 的零引用对象,目的是让一次删除重试自愈;后续发现这会把所有 owner 的回收绑在一起,也破坏“本次回收了谁的字节”这个结果语义,于是撤回。

阶段实际收敛了什么
d0945eb0先删文件再删 object row;补 MIME token、config/KV/usage/turn 文件存在性;一度让 deref 全局扫零引用对象。
066642ffblob root/shard/object 收紧到 0700/0600;全局扫按对象隔离 rm 故障。
2039f31c删空壳 uploads.ts,移除未消费 config 字段,清理契约与注释漂移。
41876086把 deref 恢复为 owner-scoped;全局自愈只留给 GC;增加写后 owner 复查。
36217367发现写后 await 仍会让出 microtask,改成同步最终复查;统一删除/统计 primitive。
78cbf70b用 ordering discriminator 固定“最终复查与 ref insert 之间零 yield”。

所以最终设计不是“多补几个 if”,而是把职责钉死:异步检查负责尽早止损;同步检查负责发布前正确性;owner deref 负责归因;GC 负责全局收敛。

3架构一图流:从目录缓存到 object/ref 存储

以前 · 文件路径就是数据模型

HTTP upload
按 session 写目录
cache/uploads
Agent turn
解析 ref + 读 sidecar
file + .meta.json
delete
递归删 session 目录
upload-store

现在 · bytes 与归属分层

HTTP / Agent
opaque ref
blob_refs
blob_refs
hash FK
blob_objects
blob_objects
sha256 shard path
blobs/aa/hash
deref / GC
回收与自愈
DB + disk

新的 storage 不是“所有东西进一个库”,而是给不同状态指定唯一归宿。关系实体继续进 SQLite;可手改的 daemon 全局配置进 config.json;机器标记复用 metadata 表;用户文件的 bytes 在磁盘、索引在 DB;设备 UI 偏好继续留在 renderer 的 localStorage

状态种类唯一归宿为什么
project / task / session / run / eventSQLite + Drizzle关系查询和生命周期真相源。
daemon 全局、用户可编辑设置<home>/config.json可手改、可加注释;不把配置藏进表。
一次性迁移标记、探测缓存metadata typed KV机器管理、可重建、读时校验。
附件 bytes<home>/blobs + object/ref 表不把大文件塞进 DB,同时保留归属与回收能力。
theme / localerenderer localStorage首屏前必须同步读取,不能等 daemon RPC。

4数据与状态先行:先记住 object、ref、owner

4.1一份 bytes 只有一个 object,一次业务持有就是一个 ref

packages/db/src/index.ts最终数据形状(节选)
export const blobObjectsTable = sqliteTable('blob_objects', {
  hash: text('hash').primaryKey(),
  sizeBytes: integer('size_bytes').notNull(),
  createdAt: integer('created_at').notNull(),
})

export const blobRefsTable = sqliteTable('blob_refs', {
  id: text('id').primaryKey(),
  hash: text('hash').notNull().references(() => blobObjectsTable.hash),
  ownerType: text('owner_type').notNull(),
  ownerId: text('owner_id').notNull(),
  name: text('name').notNull(),
  mimeType: text('mime_type').notNull(),
  createdAt: integer('created_at').notNull(),
})

object 的主键是完整 SHA-256;磁盘位置是 <home>/blobs/<前两位>/<完整 hash>。ref 的 id 才是客户端拿到的 handle。相同内容上传两次,object insert 冲突后复用同一文件,但每次仍新增 ref,因此 owner、展示名和 MIME 各自独立。

MIME 为什么不能放 object 上:相同 9 bytes 可以被 session A 作为 image/png 上传、被 session B 作为 text/plain 上传。bytes 可以共享,声明语义不能互相污染;下载 header 和 Agent 图片 gate 都读 ref 自己的 MIME。

4.2对外只暴露 opaque ref,不暴露磁盘布局

packages/api/src/dto.ts · packages/api/src/schemas.ts最终上传与 turn wire shape
export type UploadResponseDto = {
  ref: string
  name: string
  mimeType: string
  sizeBytes: number
}

export const agentInputPartSchema = z.discriminatedUnion('type', [
  z.object({ type: z.literal('text'), text: z.string().max(100_000) }),
  z.object({ type: z.literal('image'), ref: z.string().min(1).max(4000) }),
])

#113 短暂同时返回 idref,两者实际相同;#140 收成一个 ref,把它明确为上传响应、下载 query 和 timeline replay 共同使用的唯一 handle。host path 只在 daemon 内部、即将交给 provider 时出现。

4.3usage 有两个口径

blobs.totalblob_objects.size_bytes 求和,回答真实 object 占用;byOwnerType 先按 (owner_type, hash) 去重,再按类型归属。因此同一 object 同时被 session 与 project 引用时,每个类型都会看到这份 bytes,分项相加可以大于 total。零 ref 但尚未 GC 的 object 仍进入 total,却没有 owner 分项。

5共同底座:用“可恢复中间态”代替跨资源事务

SQLite transaction 管不了磁盘 rename,文件系统操作也不能跟 DB insert 一起提交。这里没有假装二者原子,而是让每个失败点都落在一种 GC 看得懂的状态里。

5.1mutation lane:只串行会改 store 的操作

apps/daemon/src/storage/blobs.ts进程内单通道
let mutationLane: Promise<unknown> = Promise.resolve()

function runExclusive<T>(operation: () => Promise<T>): Promise<T> {
  const result = mutationLane.then(operation, operation)
  // 前一次失败也要把 lane 接续下去
  mutationLane = result.then(
    () => undefined,
    () => undefined,
  )
  return result
}

putderefOwnergc 都走这条 lane。原因是 put 在“object row 已插、ref 尚未插”之间会等待文件 I/O;如果 GC 此时进入,会误把在途 object 当成零引用垃圾。串行后,每次 mutation 看到的都是上一轮已经稳定的状态。

校验、文件名清理和 SHA-256 不碰共享状态,#140 把它们移到 lane 外,避免一个 50 MiB 文件的 hash 把回收操作也堵住。resolveusage 是读操作,不进 lane。

5.2“owner 还活着”有一个统一定义

apps/daemon/src/services/sessions.tssession liveness
liveSessionIds(ids: string[]): Set<string> {
  const alive = new Set<string>()
  for (let start = 0; start < ids.length; start += 500) {
    const rows = this.db
      .select({ id: agentSessionsTable.id })
      .from(agentSessionsTable)
      .where(and(
        inArray(agentSessionsTable.id, ids.slice(start, start + 500)),
        notPurgedSessionWhere(),
      ))
      .all()
    for (const row of rows) alive.add(row.id)
  }
  return alive
}

row 存在且未被 purge_requested_at 标记,就算活。这个判断故意不看 archived_at:archive 是可逆停车,历史图片必须保留;purge 才是进入回收管线。

同一 liveness 语义被三处复用:put 发布 ref 前检查 owner;GC 判断哪些 ref 已成死引用;手工 cleanup 拒绝清理活 owner。当前只理解 session;未知 owner type 全部按活处理,是“宁可不删、不能误删”的 fail-safe。

5.3不同失败点落到什么状态

失败位置留下的状态谁来收口
object row 之后、文件写失败零 ref object row;可能有 root tempGC sweep 2 / sweep 3
文件成功,最终 owner check 失败object row + 完整文件,无 refGC sweep 2
进程在 temp rename 前崩溃.tmp-* 或零 ref row启动 GC
deref 删除 ref 后,object rm 失败零 ref row + 文件;usage 仍看得见后续全局 GC
磁盘文件被外部删掉,DB ref 尚在ref/object row 指向缺失文件turn 在开 run 前拒绝;同内容下一次 put 可重发 bytes

6旅程 A:上传一个文件,最终得到可发布的 ref

全景 · 从 multipart 到 object/ref
鉴权与 session gate
routes/blob.ts
读取 Buffer / 50 MiB cap validate + SHA-256
storage/blobs.ts
object row + atomic write sync owner recheck + ref row

A.1route 只负责 HTTP 边界,store 负责最终正确性

POST 先无条件校验 daemon token,再用 getRunnableSession() 拒绝未知、archive 或 purge 中的 session。上传是完整缓冲,不是流式:Hono bodyLimit 用“50 MiB + multipart 余量”粗拦截,读取成 Buffer 后再按真实 byte length 检查一次。

apps/daemon/src/routes/blob.tsroute 到 blob store 的交界
const bytes = Buffer.from(await file.arrayBuffer())
if (bytes.byteLength > perFileMaxBytes) {
  throw new AppError({ code: EyrieErrorCode.validation.failed })
}

const stored = await blobs.put({
  ownerType: 'session',
  ownerId: params.sessionId,
  name: file.name,
  mimeType: file.type,
  bytes,
})

return c.json({
  ref: stored.refId,
  name: stored.name,
  mimeType: stored.mimeType,
  sizeBytes: stored.sizeBytes,
}, 201)

store 自己再次执行大小、owner、MIME、空文件和文件名 gate。这不是重复劳动:未来任何非 HTTP caller 也必须受同一条底层约束。

A.2输入清洗与内容寻址

MIME 先取小写 type/subtype essence,再要求严格 token/token;允许 image、text、PDF,但明确拒绝 SVG、HTML、JavaScript、XML。#140 新增严格 token 是为了挡住 image/svg+xml,xtext/html x 这类会先命中宽 allowlist、又绕开精确 denylist 的声明。

文件名只作展示信息:取 basename、删控制字符、去前导点、trim,按 UTF-8 最多保留 200 bytes,短扩展名尽量保留。真正磁盘路径只来自 SHA-256,所以客户端文件名不会参与路径拼接。

A.3两次 owner 检查:一次省工,一次保正确

apps/daemon/src/storage/blobs.tsput 的最终顺序(真实代码裁剪)
const mimeType = validateUpload(input, maxBytes)
const name = sanitizeFilename(input.name)
const hash = createHash('sha256').update(input.bytes).digest('hex')

return runExclusive(async () => {
  await assertOwnerAlive?.(input.ownerType, input.ownerId)

  db.insert(blobObjectsTable)
    .values({ hash, sizeBytes, createdAt: now })
    .onConflictDoNothing({ target: blobObjectsTable.hash })
    .run()
  await writeObjectAtomically(blobsDir, hash, input.bytes)

  assertOwnerAliveSync?.(input.ownerType, input.ownerId)
  db.insert(blobRefsTable).values({
    id: refId, hash,
    ownerType: input.ownerType, ownerId: input.ownerId,
    name, mimeType, createdAt: now,
  }).run()
})

异步检查在写盘前,owner 已死就直接失败,避免白写。真正封口的是写盘后的同步检查:它与 SQLite ref insert 之间没有 await,所以 tombstone 不能插进“查询还活着”与“发布 ref”之间。

#113 / #140 早期 · async 最终检查
同步 SQLite 查询返回 alive
await 仍让出 microtask
并发 delete 写 tombstone
put 插入死 owner 的 ref,并可能回答 201
最终 · sync critical section
同步查询 owner liveness
零 yield:生成 ref id 并执行 insert
下一次 microtask 运行时,ref 已经发布

A.4atomic write 的保证边界

新内容先写 blob root 下的 .tmp-<uuid>,再 rename 进两字符 shard。普通进程 crash 不会让读者看到半文件。它没有 fsync,所以不承诺断电级 durability;同 hash 文件已存在时只校验长度,不重新 hash。#140 还把 root/shard/object 权限收紧为 0700/0700/0600,并在 dedup fast path 重新 chmod。

排查路标 · 旅程 A
症状从哪下手
上传 400,像是大小或 MIME 问题routes/blob.ts 看 bodyLimit / post-read cap;storage/blobs.tsvalidateUpload
上传在 session 删除附近返回 404/不存在services/blob-liveness.ts 看 async/sync assertion;sessions.tsliveSessionIds
DB 有 object row、没有 refstorage/blobs.ts 看 put 在 write/final check/insert 哪一步失败;随后看 gc
相同文件未重新写盘或权限不对writeObjectAtomically 的 size-match dedup fast path 与 chmod。

7旅程 B:下载已有 ref,或把它交给 Agent

全景 · 同一个 ref 的两种消费方式
owner-scoped resolve
storage/blobs.ts
HTTP stat + Range + stream
routes/blob.ts
image gate + provider path
agent/service.ts

B.1resolve 把跨 owner 检查放进 store

apps/daemon/src/storage/blobs.ts三元组匹配
const row = db
  .select({
    hash: blobRefsTable.hash,
    name: blobRefsTable.name,
    mimeType: blobRefsTable.mimeType,
  })
  .from(blobRefsTable)
  .innerJoin(blobObjectsTable, eq(blobRefsTable.hash, blobObjectsTable.hash))
  .where(and(
    eq(blobRefsTable.id, input.refId),
    eq(blobRefsTable.ownerType, input.ownerType),
    eq(blobRefsTable.ownerId, input.ownerId),
  ))
  .get()

if (!row) return null

即使猜到另一个 session 的 ref id,也只得到 null。把这条 containment 放在 store 里,HTTP 和 AgentService 不必各自复制“ref 是否属于当前 session”的判断。

B.2下载:archive 可读,purge 不可读

GET/HEAD 用 getSession(),不是 getRunnableSession()。archived session 不能再上传或开 turn,但历史 timeline 图片必须继续下载;purge-marked session 已进入 reaper,按不存在处理。

下载支持完整 200、单 Range 206 和不可满足 416。文件从磁盘直接 stream;header 使用 ref 上的原名与 MIME,并强制 attachment + nosniff。Unicode 文件名同时给 ASCII fallback 和 RFC 5987 filename*=

读路径刻意不进 mutation lane。resolve 后、stat 前被 GC unlink 会得到 404;stat 后、stream open 前被删可能中止 body。这是代码明确接受的窗口,换取下载不阻塞所有写与回收。

B.3Agent turn:wire 里存 ref,provider 才看到 path

apps/daemon/src/agent/service.ts图片 ref 的服务端解析
const resolved = await this.blobs.resolve({
  ownerType: 'session',
  ownerId: sessionId,
  refId: part.ref,
})
if (!resolved) {
  throw new AppError({ code: EyrieErrorCode.validation.failed })
}
if (!/^image\//.test(resolved.mimeType)) {
  throw new AppError({ code: EyrieErrorCode.validation.failed })
}
try {
  await stat(resolved.path)
} catch {
  throw new AppError({ code: EyrieErrorCode.validation.failed })
}
return { type: 'image', path: resolved.path, mimeType: resolved.mimeType }

这一步发生在 beginTurn 之前,所以坏 ref 不会留下 run row 或 runner handle。turn payload 不允许客户端自己声明 path/MIME;服务端只信 ref 元数据。合法的 text/PDF blob 仍不能冒充 image part。#140 额外 stat 磁盘文件,避免“DB 还在、bytes 被外部删了”直到 provider 启动后才以 ENOENT 失败。

provider 收到绝对路径;持久化的 user message event 仍保留 opaque ref。也就是说,host path 是一次运行时翻译,不进入可重放的 timeline。

排查路标 · 旅程 B
症状从哪下手
跨 session 下载返回 404storage/blobs.tsresolve 三元组;这是预期隔离。
archive 后能下载但不能上传routes/blob.ts 对比 POST 的 getRunnableSession 与 GET 的 getSession
turn 在创建 run 前 validation.failedagent/service.tsresolveImagePart:ref、owner、MIME、stat 四个 gate。
大文件下载中途断流routes/blob.ts 看 resolve/stat/stream 的非串行窗口,再看同期 deref/GC。

8旅程 C:删除、owner deref 与全局 GC

全景 · 用户删除到物理回收
提交 tombstone
use-case / repository
API 已隐藏实体并返回 maintenance reaper
maintenance-tick.ts
derefOwner
storage/blobs.ts
删 session / task / project row

C.1删除成功的边界是 tombstone,不是磁盘 I/O 全部成功

session/task/project 删除先写 purge_requested_at,业务读路径立刻把它当不存在。foreground 只做 best-effort runner teardown、关闭 fan-out,并请求 maintenance;文件删除不堵在用户请求里。

apps/daemon/src/maintenance/maintenance-tick.tssession 文件清理顺序
private async purgeSessionFiles(sessionId: string): Promise<boolean> {
  try {
    await this.rawWriter.purgeSession(sessionId)
    await this.derefSessionBlobs(sessionId)
    return true
  } catch (err) {
    logger.warn({ err, sessionId },
      'best-effort session filesystem purge failed')
    return false
  }
}

完整顺序是:删 event rows → 删 raw event files → deref blob → 删 runs/session → 清 task worktree → 删 leaf task → 删 project → incremental vacuum。raw purge 或 deref 失败时 session row 继续 tombstoned,backlog retry 会再跑;上层 task/project 也因仍有 child 而不会提前消失。

C.2deref 只看这个 owner;GC 才看全 store

apps/daemon/src/storage/blobs.tsowner-scoped deref(节选)
const hashes = db
  .select({ hash: blobRefsTable.hash })
  .from(blobRefsTable)
  .where(and(
    eq(blobRefsTable.ownerType, ownerType),
    eq(blobRefsTable.ownerId, ownerId),
  ))
  .all()
  .map((r) => r.hash)

if (hashes.length === 0) return { bytesReclaimed: 0 }

db.delete(blobRefsTable)
  .where(and(
    eq(blobRefsTable.ownerType, ownerType),
    eq(blobRefsTable.ownerId, ownerId),
  ))
  .run()

const bytesReclaimed = await reapZeroRefObjects(
  db, blobsDir, [...new Set(hashes)],
)

如果 hash 仍被别的 owner 引用,object 保留;只有这个 owner 刚刚让它降到零引用时才删。这样 bytesReclaimed 可以归因于本次 teardown,另一个 owner 遗留的 poison file 也不会让当前 owner 的删除失败。

GC 则分三扫:按 owner type 批量查 liveness 并删死 ref;全表清零引用 object;最后删 DB 不认识的 .tmp-* 和 shard 文件。每 500 个 ref 一批,避免 SQLite 参数上限。

derefOwner
目标是“拆掉一个 owner”
候选 hash 只来自该 owner 的 refs
自身 object rm 失败要传播,让 tombstone 暂留
结果是 owner-attributed bytes
gc
目标是“修整个 store”
扫描死 owner ref、所有 zero-ref、纯磁盘 orphan
单 object / stray rm 故障隔离,继续下一项
结果是全局回收统计

C.3文件先删、row 后删;但 deref 不是回滚事务

apps/daemon/src/storage/blobs.ts统一删除 primitive
const path = objectPath(blobsDir, hash)
const info = await lstat(path).catch(() => null)
try {
  await rm(path, { force: true })
} catch (error) {
  if (onRmFault === 'propagate') throw error
  return null
}
db.delete(blobObjectsTable)
  .where(eq(blobObjectsTable.hash, hash))
  .run()
return info?.isFile() ? info.size : 0

先删 bytes,再删 row:rm 失败时 row 仍在,usage 仍能看见,不会把泄漏藏成“只有扫磁盘才知道”的孤儿。回收量用删除前实际 lstat 大小,而不是 DB 的逻辑 size;文件已丢失就计 0,被截短就计实际短文件。

一个容易误读的细节:deref 先删 refs,再删 object。若真实 object rm 在这之后失败,下一轮 maintenance 调 deref 时已经找不到 refs,会返回 0,然后可以完成 session row 删除;残留 zero-ref object 最终由全局 GC 清。这是最终一致性,不是把第一次 deref 完整回滚后重试。

C.4archive 是停车,不进入回收

archived session 的 row 仍存在,liveness probe 把它当 alive;GC 不删其 ref,HTTP GET 仍可读,unarchive 后原图片继续可用。task/project 删除则把下属 session 都 tombstone,reaper 对每个 session 分别 deref;task/project 自己当前不直接持有 blob。

排查路标 · 旅程 C
症状从哪下手
用户已看不到 task,但磁盘 bytes 还在maintenance-tick.ts 看 tombstone backlog、purgeSessionFiles 和 retry。
删 owner A 被 owner B 的坏文件拖住storage/blobs.tsreapZeroRefObjects 是否只收 A 的 hashes;这是 #140 的关键修复。
usage 仍计入一份没有 ref 的 bytes先看 object rm 是否失败;随后看 store-wide gc 的 zero-ref sweep。
archive 后附件消失sessions.liveSessionIds 是否仍 archive-blind;检查是否误走 purge/deref。
GC 一轮只清了一部分removeZeroRefObject(..., 'isolate')removeOrphanFiles 的 per-entry fault isolation。

9配置、typed KV、usage 与启动:blob 之外的 storage 组件

全景 · daemon 启动时的 storage 准备
resolvePaths materializeConfig open DB / create services ensure blob root drop legacy once startup GC

D.1一个 home,路径只在一处推导

EYRIE_HOME 未设时为 ~/.eyrie。它是 bootstrap path,不能放进 config,因为 config 自己就在它下面。resolvePaths() 统一给出 DB、config、blobs、token、lock;agent event file tree 仍由自己的 writer 管理,不在 registry 中。

apps/daemon/src/storage/paths.tsowned path registry
return {
  home,
  db: join(home, 'eyrie.db'),
  config: join(home, 'config.json'),
  blobs: join(home, 'blobs'),
  token: join(home, DAEMON_TOKEN_FILE),
  lock: join(home, 'eyrie.lock'),
}

D.2config 的第一不变量:坏文件不能阻止 daemon 启动

v1 schema 只有 versionportretention.blobsMaxBytes?。JSONC 接受注释和 trailing comma;文件缺失、解析失败或整体 schema 不合法都退回 defaults。未知 key 通过 passthrough 保留,EYRIE_PORT 最后覆盖文件值,无效 override 只 warn。

apps/daemon/src/storage/boot.tsfirst-boot materialization
export function materializeConfig(configPath: string): EyrieConfig {
  if (existsSync(configPath)) return loadConfig(configPath)
  const defaults = configSchema.parse({})
  try {
    saveConfig(configPath, defaults)
  } catch (err) {
    logger.warn({ err, configPath },
      'config materialization failed; using in-memory defaults')
  }
  return loadConfig(configPath)
}

saveConfig 用 0600 temp + rename;#140 明确首次写文件只是“方便用户手改”,写失败不能升级为 boot failure。#113 里未消费的 defaultProviderId/defaultModel 被 #140 从 typed schema 删除;输入中同名未知 key 仍可因 passthrough 留存。

D.3typed KV:机器 marker,不是用户数据仓库

KV 复用已有 metadata(key,value,updated_at) 表。get 会 JSON.parse 再按调用方 Zod schema 验证;缺失、损坏或 shape 过期都返回 null。set JSON 编码后 upsert;#140 显式拒绝会被 JSON.stringify 丢掉的 undefined/function,避免深处才以 NOT NULL driver error 失败。

第一个生产 consumer 是 uploads.legacyCacheDropped = true。旧 cache/uploads 删除成功后才写 marker;删除失败不写,下次启动重试;marker 一旦存在,以后不再碰这个路径。旧 ref 不迁移,因为旧上传被定位为可丢缓存。

D.4启动恢复有两条互补管线

prepareBlobStore 保证 root 存在并重申 0700,然后 best-effort 删除旧缓存、跑一次全局 GC。只有 blob root 无法创建/设权会阻止启动;janitor 和 GC 失败都 warn 后继续。随后 maintenance 自己的 requestRun() 恢复上次 crash 留下的 tombstone tree。

两类恢复别混:blob GC 修的是 ref/object/file 三层孤儿;maintenance reaper 修的是 session/task/project 业务树。前者不懂上层删除顺序,后者不做全 store 垃圾扫描。

D.5usage / cleanup / gc 通过 tRPC 暴露

packages/api/src/trpc.tsstorage 控制面
const storageRouter = router({
  usage: publicProcedure.query(({ ctx }) =>
    callService(() => ctx.services.storage.usage())),
  cleanup: publicProcedure
    .input(storageCleanupSchema)
    .mutation(({ ctx, input }) =>
      callService(() => ctx.services.storage.cleanup(input))),
  gc: publicProcedure.mutation(({ ctx }) =>
    callService(() => ctx.services.storage.gc())),
})

usage 通过 stat 计 DB/WAL/SHM、config、token、lock,blob 用 DB 聚合,不扫磁盘。cleanup 先用 liveness 拒绝活 owner,再做 owner-scoped deref;gc 是全局 janitor。当前没有 production UI/CLI 调这三个 procedure,启动只直接调用 service 的 GC。

排查路标 · 配置与启动
症状从哪下手
config 写坏后 daemon 用了默认端口storage/config.tsreadJsoncOrDefaults 与 env layering。
旧 cache/uploads 每次启动都尝试删除storage/boot.ts 看 KV marker 是否成功写入。
startup GC 失败但 daemon 仍启动prepareBlobStore:这是 best-effort 约定;再查告警和手工 GC。
usage 与 du ~/.eyrie 对不上storage/usage.ts;当前明确不计 agent-events tree。

10Renderer prefs:同一 PR,另一个存储域

#113 还统一了 theme/locale 的本地偏好,但它不是把 renderer 状态搬进 daemon storage。主题必须在 React 首次绘制前同步读出,否则先闪默认主题;所以后端仍是 localStorage,变化只是 key、校验、默认值、序列化和清理能力被收进 typed registry。

apps/desktop/src/renderer/lib/prefs.ts每个 preference 的完整边界
type PrefSpec<T> = {
  key: string
  validate: (raw: string) => T | null
  serialize(value: T): string
  fallback: T
}

const REGISTRY = {
  theme: {
    key: 'eyrie.theme',
    validate: (raw) => Object.hasOwn(palettes, raw)
      ? (raw as ThemeName) : null,
    serialize: (value: ThemeName) => value,
    fallback: defaultTheme,
  },
  // locale 同样登记 validate / serialize / fallback
}

读取时 localStorage 缺失、抛错或值不再合法,返回 fallback;所谓 self-healing 是“读边界不让脏值进入业务”,不会物理改写旧值。写入失败也被吞掉,只损失跨重启持久化,不影响已经切换的内存 UI。

#140 把 #113 的通用 String(value) 改成每项自己的 serialize。对当前两个字符串没有可见行为变化,但 registry 终于形成 raw --validate→ typed --serialize→ raw 的对称边界,未来结构化 preference 不会落成 [object Object]

locale primitives 还从会立即 i18n.init()i18n/index.ts 拆到无副作用 i18n/locales.ts,避免 i18n → locale-preference → prefs → i18n 的初始化环。registry 目前只管理 scalar prefs;eyrie.workbench.layout 等 Zustand 持久状态不在 keys()/clear() 范围内。

排查路标 · Renderer prefs
症状从哪下手
主题重启后回默认prefs.ts 看 key、validate 和 localStorage 异常;theme-preference.ts 看委托路径。
locale 初始化出现循环或 undefinedi18n/locales.ts 是否保持无副作用;i18n/index.ts 只在最后 init。
prefs.clear() 后 layout 仍在这是 registry 的刻意边界;layout 由独立 Zustand persist 管理。

11心智模型补丁:以后读这块代码要换掉哪些旧假设

一个上传 ref 可以反推出 session 目录和文件名。 ref 是无布局含义的 opaque id;只有 store 用 owner 三元组解析。
磁盘路径由 SHA-256 决定,文件名只存在 ref 元数据里。
删 session 就递归删它的上传目录。 删 session 先 tombstone;reaper 删除 refs,只回收因此失去最后引用的 objects。
共享内容不能因一个 owner 删除而消失。
只要更早检查过 session 存在,上传就可以安全发布。 route gate 只是第一层;store 在写盘前 fail-fast,并在 ref insert 前做同步最终复查。
最终检查与 insert 中间不能有 microtask yield。
一次 cleanup 顺手清掉所有零引用文件也算自愈。 derefOwner 只处理目标 owner 的 hashes;历史孤儿只由 gc 全局收敛。
这同时保护故障隔离和 bytesReclaimed 的归因。
archive 和 delete 都意味着附件可以清理。 archive 是可逆停车,ref 和 bytes 必须保留;只有 purge/tombstone 进入回收。
DB row 与磁盘文件会像一个 transaction 一样一起成功或失败。 不存在跨资源事务;每个中间态必须可见、可统计,并能被 GC 收口。
storage usage 等于整个 EYRIE_HOME 的磁盘占用。 它计 DB、config、blob、token、lock;明确不计 agent-events
renderer prefs 应该跟新的 daemon storage 合并。 theme/locale 仍留在同步 localStorage;新抽象只是统一 typed registry。

12新词表

Blob 模型
blob object按 SHA-256 标识的一份真实 bytes;相同内容只存一份。
blob ref一个 owner 对 object 的一次命名引用,带 ref id、文件名和 MIME。
content-addressed身份与路径由内容 hash 决定,不由用户文件名决定。
polymorphic ownerownerType + ownerId 组成的通用归属;当前正式理解 session。
zero-ref objectDB 有 object row,但没有任何 ref;是 GC 可识别的恢复状态。
orphan file磁盘有文件,但 DB 没有对应 object row。
生命周期与并发
deref删一个 owner 的 refs,并回收这次刚掉到零引用的 objects。
liveness probe批量判断一组 owner 哪些还存在且未 purge。
mutation lane让 put/deref/gc 串行看到稳定状态的进程内 Promise 队列。
tombstone业务上已删除、等待 maintenance 做完副作用后再物理删 row。
TOCTOU“检查时成立、使用时已变”的竞态;这里具体是 owner check 与 ref insert 的 microtask 缝隙。
observed bytes删除前从磁盘实际看到的大小;区别于 DB 的 logical size。
外围存储
JSONC允许注释和 trailing comma 的 JSON 配置。
machine markertyped KV 里可重建、用来控制一次性动作的小状态。
renderer prefs registry集中登记 scalar preference 的 key、读校验、写序列化和 fallback。

13测试与风险地图

整体 diff 有 2,745 行测试,占 53.4%。我在最终分支实际运行了 10 个关键测试文件、106 个测试,全部通过;PR #140 描述另称全量 2,665 个测试通过,这一数字在本文中只作为作者提供的信息,不冒充本次重新执行的结果。

有明确测试兜底

  • hash 去重、per-ref MIME、owner-scoped resolve。
  • MIME/大小/空文件/文件名清洗。
  • atomic publish、temp 清理、缺失/截短文件重发。
  • put 与 GC/deref 的 mutation lane。
  • 同步 owner recheck 与 insert 的 microtask ordering。
  • owner deref、共享 object、poison object 隔离。
  • GC dead/live owner、零引用、磁盘孤儿、幂等。
  • HTTP auth、HEAD、Range、archive、跨 session。
  • Agent ref/MIME/stat/opaque event 持久化。
  • session/task/project tombstone 与 maintenance 回收。
  • config、boot、KV、paths、tRPC usage/cleanup/gc。
  • prefs fallback、异常吞掉、keys/remove/clear。

薄冰 / 已知边界(事实)

  • 🟡usage 不计 agent-events,不是完整 home du。
  • 🟡下载不与 GC 串行,stat 后仍可能被 unlink。
  • 🟡同 hash 现有文件只验长度,不重新 hash。
  • 🟡atomic rename 未 fsync,不承诺断电级 durability。
  • retention.blobsMaxBytes 当前 parked/inert,无 scheduler。
  • storage tRPC 已有 contract,暂无 production UI/CLI 消费者。
  • config 目前只有 first-boot 写出,无 production 修改路径。
  • config 可改 daemon port,但默认 client URL 仍固定为 19514;自动发现不在本次范围。
  • 未知 owner type 被永久保留,扩展时要显式接 liveness。
  • 0600/0700 权限在实现中明确,核心 blob suite 没有直接断言 mode。
  • prefs 的结构化 serializer 路径目前没有真实结构化 entry。

13.1关键测试按层分布

测试层钉住的行为
blobs.test.ts · 30 testsobject/ref 模型、校验、并发、deref、GC、usage、microtask race。
blob-route.test.ts · 15 tests上传下载、token、HEAD、Range、archive、跨 session、两层 size cap。
storage-service.test.tslive cleanup 拒绝、archive 保留、session/task/project 删除与故障重试。
reaper-maintenance.test.ts文件清理顺序、tombstone 保留、backlog retry、task worktree。
agent-service-methods.test.tsref → path/MIME、文件消失、跨 owner、opaque event。
config/boot/kv/paths启动不抛、JSONC、env、marker、GC best-effort、typed read。
storage-trpc.test.tsschema → router → service → store 的 usage/cleanup/gc contract。
prefs.test.ts · 12 teststyped read fallback、write/remove/keys/clear 与 storage failure。
合并前没有从本次“解读”额外推导出的必办项。上面的薄冰是代码和 PR 已明确接受或暂未接线的边界,不是本报告在做 code review。#140 当前分支的 106 个 storage 关键路径测试已重新跑绿。

14验收提示与覆盖声明

14.1别把这些现象误判成“漏做”

14.2覆盖声明

整体范围 f9499ca3..45982542 的 69 个变更文件已按子系统全量精读:storage 核心与 DB 18 个文件;daemon/API/生命周期接入 40 个文件;desktop/CI/依赖/文档杂项 11 个文件;未匹配文件为 0。每位阅读者分别查看了 #113 与 #140 两段 diff,并以 base 侧代码确认 before。

本报告中出现的所有代码片段,我又在最终工作树中亲自重读并裁剪;没有直接引用二手代码转述。机械生成物、旧实现删除和 fixture 跟随也纳入文件覆盖,但报告篇幅按设计密度分配。

验证方面,本次实际执行了 blob core、HTTP route、storage boot/config/KV/paths/service/tRPC、maintenance 和 prefs 共 10 个文件、106 个测试,结果为 106 passed。未重新执行全仓 2,665 tests / build / lint,因此不把 PR 作者的全量验证声明改写为本次结论。