feat/storage-integration:把「除关系型 DB 外一切落盘」收进一个内容寻址的 blob 总管

eyrie(PR #113) · f89b62b..HEAD · 2026-07-06 · 自包含,读完即弃

12 commits
67 文件
+3543 / −917
~52% 是测试代码
类型:功能 + 修复 混合

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

1TL;DR

这个 PR 把一件散落的事收成了一件事:用户上传的文件字节以前住在「按 session 分目录、按文件名拼路径」的 cache/uploads/<sessionId>/<id>-<name> 树里、旁边还搁一个 .meta.json sidecar;现在换成内容寻址(content-addressed)的 blob 总管:字节按其 sha256 哈希去重后存进 blobs/<两位分片>/<hash>,元数据(谁拥有、原文件名、mime)进两张 DB 表。动机是 CLAUDE.md 里那条存储章程——「除关系型实体外,每种落盘状态都有唯一指定的家;文件走磁盘 blob 店、由 DB 元数据引用、owner 删了就回收」。

同批还夹带了一组把这套东西做扎实的修复:上传熬过并发硬删的 TOCTOU 竞态、MIME 黑名单被「分号前加空格」绕过的漏洞、boot 期一次性清理失败不该拖垮启动、config 端口一处解析、以及桌面端把 theme/locale 偏好收进一个可枚举的 localStorage 注册表。

2变更地图(称重)

称重结论要诚实:这不是一个纯搬运 PR。核心 storage/blobs.ts(476 行新增)是承载设计的算法代码——去重、原子落盘、三路 mark-and-sweep、互斥车道全在里面;测试占了约一半体量(六个新 storage-*.test.ts + 598 行的 blobs.test.ts)。真正「可放心略过」的机械量很小:删掉的旧 upload-store.ts 及其测试(−522 行)是被内容寻址店整体取代的等价物,migration 快照 JSON 是 drizzle 生成产物。

daemon/storage
~810 行 · 新店核心
daemon/tests
~1780 行 · 测试
daemon/services
~130 行 · 装配 + 存活闸
daemon/routes
~160 行 · blob HTTP
daemon/agent
~66 行 · turn 解析
packages/api
~110 行 · DTO + 路由
packages/db
~160 行 · 表 + migration
desktop/renderer
~110 行 · prefs 注册表
子系统设计重心(要细读)可放心略过
storage/blobs.tsput 的车道内重查 + 去重落盘顺序、三路 GC、validateUpload/mimeEssence
services/blob-liveness.ts探针(GC 用)与断言(put 用)两个不同用途的存活判定
routes/blob.ts动词无差别验身(堵 HEAD 泄露)、Range 解析、流式下载RFC 5987 文件名编码细节
services/upload-store.ts整文件删除,被内容寻址店取代
migrations/meta/*.jsondrizzle 生成的快照,非手写

3架构一图流

变的是「字节以什么身份落盘、谁能读到」这条链。旧世界里字节的身份 = 路径(session 目录 + id 前缀文件名),mime 存在旁边的 sidecar 文件;新世界里字节的身份 = 内容哈希,所有元数据(owner、名字、mime)进 DB,一个 opaque refId 同时当上传回执和下载凭证。

以前 · 路径即身份

POST /blob
storeUpload 写
cache/uploads/<sid>/<id>-<name>
.meta.json sidecar
turn 读 sidecar 拿 mime
AgentService
删 session
rm -rf session 目录
整棵子目录

现在 · 内容即身份

POST /blob
blobs.put 去重
blobs/<xx>/<hash> + 两张 DB 表
blob_refs(含 mime)
turn/下载 resolve 拿路径+mime
AgentService / GET
删 session
derefOwner + GC 兜底
仅回收零引用对象

三条通道各自有了新性质:——相同字节只落一份盘(去重);——mime 跟着 ref 走(per-ref,不是 per-object),所以两个人上传同样的字节但声明不同 mime 不会串味;——不再 rm -rf 一整棵目录,而是先解引用、只回收「掉到零引用」的对象,别的 owner 还引用着的对象留着。

4数据与状态先行

先把这次新增的形状过一遍,后面旅程直接用这些词,不再解释。只看形状不讲行为。

两张 DB 表:对象与引用分家

packages/db/src/index.tsschema(节选)
// 一行一个不同的内容哈希 —— 去重点就在这
export const blobObjectsTable = sqliteTable('blob_objects', {
  hash: text('hash').primaryKey(),        // sha256 hex,同时也是磁盘文件名
  sizeBytes: integer('size_bytes').notNull(),
  createdAt: integer('created_at').notNull(),
}, (t) => ({ sizePositive: check('...', sql`${t.sizeBytes} > 0`) }))

// 一个 owner(session / project …)对某个对象的具名引用;GC 的最小单位
export const blobRefsTable = sqliteTable('blob_refs', {
  id: text('id').primaryKey(),            // 返回给客户端的 opaque 句柄
  hash: text('hash').notNull().references(() => blobObjectsTable.hash),
  ownerType: text('owner_type').notNull(),// 'session' | 'project' … 多态,故意不做 FK
  ownerId: text('owner_id').notNull(),
  name: text('name').notNull(),           // 原文件名,per-ref
  mimeType: text('mime_type').notNull(),  // per-ref 声明类型,不是 per-object
  createdAt: integer('created_at').notNull(),
}, /* owner_type 非空 / owner_id 非空 / (owner_type,owner_id) 索引 / hash 索引 */)

