ARCHITECTURE REVIEW · 2026-06-12 · informed by CONTEXT.md (absent) + ADRs (absent)

cspy / nexora-loop

Project skill 给的 fact + 当前 lib/ 实际代码扫描得到 4 个 deepening 候选。 每条用 deletion test 验过(假装删了,复杂度是消失还是分散到 N 个 caller)。 本仓 175 execution flows + 2098 symbols(gitnexus index),候选按修改成本 / 影响 / 收益排序。

2
Strong
1
Worth exploring
1
Speculative
26.5k
total lines (ts+tsx)
⚠️ 缺 CONTEXT.md / docs/adr/ — skill 默认应该按域语言写"the Ticket intake module" / "the Bot invocation seam"。 但 nexora-loop 没有 CONTEXT.md 也没有 ADR(setup-matt-pocock-skills 还没跑过)。我下面用观察到的真实代码命名(handle / chat / sendAndWait),如果决定推进重构,先把领域词钉到 CONTEXT.md 让命名一致。
CANDIDATE 1 · the Channel chat module

三个 channel 的 chat 流水线是同一段逻辑写三遍

Strong
Files
  • lib/wechat/chat.ts (194)
  • lib/feishu/chat.ts (~120)
  • lib/widget/chat.ts (~110)
Estimated overlap
  • ~70% structural similarity
  • 3 callers / 3 seams / 1 deep behavior
  • callBotChat 已有,但只是底层 transport

Problem

三个 channel 的 chat handler 干同样的事:get-or-create conversation → persist inbound message → callBotChat → parseEscalate → 持久化 reply → 异步起 generateTicketcallBotChat 抽出来了(底层 transport),但上层流水线没抽。结果是每次加一个能力(比如 fire-and-forget 工单生成 / metadata 标准化 / 流式回复 / 跨 conversation 续接)都要在 3 个文件里手动同步。

展开 deletion test
假装把 lib/widget/chat.tslib/feishu/chat.ts 删了:复杂度消失? 不会——逻辑会立刻散到 webhook route 里。这说明它们不是 pass-through。但 3 份代码同时存在没有任何 leverage: 改一处不会自动改另两处,且 wechat 跑过的优化(MM 双客户端 / fire-and-forget ticket)其它两个 channel 拿不到。 这是"假深"——每个文件单看是深的,但三份合起来是浅的。一个 deep ChatPipeline + 3 个 ChannelAdapter 才是真深。

Solution

lib/messaging/chat-pipeline.ts(已有 lib/messaging/ 空目录,signal): 接收一个 ChatContext + ChannelAdapter,跑统一流水线。 Channel-specific 的事(怎么拿/建 conversation / 怎么读 user meta / 怎么 send back / 怎么 mapMessageId)走 adapter 接口。

Before / After

BEFORE — 3 浅文件
wechat/chat.ts:
  handleChat(openid, content, opts)
    └─ conv = getOrCreate(openid, bot)
    └─ Message.create(inbound)
    └─ result = callBotChat(bot, {...})
    └─ parseEscalateAnalysis(result.reply)
    └─ Message.create(outbound)
    └─ generateTicket(conv.id) .catch()

feishu/chat.ts:        // ~同上,8 处不同
widget/chat.ts:        // ~同上,5 处不同

新增能力 X 时 → 改 3 个文件
AFTER — 1 深 module + 3 adapter
messaging/chat-pipeline.ts (deep):
  runChat(adapter, input) {
    conv  = adapter.openConversation(input)
    inMsg = adapter.persistInbound(conv, input)
    res   = callBotChat(adapter.bot, mapReq(input, conv))
    parsed = parseEscalateAnalysis(res.reply)
    adapter.persistOutbound(conv, parsed)
    generateTicketAsync(conv.id)
    return adapter.formatReply(parsed)
  }

wechat/adapter.ts  ~30 行
feishu/adapter.ts  ~25 行
widget/adapter.ts  ~25 行

新增能力 X → 只改 chat-pipeline.ts

Benefits

