PR #113 + #140:把“上传缓存”升格为正式 Storage 基建
figuretu/eyrie · f9499ca3..45982542 · 2026-07-12 · #113 已合并,#140 待合并 · 自包含,读完即弃
写法说明:本文按「逐跳走读」展开——每个机制都给真实代码片段(取自分支、经裁剪,青色斜体注释为解读所加,灰色斜体是源码原注释的保留或意译),每段代码标注所在文件。每条旅程结尾有一张「排查路标」:将来出问题时,症状对应去哪个文件看哪个函数。
1先说结论
这两个 PR 合起来做的事情,可以用一句话概括:Eyrie 不再把上传文件当成 session 目录里的临时缓存,而是把所有本地持久化明确分家,并为用户文件建立了一套有归属、可去重、可统计、可回收的正式存储组件。
#113 建骨架:统一 EYRIE_HOME 路径、JSONC 配置、typed KV、内容寻址 blob store、HTTP 上传下载、Agent 图片输入、tombstone 回收、启动 GC、tRPC 运维面和 renderer prefs。#140 不改这套大方向,而是重新按“基建代码”标准过了一遍,把写入与删除竞态、owner 归因、文件删除失败后的可恢复状态、权限、契约冗余和注释漂移逐层收紧。
它不是一个孤立的 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 复查:它没有再造模型,而是把第一版里“正常时能跑、异常时语义还不够硬”的部分收口。
blob_objects / blob_refs 两表2.1称重:一半以上是测试,不代表主体很小
| 设计重心(要细读) | 可放心略过或快速扫 |
|---|---|
storage/blobs.ts:一致性、竞态、回收、权限 | Drizzle snapshot / journal:跟随 schema 生成 |
routes/blob.ts、agent/service.ts:端到端消费 | bun.lock:只增加 jsonc-parser |
maintenance-tick.ts、blob-liveness.ts:生命周期 | 大量 provider fixture:移除死字段 secretRef |
config.ts、boot.ts、kv.ts:启动不变量 | 旧 upload-store 的 535 行删除:理解 before 后无需逐行背 |
2.2#140 本身也不是一条直线
#140 值得按提交顺序看,因为中间方案被后续复查再次收紧。第一轮曾让 derefOwner 顺便扫描全 store 的零引用对象,目的是让一次删除重试自愈;后续发现这会把所有 owner 的回收绑在一起,也破坏“本次回收了谁的字节”这个结果语义,于是撤回。
| 阶段 | 实际收敛了什么 |
|---|---|
d0945eb0 | 先删文件再删 object row;补 MIME token、config/KV/usage/turn 文件存在性;一度让 deref 全局扫零引用对象。 |
066642ff | blob 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 存储
以前 · 文件路径就是数据模型
现在 · bytes 与归属分层
新的 storage 不是“所有东西进一个库”,而是给不同状态指定唯一归宿。关系实体继续进 SQLite;可手改的 daemon 全局配置进 config.json;机器标记复用 metadata 表;用户文件的 bytes 在磁盘、索引在 DB;设备 UI 偏好继续留在 renderer 的 localStorage。
| 状态种类 | 唯一归宿 | 为什么 |
|---|---|---|
| project / task / session / run / event | SQLite + Drizzle | 关系查询和生命周期真相源。 |
| daemon 全局、用户可编辑设置 | <home>/config.json | 可手改、可加注释;不把配置藏进表。 |
| 一次性迁移标记、探测缓存 | metadata typed KV | 机器管理、可重建、读时校验。 |
| 附件 bytes | <home>/blobs + object/ref 表 | 不把大文件塞进 DB,同时保留归属与回收能力。 |
| theme / locale | renderer localStorage | 首屏前必须同步读取,不能等 daemon RPC。 |
4数据与状态先行:先记住 object、ref、owner
4.1一份 bytes 只有一个 object,一次业务持有就是一个 ref
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 各自独立。
image/png 上传、被 session B 作为 text/plain 上传。bytes 可以共享,声明语义不能互相污染;下载 header 和 Agent 图片 gate 都读 ref 自己的 MIME。
4.2对外只暴露 opaque ref,不暴露磁盘布局
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 短暂同时返回 id 和 ref,两者实际相同;#140 收成一个 ref,把它明确为上传响应、下载 query 和 timeline replay 共同使用的唯一 handle。host path 只在 daemon 内部、即将交给 provider 时出现。
4.3usage 有两个口径
blobs.total 对 blob_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 的操作
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
}
put、derefOwner、gc 都走这条 lane。原因是 put 在“object row 已插、ref 尚未插”之间会等待文件 I/O;如果 GC 此时进入,会误把在途 object 当成零引用垃圾。串行后,每次 mutation 看到的都是上一轮已经稳定的状态。
校验、文件名清理和 SHA-256 不碰共享状态,#140 把它们移到 lane 外,避免一个 50 MiB 文件的 hash 把回收操作也堵住。resolve 和 usage 是读操作,不进 lane。
5.2“owner 还活着”有一个统一定义
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 temp | GC sweep 2 / sweep 3 |
| 文件成功,最终 owner check 失败 | object row + 完整文件,无 ref | GC 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
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 检查一次。
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,x、text/html x 这类会先命中宽 allowlist、又绕开精确 denylist 的声明。
文件名只作展示信息:取 basename、删控制字符、去前导点、trim,按 UTF-8 最多保留 200 bytes,短扩展名尽量保留。真正磁盘路径只来自 SHA-256,所以客户端文件名不会参与路径拼接。
A.3两次 owner 检查:一次省工,一次保正确
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”之间。
await 仍让出 microtaskA.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.ts 看 validateUpload。 |
| 上传在 session 删除附近返回 404/不存在 | services/blob-liveness.ts 看 async/sync assertion;sessions.ts 看 liveSessionIds。 |
| DB 有 object row、没有 ref | storage/blobs.ts 看 put 在 write/final check/insert 哪一步失败;随后看 gc。 |
| 相同文件未重新写盘或权限不对 | writeObjectAtomically 的 size-match dedup fast path 与 chmod。 |
7旅程 B:下载已有 ref,或把它交给 Agent
storage/blobs.ts→ HTTP stat + Range + stream
routes/blob.ts 或 image gate + provider path
agent/service.ts
B.1resolve 把跨 owner 检查放进 store
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
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 下载返回 404 | storage/blobs.ts 的 resolve 三元组;这是预期隔离。 |
| archive 后能下载但不能上传 | routes/blob.ts 对比 POST 的 getRunnableSession 与 GET 的 getSession。 |
| turn 在创建 run 前 validation.failed | agent/service.ts 的 resolveImagePart:ref、owner、MIME、stat 四个 gate。 |
| 大文件下载中途断流 | routes/blob.ts 看 resolve/stat/stream 的非串行窗口,再看同期 deref/GC。 |
8旅程 C:删除、owner deref 与全局 GC
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;文件删除不堵在用户请求里。
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
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 参数上限。
C.3文件先删、row 后删;但 deref 不是回滚事务
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.ts 看 reapZeroRefObjects 是否只收 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 组件
D.1一个 home,路径只在一处推导
EYRIE_HOME 未设时为 ~/.eyrie。它是 bootstrap path,不能放进 config,因为 config 自己就在它下面。resolvePaths() 统一给出 DB、config、blobs、token、lock;agent event file tree 仍由自己的 writer 管理,不在 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 只有 version、port、retention.blobsMaxBytes?。JSONC 接受注释和 trailing comma;文件缺失、解析失败或整体 schema 不合法都退回 defaults。未知 key 通过 passthrough 保留,EYRIE_PORT 最后覆盖文件值,无效 override 只 warn。
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。
D.5usage / cleanup / gc 通过 tRPC 暴露
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.ts 看 readJsoncOrDefaults 与 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。
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 初始化出现循环或 undefined | i18n/locales.ts 是否保持无副作用;i18n/index.ts 只在最后 init。 |
prefs.clear() 后 layout 仍在 | 这是 registry 的刻意边界;layout 由独立 Zustand persist 管理。 |
11心智模型补丁:以后读这块代码要换掉哪些旧假设
derefOwner 只处理目标 owner 的 hashes;历史孤儿只由 gc 全局收敛。
EYRIE_HOME 的磁盘占用。
它计 DB、config、blob、token、lock;明确不计 agent-events。
12新词表
| Blob 模型 | |
|---|---|
blob object | 按 SHA-256 标识的一份真实 bytes;相同内容只存一份。 |
blob ref | 一个 owner 对 object 的一次命名引用,带 ref id、文件名和 MIME。 |
content-addressed | 身份与路径由内容 hash 决定,不由用户文件名决定。 |
polymorphic owner | ownerType + ownerId 组成的通用归属;当前正式理解 session。 |
zero-ref object | DB 有 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 marker | typed 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 tests | object/ref 模型、校验、并发、deref、GC、usage、microtask race。 |
blob-route.test.ts · 15 tests | 上传下载、token、HEAD、Range、archive、跨 session、两层 size cap。 |
storage-service.test.ts | live cleanup 拒绝、archive 保留、session/task/project 删除与故障重试。 |
reaper-maintenance.test.ts | 文件清理顺序、tombstone 保留、backlog retry、task worktree。 |
agent-service-methods.test.ts | ref → path/MIME、文件消失、跨 owner、opaque event。 |
config/boot/kv/paths | 启动不抛、JSONC、env、marker、GC best-effort、typed read。 |
storage-trpc.test.ts | schema → router → service → store 的 usage/cleanup/gc contract。 |
prefs.test.ts · 12 tests | typed read fallback、write/remove/keys/clear 与 storage failure。 |
14验收提示与覆盖声明
14.1别把这些现象误判成“漏做”
- 看不到 storage 管理 UI:本次接的是 daemon service + tRPC contract;usage/cleanup/gc 暂无 production UI/CLI caller。
- storage cleanup/gc 不是 localOnly:它们是经过 daemon 认证的 public tRPC procedure,合法远程 daemon client 也可调用;localOnly 的是 debug counters。
- renderer prefs 没走 daemon:这是首屏同步读取的刻意边界,不是未完成迁移。
- archive 后 blob 仍在:archive 可逆,保留才是正确行为。
- 旧 uploads 没迁移:实现选择首次启动一次性删除旧缓存,KV marker 防重复。
- migration snapshot 变化很大:实质 schema 是两表、两个索引、约束/FK;其余多为生成基线。
AgentService.blobs类型仍 optional:是为了不测图片的 harness;生产装配总会注入,实际处理图片时缺失会报 internal error。- raw event files 不在 blob tree:它是独立 subsystem,只是由同一 maintenance pass 清理。
- CI 多了一段 migration generate:它用 git status 同时抓 tracked 和新生成的 untracked drift。
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 作者的全量验证声明改写为本次结论。