关键设计:ownerType/ownerId 不是外键——它是多态的,一个对象可以被 session 引用、将来也能被 project 引用,所以身份判活交给运行时探针,而不是 DB 级联。mime 挂在 ref 上而非 object 上,是为了让「同字节不同声明 mime」两条 ref 各留各的标签。

put 的入参与回执

apps/daemon/src/storage/blobs.ts类型
export type BlobPutInput = {
  ownerType: string; ownerId: string
  name: string       // 原始文件名,会被 sanitize
  mimeType: string   // 声明 mime,进 allowlist 前先算 essence
  bytes: Buffer      // 原始字节,就地哈希做内容寻址
}
export type BlobPutResult = {
  refId: string; hash: string; name: string
  mimeType: string   // 落库的规范化 type/subtype(不是客户端原样)
  sizeBytes: number
}

对外 DTO:占用面板要的形状

packages/api/src/dto.ts新增 DTO(节选)
export type StorageUsageDto = {
  total: number      // database + blobs + runtime
  database: number   // eyrie.db 加 -wal / -shm 两个 sidecar
  blobs: BlobUsageDto
  runtime: number    // token + lock,可忽略,为完整性计入
}
export type BlobUsageDto = {
  total: number      // 所有不同对象 size 之和,共享内容只算一次
  byOwnerType: Array<{ ownerType: string; bytes: number }>
}
路由已上线,UI 还没接appRouter.storage 三个 procedure(usage query、cleanup mutation、gc mutation)本 PR 已注册并有测试兜底,但桌面端「占用明细 + 清理按钮」是纯消费侧 UI,本期不做(见第 14 节验收提示)。

5底座:三条共用机制

四条旅程共享同三个机制。先在这里讲透,旅程里就只讲各自特有的逻辑。

5.1内容寻址:对象与引用为什么分家

「内容寻址」一句话:文件的身份是它字节的哈希,不是它的名字或路径。两个人上传一模一样的图,哈希相同,磁盘只存一份;但他们各自需要一个能独立命名、独立回收的把手——那就是 ref。所以一份字节(object,按 hash 主键)可以挂多个 ref。回收的单位是 ref,不是 object:只有当某个 object 掉到「零 ref」,它的磁盘字节才真正删。

5.2单车道互斥(mutation lane)

better-sqlite3 每条语句各自原子,但 put 有一个「半挂」窗口:它先插 object 行、再 await 落盘、再插 ref 行——在落盘那次 await 让出事件循环时,object 已在但 ref 还没有。如果此刻一个 gc() 挤进来,它会把这个「零 ref 对象」当垃圾收走,导致随后的 ref 插入失败、上传丢失。解法是让所有会变更的操作(put / derefOwner / gc)排进同一条串行车道:

apps/daemon/src/storage/blobs.tscreateBlobs 内
let mutationLane: Promise<unknown> = Promise.resolve()
function runExclusive<T>(operation: () => Promise<T>): Promise<T> {
  const result = mutationLane.then(operation, operation)
  // 失败也让车道活着,一次失败的变更不能掐断后续
  mutationLane = result.then(() => undefined, () => undefined)
  return result
}

mutationLane.then(operation, operation) 两个参数都是 operation:无论前一笔成功还是失败,本笔都接着跑——车道不会因为一次异常就断链。路径(resolveusage)不进车道,因为它们不改状态,多读并发无害。