看 Mermaid 调用图
flowchart LR W[wechat webhook] --> WC[wechat/chat.ts] F[feishu webhook] --> FC[feishu/chat.ts] D[widget API] --> DC[widget/chat.ts] WC --> CB[callBotChat] FC --> CB DC --> CB CB --> MM[(MM)] CB --> HTTP[(HTTP)] classDef shallow fill:#FEF3F3,stroke:#B91C1C,stroke-dasharray: 4 2; class WC,FC,DC shallow;
3 个红框是浅的(各自重复同一段流水线)
CANDIDATE 2 · the Ticket lifecycle module

Ticket 状态机散在 4 个文件,没有单一权威

Strong
Files
  • lib/ai/generate-ticket.ts (262)
  • lib/ai/execute-ticket.ts (52)
  • lib/ai/analyze-ticket.ts (55)
  • app/admin/(authed)/tickets/[id]/actions.ts (405)
Symptoms
  • 4 态: open → working → replied → closed
  • 但 actions.ts 有 13 个 server action 各自检 status + 转换
  • "need_human 不在 ACTIVE_STATUSES → 开第二张单"(skill pitfall #1)
  • generate-ticket 知道太多: 分类 / 指派 engineer / 状态判定

Problem

"状态机"在 cspy 里没有显式存在。它的实现散在: ① generate-ticket.tsACTIVE_STATUSES 数组 + "是否复用 existing" 的判定; ② execute-ticket.ts 里 status awaiting_confirm → executing → awaiting_user_push 的硬编码转换; ③ actions.ts 里 13 个 server action 各自检查 + 写不同 status; ④ admin UI 里读 status 显示不同按钮。没有任何一处文档/代码说"合法转换是哪些"。 Skill 里记的 pitfall #1("need_human 漏在 ACTIVE_STATUSES")就是这种散开必然的结果: 改了一处忘了另一处。

展开 deletion test
假装把 ACTIVE_STATUSES 这个数组删了:复杂度分散到 N 处需要判定"工单是否进行中"的 caller。 但反过来:把 generate-ticket / execute-ticket / actions.ts 里所有 status 转换集中到一个 TicketLifecycle module? 复杂度消失成一个状态机定义。这是典型的"应该集中"信号。

Solution

lib/ticket/lifecycle.ts(已有 lib/ticket/ 空目录,signal): 显式状态机 + transition function。所有 status 转换必须过这一层,callers 不能直接 db.ticket.update({status})。 状态机内部知道 "这个转换需要发什么 event / 通知谁 / 触发哪个 hook"。

Before / After

BEFORE
// generate-ticket.ts:21
const ACTIVE_STATUSES = ["new","analyzing",
  "analyzed","awaiting_confirm","executing"];
// 没列 need_human → bug #1

// actions.ts:158
changeTicketStatus(id, status) {
  await db.ticket.update({where:{id}, data:{status}});
  // 没记 event,没触发 notification
}

// actions.ts:69
submitAdminReply(...) {
  // 内嵌 status="replied" 转换 + 写 message
  // + 推 fanout + 写 event ... 200 行
}
AFTER
// lib/ticket/lifecycle.ts
type TicketEvent =
  | { type: "user_message_received", ... }
  | { type: "admin_reply_submitted", ... }
  | { type: "ai_suggestion_pushed", ... }
  | ...

export async function applyEvent(
  ticketId: string,
  event: TicketEvent
): Promise<{ticket: Ticket, sideEffects: SideEffect[]}>

// caller 只表达"发生了什么",
// 不决定"应该转到哪个 status / 写什么 event"
// 状态机内部维护 transition table

Benefits

CANDIDATE 3 · the Bot transport module

callBotChat 接口已经够小,但 MM 双客户端(ws + http) 是隐藏 if-else

Worth exploring
Files
  • lib/bot/chat-client.ts (50)
  • lib/mm/client.ts (424)
  • lib/mm/ws-client.ts
Observation
  • callBotChat = 50 行 ✓ 已经很深
  • 但 sendAndWait = 424 行,内含 ws / http 双路径
  • selectTransport(opt) 在内部决定
  • 两个 transport 失败模式不同,但 caller 都看到同一个 result type

Problem

callBotChat 接口形式上很深(传 bot + req,得 reply), 但实现里的复杂度集中在 mm/client.ts 单文件 424 行: ws 路径(sendAndWaitViaWs)+ http 路径(sendAndWaitViaHttp) + transport 选择 + edit-aware quiescence(skill 里记的 Bug A 修复)。 这是真正的 deep module(高 leverage),但 424 行混在一起难审

为什么这条只是 Worth exploring 不是 Strong
因为 deletion test 不强:把 ws/http 拆出来,复杂度只是移到两个文件,不会消失也不会显著降低。 sendAndWait 单一对外接口对 caller 来说已经足够深;拆开主要为了可读性,不是可测/可维护性。 除非你计划再加第 3 个 transport(如直接 socket / 第三方平台),拆它的 ROI 不高。

Solution(若推进)

把 mm/client.ts 按 transport 切: lib/mm/transport.ts(对外 sendAndWait,做 transport 选择 + 失败兜底) + lib/mm/ws-transport.ts + lib/mm/http-transport.ts每个 transport 满足同一个 Transport interface = 真 seam(skill 原话:"两个 adapter 才是真 seam")。

CANDIDATE 4 · the Relay module

lib/relay/ 已半死,3 个文件 351 行只剩 1 处 caller

Speculative
Files
  • lib/relay/client.ts (221)
  • lib/relay/sync.ts (125)
  • lib/relay/config.ts (5)
实际 callers
  • handler-image.ts → relayUploadMedia
  • (consult/index.ts 已切 MM 不调 relay 了)
  • (chat-client.ts 已切 MM)

Problem

MM 切换上线时 skill 写了 "lib/relay/* 仍保留供 lib/relay/sync.ts / lib/consult/index.ts 等模块使用"。 但实际 consult/index.ts 现在 import 的是 mm 不是 relay(我刚刚 grep 验证)。 真正还活着的只有 relayUploadMedia 给 wechat 图片消息上传媒体用。

Solution

① 把 relayUploadMedia 这个具体函数搬到 lib/wechat/media-upload.ts(它是个 wechat 专属能力) ② 删 lib/relay/client.ts 余下 220 行 + sync.ts + config.ts ③ 顺手清 .env 里 RELAY_BASE_URL / RELAY_ADMIN_TOKEN / RELAY_CHANNEL_ID(skill 里还在,但已无 caller)

为什么这条只是 Speculative
Skill 里说 relay 还有 dead-code path("callBotExecute HTTP 路径仍保留 dead code")。删之前要确认: ① 真的没人调?(派 gitnexus impact 分析最稳) ② 删了会不会让"未来切回 relay"变贵?(MM 路线已坚定的话不是 cost) ③ relay 在测试里有没有用?(integration test 用 mock 还是真 endpoint?) 这条是清理性 refactor,不是"deepening",严格说不属于本 skill 的目标,但作为 candidate 列出以便决定要不要顺手做。
TOP RECOMMENDATION

先做 Candidate 2 (Ticket lifecycle module)

原因:

  1. 正在出 bug:skill pitfall list 里 #1 / #2 / #3 全是状态机散开导致的(need_human 漏在 ACTIVE_STATUSES / 复用 ticket 不刷新 summary / 跨 conversation 无合并),修一个还会再出
  2. 正在加新功能:P9.3 consult 多轮对话进行中,新 transition 又要加;现在不收口,P10 还要再散一遍
  3. 测得动:状态机 = 纯函数,可不依赖 PG 测,补 17 个 integration case 测不到的边界
  4. ROI 最高:动 4 个文件,但 callers 改动小(db.ticket.update({status}) 全部替换成 applyEvent(...))。Candidate 1 涉及 3 个 channel + 多入口,Phase 化派 CC 更复杂

反对意见(我自己 review):cspy 当前节奏是"派 CC 加 feature",抽状态机意味着插一段"refactor 不出 feature"。 如果客户/业务侧有强 deadline,这条放下个 sprint。但每加一个新 ticket 状态都会让回滚成本翻倍,越拖越贵。

什么不在这份报告里