feat/log-system:为三个 Eyrie 运行时进程统一接入结构化诊断日志
eyrie · main...feat/log-system · 2025 · 自包含,读完即弃
写法说明:本文按「逐跳走读」展开——每个机制都给真实代码片段(取自分支、经裁剪,青色斜体注释为解读所加,灰色斜体是源码原注释的保留或意译),每段代码标注所在文件。每条旅程结尾有一张「排查路标」:将来出问题时,症状对应去哪个文件看哪个函数。
1TL;DR
这个 PR 新建了 packages/log(@eyrie/log)作为共用底座,为三个运行时进程——daemon(本地 AI 服务)、cli(命令行工具)、shell-main(Electron 主进程)——统一接入结构化诊断日志,并在 desktop renderer 端提供日志查看器 IPC 入口。
以前各端或依赖裸 pino 实例、或没有文件落盘、或把 Codex 子进程 stderr 原文(可能含 API key)纳入日志。现在每个进程启动时调 createRootLogger,写 JSON Lines 到 $EYRIE_HOME/log/<source>/<source>.log(单文件上限 10 MiB,保留 5 份滚动),进程退出前等待 flush 完成后才 process.exit。Desktop renderer 可通过 Electron IPC 读取历史日志(tail)或订阅实时流;renderer 侧全局 unhandled error 也通过 IPC 写入 shell-main 日志文件。
PR 还搭带了四项独立交付:任务卡右键菜单(archive/delete/priority)、任务子树级联归档/删除、P4 优先级级别、以及 CI 颜色 token 校验脚本。
2变更地图
测试行约占 40%(2332 / 5827 净增行)。apps/desktop 量最大但设计密度参差:日志 IPC 是核心,任务卡右键菜单等是搭带功能,互不依赖。
| 子系统 | 设计重心(要细读) | 可放心略过 |
|---|---|---|
packages/log | 全部新建:root.ts、file-sink.ts、live-hub.ts、reader.ts、serializer.ts、context.ts | pino-roll.d.ts(类型补丁),package.json |
apps/desktop | shell-log-bridge.ts、quit.ts、error-reporting.ts、board-remove-model.ts、use-deselect-removed-task.ts、client-provider.tsx 的 createDaemonConnectionReporter | context-menu.tsx(Radix 薄包装),i18n JSON 文案,tailwind 颜色 token |
apps/daemon | index.ts 初始化时序、app.ts 的 withLogCtx、codex/index.ts stderr 策略、trpc/services.ts logs 服务 | 其余文件大多是 pino 调用签名的机械对齐替换 |
apps/cli | index.ts 生命周期包装、helpers.ts 错误路径 async 化、output.ts logCliFailure | 依赖声明 |
packages/api/client | LogService 接口、logsRouter、CreateTransportOptions 连接回调 | bun.lock |
3架构一图流
以前 · 无结构化文件日志
现在 · 统一 JSON Lines + IPC
4数据与状态先行
读旅程之前先认识三个核心类型,它们是整个系统的词汇表。
LogEntry — 写入磁盘的最终行形状
export type LogEntry = {
time: number // Unix ms,来自发出方进程
rootId: string // 每次 createRootLogger 生成一个 UUID;跨重启可区分
seq: number // 该 rootId 下单调递增序号,与 rootId 构成消费方去重 id
level: LogLevel // 'info' | 'warn' | 'error' | 'fatal'
source: LogSource // 'daemon' | 'shell-main' | 'cli'
component: string // 调用方传入的模块名,如 'daemon.agent'
msg: string
code?: string // error/fatal 无显式 code 时自动填 'NO_SOURCE_CODE'
err?: SerializedError
dropped?: number // sink 降级时跳过的条数,恢复后附在下一条成功写入上
extra?: Record<string, unknown> // withLogCtx 上下文 + call-site extra 合并结果
}
LogReadRecord — 消费方(tRPC/IPC)收到的 DTO
export type LogReadRecord = {
entry: LogEntry | ReaderSyntheticEntry // ReaderSyntheticEntry = 文件损坏行的占位哨兵
source: LogSource
origin: 'live' | 'file' // live = LiveLogHub 实时推送;file = 文件 tail 读取
file?: string
offset?: number // 文件内字节偏移,用于排序
id: string // 'daemon:${rootId}:${seq}'(正常行)或 'daemon:${file}:${offset}'(坏行)
}
IPC channel 常量 — desktop 三层共享的协议契约
export const REPORT_RENDERER_ERROR_CHANNEL = 'eyrie:renderer-error' // renderer→main,单向
export const SHELL_LOGS_TAIL_CHANNEL = 'eyrie:shell-logs-tail' // invoke
export const SHELL_LOGS_SUBSCRIBE_CHANNEL = 'eyrie:shell-logs-subscribe' // invoke
export const SHELL_LOGS_UNSUBSCRIBE_CHANNEL = 'eyrie:shell-logs-unsubscribe' // invoke
export const SHELL_LOGS_RECORD_CHANNEL = 'eyrie:shell-logs-record' // main→preload,广播
5底座:packages/log
@eyrie/log 是本次 PR 全量新建的共用包,提供两个导出入口:@eyrie/log(纯类型)和 @eyrie/log/node(运行时实现)。三个进程都依赖它,先把底座走通。
5.1写入路径:emitLog → sink → 磁盘
进程内只有一个全局 currentRoot(RootState),由 createRootLogger 安装。getLogger(component) 返回的 logger 不持有任何状态,每次调用时才惰性读取 currentRoot——createRootLogger 之前的写请求静默丢弃。
export function emitLog(component, level, msg, fields = {}) {
const root = currentRoot // 调用时才读,不是创建 logger 时
if (!root || !shouldEmit(root.minLevel, level)) return
const entry = buildEntry(root, component, level, msg, fields)
for (const sink of root.sinks) {
try { sink.write(entry) } catch { // sinks are allowed to degrade }
}
root.hub.publish(entry) // 同步推送给 LiveLogHub
}
buildEntry 做三件事:序列化 err(含循环引用检测)、从 AsyncLocalStorage 读取 withLogCtx 注入的关联字段并与 call-site extra 合并、以及递增 root.seq。
fileSink.write 是同步方法(接口要求),但底层流是异步初始化的,实际写入通过 void streamPromise.then(writeEntry) 排入微任务队列——fire-and-forget,流初始化失败时丢弃并计数。
write(entry) {
void streamPromise.then(
(stream) => writeEntry(stream, state, entry),
(error) => { markDegraded(state, error); state.dropped += 1 },
)
},
降级恢复时,withDropped 把累计丢弃数附加到成功写入的那条的 dropped 字段,然后清零。历史丢失内容不补写。
5.2读取与实时订阅
readLogTail 从文件末尾只读最后 10 MiB,截断处跳过不完整的第一行,逐行 JSON 解析,按 time + offset 升序返回 LogReadRecord[]。
LiveLogHub 是进程内纯手工 AsyncIterable 多路广播,无回放缓冲。每个订阅者持有独立的 queue(已到达未取)和 waiting(悬挂 resolve):
function pushRecord(subscriber, record) {
if (subscriber.closed) return
if (subscriber.waiting) {
const resolve = subscriber.waiting
subscriber.waiting = undefined
resolve({ done: false, value: record }) // 消费方正在等:直接交付
return
}
subscriber.queue.push(record) // 消费方还没来 next():入队
}
6旅程 A:业务代码写一条日志到磁盘
以 daemon 里任意 logger.error(...) 为例,走完整链路。
daemon/src/agent/...→ emitLog
packages/log/src/node/root.ts→ fileSink.write
packages/log/src/node/file-sink.ts→ pino-roll Writable
→ daemon.log
A.1根 Logger 初始化
daemon 的 main() 函数第一行就调 createRootLogger——早于文件系统初始化和加锁。这保证了加锁失败等启动错误也能落盘。
async function main(): Promise {
createRootLogger({
source: 'daemon',
level: process.env.EYRIE_LOG_LEVEL ?? 'info',
destinations: [diagnosticFileSink('daemon', DAEMON_LOG_DIR)],
}) // 第一行:此后任何错误都能落盘
mkdirSync(paths.home, { recursive: true })
// ... lockfile, DB open ...
}
diagnosticFileSink('daemon', dir) 是 fileSink 的固化封装:文件名 daemon.log,单文件上限 10 MiB,保留 5 份滚动文件。CLI 和 shell-main 用相同调用模式,只换 source 和目录。
A.2emitLog 发射链
组件代码调 logger.error('Codex exited', { err, extra: { exitCode } }),进入 emitLog。buildEntry 先从 AsyncLocalStorage 读取 withLogCtx 注入的 requestId 等字段,与 call-site extra 合并(call-site 优先级更高),然后拼出完整 LogEntry(含自增 seq)。error/fatal 级别若无显式 code 字段,自动填 'NO_SOURCE_CODE'。
A.3fileSink 写盘
sink.write(entry) 调用后立刻返回;void streamPromise.then(writeEntry) 在当前 tick 结束后执行。writeEntry 调 safe-stable-stringify(保证 key 顺序稳定,对循环引用不崩溃),写出一行 JSON + 换行。流由 pino-roll 管理,按 10 MiB 自动滚动,文件名规律为 daemon.log(当前)和 daemon.1.log、daemon.2.log...(历史)。
进程启动时 retainPriorRolledFiles 会先清理上次进程留下的超量旧文件(pino-roll 的保留逻辑只在当前进程的滚动事件触发)。
排查路标 · 旅程 A
| 症状 | 从哪下手 |
|---|---|
| 写日志后文件没内容 | file-sink.ts:看 markDegraded,检查 console.error 输出的降级消息 |
| 日志文件超 5 个 | file-sink.ts:retainPriorRolledFiles 只清理 baseName.N.ext 格式,检查文件名是否匹配 |
| error 日志无 code 字段 | root.ts:defaultCode,确认 level 是 error/fatal |
| 日志里没有 requestId | app.ts:检查 withLogCtx 中间件是否包裹了该请求路径 |
7旅程 B:Desktop 读取 shell-main 日志
renderer 通过 Electron IPC 读取/订阅 shell-main.log,全程经过 preload 边界。重点在 main 侧的 shell-log-bridge.ts。
shellLogs.tail / subscribe→ preload
apps/desktop/src/preload/index.ts→ shell-log-bridge
apps/desktop/src/main/shell-log-bridge.ts→ @eyrie/log/node
readLogTail / subscribeLiveLogs
B.1Tail 历史快照
renderer 调 window.eyrie.shellLogs.tail({ limit: 100 }),preload 直接 ipcRenderer.invoke(SHELL_LOGS_TAIL_CHANNEL, input),main 端的 handler 做两道校验后调底层函数。
export async function readShellLogTail(logDir, input): Promise<LogReadRecord[]> {
const request = parseTailInput(input) // 校验 source 只能是 'shell-main'
return readLogTail({
source: SHELL_LOG_SOURCE,
dir: logDir,
limit: normalizeTailLimit(request.limit), // 夹至 1–5000,默认 500
})
}
parseTailInput 拒绝非 'shell-main' 的 source(该阶段仅暴露一个日志来源,注释标注"this phase"预留扩展)。limit 被 normalizeTailLimit 夹在 1–5000 之间,防止 renderer 传入恶意大值。
B.2实时订阅流
实时订阅比 tail 多一层 ID 机制:preload 维护一个模块级计数器,每次 subscribe 调用生成 "shell-log-N" 作为 subscriptionId。main 端把每条 live 记录广播给所有 preload 监听者,preload 靠 subscriptionId 过滤自己的记录——这避免了 main 需要持有 renderer 引用才能回调的循环依赖。
subscribe: (input, onRecord) => {
const subscriptionId = `shell-log-${nextShellLogSubscriptionId}`
nextShellLogSubscriptionId += 1
const listener = (_event, payload) => {
if (payload.subscriptionId === subscriptionId) onRecord(payload.record) // 过滤自己的记录
}
ipcRenderer.on(SHELL_LOGS_RECORD_CHANNEL, listener)
void ipcRenderer.invoke(SHELL_LOGS_SUBSCRIBE_CHANNEL, { ...input, subscriptionId })
return () => { // 返回 unsubscribe 函数
ipcRenderer.off(SHELL_LOGS_RECORD_CHANNEL, listener)
void ipcRenderer.invoke(SHELL_LOGS_UNSUBSCRIBE_CHANNEL, subscriptionId)
}
}
main 端的 pump 循环在 pumpShellLogSubscription 里运行(不 await,后台跑)。pump 的 finally 块有一处关键保护:只有当 subscriptions.get(id)?.iterator === iterator(当前 iterator 仍是 map 里的那个)才清理 map。这处理了「同一 subscriptionId 打开第二个订阅」的替换场景——第一个 pump 的 finally 看到 map 里已是第二个 iterator,静默退出。
finally {
const current = subscriptions.get(subscriptionId)
if (current?.iterator === iterator) { // 只有「我」是当前持有者才清理
subscriptions.delete(subscriptionId)
sender.off('destroyed', current.cleanupSender)
await iterator.return?.()
}
}
排查路标 · 旅程 B
| 症状 | 从哪下手 |
|---|---|
| tail 返回空数组,文件有内容 | shell-log-bridge.ts:parseTailInput source 校验,normalizeTailLimit limit 值 |
| 实时订阅收不到新日志 | shell-log-bridge.ts:pumpShellLogSubscription pump 是否还在跑;检查 sender 是否已 destroyed |
| 同一窗口多个订阅互相干扰 | preload/index.ts:nextShellLogSubscriptionId 计数器 + listener 的 subscriptionId 过滤 |
8旅程 C:Renderer 错误上报到 shell-main 日志
renderer 全局 window.error 和 unhandledrejection 不再 console.error,而是通过 IPC 写入 shell-main.log,供排查时与 shell-main 的其他日志关联。
error-reporting.ts→ preload send
REPORT_RENDERER_ERROR_CHANNEL→ shell-log-bridge
parseRendererErrorReport + logger.error→ shell-main.log
renderer 侧的 installRendererErrorReporting 捕获 ErrorEvent 或 PromiseRejectionEvent,调 errorLike 把任意 thrown value 归一化为 { message, stack? },避免把 ErrorEvent 等宿主对象传过 preload 边界(preload 无法序列化)。
function errorLike(value: unknown): { message: string; stack?: string } {
if (value instanceof Error) {
return value.stack ? { message: value.message, stack: value.stack } : { message: value.message }
}
return { message: nonErrorMessage(value) } // 非 Error:string/symbol/primitive/null/undefined 各有分支
}
main 侧的 parseRendererErrorReport 做第二道过滤:只接受 isRecord(input) && typeof input.message === 'string' 且非空的输入,并对每个字符串字段调 truncate(message: 2000 字符,stack: 16000 字符,route/windowId: 200–2000 字符)。两道过滤的意图:renderer 侧防止序列化失败,main 侧防止恶意/意外大负载写入日志文件。
除全局 error 监听外,还有三个显式触发点走同一路径:optimistic-mutation.ts 的 overlapping write 警告、i18n missing key(仅开发环境)、以及 createDaemonConnectionReporter 的 WebSocket 非正常断连报告。
排查路标 · 旅程 C
| 症状 | 从哪下手 |
|---|---|
| renderer 抛错但 shell-main.log 无记录 | error-reporting.ts:installRendererErrorReporting 是否已挂载;parseRendererErrorReport 的 message 非空校验 |
| 日志里 message 被截断 | shell-log-bridge.ts:truncate 各字段上限(message 2000 / stack 16000 字符) |
| daemon 断线日志重复 | client-provider.tsx:createDaemonConnectionReporter 的 outageReported flag,onConnectionOpen 会重置它 |
9进程退出前 flush
日志文件写入是异步的(pino-roll 底层用 SonicBoom)。每个进程都需要在 process.exit 前等待 closeLogs() 完成,否则最后几条日志会丢失。三个进程各自的做法:
daemon:shutdown()、加锁失败路径、以及 main fatal 路径三处各自 await closeLogs()。
cli:runCli() 用 try/finally 保证正常退出路径;exitWithError 内在 process.exit 前显式 await closeLogs()(这意味着两者可能双调,closeLogs 内部通过清空 currentRoot 保证幂等)。
shell-main:最复杂——Electron 的 will-quit 事件在 macOS 可多次触发(Dock 二次点击等)。createLogFlushWillQuitHandler 用两个 flag 实现幂等:
export function createLogFlushWillQuitHandler(closeLogs, quit, reportError) {
let closing = false // flush 已启动
let closed = false // flush 已完成
return (event) => {
if (closed) return // flush 完成后直接放行
event.preventDefault()
if (closing) return // 已在 flush:再次拦截,不重复启动
closing = true
void (async () => {
try { await closeLogs() }
catch (error) { reportError(error) }
finally { closed = true; quit() } // flush 完成后才真正退出
})()
}
}
main() 第一行调 createRootLogger({ source, destinations: [diagnosticFileSink(source, logDir)] });② 在所有退出路径(正常/异常/fatal)await closeLogs();③ 用 getLogger('module.name') 获取组件级 logger。
10Codex stderr 隐私保护
Codex 子进程的 stderr 可能含 API key(测试 fixture 里就有 OPENAI_API_KEY=...)。以前把最后一行 stderr 原文写入日志;现在改为只记录「有无 stderr 产生」这一布尔事实。
stderrLines: string[] 有界环形缓冲(50 行)logger.error({ stderr: this.stderrLines.at(-1) }, 'Codex exited')stderrEmitted = false 布尔标志child.stderr.on('data', () => { this.stderrEmitted = true })child.stderr.resume()(消费流防背压堵死)logger.error('Codex exited', { extra: { ...(stderrEmitted ? { stderrPresent: true } : {}) } })child.stderr.resume() 是关键:必须消费流,否则 pipe buffer 填满后子进程会阻塞。测试 tests/codex-runner.test.ts 的断言显式验证序列化后的日志 JSON 不包含 codex-test-api-key-secret。
11心智模型补丁
$EYRIE_HOME/log/<source>,单文件 10 MiB,最多保留 5 份。
shell-main.log,可与 shell-main 的其他结构化日志关联排查。
preventDefault() 拦截,等待 closeLogs() flush 完成后才真正 quit(),期间重复触发 will-quit 幂等处理。
stderrPresent: true 布尔标记。resume() 消费流防背压。
getLogger 在模块顶层获取 logger 后立即写日志。
getLogger 返回的 logger 在 createRootLogger 之前调用会静默丢弃(不报错)。进程入口必须先初始化 root。
max(3)。
P4 级别加入,schema 改为 max(4),PriorityIndicator 由色块方块改为方向箭头图标。
12新词表
| packages/log 核心概念 | |
|---|---|
LogSource | 'daemon' | 'shell-main' | 'cli',标识日志条目来自哪个运行时进程,写入每条 LogEntry |
rootId | 每次 createRootLogger 生成的 UUID,同一进程同一次启动共享一个,跨重启可区分 |
diagnosticFileSink | fileSink 的固化封装:文件名 ${source}.log,10 MiB 上限,保留 5 份滚动文件 |
closeLogs | flush 所有注册 sink 的缓冲并关闭文件句柄;process.exit 前必须 await |
LiveLogHub | 进程内无 replay 的 fan-out:emitLog 时同步推给所有 subscribeLiveLogs() 调用者 |
ReaderSyntheticEntry | reader 在读到无法解析的文件行时构造的哨兵条目,带 rawPreview 和错误 code,让消费方知道有损坏行但不崩溃 |
withLogCtx | 基于 AsyncLocalStorage,在当前 async 调用链中注入关联字段(如 requestId),emitLog 时自动合并进 entry.extra |
| desktop IPC 层新概念 | |
subscriptionId | preload 本地生成的字符串("shell-log-1","shell-log-2"…),用于在广播 channel 上区分同 window 内的多个并发订阅 |
createLogFlushWillQuitHandler | 工厂函数,返回幂等的 Electron will-quit handler,内含 closing/closed 双 flag |
createDaemonConnectionReporter | 工厂函数,把 WebSocket 断线报告去重为每次断连只写一条日志,reconnect 后复位 |
| 搭带交付(与日志无关) | |
dropTaskSubtreeFromBoard | 乐观删除/归档时在 board 快照上一并删掉所有后代,用固定点循环处理任意排列的 cards |
hasPendingTaskRemoval | 替代旧 hasPendingTaskDelete,同时覆盖 delete 和 archive,且检查任务的整条祖先链而不只是单个 id |
PriorityIndicator | 优先级符号组件,由色块方块改为 Lucide 方向箭头(P0=ChevronsUp…P4=ChevronsDown) |
13测试与风险地图
| 有测试兜底的行为 | 文件 |
|---|---|
| fileSink 写入后可被 readLogTail 读回(真实 tmpdir 集成) | packages/log/src/node/log.test.ts |
| sink 降级恢复后 dropped 附在下一条成功写入上 | packages/log/src/node/log.test.ts |
| LiveLogHub 推送 + 订阅者 close | packages/log/src/node/log.test.ts |
| 跨进程文件保留逻辑(只删 baseName.N.ext 格式) | packages/log/src/node/log.test.ts |
| will-quit 多次触发幂等,flush 失败仍能 quit | apps/desktop/src/main/quit.test.ts |
| shell-log-bridge:tail/subscribe/unsubscribe/sender destroyed 全链路 | apps/desktop/tests/shell-log-bridge.test.ts |
| preload subscriptionId 生成与 payload 过滤 | apps/desktop/tests/preload.test.ts |
| renderer error 归一化(Error/非 Error/循环引用) | apps/desktop/tests/error-reporting.test.ts |
| Codex stderr 内容不进日志文件 | apps/daemon/tests/codex-runner.test.ts |
| daemon logs.tail + logs.subscribe tRPC 过程 | apps/daemon/tests/diagnostic-log.test.ts |
| board-remove-model 固定点子树收集(深层乱序、环路保护) | apps/desktop/src/renderer/entities/task/board-remove-model.test.ts |
| use-deselect-removed-task 的 seenPresent 边沿触发与 archive pending 延迟 | apps/desktop/src/renderer/features/tasks/use-deselect-removed-task.test.tsx |
| 薄冰(重要逻辑无测试兜底) | 风险 |
|---|---|
closeLogs() 双调的幂等性 | 🟡 CLI 正常退出和错误退出路径各调一次,root.ts 在 closeLogs 里先清空 currentRoot,第二次调时 root 已是 undefined 直接 return——逻辑上安全,但无显式测试 |
| daemon fatal 路径的 flush+exit 时序 | 🟡 void closeLogs().finally(() => process.exit(1)),难以在 vitest 里断言 exit 时序 |
| shell-main 日志文件滚动端到端 | 🟠 10 MiB 上限和 5 份保留的实际滚动行为无 desktop 侧集成测试 |
logsRouter.tail 的 limit 默认值 500 和 callService 错误路径 | 🟡 daemon 侧 diagnostic-log.test.ts 间接覆盖,router 层无直接断言 |
StorageUsageDto.diagnosticLogs 在目录不存在时静默返回 0 | ⚪ 防御路径,功能影响小 |
eslint.config.js 给 apps/** 全局加了 'no-console': 'error',所有现有 console.log/warn/error 调用必须在合并前清理或移入测试文件(测试文件有 'no-console': 'off' 豁免)。
14覆盖声明
全量精读范围:packages/log 所有新建文件、apps/daemon 所有变更文件、apps/cli 所有变更文件、packages/api/packages/client 所有变更文件、apps/desktop/src/main/shell-log-bridge.ts、quit.ts、error-reporting.ts、preload/index.ts、shared/logs.ts、client-provider.tsx(ConnectionReporter 部分)、board-remove-model.ts、eslint.config.js。
desktop 搭带功能(TaskCardMenu、useArchiveTask、use-session-task-deletion-cascade.ts、PriorityPicker、use-deselect-removed-task.ts)的设计要点来自 subagent 精读,经本文作者对关键文件二次 Read 验证后引用,代码片段均取自亲读文件。