5.3owner 存活:探针 vs 断言(一对孪生,用途相反)

同一个问题「这个 owner 还活着吗」,这个 PR 给了两个函数,因为用途相反。GC 要的是批量、返回活着的集合(把不在集合里的 ref 收掉);put 要的是单个、死了就抛(拦住给死 owner 发 ref)。两者都对 archive「视而不见」——只看行是否物理存在,因为归档是可逆的、归档 session 的图必须留着。

apps/daemon/src/services/blob-liveness.ts孪生构造器
// 探针:GC 用,返回「还活着的 id 集合」
export function createOwnerLivenessProbe(sessions: SessionService): OwnerLivenessProbe {
  return async (ownerType, ownerIds) => {
    if (ownerType === 'session') return sessions.liveSessionIds(ownerIds)
    return new Set(ownerIds) // 不认识的 owner 类型一律报活,GC 绝不收它推理不了的 ref
  }
}
// 断言:put 用,死了就抛 session.notFound
export function createOwnerAliveAssertion(sessions: SessionService): BlobOwnerAssertion {
  return async (ownerType, ownerId) => {
    if (ownerType !== 'session') return // 同样:不认识的类型放行(opt-in 语义一致)
    if (!sessions.liveSessionIds([ownerId]).has(ownerId)) {
      throw new AppError({ code: EyrieErrorCode.session.notFound })
    }
  }
}

「不认识的 owner 类型一律放行」是刻意的对称:GC 不敢收它推理不了的东西,put 也不拦它管不了的东西——将来加一个 owner 类型,两处各加一个分支即可 opt-in。

