feat/storage-integration:把「除关系型 DB 外一切落盘」收进一个内容寻址的 blob 总管
eyrie(PR #113) · f89b62b..HEAD · 2026-07-06 · 自包含,读完即弃
写法说明:本文按「逐跳走读」展开——每个机制都给真实代码片段(取自 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 生成产物。
| 子系统 | 设计重心(要细读) | 可放心略过 |
|---|---|---|
storage/blobs.ts | put 的车道内重查 + 去重落盘顺序、三路 GC、validateUpload/mimeEssence | — |
services/blob-liveness.ts | 探针(GC 用)与断言(put 用)两个不同用途的存活判定 | — |
routes/blob.ts | 动词无差别验身(堵 HEAD 泄露)、Range 解析、流式下载 | RFC 5987 文件名编码细节 |
services/upload-store.ts | — | 整文件删除,被内容寻址店取代 |
migrations/meta/*.json | — | drizzle 生成的快照,非手写 |
3架构一图流
变的是「字节以什么身份落盘、谁能读到」这条链。旧世界里字节的身份 = 路径(session 目录 + id 前缀文件名),mime 存在旁边的 sidecar 文件;新世界里字节的身份 = 内容哈希,所有元数据(owner、名字、mime)进 DB,一个 opaque refId 同时当上传回执和下载凭证。
以前 · 路径即身份
现在 · 内容即身份
三条通道各自有了新性质:写——相同字节只落一份盘(去重);读——mime 跟着 ref 走(per-ref,不是 per-object),所以两个人上传同样的字节但声明不同 mime 不会串味;删——不再 rm -rf 一整棵目录,而是先解引用、只回收「掉到零引用」的对象,别的 owner 还引用着的对象留着。
4数据与状态先行
先把这次新增的形状过一遍,后面旅程直接用这些词,不再解释。只看形状不讲行为。
两张 DB 表:对象与引用分家
// 一行一个不同的内容哈希 —— 去重点就在这
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 的入参与回执
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:占用面板要的形状
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 }>
}
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)排进同一条串行车道:
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:无论前一笔成功还是失败,本笔都接着跑——车道不会因为一次异常就断链。读路径(resolve、usage)不进车道,因为它们不改状态,多读并发无害。
5.3owner 存活:探针 vs 断言(一对孪生,用途相反)
同一个问题「这个 owner 还活着吗」,这个 PR 给了两个函数,因为用途相反。GC 要的是批量、返回活着的集合(把不在集合里的 ref 收掉);put 要的是单个、死了就抛(拦住给死 owner 发 ref)。两者都对 archive「视而不见」——只看行是否物理存在,因为归档是可逆的、归档 session 的图必须留着。
// 探针: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 结束。走通它你就掌握了这套存储的写入侧全部关口。
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),但拒绝往里传新图:
// 上传是新 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-8(xml 和 ; 之间一个空格)——它能过 image/* allowlist,却滑过所有锚在裸 subtype 上的 denylist 模式,把一个能带内联 <script> 的 SVG 放进来。修复是先把 mime「削成 essence」(只留 type/subtype、去参数去空白、转小写)再匹配,并返回规范化值让调用方落库,保证存的值和校验的值永不分叉:
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\///^image\/svg\+xml(;|$)/:因空格锚不上,漏image/svg+xml ; charset=utf-8mimeEssence 削成 image/svg+xmlDENIED_MIME.has('image/svg+xml'):命中,拒A.3车道内重查 owner:拦住熬过并发硬删的上传(TOCTOU 修复)
这一跳是这次的另一处硬核修复,讲清楚要先画时间线。「TOCTOU」= time-of-check to time-of-use:检查(route 那道 getRunnableSession)发生在字节被缓冲之前,而使用(真正写 ref)发生在缓冲之后。一个大文件缓冲要时间,这中间 session 可能被硬删掉。若不管,put 会给一个已死的 owner 发一行 ref,还回 201——制造一个 GC 也未必及时收的孤儿。
修复:在 put 的车道内、发布 ref 之前,再断言一次 owner 还活着:
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:
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.ts:validateUpload / mimeEssence,看 essence 是否命中 DENIED_MIME |
大文件上传偶发 session.notFound | blob-liveness.ts:createOwnerAliveAssertion——缓冲期间 session 被删是预期拒绝,不是 bug |
| 上传成功但磁盘没多文件 | 去重:内容已存在则 writeObjectAtomically 早返回;查 blob_refs 是否多了一行指向同一 hash |
| 落库 mime 和客户端发的不一样 | 预期:validateUpload 返回规范化 essence,put 存的是它 |
7旅程 B:turn 里用这张图(startTurn → resolveImagePart)
图传上来后,用户在一轮对话里引用它。这条旅程短,但有一个安全要点:mime 从服务器记录的 ref 拿,绝不信 turn 输入——这是保护那些自己不做 mime 校验的 provider(如 Codex)的唯一一道闸。
agent/service.ts→ resolve 跨 session 守卫
storage/blobs.ts→ 服务端 mime 做 image 闸
agent/service.ts
顺序上,startTurn 先把图片 ref 解析成「session 作用域的文件路径 + 服务端 mime」再认领这一轮 turn——这样一个坏 ref 会让请求直接失败,而不会留下一行孤儿 run 或泄漏一个 runner 句柄。解析每个 image part 时:
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.failed | agent/service.ts:resolveImagePart——ref 属别的 session(resolve→null),或落库 mime 非 image/* |
| 装配漏接 blobs,图 turn 全挂 | agent/service.ts:blobs? 是可选参数,未接线时 resolveImagePart 直接抛(见风险地图 ⚪) |
8旅程 C:下载(GET /api/blob/:sessionId?ref=,支持 Range)
下载侧的看点是两处:一个把 HEAD 请求也拦进验身(否则泄露文件存在性/大小/名字),一个是把 Range 请求和零长度文件的边角情况处理干净、并且流式吐字节不缓冲。
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 溜过去,泄露文件存在/大小/名字。无条件在中间件里验身把这个绕过关死:
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 读不到东西:
.where(and(
eq(blobRefsTable.id, input.refId),
eq(blobRefsTable.ownerType, input.ownerType),
eq(blobRefsTable.ownerId, input.ownerId), // 三者全中才解析,跨 owner 猜 id 无效
))
拿到路径后解析 Range。有一个被特意处理的边角:零长度读(0 字节文件,或截断后的退化全量请求)不能去建流——createReadStream 用 end:-1 会同步抛 ERR_OUT_OF_RANGE,把一个合法的空文件变成 500。所以直接回一个带同样头的空 body:
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.ts:resolve 的三 eq where;少一个都会漏 |
| 0 字节文件下载 500 | routes/blob.ts:range.length === 0 早返回那行是否还在 |
| 中文文件名下载乱码 | routes/blob.ts:attachmentDisposition / encodeRfc5987 |
9旅程 D:删除回收字节 + GC 兜底
删一个 session(直接删,或删父 task 级联),要保证它的 blob 字节被回收。FK 级联只清 DB 行、够不着磁盘字节,所以要额外一步 derefOwner;而这步是尽力而为的,真失败了留给 GC 这张安全网兜底——「删晚一点只费磁盘,不伤正确性」。
use-cases/tasks.ts→ DROP 行(FK 级联)
db/cascade→ derefOwner 回收零引用
storage/blobs.ts→ GC 三路清扫兜底
storage/blobs.ts
删 session 的收尾(AgentService.deleteSession)在硬删行之后调 purgeSessionUploads,它转手到一个永不失败的 best-effort 包装:
// 硬删在其 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 还引用的对象留着)。而 GC(gc(probe))是三路 mark-and-sweep 安全网,boot 时和手动触发时各跑一次:
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 返回 409 | services/storage.ts:cleanup 的 alive.has 闸——目标 owner 还活着,拒绝清理是对的 |
| boot 日志有 gc 结果 / 或 gc 失败告警 | storage/boot.ts:prepareBlobStore 里 gc 是 best-effort,失败只 warn 不拦启动 |
磁盘有 .tmp-* 残留 | storage/blobs.ts:removeOrphanFiles 会在 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 表,回收按「零引用对象」单位。
<sessionId>/<id>-<name> 这种半路径)。
一个 opaque refId 两用:既是上传 id 也是下载凭证,服务端解析成路径,永不暴露 host 路径布局。
derefOwner(best-effort),失败由 GC 兜底。
join(EYRIE_HOME, ...) 拼路径。
一处 resolvePaths() 出 EyriePaths(home/db/config/blobs/token/lock),装配注入;harness 传自己的 paths,不会误指真实 home。
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 → 零引用对象 → 磁盘上无对象行的文件。 |
| 安全与竞态 | |
TOCTOU | time-of-check to time-of-use:检查和使用之间状态变了;这里是缓冲期 session 被删。 |
mime essence | 媒体类型削到 type/subtype、去参数去空白、转小写后的规范形。 |
owner 存活探针 / 断言 | 探针=批量返回活集合(GC);断言=单个死即抛(put)。都对 archive 视而不见。 |
| 周边 | |
EyriePaths | daemon 在 EYRIE_HOME 下所有路径的一处解析结果。 |
kv marker | metadata 表里的机器标记(如「旧 uploads 已删」),可重建、非用户数据、读时校验坏了退化为 null。 |
suffix range | Range: bytes=-N 表示「最后 N 字节」。 |
prefs 注册表 | 桌面端把 theme/locale 收成一张「key + 校验器 + 默认值」表,可枚举、可一键 clear、读时自愈。 |
13测试与风险地图
纯事实陈述:哪些行为有测试钉住,哪些是已知薄冰。
有兜底的(测试钉住)
blobs.test.ts(598 行)——put 去重 / 车道内 owner 断言 / mime essence 各分支 / resolve 跨 owner / derefOwner / gc 三路 / usage 聚合,核心算法覆盖最厚。blob-route.test.ts——HTTP 上传下载、HEAD 验身、Range 分段、大小闸。storage-service.test.ts+storage-trpc.test.ts——usage/cleanup/gc 服务与 tRPC 边界,含「cleanup 活 owner 报 409」「gc 收死 owner ref」。storage-boot.test.ts——首启物化 config、一次性删 uploads 幂等、gc 失败不拖垮 boot。storage-kv.test.ts/storage-paths.test.ts/config.test.ts——kv 读时校验、路径解析、config 端口/损坏文件退化默认。prefs.test.ts(138 行)——桌面 prefs 读时校验/回退/一键 clear 逐 key 容错。
薄冰(重要逻辑无测试 / 已知遗留)
- 🟡gc/cleanup 用
publicProcedure(靠 daemon loopback 绑定兜底),不是localOnlyProcedure。破坏性操作的鉴权姿态是个跨面威胁模型决策,本次没夹带——要做整面一起做。 - ⚪
AgentService.blobs是可选参数:装配漏接线时 purge 静默 no-op、图 turn 误报 validation.failed。改必传会波及多处测试装配,deferred。 - ⚪put 的 sha256 同步哈希在车道内:50MB 上限下约百毫秒级事件循环停摆 + lane 串行排队。单用户本地规模可接受,吞吐迭代时再流式/worker。
- ⚪zero-size +
Range: bytes=-1:返回 206 +content-range bytes 0--1/0,严格说 size===0 的 suffix range 该返回 416。极边角,未修。 - ⚪
usage.total少算 agent-events 树,且Σ byOwnerType≥blobs.total(同对象被多 owner 类型引用时 per-type 求和 > total)。占用面板渲染时别把Σ byOwnerType当 total(DTO 已注释)。
bc2fe70;交叉审已补跑,must-fix 已修 a36d7d9)。当前 只差 CI 绿 + 用户 squash-merge + 删 worktree。上面的 🟡/⚪ 都是有意延后项,不阻塞合并。
14验收提示(别被这些吓到)
appRouter.storage三个 procedure 没有 UI 挂载点——不是缺陷。usage/cleanup/gc 数据通路和类型已就绪,桌面「占用面板」是纯消费侧 UI,本期不做。- 删掉的
upload-store.ts/upload-store.test.ts(−522 行)是被内容寻址店整体取代的旧实现,不是丢功能。 - 只有单个
0000_init.sql、无0001——是集成决策(blob 两表并入 0000),不是漏生成 migration。_journal.json单条目0000_init是对的。 - 桌面 prefs / theme / locale 那批改动(~110 行)看似和存储无关,实为存储章程「per-device UI state → renderer prefs」那条的落地,收进一张可枚举注册表。
- CI 的「Migrations match the schema」新步骤会跑
db:generate再查git status——它防的是「改了 schema 忘生成 migration」的漂移,和「接受重建」这个决策正交,不冲突。
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.sql、packages/api 的 dto/schemas/services/trpc 契约面、apps/daemon/src/index.ts+app.ts 的 boot 装配、桌面 prefs.ts。随附文档(storage-component-followups.md、config-port-discovery-handoff.md、交叉审 run-log)作为第 10 节偏差矿源,结论已内联。
诚实边界:测试文件按文件名与体量分类、抽读关键用例名,未逐个 test body 精读;db/cascade.ts、runtime-auth.ts 等被引用但非本 PR 新增的既有文件只读到接口层,未展开其内部。这些不影响本文四条旅程的准确性。