6旅程 A:上传一张图(POST /api/blob/:sessionId

这是最主干的一条:从 HTTP 请求进来,到磁盘上多一份去重字节、DB 里多一行 ref、客户端拿到一个 opaque refId 结束。走通它你就掌握了这套存储的写入侧全部关口。

全景 · 涉及 4 个文件
动词验身
routes/blob.ts
session 可运行闸
routes/blob.ts
size/mime 校验
storage/blobs.ts
车道内重查 owner
blob-liveness.ts
去重落盘 + 建 ref
storage/blobs.ts

A.1进门三道闸:body 上限 → 可运行 session → post-read size

路由层先挡三关,都是「便宜的第一道防线」,权威判定留给下游 store。第一关是 Hono 的 bodyLimit(按 per-file + multipart 信封开销 算,溢出返回 400 而不是抛);第二关,上传是新一轮 turn 的输入,所以闸在「可运行」——归档 session 的旧图还能读(下面 GET 用 getSession),但拒绝往里传新图:

apps/daemon/src/routes/blob.tsPOST 处理器(节选)
// 上传是新 turn 输入,闸在可运行性:归档 session 拒新传,但保留旧 ref 可读
await sessions.getRunnableSession(params.sessionId)

const body = await c.req.parseBody().catch(() => {
  throw new AppError({ code: EyrieErrorCode.validation.failed })
})
const file = body.file
if (!(file instanceof File)) throw new AppError({ code: EyrieErrorCode.validation.failed })

const bytes = Buffer.from(await file.arrayBuffer())
// 复查 post-read size:file.size 是客户端声明的头,敌意客户端能少报
if (bytes.byteLength > perFileMaxBytes) throw new AppError({ code: EyrieErrorCode.validation.failed })

第三关 bytes.byteLength > perFileMaxBytes 复查真实字节长度——因为 file.size 是 multipart 头里客户端自己声明的数,能少报,真实读进来才是权威。

A.2MIME essence 闸:堵掉「分号前加空格」绕过(本 PR 的 must-fix)

这一跳是这次交叉审抓到的唯一 must-fix,值得单独放大。put 进门先过 validateUpload:校验 owner 非空、size 在 (0, maxBytes]、mime 在 allowlist 且不在 denylist。坑在 mime 匹配的口径

旧写法把 allowlist / denylist 都写成带 (;|$) 后缀容忍的正则、直接匹配原始字符串。媒体类型大小写不敏感(RFC 2045)、且参数分隔符前容许空白(RFC 9110 的 OWS)。于是 image/svg+xml ; charset=utf-8xml; 之间一个空格)——它能过 image/* allowlist,却滑过所有锚在裸 subtype 上的 denylist 模式,把一个能带内联 <script> 的 SVG 放进来。修复是先把 mime「削成 essence」(只留 type/subtype、去参数去空白、转小写)再匹配,并返回规范化值让调用方落库,保证存的值和校验的值永不分叉:

apps/daemon/src/storage/blobs.tsvalidateUpload + mimeEssence
const ALLOWED_MIME = [/^image\//, /^text\//, /^application\/pdf$/]
const DENIED_MIME = new Set(['image/svg+xml', 'text/html', 'text/javascript', 'text/xml'])

function validateUpload(input: BlobPutInput, maxBytes: number): string {
  // 空 owner 会造出永远收不掉的退化 ref(探针判不了它死没死),先拦
  if (input.ownerType.trim().length === 0 || input.ownerId.trim().length === 0)
    throw new AppError({ code: EyrieErrorCode.validation.failed })
  if (input.bytes.byteLength === 0 || input.bytes.byteLength > maxBytes)
    throw new AppError({ code: EyrieErrorCode.validation.failed })
  const mimeType = mimeEssence(input.mimeType)   // ← 先削成 essence 再判
  if (!ALLOWED_MIME.some((p) => p.test(mimeType)) || DENIED_MIME.has(mimeType))
    throw new AppError({ code: EyrieErrorCode.validation.failed })
  return mimeType                                 // ← 落库用这个规范值,不用客户端原样
}
// 声明 mime 的小写 type/subtype,参数和前后空白全丢掉
function mimeEssence(raw: string): string {
  return (raw.split(';', 1)[0] ?? '').trim().toLowerCase()
}

顺带把 denylist 从正则数组换成 Set——因为现在比的是规范化后的精确字符串,Set.has 比逐个正则测更直白也更快。

以前(可绕过)
image/svg+xml ; charset=utf-8
正则匹配原始串:过 /^image\//
denylist /^image\/svg\+xml(;|$)/因空格锚不上,漏
SVG 入库 ⚠️
现在(堵死)
image/svg+xml ; charset=utf-8
mimeEssence 削成 image/svg+xml
DENIED_MIME.has('image/svg+xml')命中,拒
400 validation.failed ✓

A.3车道内重查 owner:拦住熬过并发硬删的上传(TOCTOU 修复)

这一跳是这次的另一处硬核修复,讲清楚要先画时间线。「TOCTOU」= time-of-check to time-of-use:检查(route 那道 getRunnableSession)发生在字节被缓冲之前,而使用(真正写 ref)发生在缓冲之后。一个大文件缓冲要时间,这中间 session 可能被硬删掉。若不管,put 会给一个已死的 owner 发一行 ref,还回 201——制造一个 GC 也未必及时收的孤儿。

修复:在 put车道内、发布 ref 之前,再断言一次 owner 还活着:

apps/daemon/src/storage/blobs.tsput 内(节选)
put(input) {
  return runExclusive(async () => {
    const mimeType = validateUpload(input, maxBytes)
    const name = sanitizeFilename(input.name)
    if (!name) throw new AppError({ code: EyrieErrorCode.validation.failed })

    // 车道内重查 owner:route 的存活闸跑在缓冲字节之前,硬删可能落在那个窗口,
    // 否则这笔 put 会给死 owner 发 ref 还回 201。断言之后才落地的硬删仍安全 ——
    // 它的 derefOwner 排在这笔 put 后面,会把这行新 ref 收走。
    await assertOwnerAlive?.(input.ownerType, input.ownerId)
    // ...接下去才哈希、落盘、插 ref(见 A.4)
  })
}

注意注释后半句的巧思:断言只需拦住「断言之前就已死」的 owner;如果硬删晚于断言落地,也不怕——因为硬删的 derefOwner 同样进这条车道,会排在这笔 put 后面,把刚发布的新 ref 一并收走。两个方向都被车道的串行性兜住了。

A.4落盘 + 建 ref:先记对象、再落字节、后插引用

三步的顺序是刻意的,为了关掉 GC 竞态。先插 object 行(去重点)、再原子落盘、最后插 ref:

apps/daemon/src/storage/blobs.tsput 尾段
const hash = createHash('sha256').update(input.bytes).digest('hex')
// 先记对象(去重点),再靠原子 rename 让字节可见。行排在可见文件之前,
// 关掉 GC 竞态:removeOrphanFiles 只收「没有 object 行」的文件。
db.insert(blobObjectsTable).values({ hash, sizeBytes, createdAt: now })
  .onConflictDoNothing({ target: blobObjectsTable.hash }).run()
// 内容新才写盘;相同内容去重成一份。临时文件 + rename 原子,崩溃不会留半截文件。
await writeObjectAtomically(blobsDir, hash, input.bytes)
const refId = createId()
db.insert(blobRefsTable).values({ id: refId, hash, ownerType: input.ownerType,
  ownerId: input.ownerId, name, mimeType, createdAt: now }).run()
return { refId, hash, name, mimeType, sizeBytes }

onConflictDoNothing 让同一份内容第二次上传时跳过对象插入(去重)。writeObjectAtomically 里还有一层:若目标路径已存在且 size 吻合就直接返回(信任既有文件、走热路径),否则写到 store 根目录下的 .tmp-<uuid>、再 rename 进两位分片目录——读者和清扫只会看到完整文件,永不看到半截。

排查路标 · 旅程 A(上传)
症状从哪下手
上传某类文件被拒(400)storage/blobs.tsvalidateUpload / mimeEssence,看 essence 是否命中 DENIED_MIME
大文件上传偶发 session.notFoundblob-liveness.tscreateOwnerAliveAssertion——缓冲期间 session 被删是预期拒绝,不是 bug
上传成功但磁盘没多文件去重:内容已存在则 writeObjectAtomically 早返回;查 blob_refs 是否多了一行指向同一 hash
落库 mime 和客户端发的不一样预期:validateUpload 返回规范化 essence,put 存的是它

7旅程 B:turn 里用这张图(startTurn → resolveImagePart

图传上来后,用户在一轮对话里引用它。这条旅程短,但有一个安全要点:mime 从服务器记录的 ref 拿,绝不信 turn 输入——这是保护那些自己不做 mime 校验的 provider(如 Codex)的唯一一道闸。

全景 · 涉及 2 个文件
startTurn 先解析后认领
agent/service.ts
resolve 跨 session 守卫
storage/blobs.ts
服务端 mime 做 image 闸
agent/service.ts

顺序上,startTurn 先把图片 ref 解析成「session 作用域的文件路径 + 服务端 mime」认领这一轮 turn——这样一个坏 ref 会让请求直接失败,而不会留下一行孤儿 run 或泄漏一个 runner 句柄。解析每个 image part 时:

apps/daemon/src/agent/service.tsresolveImagePart
const resolved = await this.blobs.resolve({ ownerType: 'session', ownerId: sessionId, refId: part.ref })
if (!resolved) throw new AppError({ code: EyrieErrorCode.validation.failed })
// 每个 provider 共享的中央 image 闸:服务端记录的 mime 不是 image 就不是合法 image part。
// blob 店对内容不置可否,所以这是唯一保护「自己不校验 mime 的 provider(如 Codex)」的检查。
if (!/^image\//.test(resolved.mimeType)) {
  throw new AppError({ code: EyrieErrorCode.validation.failed })
}
return { type: 'image', path: resolved.path, mimeType: resolved.mimeType }

两点值得记:(1)mime 用的是 resolved.mimeType——即上传时服务器落库的那个规范值,不是客户端在 turn 里声明的;(2)resolve 本身带跨 session 守卫(下面 C.2 会看到它的 SQL),别的 session 猜一个 ref id 也 resolve 成 null。

排查路标 · 旅程 B(turn 用图)
症状从哪下手
带图的 turn 报 validation.failedagent/service.tsresolveImagePart——ref 属别的 session(resolve→null),或落库 mime 非 image/*
装配漏接 blobs,图 turn 全挂agent/service.tsblobs? 是可选参数,未接线时 resolveImagePart 直接抛(见风险地图 ⚪)

8旅程 C:下载(GET /api/blob/:sessionId?ref=,支持 Range)

下载侧的看点是两处:一个把 HEAD 请求也拦进验身(否则泄露文件存在性/大小/名字),一个是把 Range 请求和零长度文件的边角情况处理干净、并且流式吐字节不缓冲。

全景 · 涉及 1 个文件
动词无差别验身中间件
routes/blob.ts
仅存在闸(getSession) resolve 跨 owner 守卫
routes/blob.ts
Range 解析 + 流式
routes/blob.ts

C.1每个动词都验身:堵住 HEAD 绕过

路由用一个 app.use 中间件对 /blob/:sessionId每个动词都验头凭证、装上 principal。为什么不在各 handler 里按方法验?因为全局 mutation 边缘会豁免安全方法(requiresDaemonToken),而 Hono 会把 HEAD 重新走 GET 的处理链、但 c.req.method 仍是 'HEAD'——一个写在 handler 里的动词判断会让未认证的 HEAD 溜过去,泄露文件存在/大小/名字。无条件在中间件里验身把这个绕过关死:

apps/daemon/src/routes/blob.ts验身中间件
app.use('/blob/:sessionId', async (c, next) => {
  c.set('principal', verifyBlobPrincipal(daemonToken, c.req.header(DAEMON_TOKEN_HEADER) ?? null))
  await next()
})

下载侧的 session 闸和上传不同:GET 用 getSession(仅存在),不用 getRunnableSession(可运行)。因为归档 session 的行和磁盘 blob 会一直留到硬删,它的时间线图必须还能看;用可运行闸会把这些已存在的 ref 误判成 409。

C.2跨 owner 守卫 + 零长度 + 流式落盘

resolve 用一条带三个 eq 的 join 强制「ref 只对拥有它的 owner 解析」——ref id + ownerType + ownerId 三者全中才返回路径,否则 null(上层转 404)。所以别的 session 拿一个猜来的 ref id 读不到东西:

apps/daemon/src/storage/blobs.tsresolve(跨 owner 守卫)
.where(and(
  eq(blobRefsTable.id, input.refId),
  eq(blobRefsTable.ownerType, input.ownerType),
  eq(blobRefsTable.ownerId, input.ownerId),   // 三者全中才解析,跨 owner 猜 id 无效
))

拿到路径后解析 Range。有一个被特意处理的边角:零长度读(0 字节文件,或截断后的退化全量请求)不能去建流——createReadStreamend:-1 会同步抛 ERR_OUT_OF_RANGE,把一个合法的空文件变成 500。所以直接回一个带同样头的空 body:

apps/daemon/src/routes/blob.tsGET 尾段
if (c.req.method === 'HEAD') return c.body(null, status, headers)  // HEAD 不开 fd
if (range.length === 0) return c.body(null, status, headers)       // 零长度不建流,躲开 ERR_OUT_OF_RANGE
// 从磁盘直接流出选中区间,每请求内存有界;blob 走 HTTP 正是为了大二进制读不像 RPC 那样缓冲。
return c.body(streamStoredBytes(resolved.path, range), status, headers)

响应头一律带 content-disposition: attachment + x-content-type-options: nosniff——即便 mime 是可信的(上传时已挡掉活性内容),下载仍强制附件下载 + 禁浏览器类型猜测,双保险。文件名走 RFC 6266/5987:ASCII 兜底 + filename*=UTF-8'' 真名,因为 HTTP 头值是 ByteString(>0xFF 的码点 undici 会抛),而存的名字可以是任意可打印 Unicode。

排查路标 · 旅程 C(下载)
症状从哪下手
未认证请求能探到文件是否存在routes/blob.ts:确认走的是 app.use 中间件而非 handler 内动词判断
跨 session 读到别人的图storage/blobs.tsresolve 的三 eq where;少一个都会漏
0 字节文件下载 500routes/blob.tsrange.length === 0 早返回那行是否还在
中文文件名下载乱码routes/blob.tsattachmentDisposition / encodeRfc5987

9旅程 D:删除回收字节 + GC 兜底

删一个 session(直接删,或删父 task 级联),要保证它的 blob 字节被回收。FK 级联只清 DB 行、够不着磁盘字节,所以要额外一步 derefOwner;而这步是尽力而为的,真失败了留给 GC 这张安全网兜底——「删晚一点只费磁盘,不伤正确性」。

全景 · 涉及 4 个文件
硬删先拆 runtime
use-cases/tasks.ts
DROP 行(FK 级联)
db/cascade
derefOwner 回收零引用
storage/blobs.ts
GC 三路清扫兜底
storage/blobs.ts

删 session 的收尾(AgentService.deleteSession)在硬删行之后调 purgeSessionUploads,它转手到一个永不失败的 best-effort 包装:

apps/daemon/src/storage/deref.tsderefOwnerBestEffort
// 硬删在其 blob 清理之前就已提交,所以清理出错不能把已提交的删除变成失败的变更
//(否则客户端重试会撞 notFound)。失败的话孤儿 ref 变成 cleanup-debt,下一轮 GC 收。
export async function derefOwnerBestEffort(blobs, logger, ownerType, ownerId): Promise<void> {
  try {
    await blobs.derefOwner(ownerType, ownerId)
  } catch (err) {
    logger.warn({ err, ownerType, ownerId }, 'blob deref failed after delete; left to GC')
  }
}

derefOwner 本身进车道:删掉该 owner 的所有 ref,然后只回收「刚掉到零 ref」的对象(别的 owner 还引用的对象留着)。而 GCgc(probe))是三路 mark-and-sweep 安全网,boot 时和手动触发时各跑一次:

apps/daemon/src/storage/blobs.tsgc 三路清扫(结构)
gc(probe) {
  return runExclusive(async () => {
    // 一:按 owner 类型分组,每型只探一次,收掉 owner 已死的 ref
    const refsByType = groupRefsByOwnerType(db)
    for (const [ownerType, refs] of refsByType) {
      const alive = await probe(ownerType, [...new Set(refs.map(r => r.ownerId))])
      const deadRefIds = refs.filter(r => !alive.has(r.ownerId)).map(r => r.id)
      // 分 500 一批删,绕开 SQLite 绑定参数上限
    }
    // 二:收掉刚掉零 ref 的对象 + 其它零 ref 对象。三:删磁盘上没有 object 行的文件(含残留 .tmp-)
    const { objectsRemoved, bytesReclaimed } = await reapAllZeroRefObjects(db, blobsDir)
    await removeOrphanFiles(db, blobsDir)
    return { refsRemoved, objectsRemoved, bytesReclaimed }
  })
}

手动清理(StorageService.cleanup)和 GC 共享一条存活规则:只有死 owner 的 ref 可回收。没这道闸,一个瞄错的 owner id 会悄悄抹掉一个活 session 的附件——这正是探针存在要防的事。所以 cleanup 先探活,owner 还活着就抛 resource.conflict(409)而不是删。

排查路标 · 旅程 D(回收)
症状从哪下手
删 session 后磁盘字节没马上少预期:derefOwner best-effort,失败留 GC;或该对象仍被别的 owner 引用
cleanup 返回 409services/storage.tscleanupalive.has 闸——目标 owner 还活着,拒绝清理是对的
boot 日志有 gc 结果 / 或 gc 失败告警storage/boot.tsprepareBlobStore 里 gc 是 best-effort,失败只 warn 不拦启动
磁盘有 .tmp-* 残留storage/blobs.tsremoveOrphanFiles 会在 gc 时清;崩溃留下的临时文件下轮扫掉

10计划 vs 实现的偏差

这是自己的 PR、带随附文档(run-log / followups / 交叉审 workflow),所以有这一节。照计划做成的部分你已知,这里只收实质偏差——认知裂缝在这。

条目计划实际做成 / 为什么变
migration 形态 feat/storage 分支走增量 0001_blob_store.sql(含内联 PRAGMA) 集成时弃增量,两张 blob 表并入单一 0000_init.sql。代价:旧库重放 0000 会崩——已知且接受(greenfield 无真实用户,不承诺免重置升级)。0001 那份文件已不存在。
MIME 校验 沿用旧 upload-store.ts 的正则数组 + (;|$) 后缀容忍,匹配原始串 交叉审(Codex)抓到「分号前加空格」绕过(两轮架构审 + Claude 侧都漏了)。改成 essence 精确匹配 + 落库规范化值a36d7d9)。见 A.2。
并发上传 vs 硬删 初版未识别这个 TOCTOU 窗口 车道内 owner 断言8ff1d8f),把「给死 owner 发 ref 回 201」变成 session.notFound。见 A.3。
config.port ↔ 客户端 「配置文件=真相源」想做全套 发现 CLI/desktop 的默认 daemon URL 硬编码 19514,用户改 config.port 会静默失联。判定中等偏大、拆到独立 session(renderer 拿 URL 要从同步改异步)。不并进本 PR,专属 handoff 已写。
agent-events 文件店 存储章程说「除 DB 外一切落盘归一个总管」 main 的 #108 抢先把 per-run JSONL 落到 <home>/agent-events/、自带清扫、不进 usage 会计。本轮不收编(决策④),后续单开 PR。副作用:usage.total 少算这棵树的字节(已在注释声明)。
boot 一次性清理 首启 rm -rf cache/uploads 后置 kv marker 补 try/catch:删失败不置 marker,下次 boot 重试,且不拖垮启动(99563d0)。

11心智模型补丁

「以前可以假设 X,现在必须 Y」。读完这几条,你对这个项目存储侧的心智模型就更新到位了。

上传的文件住在 cache/uploads/<sessionId>/<id>-<name>,mime 在旁边 .meta.json,删 session 就 rm -rf 目录。 字节按 sha256 存 blobs/<xx>/<hash>(去重),owner/名字/mime 进 blob_refs 表,回收按「零引用对象」单位。
上传回执 id 和下载 ref 是两个概念(ref 是 <sessionId>/<id>-<name> 这种半路径)。 一个 opaque refId 两用:既是上传 id 也是下载凭证,服务端解析成路径,永不暴露 host 路径布局。
mime 校验拿客户端声明的串正则匹配就行。 必须先削成 essence(type/subtype、去参去空白、小写)再判,且落库存规范值——校验口径和存储口径必须一致,否则有绕过。
删 session 只要 DROP DB 行,FK 级联清干净。 FK 级联够不着磁盘字节;必须额外 derefOwner(best-effort),失败由 GC 兜底。
「删晚一点只费磁盘、不伤正确性」——所以清理永远不能把已提交的删除回滚成失败。
daemon 各处自己 join(EYRIE_HOME, ...) 拼路径。 一处 resolvePaths()EyriePaths(home/db/config/blobs/token/lock),装配注入;harness 传自己的 paths,不会误指真实 home。
「这个 owner 活没活」一个判断到处用。 拆成探针(批量返回活集合,GC 用)和断言(单个死即抛,put 用);两者都对 archive 视而不见(归档可逆,图要留)。

12新词表

存储核心
content-addressed(内容寻址)文件身份 = 字节的哈希,而非名字/路径;相同字节只存一份。
blob object一份去重后的字节,按 hash 主键,磁盘文件名就是 hash。
blob ref某 owner 对某 object 的具名引用(带 owner/名字/mime);回收的最小单位。
mutation lane(互斥车道)让 put/deref/gc 排一条串行队列,避免「半挂」窗口被并发 GC 撞穿。
mark-and-sweep GC三路清扫:死 owner 的 ref → 零引用对象 → 磁盘上无对象行的文件。
安全与竞态
TOCTOUtime-of-check to time-of-use:检查和使用之间状态变了;这里是缓冲期 session 被删。
mime essence媒体类型削到 type/subtype、去参数去空白、转小写后的规范形。
owner 存活探针 / 断言探针=批量返回活集合(GC);断言=单个死即抛(put)。都对 archive 视而不见。
周边
EyriePathsdaemon 在 EYRIE_HOME 下所有路径的一处解析结果。
kv markermetadata 表里的机器标记(如「旧 uploads 已删」),可重建、非用户数据、读时校验坏了退化为 null。
suffix rangeRange: bytes=-N 表示「最后 N 字节」。
prefs 注册表桌面端把 theme/locale 收成一张「key + 校验器 + 默认值」表,可枚举、可一键 clear、读时自愈。

13测试与风险地图

纯事实陈述:哪些行为有测试钉住,哪些是已知薄冰。

有兜底的(测试钉住)

薄冰(重要逻辑无测试 / 已知遗留)

合并前必办:🔴 全清(jsonc-parser 幽灵依赖已补声明 bc2fe70;交叉审已补跑,must-fix 已修 a36d7d9)。当前 只差 CI 绿 + 用户 squash-merge + 删 worktree。上面的 🟡/⚪ 都是有意延后项,不阻塞合并。

14验收提示(别被这些吓到)

15覆盖声明

本报告由主力(未分发子 agent)亲自 Read 全部引用文件后裁剪:storage/ 全模块(blobs / boot / config / usage / kv / paths / deref / index)、services/(blob-liveness / storage / sessions / index)、routes/(blob / uploads / api)、agent/service.ts 的图片解析段、use-cases/tasks.ts 的删除级联、packages/db/src/index.ts 两张 blob 表 + 0000_init.sqlpackages/api 的 dto/schemas/services/trpc 契约面、apps/daemon/src/index.ts+app.ts 的 boot 装配、桌面 prefs.ts。随附文档(storage-component-followups.mdconfig-port-discovery-handoff.md、交叉审 run-log)作为第 10 节偏差矿源,结论已内联。

诚实边界:测试文件按文件名与体量分类、抽读关键用例名,未逐个 test body 精读;db/cascade.tsruntime-auth.ts 等被引用但非本 PR 新增的既有文件只读到接口层,未展开其内部。这些不影响本文四条旅程的准确性。