feat(flow): 链路图本地落盘 + 多轮历史回放(design/19 §P3,契约 v0.73)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

主人要求「链路流程应该有本地数据,不然每次重载之后页面就没数据了」:
- 落盘:迁移 0022_flow_graphs(session_id/turn_id/graph_json/时间戳 + 唯一键 upsert)+ internal/runtime/flowgraph(RecordRound/Graph/Latest/Turns/Turn + 保留期 GC)
- 写入:loop.EndRound **每轮一条**(锁外深拷贝后写;失败 WARN 且不打印图内容);只落发布会话白名单(复用 flowSessionAllowed)
- 读回:端点加 turnId + source(live/persisted/none) + turns[](最新在前,空为 []);**内存优先、miss 才落盘**(最新一轮永远最新)
- 前端:flowGraphs 纳入 persistOptions.partialize(**刷新即刻有图**,version 3→4 + migrate)+ 历史轮选择器 + 诚实文案;顺带收 L1 转交的 flow/NodeDetail.tsx 节点日志倒序
- 新 env 键:FLOW_GRAPH_RETENTION=168h / FLOW_GRAPH_GC_INTERVAL=1h
- 变异实测:每 span 一行 / 保留期守卫失效 / partialize 去掉 flowGraphs / 去掉内存优先 / 白名单失效 ⇒ 全部打红
- 3 个禁动面文件的纯追加改动(Flow.tsx 挂选择器、FlowSnapshotHint 纠已成假话的文案、其渲染断言随之更新)由主会话裁定认可
- 登记存疑:图桶持久化无体积上限;useFlowTurns 的 effect 无自动化证据(项目无 jsdom)
This commit is contained in:
AskaEth committed 2026-10-05 16:19:32 +08:00
1 parent 84c124f5a9
commit a6cb44b8a8
38 files changed
+3047 -91

No files matched your search

+12
View File
@@ -70,6 +70,18 @@ CHANNEL_EVENT_RETENTION=720h
CHANNEL_EVENT_GC_INTERVAL=1h CHANNEL_EVENT_GC_INTERVAL=1h
CHANNEL_EVENT_COUNTER_FLUSH=30s CHANNEL_EVENT_COUNTER_FLUSH=30s
# ---- 链路图本地落盘(flow_graphs 表;切片 F2,design/19 §4 P3) ----
# 每个会话**每轮收尾落一条**链路图(web / web:<uuid> / sess-backthink 三个白名单会话;
# 隔离的群聊 / 陌生人会话绝不入库)。让「刷新 / 重启后翻历史轮」有本地数据,
# 而不是图只活在进程内存里。
# FLOW_GRAPH_RETENTION:flow_graphs 行的保留期(Go duration),默认 168h(7 天)。
# 图的用途是「回看最近若干轮」,不是事后定案证据(后者是 channel_events 的 720h)。
# FLOW_GRAPH_GC_INTERVAL:保留期清扫间隔(Go duration),默认 1h。
# ⚠️ 任一键写 0 或负数 = **关闭清扫**(同时关闭启动期那一次,日志会写明原因)⇒
# 落盘的轮次图将**无限增长**;`FLOW_GRAPH_RETENTION=0` 不是「立即清空」。
FLOW_GRAPH_RETENTION=168h
FLOW_GRAPH_GC_INTERVAL=1h
# ---- LLM 用量样本保留期(llm_usage 表;切片 U1,主人裁决「2.4 落」) ---- # ---- LLM 用量样本保留期(llm_usage 表;切片 U1,主人裁决「2.4 落」) ----
# 每次成功模型调用都会把一条**非敏感**用量样本(token 计数 / 缓存两档 / 模型名 / # 每次成功模型调用都会把一条**非敏感**用量样本(token 计数 / 缓存两档 / 模型名 /
# 用途 / 耗时)异步落进 llm_usage 表,供 GET /api/v1/admin/llm/usage 与面板的 # 用途 / 耗时)异步落进 llm_usage 表,供 GET /api/v1/admin/llm/usage 与面板的
+37 -17
View File
@@ -1,6 +1,6 @@
# Cyrene v2 客户端 API 契约 # Cyrene v2 客户端 API 契约
> **契约版本:`v0.72`**(上一版 `v0.71`,其条目见 §6;`v0.70` 及更早见本文件 §6,`v0.67` 及更早的基线已归档、只读:`Docs/archive/api-client.v0.65.md`)。 > **契约版本:`v0.73`**(上一版 `v0.72`,其条目见 §6;`v0.71` 及更早见本文件 §6,`v0.67` 及更早的基线已归档、只读:`Docs/archive/api-client.v0.65.md`)。
> **读者**:Web 前端 / 移动端原生客户端(含 Android)/ IM 桥接等**跨平台客户端开发者**。 > **读者**:Web 前端 / 移动端原生客户端(含 Android)/ IM 桥接等**跨平台客户端开发者**。
> **唯一真源 = 当前代码**(`internal/services/gateway/*` 等)。本文档由代码逐条核对得出;与代码不符时**以代码为准**,并回改本文档。 > **唯一真源 = 当前代码**(`internal/services/gateway/*` 等)。本文档由代码逐条核对得出;与代码不符时**以代码为准**,并回改本文档。
> **旧的超详细版已归档、只读**:`Docs/archive/api-client.v0.65.md`(412 KB,含 v0.28→v0.65 全部历史条目)与 `Docs/archive/api-reference.v0.65.md`(170 KB,后端全量字段级文档)。归档的原因是**结构性臃肿 + 历史条目并列 + 长期漂移**;本文档**不复制**那段历史,需要考古时去归档。 > **旧的超详细版已归档、只读**:`Docs/archive/api-client.v0.65.md`(412 KB,含 v0.28→v0.65 全部历史条目)与 `Docs/archive/api-reference.v0.65.md`(170 KB,后端全量字段级文档)。归档的原因是**结构性臃肿 + 历史条目并列 + 长期漂移**;本文档**不复制**那段历史,需要考古时去归档。
@@ -14,6 +14,8 @@
> **v0.72 是主动推送可见性版(已实现,含一处行为变更)**:`proactive_message` 那条消息现在**落进主会话历史**(`web` / `assistant` / `mode:"chat"`,先落库再发帧),帧新增可选 `id`/`createdAt`(主会话历史行的权威 id 与时间戳)供客户端把实时气泡对齐到历史行(§3.4.5 与随后的「落库口径」说明)。修的是主人 2026-10-05 报的「后台思考的主动推送一个都没推出来」—— 活跃客户端路由此前**只广播帧、一行都不写**,刷新即丢且前端零消费点。**两条路由互斥 ⇒ 主会话里恰好一条(不双份)**;`proactive_message` 帧**逐字不变的部分**是既有全部键,客户端不读新键即行为与 v0.71 相同(只会多看到那条历史行)。 > **v0.72 是主动推送可见性版(已实现,含一处行为变更)**:`proactive_message` 那条消息现在**落进主会话历史**(`web` / `assistant` / `mode:"chat"`,先落库再发帧),帧新增可选 `id`/`createdAt`(主会话历史行的权威 id 与时间戳)供客户端把实时气泡对齐到历史行(§3.4.5 与随后的「落库口径」说明)。修的是主人 2026-10-05 报的「后台思考的主动推送一个都没推出来」—— 活跃客户端路由此前**只广播帧、一行都不写**,刷新即丢且前端零消费点。**两条路由互斥 ⇒ 主会话里恰好一条(不双份)**;`proactive_message` 帧**逐字不变的部分**是既有全部键,客户端不读新键即行为与 v0.71 相同(只会多看到那条历史行)。
> **v0.73 是链路图本地落盘版(已实现,纯增量)**:链路图从「只活在进程内存」变成**每轮一行落进本地库**(新表 `flow_graphs`,迁移 `0022`),`GET /api/v1/admin/flow/graph` 在 v0.71 之上**增量**:新增可选查询参数 `turnId`(省略 = 最近一轮;**先查内存 `LastGraph`,miss 才回退落盘**)、响应新增 `turnId` / `source` / `turns[]` 三键(**多轮历史回放**,`design/19 §4` 的 P3)。修的是主人 2026-10-05 追加要求的那句「链路流程应该有本地数据,不然每次重载之后页面就没数据了」—— v0.71 只覆盖「后端进程活着时的最近一轮」,后端一重启、或翻更早的轮次,仍然什么都查不到。**无破坏性变更**(旧键 `sessionId`/`graph` 形状语义不变,`graph_update` 帧、角色门、发布会话范围一字未改;老客户端不读新键即行为与 v0.71 相同)。落盘范围**只有**发布会话白名单内的会话(`flowSessionAllowed`),隔离渠道会话绝不入库。详见 **§2.2 B13 / §4.11**。
--- ---
## 0. 基础约定 ## 0. 基础约定
@@ -167,7 +169,7 @@
| B10 | GET | `/api/v1/admin/sessions/{id}/export` | 完整会话导出 | | B10 | GET | `/api/v1/admin/sessions/{id}/export` | 完整会话导出 |
| B11 | POST | `/api/v1/admin/rag/query` | 知识库检索(带审计的读型 POST) | | B11 | POST | `/api/v1/admin/rag/query` | 知识库检索(带审计的读型 POST) |
| B12 | GET | `/api/v1/admin/config-facts` | **配置事实面(v0.68 新增,只读)**:记忆整理 7 键 + 安静时段 `QUIET_START_HOUR`/`QUIET_END_HOUR` 的**进程生效值**,逐条带 `default`/`isDefault`。**不含任何密钥/令牌值**(连「是否已配置」都不回凭据类的)。形状见 §4.10 | | B12 | GET | `/api/v1/admin/config-facts` | **配置事实面(v0.68 新增,只读)**:记忆整理 7 键 + 安静时段 `QUIET_START_HOUR`/`QUIET_END_HOUR` 的**进程生效值**,逐条带 `default`/`isDefault`。**不含任何密钥/令牌值**(连「是否已配置」都不回凭据类的)。形状见 §4.10 |
| B13 | GET | `/api/v1/admin/flow/graph` | **最近一轮链路图快照(v0.71 新增,只读、不审计)**。`?sessionId=`(省略 = 主会话 `web`);`{data:{sessionId,graph}}`,`graph` 是**一份 §3.4.5 `graph_update` 同形快照**,该会话还没有图时**如实为 `null`**(**绝不画假图**)。**只回最近一轮**(更早轮次的历史回放未实现,见 §7.4-18);未接线图源 → `500 flow_unavailable`。语义见 §4.11 | | B13 | GET | `/api/v1/admin/flow/graph` | **链路图快照 / 多轮历史回放(v0.71 新增;v0.73 增量)**,只读、不审计。`?sessionId=`(省略 = 主会话 `web`)+ **v0.73 新增 `?turnId=`**(省略 = 最近一轮,**先内存后落盘**)。响应 `{data:{sessionId,turnId,source,graph,turns[]}}`:`graph` 是**一份 §3.4.5 `graph_update` 同形快照**,没有该轮时**如实为 `null`**(**绝不画假图**);`source` ∈ `live`(内存最近一轮)/`persisted`(落盘的历史轮)/`none`;`turns[]`(`{turnId,createdAt}`,最新在前)供面板选历史轮(**v0.73**)。未接线图源 → `500 flow_unavailable`。语义见 §4.11 |
> **读取 `GET /admin/observability/{logs,metrics,trace}` 兼任可观测推送的订阅握手**(该凭据的连接全部断开即退订)。REST 响应形状不变。 > **读取 `GET /admin/observability/{logs,metrics,trace}` 兼任可观测推送的订阅握手**(该凭据的连接全部断开即退订)。REST 响应形状不变。
@@ -731,9 +733,9 @@
- **下限推导(只对没有落库档位的行)**:该列为空的行 —— 该列出现之前的**旧行**,以及从未走到档位判定点的行(系统事件降级、思考失败)—— 才回落到 v0.68 的安静时段窗口推导,并**必须**带 `urgencyLowerBound:true`。⚠️ 旧行**不得**被显示成精确档位,未知也不得被显示成某个档位。 - **下限推导(只对没有落库档位的行)**:该列为空的行 —— 该列出现之前的**旧行**,以及从未走到档位判定点的行(系统事件降级、思考失败)—— 才回落到 v0.68 的安静时段窗口推导,并**必须**带 `urgencyLowerBound:true`。⚠️ 旧行**不得**被显示成精确档位,未知也不得被显示成某个档位。
- 每轮 `Import` 落库(`urgency` 列)在 X2a 切片实现(`213aff2`);「该轮实际生效」的取数点见 `internal/services/backthink/backthink.go` 的判定点。⚠️ 本版**不改** `quietHoursBypassed` 的语义。 - 每轮 `Import` 落库(`urgency` 列)在 X2a 切片实现(`213aff2`);「该轮实际生效」的取数点见 `internal/services/backthink/backthink.go` 的判定点。⚠️ 本版**不改** `quietHoursBypassed` 的语义。
### 4.11 v0.71 新增:最近一轮链路图快照(`GET /api/v1/admin/flow/graph`) ### 4.11 链路图快照 + 多轮历史回放(`GET /api/v1/admin/flow/graph`;v0.71 新增,v0.73 增量)
**为什么有这条**:`graph_update`(§3.4.5)是**纯实时帧** —— 后端在每个 span 开始/结束时广播一次,**不重放**;前端把图存在**内存**的会话图桶里(`flowGraphs`,**不持久化**)。于是「**轮次结束后才打开页面**」或「**刷新过页面**」看到的永远是空态(`/#/admin/flow` 的「暂无流程」、后台思考行「查看完整链路导图」的「该会话还没有 graph_update 快照」)。本端点补上唯一缺的那一环:**打开页面时按会话只读拉一次最近一轮的快照**。 **为什么有这条**:`graph_update`(§3.4.5)是**纯实时帧** —— 后端在每个 span 开始/结束时广播一次,**不重放**;前端把图存在**内存**的会话图桶里(`flowGraphs`)。于是「**轮次结束后才打开页面**」或「**刷新过页面**」看到的永远是空态(`/#/admin/flow` 的「暂无流程」、后台思考行「查看完整链路导图」的「该会话还没有 graph_update 快照」)。v0.71 补上**打开页面时按会话只读拉一次最近一轮的快照**;v0.73 再补上**本地落盘 + 多轮历史回放** —— 图从「只活在进程内存」变成每轮一行落进本地库(新表 `flow_graphs`,迁移 `0022`),后端重启后仍能查回,且可按 `turnId` 翻更早的轮次(`design/19 §4` 的 P3)。
#### (a) 请求与响应 #### (a) 请求与响应
@@ -741,23 +743,25 @@
|---|---| |---|---|
| 方法 / 路径 | `GET /api/v1/admin/flow/graph` | | 方法 / 路径 | `GET /api/v1/admin/flow/graph` |
| 鉴权档 | `writeAuth`(**owner / admin**;`viewer` → `403`,未认证 → `401`)。与 `design/19 §3`「flow 可视化仅 owner/admin」一致 | | 鉴权档 | `writeAuth`(**owner / admin**;`viewer` → `403`,未认证 → `401`)。与 `design/19 §3`「flow 可视化仅 owner/admin」一致 |
| 查询参数 | `sessionId`(**可选**)。省略 / 空串 ⇒ 主会话 `web`(`internal/runtime/history.MainSessionID`)。其它取值就是该会话 id,如 `sess-backthink`(后台思考,design/20 §2)、`web:<uuid>`(次级会话) | | 查询参数 | `sessionId`(**可选**)。省略 / 空串 ⇒ 主会话 `web`(`internal/runtime/history.MainSessionID`)。其它取值就是该会话 id,如 `sess-backthink`(后台思考,design/20 §2)、`web:<uuid>`(次级会话)。**v0.73 新增** `turnId`(**可选**):省略 / 空串 ⇒ **最近一轮**(**先查内存 `loop.FlowPublisher.LastGraph`,miss 才回退落盘** ⇒ 最新一轮永远是最新的);给了就是该会话已落盘的某一轮(形如 `web-7`,取自 `turns[].turnId` / `graph.id`) |
| 响应体 | `{data:{sessionId:"<解析后的会话 id>", graph: <FlowGraph 对象>|null}}` | | 响应体 | `{data:{sessionId, turnId, source, graph, turns}}`(**v0.73**;v0.71 只有 `{sessionId,graph}`)。`sessionId` = 解析后的会话;`turnId` = **实际返回的那一轮**的标识(无图时 `""`);`source` ∈ `"live"`(内存最近一轮)/`"persisted"`(落盘的历史轮)/`"none"`(没有图);`graph` = 该轮的图或 `null`;`turns` = 该会话**已落盘轮次**摘要 `[{turnId,createdAt}]`,**最新在前**(未接线持久化或没有轮次时是 `[]`,恒为非 null 数组) |
| `graph` 形状 | **与 `graph_update` 帧的 `data` 逐字同形**(§3.4.5:`{id,nodes[],edges[],sessionId,startedAt,endedAt}`,节点 `props`/`log` 同 §1.1 的脱敏口径) | | `graph` 形状 | **与 `graph_update` 帧的 `data` 逐字同形**(§3.4.5:`{id,nodes[],edges[],sessionId,startedAt,endedAt}`,节点 `props`/`log` 同 §1.1 的脱敏口径) |
| 归档 | **不审计**(只读查询,与同档的 `/admin/llm`、`/admin/config-facts` 一致) | | 归档 | **不审计**(只读查询,与同档的 `/admin/llm`、`/admin/config-facts` 一致) |
| 失败 | 图源未接线 ⇒ `500 {"code":"flow_unavailable"}`(**不假装「这一轮没有图」**);该会话**确实**还没有图 ⇒ `200` + `graph:null` | | 失败 | 图源(内存 + 落盘)**都没接线** ⇒ `500 {"code":"flow_unavailable"}`(**不假装「这一轮没有图」**);该轮**确实**没有图 / 该 `turnId` 不存在 ⇒ `200` + `graph:null`(`source:"none"`)。落盘查询本身失败(库不可用)⇒ 同样 `500 flow_unavailable`(**不把「读库失败」伪装成「没有这一轮」**) |
#### (b) 语义硬约束(客户端必须照这个读) #### (b) 语义硬约束(客户端必须照这个读)
1. **它是「最近一轮」而不是「你要的那一轮」。** 后端只保留每个会话**最新**的一份图(`loop.FlowPublisher` 每会话一张,`StartRound` 重置)。因此后台思考行上点开的图**可能不是该行那一轮**——**面板文案必须说明这一点**(当前实现:`frontend/web/src/admin/modules/flow/FlowSnapshotHint.tsx`),不得让用户以为「每行都有自己那轮的图」。**多轮历史回放**仍是未实现项(`design/19 §4` 的 P3,见 §7.4-18)。 1. **省略 `turnId` = 「最近一轮」而不是「你要的那一轮」。** 省略或空串时后端**先**取内存里该会话最新的一张图(`loop.FlowPublisher.LastGraph`,即正在跑/刚跑完的那一轮),**取不到才**回退到落盘的最新一行 —— 因此「最新一轮永远是最新的」。**不给 `turnId` 就不要假定它等于你眼前那条记录那一轮**:后台思考行上点开的图可能不是该行那轮 ⇒ 面板**必须**说明(当前实现:`frontend/web/src/admin/modules/flow/FlowSnapshotHint.tsx`)。要精确取某轮就带上 `turns[].turnId`。
2. **只读、不发布。** 拉快照**不产生** `graph_update` 帧、不改变发布白名单、不改变任何会话的实时图。 2. **给了 `turnId` = 精确取那一轮。** 该 `turnId` 正好等于内存里最新一轮 ⇒ 回内存图(`source:"live"`,更实时);否则读落盘行(`source:"persisted"`)。**该轮不存在 ⇒ `graph:null` + `source:"none"`**(诚实空态),**绝不**用别的轮次顶替。
3. **`graph:null` 是诚实的空。** 会话从未跑过有 span 的轮次、或图源里没有该会话(如发布白名单之外的隔离渠道会话 `sess-xxx` 从未建图)时返回 `null` ⇒ 客户端**必须**渲染空态,**绝不允许**补画任何节点/假图。 3. **只读、不发布。** 拉快照**不产生** `graph_update` 帧、不改变发布白名单、不改变任何会话的实时图、也不落盘。
4. **快照会晚于实时帧到达,反之亦然。** 客户端已有实时图时**不应**被快照覆盖(实时图更新;快照只是补冷启动的洞)——当前实现只在**本会话还没有任何图**时才拉。 4. **`graph:null` 是诚实的空。** 该会话从未跑过有 span 的轮次、或图源里没有该会话(如发布白名单之外的隔离渠道会话 `sess-xxx` 从未建图、也从不落盘)时返回 `null` ⇒ 客户端**必须**渲染空态,**绝不允许**补画任何节点/假图。
5. **不替代** `graph_update`。实时帧仍是「正在跑的那一轮」的唯一来源;本端点只补「已经跑完、帧已经错过」的那一轮。 5. **落盘只覆盖发布会话白名单内的会话,且每轮一行**(`EndRound` 时写入,**不是每 span 一行**):图里可能含用户输入文本 ⇒ 客户端按既有 §1.1 脱敏口径读即可,但**不得**把图当成「有全部原文」的凭据。落盘件按保留期清扫(`FLOW_GRAPH_RETENTION`,默认 168h),**过期轮次会被回收** ⇒ `turns[]` 只反映「仍在保留期内」的轮次。
6. **快照会晚于实时帧到达,反之亦然。** 客户端已有实时图时**不应**被快照覆盖(实时图更新;快照只是补冷启动的洞)——当前实现只在**本会话还没有任何图**时才拉。
7. **不替代** `graph_update`。实时帧仍是「正在跑的那一轮」的唯一来源;本端点补的是「已经跑完、帧已经错过」的那些轮次(最近一轮 + 可选的任意历史轮)。
#### (c) 与 design/19 的关系 #### (c) 与 design/19 的关系
`design/19 §4` 把「多轮历史回放」列为 **P3**;本端点落实的是 P3 里**最小的一步**(每会话最近一轮的只读快照),**不改** design/19 §1 的发布会话范围(`web` / `web:<uuid>` / `sess-backthink`,裁决 01.4.5)、**不改** `graph_update` 两路互顶的既有裁决(见 §7.4-19)、**不改**前端图桶的按会话分桶口径(裁决 16.4.2)。 `design/19 §4` 把「多轮历史回放」列为 **P3**;v0.71 落实了 P3 里**最小的一步**(每会话最近一轮的只读快照),**v0.73 把 P3 补全**(本地落盘 + 按 `turnId` 取任意保留期内的轮次 + 轮次列表)。二者都**不改** design/19 §1 的发布会话范围(`web` / `web:<uuid>` / `sess-backthink`,裁决 01.4.5)、**不改** `graph_update` 两路互顶的既有裁决(见 §7.4-19)、**不改**前端图桶的按会话分桶口径(裁决 16.4.2)。落盘载体是新表 `flow_graphs`(迁移 `0022`),GC 口径照 `events`/`documents`(env 键 + 启动期扫一次 + 定时扫 + 失败不退出)。
--- ---
@@ -808,6 +812,8 @@
| `CHANNEL_EVENT_RETENTION` | `720h` | **v0.67 登记,已接线**(E4 `afe9afb`;`.env.example` 同步于 `f9c5b56`):`channel_events` 保留期 —— 比文档的 `168h` 长,因为事件表的核心用途是**事后定案**(一周太短);`≤0` = 关闭 GC | | `CHANNEL_EVENT_RETENTION` | `720h` | **v0.67 登记,已接线**(E4 `afe9afb`;`.env.example` 同步于 `f9c5b56`):`channel_events` 保留期 —— 比文档的 `168h` 长,因为事件表的核心用途是**事后定案**(一周太短);`≤0` = 关闭 GC |
| `CHANNEL_EVENT_GC_INTERVAL` | `1h` | **v0.67 登记,已接线**(E4 `afe9afb`):渠道事件 GC 扫描间隔(与 `DOCUMENT_GC_INTERVAL` 同范式) | | `CHANNEL_EVENT_GC_INTERVAL` | `1h` | **v0.67 登记,已接线**(E4 `afe9afb`):渠道事件 GC 扫描间隔(与 `DOCUMENT_GC_INTERVAL` 同范式) |
| `CHANNEL_EVENT_COUNTER_FLUSH` | `30s` | **v0.67 登记,已接线**(E4 `afe9afb`):计数内存聚合的 flush 间隔。计数是**尽力而为的近似值**(进程崩丢 ≤30s),查询面以 `approx:true` 如实标注(见 §2.4(a)) | | `CHANNEL_EVENT_COUNTER_FLUSH` | `30s` | **v0.67 登记,已接线**(E4 `afe9afb`):计数内存聚合的 flush 间隔。计数是**尽力而为的近似值**(进程崩丢 ≤30s),查询面以 `approx:true` 如实标注(见 §2.4(a)) |
| `FLOW_GRAPH_RETENTION` | `168h` | **v0.73 新增,已接线**(切片 F2):`flow_graphs` 轮次图保留期;`≤0` = 关闭 GC(显式选择,「配了却不生效」不存在)。过期轮次从 `turns[]` 消失(见 §4.11(b)-5 / §7.4-20) |
| `FLOW_GRAPH_GC_INTERVAL` | `1h` | **v0.73 新增,已接线**(切片 F2):链路图保留期清扫间隔(与 `CHANNEL_EVENT_GC_INTERVAL` / `DOCUMENT_GC_INTERVAL` 同范式;启动期扫一次 + 定时 + 失败不退出) |
**与契约相关但「配了不生效」的键**: **与契约相关但「配了不生效」的键**:
@@ -822,7 +828,20 @@
> 只保留最近若干版(**v0.60 起**);更早的全部历史见归档 `Docs/archive/api-client.v0.65.md` §5(v0.28 → v0.59)。 > 只保留最近若干版(**v0.60 起**);更早的全部历史见归档 `Docs/archive/api-client.v0.65.md` §5(v0.28 → v0.59)。
> 每版一句话 + 三档标注。**三档定义见 [§0.4](#04-三档标注惯例)。** > 每版一句话 + 三档标注。**三档定义见 [§0.4](#04-三档标注惯例)。**
### v0.72(当前契约版本) ### v0.73(当前契约版本)
**链路图本地落盘 + 多轮历史回放 —— 图从「只活在进程内存」变成每轮一行落进本地库,`GET /api/v1/admin/flow/graph` 增量支持按 `turnId` 取历史轮**(依据主人 2026-10-05 追加要求「链路流程应该有本地数据,不然每次重载之后页面就没数据了」;对齐 `design/19 §4` 的 **P3**「多轮历史回放」—— v0.71 只做了其中最小一步)。
- **纯增量**:
① **落盘**:每轮收尾(`EndRound`)把该轮的图写进新表 `flow_graphs`(迁移 `0022`),**每轮一行**(不是每 span 一行);只落**发布会话白名单内**的会话(复用 `flowSessionAllowed`,与实时发布同一判据)。新 env 键 `FLOW_GRAPH_RETENTION`(默认 `168h`)/ `FLOW_GRAPH_GC_INTERVAL`(默认 `1h`),GC 口径照 `events`/`documents`(启动期扫一次 + 定时 + 失败不退出;`≤0` = 关闭;**不许无限增长**)。
② **`GET /api/v1/admin/flow/graph` 新增 `?turnId=`**:省略 = 最近一轮(**先查内存 `LastGraph`,miss 才回退落盘** ⇒ 最新一轮永远最新);给了 = 精确取该落盘轮。
③ **响应新增三键 `turnId` / `source` / `turns[]`**:`source` ∈ `live`/`persisted`/`none`;`turns[]`(`{turnId,createdAt}`,最新在前)供面板选历史轮 —— `graph`/`sessionId` 两键的既有语义一字未改。 —— **§2.2 B13 / §4.11**。
④ 前端:图桶 `flowGraphs` 纳入 store 持久化(刷新即刻有图)+ 历史轮次选择 UI + 诚实文案(白名单外会话永远没有图;未跑新轮次前的旧轮次靠落盘)。
- **行为变更**:无(`graph_update` 帧形状、角色门、发布会话范围全部未改;`graph`/`sessionId` 语义未改)。
- **破坏性**:无(新查询参数可选、新响应键可选;老客户端不读即行为与 v0.71 相同)。
- ⚠️ **诚实边界**:落盘只覆盖**保留期内**的轮次(默认 7 天)—— `turns[]` 不是全历史;`graph_update` 两路互顶的既有裁决**本版未动**(§7.4-19)。当前仍**没有图**的轮次见 §7.4-20。
### v0.72(上一版)
**主动推送可见性 —— 主动消息成为主会话里的一条真实消息,并给帧补上权威行 id**(依据主人 2026-10-05 报「后台思考的主动推送一个都没推出来」;对齐 `design/12 §3` 的主动推送与 `design/16 §4.4` 的镜像口径)。 **主动推送可见性 —— 主动消息成为主会话里的一条真实消息,并给帧补上权威行 id**(依据主人 2026-10-05 报「后台思考的主动推送一个都没推出来」;对齐 `design/12 §3` 的主动推送与 `design/16 §4.4` 的镜像口径)。
@@ -1055,9 +1074,10 @@
- **v0.70 更正**:每轮 `Import` **已落库** ⇒ `urgency` **默认就是精确档位**(含窗口之外/白天的轮次);只有与 `urgencyLowerBound:true` 同时出现时才是**下限**(旧行 / 从未走到档位判定点的行,值由安静时段窗口规则推导)。实时 `backthink` 帧也带 `urgency`(精确值),但**不带** `urgencyLowerBound` / `quietHoursBypassed`(见 §4.10(b) 更正)。 - **v0.70 更正**:每轮 `Import` **已落库** ⇒ `urgency` **默认就是精确档位**(含窗口之外/白天的轮次);只有与 `urgencyLowerBound:true` 同时出现时才是**下限**(旧行 / 从未走到档位判定点的行,值由安静时段窗口规则推导)。实时 `backthink` 帧也带 `urgency`(精确值),但**不带** `urgencyLowerBound` / `quietHoursBypassed`(见 §4.10(b) 更正)。
- **客户端两条硬约束(v0.70 新增)**:① **不得把带 `urgencyLowerBound:true` 的「下限」显示成精确档位**(`≥ 高` ≠ `高`;同形就等于把推导冒充事实);② **不得把键缺席显示成「例常 / routine」**(缺席 = 存档里没有这个事实),也不得据此推断安全域。 - **客户端两条硬约束(v0.70 新增)**:① **不得把带 `urgencyLowerBound:true` 的「下限」显示成精确档位**(`≥ 高` ≠ `高`;同形就等于把推导冒充事实);② **不得把键缺席显示成「例常 / routine」**(缺席 = 存档里没有这个事实),也不得据此推断安全域。
18. **(v0.71 登记)「最近一轮」≠「你要的那一轮」**:`GET /api/v1/admin/flow/graph`(§4.11)只回该会话**最新**的一张图(`loop.FlowPublisher` 每会话只留一份)。**后台思考行**上点开的图因此可能不是该行那一轮 ⇒ 面板**必须在图上说明**(当前 `frontend/web/src/admin/modules/flow/FlowSnapshotHint.tsx`),客户端**不得**把它渲染成「这一行的链路」。**多轮历史回放**(按 turn/时间点取任意一轮)**未实现**,属 `design/19 §4` 的 **P3**。 18. **(v0.71 登记 · v0.73 更新)「最近一轮」≠「你要的那一轮」**:`GET /api/v1/admin/flow/graph`(§4.11)在**省略 `turnId`** 时回该会话**最新**的一张图(先内存 `loop.FlowPublisher.LastGraph`,miss 才落盘)。**后台思考行**上点开的图因此可能不是该行那一轮 ⇒ 面板**必须在图上说明**(当前 `frontend/web/src/admin/modules/flow/FlowSnapshotHint.tsx`),客户端**不得**把它渲染成「这一行的链路」。**v0.73 起**客户端要精确取某轮就带 `turns[].turnId`(**多轮历史回放已实现**,`design/19 §4` 的 **P3**)。
19. **(v0.71 登记)图源是**进程内内存**,进程重启即清空**:`loop.FlowPublisher` 的每会话最近一图**不落库**(design/19 §1 的实时口径)⇒ 后端重启后、该会话还没跑新轮次时,端点如实回 `graph:null`。这是**有意**的诚实空态,不是丢数据(图从来不是持久资产)。同时**登记既有裁决不改**:`graph_update` 的两路互顶(同会话并行两路时后发的那张图会顶掉先发的)仍**未修**,本版**不碰**它。 19. **(v0.71 登记 · v0.73 更新)图源是**进程内内存**,但**每轮收尾会落盘**(v0.73)**:`loop.FlowPublisher` 的每会话最近一图仍是**内存**(design/19 §1 的实时口径),但 `EndRound` 会把该轮写进 `flow_graphs`(迁移 `0022`)⇒ 后端重启后、该会话还没跑新轮次时,省略 `turnId` 的查询**回退落盘**即可拿到最近一轮(`source:"persisted"`),不再一律 `graph:null`。⚠️ **仍会 `graph:null` 的情形**:该会话本就在**发布会话白名单之外**(隔离渠道会话,从不建图也从不落盘)、或该会话**从未跑过带 span 的轮次**、或该轮已被**保留期 GC 回收**(默认 168h)。**登记既有裁决不改**:`graph_update` 的两路互顶(同会话并行两路时后发的那张图会顶掉先发的)仍**未修**,本版**不碰**它。
20. **(v0.73 登记)落盘只覆盖「保留期内」且只覆盖白名单会话**:`turns[]` 是**已落盘且未被 GC 回收**的轮次摘要,**不是全历史**(`FLOW_GRAPH_RETENTION` 默认 168h,`≤0` = 关闭 GC ⇒ 那时才会无限增长,属显式选择)。落盘范围与实时发布**同一白名单**(`flowSessionAllowed`:`web` / `web:<uuid>` / `sess-backthink`)—— **群聊 / 陌生人会话永远不会出现在 `turns[]` 里**。此外**正在跑、尚未 `EndRound`** 的那一轮不会在 `turns[]` 中出现(它是内存里的「实时」那一轮,`source:"live"`),这是如实而非缺失。
--- ---
*本文件由「以代码为真源」维护(当前 `v0.71`;`v0.70` / `v0.69` / `v0.68` 见本文件 §6,`v0.67` 及更早见本文件 §6 与 `Docs/archive/`)。范围:`v0.71` 同步了 `internal/services/gateway/**`、`internal/runtime/loop/flow.go`、`frontend/web/src/**` 与本文件;`v0.70` 同步了 `internal/services/gateway/**`、`frontend/web/src/**` 与本文件;`v0.69` 同步了 `internal/services/gateway/**` / `internal/config/**` 与本文件。* *本文件由「以代码为真源」维护(当前 `v0.73`;`v0.72` / `v0.71` / `v0.70` / `v0.69` / `v0.68` 见本文件 §6,`v0.67` 及更早见本文件 §6 与 `Docs/archive/`)。范围:`v0.73` 同步了 `internal/services/gateway/**`、`internal/runtime/loop/flow.go`、`internal/runtime/flowgraph/**`、`internal/db/migrations/0022_flow_graphs.*`、`frontend/web/src/**` 与本文件;`v0.72` 同步了 `internal/services/gateway/**`(`proactive*`)、`internal/services/backthink/**`、`frontend/web/src/**` 与本文件;`v0.71` 同步了 `internal/services/gateway/**`、`internal/runtime/loop/flow.go`、`frontend/web/src/**` 与本文件。*
+3
View File
@@ -102,6 +102,9 @@ func run() error {
// 渠道事件留存维护(切片 E3+E4):保留期清扫一次 + 此后定时清扫,内存计数 // 渠道事件留存维护(切片 E3+E4):保留期清扫一次 + 此后定时清扫,内存计数
// 按 CHANNEL_EVENT_COUNTER_FLUSH 定时落盘(退出前最后一次落盘在循环里)。 // 按 CHANNEL_EVENT_COUNTER_FLUSH 定时落盘(退出前最后一次落盘在循环里)。
a.startChannelEvents(ctx) a.startChannelEvents(ctx)
// 链路图留存维护(切片 F2):保留期清扫一次 + 此后按 FLOW_GRAPH_GC_INTERVAL 定时清扫。
// 没有它,落盘的轮次图会**无限增长**(design/19 §4 P3 的 GC 口径)。
a.startFlowGraphs(ctx)
// LLM 用量落库与保留期清理(切片 U1):单写者 + 有界队列的异步写入器, // LLM 用量落库与保留期清理(切片 U1):单写者 + 有界队列的异步写入器,
// 加上按 LLM_USAGE_RETENTION 的过期样本清理。 // 加上按 LLM_USAGE_RETENTION 的过期样本清理。
a.startUsage(ctx) a.startUsage(ctx)
+9
View File
@@ -21,6 +21,7 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/exec" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/exec"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/events" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/events"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/flowgraph"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
@@ -130,6 +131,14 @@ type app struct {
// 发布器同时交给 loop/orchestration(写入面)与网关(查询面),因此查询到的 // 发布器同时交给 loop/orchestration(写入面)与网关(查询面),因此查询到的
// 就是实时发布的那张图,不会出现两份状态。 // 就是实时发布的那张图,不会出现两份状态。
flow *loop.FlowPublisher flow *loop.FlowPublisher
// flowStore 是链路图的**本地落盘**(design/19 §4 P3;切片 F2;迁移 0022):发布器
// 每轮收尾经它落一条,网关按 turnId / turns 读回。库未接时为 nil(发布器随即走
// 纯内存路径,见 wire_flow_graphs.go)。它与 flow 是两件事:flow 是实时内存图,
// flowStore 是收尾存档。
flowStore *flowgraph.SQLStore
// flowGraphKeeper 是链路图留存的常驻维护任务(保留期清扫),由 main 的
// startFlowGraphs 拉起(见 wire_flow_graphs.go)。
flowGraphKeeper *flowGraphKeeper
} }
// newApp 依据 cfg 与 log 组装完整的 Cyrene 运行时:打开数据库、迁移表结构,接线 // newApp 依据 cfg 与 log 组装完整的 Cyrene 运行时:打开数据库、迁移表结构,接线
+4 -3
View File
@@ -81,12 +81,13 @@ func (a *app) newChannelEventStore(envFilePath string) *events.SQLStore {
} }
// durationEnv 读一个 duration 型 env 键(进程环境 > `.env` > def)。配了但读不懂时记 // durationEnv 读一个 duration 型 env 键(进程环境 > `.env` > def)。配了但读不懂时记
// 一条带原始取值的 WARN 并回落 def —— 错的是那一个键,不该让整条留存面失效 // 一条带键名与原始取值的 WARN 并回落 def —— 错的是那一个键,不该让整条留存面失效
// (AGENTS §10:非法配置不许静默失效,但也不许一票否决)。 // (AGENTS §10:非法配置不许静默失效,但也不许一票否决)。消息刻意保持中立(不带
// 具体功能名),使渠道事件与链路图两处共用同一份读取口径(切片 E4 / F2)。
func durationEnv(log *slog.Logger, envFilePath, key string, def time.Duration) time.Duration { func durationEnv(log *slog.Logger, envFilePath, key string, def time.Duration) time.Duration {
value, err := config.LoadDurationEnv(envFilePath, key, def) value, err := config.LoadDurationEnv(envFilePath, key, def)
if err != nil { if err != nil {
log.Warn("channel event env ignored, falling back to default", log.Warn("duration env ignored, falling back to default",
"env", key, "error", err, "fallback", def.String()) "env", key, "error", err, "fallback", def.String())
} }
return value return value
+156
View File
@@ -0,0 +1,156 @@
package main
// 本文件把**链路图落盘**接进组合根(design/19 §4 P3;切片 F2):按配置构造链路图
// 存储,交给发布器当「每轮收尾落一条」的出口,并拉起常驻的保留期清扫循环。网关的
// 只读出口(契约 v0.73 的 `?turnId=` 与 `turns[]`)在 wire_frontend.go 里注入**同一个**
// 存储句柄,因此「写进去的」与「查得到的」永远是一份状态。
//
// 三条取舍逐字对齐参照实现 `wire_channel_events.go` / `wire_document_gc.go`
// (design 明写「不另造」):
//
// 1. **启动期清扫不阻塞启动**:它是常驻 goroutine 的第一件事,而不是在 newApp/run 里
// 同步等它跑完。
// 2. **失败不退出进程**:清扫失败只记一条 WARN 并等下一轮;一轮失败只是「这一轮没
// 扫干净」,绝不让进程退出。
// 3. **关闭 = 显式配 ≤0**:`FLOW_GRAPH_RETENTION=0`(或负值)或
// `FLOW_GRAPH_GC_INTERVAL=0` 都不开启清扫,NewStore 会把关闭原因记进日志(静默的
// 「配了却不生效」不存在)。
//
// ⚠️ 与 channelEventKeeper 的一处刻意留白:本 keeper 只在 ctx 取消时退出循环(不额外
// 挂退出钩子)—— 与 `wire_channel_events.go` 的已知差异一致,因为 `wire_services.go`
// 由别的切片在改(本片不许动)。清扫本就是幂等且尽力而为的。
//
// 两个键的读取走 `config.LoadDurationEnv`(进程环境 > `.env` > 本包导出的默认常量):
// `.env` 不写回进程环境,直接 os.Getenv 会让只写在 `.env` 里的声明静默失效。
import (
"context"
"log/slog"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/flowgraph"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/gateway"
)
// 两个 env 键名:必须与 `.env.example` **逐字一致**(AGENTS §10 的 E6 条目)。
const (
flowGraphRetentionEnv = "FLOW_GRAPH_RETENTION"
flowGraphGCIntervalEnv = "FLOW_GRAPH_GC_INTERVAL"
)
// wireFlowGraphs 构造链路图存储并挂到 app 上(随后 wireRuntime 把它交给发布器作落盘
// 出口,wireFrontend 把它交给网关作只读查询出口)。它**不**启动任何 goroutine:常驻
// 循环由 main 的 startFlowGraphs 在子系统启动之后拉起(与渠道事件留存的分工逐字相同)。
func (a *app) wireFlowGraphs() {
a.flowStore = a.newFlowGraphStore(envPath)
}
// newFlowGraphStore 按配置构造链路图存储。库未接(`a.db == nil`,只可能出现在测试
// 夹具里)时返回 nil:发布器随即走「不落盘」的纯内存路径(F1 之前的行为),会话收尾
// 照常,绝不因为没有存储而失败或改变实时帧。
//
// 默认值一律取 `internal/runtime/flowgraph` 导出的常量 —— **不在这里另写一份字面量**:
// 两处各写一份 168h/1h,改一处就会让「文档承诺的默认值」与「代码里的默认值」漂移。
func (a *app) newFlowGraphStore(envFilePath string) *flowgraph.SQLStore {
if a.db == nil {
a.log.Warn("flow graph store skipped: no database connection")
return nil
}
store, err := flowgraph.NewStore(flowgraph.Config{
DB: a.db,
Log: a.log,
GCInterval: durationEnv(a.log, envFilePath, flowGraphGCIntervalEnv, flowgraph.DefaultGCInterval),
Retention: durationEnv(a.log, envFilePath, flowGraphRetentionEnv, flowgraph.DefaultRetention),
})
if err != nil {
// NewStore 唯一的失败是 DB 为 nil(上面已挡掉):仍如实处理,绝不吞错。
a.log.Warn("flow graph store unavailable", "err", err)
return nil
}
return store
}
// flowRoundRecorder 返回注入发布器的落盘出口:未接线时返回**真正的 nil 接口**,而不是
// 「装着 nil 指针的接口」—— 后者在 Go 里不等于 nil,会让发布器的 `p.record != nil`
// 判据失效(症状是每一轮收尾都撞一次 nil 接收者并记一条 WARN)。
func (a *app) flowRoundRecorder() loop.RoundRecorder {
if a.flowStore == nil {
return nil
}
return a.flowStore
}
// flowGraphStore 返回注入网关的只读出口:未接线时同样交出**真 nil 接口**(与
// channelEventStore 同一取舍)。发布器与网关拿到的是**同一个**存储句柄。
func (a *app) flowGraphStore() gateway.FlowGraphStore {
if a.flowStore == nil {
return nil
}
return a.flowStore
}
// startFlowGraphs 拉起链路图留存的维护循环(未接线时是空操作)。
func (a *app) startFlowGraphs(ctx context.Context) {
if a.flowStore == nil {
a.log.Info("flow graph upkeep disabled: store not wired",
"effect", "rounds are kept in memory only (no local history)")
return
}
keeper := &flowGraphKeeper{
store: a.flowStore,
log: logger.WithSource(a.log, "cmd/cyrene/wire_flow_graphs"),
done: make(chan struct{}),
}
a.flowGraphKeeper = keeper
go keeper.loop(ctx)
}
// flowGraphKeeper 是链路图留存的常驻维护任务(保留期清扫)。它持有**存储句柄**
// (不是 app),因此生命周期与用法一目了然。
type flowGraphKeeper struct {
store *flowgraph.SQLStore
log *slog.Logger
// done 在循环退出时关闭(让退出路径可观测)。
done chan struct{}
}
// loop 是维护循环:启动期先清扫一次,此后按间隔定时清扫,直到 ctx 被取消。
// GC 关闭时启动期那次是空操作,循环随即返回(不留空转 goroutine)。
func (k *flowGraphKeeper) loop(ctx context.Context) {
defer close(k.done)
k.sweep(ctx)
ticks, stop := k.gcTicker()
defer stop()
if ticks == nil {
return
}
for {
select {
case <-ctx.Done():
return
case <-ticks:
k.sweep(ctx)
}
}
}
// gcTicker 给出清扫的定时通道;GC 关闭时返回 **nil 通道**(select 上永不触发)。
// `time.NewTicker(0)` 会 panic,所以「配了 ≤0 = 关闭」必须**在这里**就分岔,而不是
// 建一个零间隔 ticker 再指望它不跑。
func (k *flowGraphKeeper) gcTicker() (<-chan time.Time, func()) {
if !k.store.Enabled() {
return nil, func() {}
}
ticker := time.NewTicker(k.store.Interval())
return ticker.C, ticker.Stop
}
// sweep 跑一轮保留期清扫。每轮的**成功**汇总由 store 自己记(含删除行数与批次),
// 这里只负责把「这一轮整个没跑成」记成 WARN 并让循环继续。
func (k *flowGraphKeeper) sweep(ctx context.Context) {
if _, err := k.store.Sweep(ctx); err != nil {
k.log.Warn("flow graph sweep failed", "err", err)
}
}
+290
View File
@@ -0,0 +1,290 @@
package main
// 本文件是**链路图落盘组合根接线**的验收(切片 F2;design/19 §4 P3)。
//
// 两道并行的取证(与 `wire_channel_events_test.go` 同一手法):
//
// 1. **接线存在性**:组合根本身无法在单元测试里实例化(开库、迁移、绑端口,而后端还在
// 运行),因此「存储真的被构造」「真的交给发布器作落盘出口」「真的注入网关作查询
// 出口」「常驻循环真的被拉起」只能在源码层钉住 —— 少任何一处,落盘会**静默不工作**
// (图照旧只在内存、日志里一句都没有),而这不红任何既有用例。最后一段还核对
// `.env.example` 的两个键(AGENTS §10 的 E6 条目)。
// 2. **行为**:两个 env 键的默认值必须来自 `flowgraph` 导出的常量(**不另写一份字面量**)、
// 显式 ≤0 = 关闭清扫、只写在 `.env` 里也真实生效、非法取值回落默认 + WARN;以及
// GC 关闭时不建零间隔 ticker(`time.NewTicker(0)` 会 panic)。
import (
"bytes"
"context"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/flowgraph"
)
// TestWireFlowGraphs_IsWired 钉住组合根的接缝 + `.env.example` 同步。
func TestWireFlowGraphs_IsWired(t *testing.T) {
t.Parallel()
seams := map[string][]string{
"wire_flow_graphs.go": {
// 存储真的被构造,且默认值取 flowgraph 导出的常量(不另写字面量)。
"flowgraph.NewStore(flowgraph.Config{",
"durationEnv(a.log, envFilePath, flowGraphGCIntervalEnv, flowgraph.DefaultGCInterval)",
"durationEnv(a.log, envFilePath, flowGraphRetentionEnv, flowgraph.DefaultRetention)",
// 未接线时交出**真 nil 接口**(装 nil 指针的接口不等于 nil,会让发布器/网关的
// 未接线判据失效)。
"func (a *app) flowRoundRecorder() loop.RoundRecorder {",
"func (a *app) flowGraphStore() gateway.FlowGraphStore {",
// GC 关闭时不许建零间隔 ticker(time.NewTicker(0) 会 panic)。
"if !k.store.Enabled() {",
},
"wire_runtime.go": {
// 先建存储,再把它交给发布器作每轮落盘出口。
"a.wireFlowGraphs()",
"loop.WithRoundRecorder(a.flowRoundRecorder())",
},
"wire_frontend.go": {
// 网关只读出口注入**同一个**存储句柄。
"FlowGraphs: a.flowGraphStore(),",
},
"main.go": {
// 常驻循环:没有这一行,落盘的轮次图从不被清理(保留期形同虚设、无限增长)。
"a.startFlowGraphs(ctx)",
},
}
for file, wants := range seams {
src, err := os.ReadFile(file)
if err != nil {
t.Fatalf("read %s: %v", file, err)
}
for _, want := range wants {
if !strings.Contains(string(src), want) {
t.Fatalf("%s 里找不到 %q —— 链路图落盘没有被接上组合根", file, want)
}
}
}
// 两个新键必须与代码里的键名**逐字一致**(AGENTS §10 的 E6 条目)。
src, err := os.ReadFile("wire_flow_graphs.go")
if err != nil {
t.Fatalf("read wire_flow_graphs.go: %v", err)
}
raw, err := os.ReadFile(filepath.Join("..", "..", ".env.example"))
if err != nil {
t.Fatalf("read .env.example: %v", err)
}
for _, key := range []string{flowGraphRetentionEnv, flowGraphGCIntervalEnv} {
if !strings.Contains(string(src), `"`+key+`"`) {
t.Fatalf("wire_flow_graphs.go 里没有键名常量 %q", key)
}
if !strings.Contains(string(raw), key+"=") {
t.Fatalf(".env.example 里找不到 %q(新 env 键必须同步模板,AGENTS §10)", key+"=")
}
}
}
// flowGraphTestApp 构造一个只带落盘面所需依赖的 app(库 + 静默 logger)。
func flowGraphTestApp(t *testing.T, log *slog.Logger) *app {
t.Helper()
if log == nil {
log = testLog()
}
return &app{db: openMigratedTestDB(t, filepath.Join(t.TempDir(), "cyrene.db")), log: log}
}
// unsetFlowGraphEnv 把两个键从**进程环境**里拿掉并在用例结束后复原。
// ⚠️ 不能用 `t.Setenv(key, "")` 代替:空值在优先级链里仍然「存在」,会盖住 `.env`
// 里的同名声明,而「只写在 .env 里也生效」正是要验的东西(与渠道事件同一取舍)。
func unsetFlowGraphEnv(t *testing.T) {
t.Helper()
for _, key := range []string{flowGraphRetentionEnv, flowGraphGCIntervalEnv} {
key := key
prev, had := os.LookupEnv(key)
if had {
t.Cleanup(func() { _ = os.Setenv(key, prev) })
} else {
t.Cleanup(func() { _ = os.Unsetenv(key) })
}
if err := os.Unsetenv(key); err != nil {
t.Fatalf("unset %s: %v", key, err)
}
}
}
// TestNewFlowGraphStore_默认值取导出常量_正常:未配置时两个旋钮必须**就是** flowgraph
// 导出的默认值(168h / 1h),且清扫开启 —— 另写字面量迟早与文档承诺漂移。
func TestNewFlowGraphStore_默认值取导出常量_正常(t *testing.T) {
unsetFlowGraphEnv(t)
store := flowGraphTestApp(t, nil).newFlowGraphStore("")
if store == nil {
t.Fatal("有库时必须构造出链路图存储")
}
if store.Retention() != flowgraph.DefaultRetention {
t.Fatalf("Retention = %v, want %v(导出的默认常量)", store.Retention(), flowgraph.DefaultRetention)
}
if store.Interval() != flowgraph.DefaultGCInterval {
t.Fatalf("GC 间隔 = %v, want %v", store.Interval(), flowgraph.DefaultGCInterval)
}
if !store.Enabled() {
t.Fatal("未配置时清扫必须开启(漏配 != 关闭)")
}
}
// TestNewFlowGraphStore_显式零关闭清扫_边界:`≤0 = 关闭`—— 0 刻意不解释成「立即清空」,
// 那会让一次笔误抹掉刚落盘的历史轮。
func TestNewFlowGraphStore_显式零关闭清扫_边界(t *testing.T) {
cases := []struct {
name string
key string
value string
}{
{"保留期为 0", flowGraphRetentionEnv, "0"},
{"保留期为负", flowGraphRetentionEnv, "-1h"},
{"清扫间隔为 0", flowGraphGCIntervalEnv, "0"},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
unsetFlowGraphEnv(t)
t.Setenv(tc.key, tc.value)
store := flowGraphTestApp(t, nil).newFlowGraphStore("")
if store == nil {
t.Fatal("配置非法不该让存储整个消失")
}
if store.Enabled() {
t.Fatalf("%s=%s 时清扫仍开启,want 关闭(显式 ≤0 = 关闭)", tc.key, tc.value)
}
})
}
}
// TestNewFlowGraphStore_只写在env文件里也生效_正常:`.env` 不写回进程环境,直接
// os.Getenv 会**静默忽略**它。本用例把两个键只写进临时 `.env`,取值必须原样生效。
func TestNewFlowGraphStore_只写在env文件里也生效_正常(t *testing.T) {
unsetFlowGraphEnv(t)
path := filepath.Join(t.TempDir(), ".env")
body := strings.Join([]string{
flowGraphRetentionEnv + "=24h",
flowGraphGCIntervalEnv + "=30m",
}, "\n") + "\n"
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("写临时 .env: %v", err)
}
store := flowGraphTestApp(t, nil).newFlowGraphStore(path)
if store == nil {
t.Fatal("有库时必须构造出链路图存储")
}
if store.Retention() != 24*time.Hour || store.Interval() != 30*time.Minute {
t.Fatalf("取值 = (%v, %v), want (24h, 30m)", store.Retention(), store.Interval())
}
}
// TestNewFlowGraphStore_非法取值回落默认并告警_异常:配了但读不懂 ⇒ 回落默认值**且**记
// 一条带键名的 WARN(非法配置不许静默失效,也不许一票否决整条落盘面)。
func TestNewFlowGraphStore_非法取值回落默认并告警_异常(t *testing.T) {
unsetFlowGraphEnv(t)
path := filepath.Join(t.TempDir(), ".env")
if err := os.WriteFile(path, []byte(flowGraphRetentionEnv+"=七天\n"), 0o600); err != nil {
t.Fatalf("写临时 .env: %v", err)
}
var buf bytes.Buffer
log := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
store := flowGraphTestApp(t, log).newFlowGraphStore(path)
if store == nil {
t.Fatal("单个键非法不该让存储整个消失")
}
if store.Retention() != flowgraph.DefaultRetention {
t.Fatalf("非法取值时的保留期 = %v, want %v", store.Retention(), flowgraph.DefaultRetention)
}
logged := buf.String()
if !strings.Contains(logged, "level=WARN") || !strings.Contains(logged, flowGraphRetentionEnv) {
t.Fatalf("非法取值没有留下带键名的 WARN:%q", logged)
}
}
// TestFlowGraphStore_未接线时交出真nil接口_边界:未接数据库时两个出口都必须返回**真
// nil 接口** —— 返回「装着 nil 指针的接口」会让发布器/网关的 nil 判据失效。
func TestFlowGraphStore_未接线时交出真nil接口_边界(t *testing.T) {
blank := &app{log: testLog()}
if store := blank.newFlowGraphStore(""); store != nil {
t.Fatalf("库未接时不该构造存储:%+v", store)
}
if rec := blank.flowRoundRecorder(); rec != nil {
t.Fatalf("未接线时落盘出口必须是真 nil 接口,拿到的是 %#v", rec)
}
if got := blank.flowGraphStore(); got != nil {
t.Fatalf("未接线时网关只读出口必须是真 nil 接口,拿到的是 %#v", got)
}
}
// TestFlowGraphKeeper_GC关闭时不建零间隔ticker_边界:`time.NewTicker(0)` 会 panic,所以
// 「配了 ≤0 = 关闭清扫」必须在建 ticker 之前分岔,且 loop 立即返回(不留空转 goroutine)。
func TestFlowGraphKeeper_GC关闭时不建零间隔ticker_边界(t *testing.T) {
unsetFlowGraphEnv(t)
t.Setenv(flowGraphGCIntervalEnv, "0")
store := flowGraphTestApp(t, nil).newFlowGraphStore("")
if store == nil || store.Enabled() {
t.Fatalf("GC 应关闭:store=%v enabled=%v", store, store != nil && store.Enabled())
}
keeper := &flowGraphKeeper{store: store, log: testLog(), done: make(chan struct{})}
ticks, stop := keeper.gcTicker()
if ticks != nil {
t.Fatal("GC 关闭时不该建 ticker(零间隔 ticker 会 panic)")
}
stop()
// loop 在 GC 关闭时必须立刻返回并关闭 done(否则常驻 goroutine 泄漏)。
keeper.loop(context.Background())
select {
case <-keeper.done:
default:
t.Fatal("GC 关闭时 loop 应立即返回并关闭 done")
}
}
// TestFlowGraphKeeper_开启时给出定时通道_正常:默认配置下 GC 开启,必须给出非 nil 通道。
func TestFlowGraphKeeper_开启时给出定时通道_正常(t *testing.T) {
unsetFlowGraphEnv(t)
store := flowGraphTestApp(t, nil).newFlowGraphStore("")
if store == nil || !store.Enabled() {
t.Fatal("默认配置下 GC 应开启")
}
ticks, stop := (&flowGraphKeeper{store: store, log: testLog()}).gcTicker()
if ticks == nil {
t.Fatal("GC 开启时必须给出定时通道")
}
stop()
}
// TestFlowGraphEnv_模板取值是合法duration_正常 是 E6 自查的机械化版本:两个键名必须能
// 在 `.env.example` 里逐字找到,且模板值是合法 duration(占位符形态,从不放凭据)。
func TestFlowGraphEnv_模板取值是合法duration_正常(t *testing.T) {
t.Parallel()
raw, err := os.ReadFile(filepath.Join("..", "..", ".env.example"))
if err != nil {
t.Fatalf("read .env.example: %v", err)
}
lines := strings.Split(string(raw), "\n")
for _, key := range []string{flowGraphRetentionEnv, flowGraphGCIntervalEnv} {
found := false
for _, line := range lines {
value, ok := strings.CutPrefix(line, key+"=")
if !ok {
continue
}
found = true
if _, err := time.ParseDuration(value); err != nil {
t.Fatalf("%s 的模板值 %q 不是合法 duration", key, value)
}
}
if !found {
t.Fatalf(".env.example 缺少 %s(AGENTS §10:键名必须与代码逐字一致)", key)
}
}
}
+4
View File
@@ -89,6 +89,10 @@ func (a *app) wireFrontend() error {
// loop/orchestration 用的**同一个** *loop.FlowPublisher,因此 // loop/orchestration 用的**同一个** *loop.FlowPublisher,因此
// GET /api/v1/admin/flow/graph 读到的就是实时发布的那张图。 // GET /api/v1/admin/flow/graph 读到的就是实时发布的那张图。
Flow: a.flow, Flow: a.flow,
// FlowGraphs 是**已落盘轮次图**的只读出口(契约 v0.73;切片 F2):注入的正是
// 发布器每轮落盘用的**同一个**存储句柄,因此 `?turnId=` 与 `turns[]` 读到的就是
// 收尾时写进去的历史轮。未接线时为真 nil(端点回退到只有内存的行为)。
FlowGraphs: a.flowGraphStore(),
// Usage 是 `llm_usage` 表的只读汇总出口(切片 U1):网关复用组合根已打开的 // Usage 是 `llm_usage` 表的只读汇总出口(切片 U1):网关复用组合根已打开的
// 连接池,自己不开池、不读环境变量。库未接时为 nil(端点回 500 // 连接池,自己不开池、不读环境变量。库未接时为 nil(端点回 500
// usage_unavailable,不回假汇总)。 // usage_unavailable,不回假汇总)。
+9
View File
@@ -125,6 +125,10 @@ func (a *app) wireMemory(ctx context.Context) error {
// 追踪、持久化与编排 scheduler。 // 追踪、持久化与编排 scheduler。
func (a *app) wireRuntime() error { func (a *app) wireRuntime() error {
a.attachToolPolicyStore() a.attachToolPolicyStore()
// 链路图本地落盘(design/19 §4 P3;切片 F2):先建存储,随后在下面交给发布器作
// 每轮收尾的落盘出口。它的常驻清扫由 main 的 startFlowGraphs 拉起(见
// wire_flow_graphs.go);网关的只读出口在 wireFrontend 注入**同一个**句柄。
a.wireFlowGraphs()
// LLM 提供商栈可热插拔:它加载 models.json(或回落 .env),面板的保存端点会 // LLM 提供商栈可热插拔:它加载 models.json(或回落 .env),面板的保存端点会
// 就地重载它而无需重启(design/04 §3.1)。a.modelAdapter 仍保持 Adapter 接口 // 就地重载它而无需重启(design/04 §3.1)。a.modelAdapter 仍保持 Adapter 接口
@@ -219,6 +223,11 @@ func (a *app) wireRuntime() error {
flowPub := loop.NewFlowPublisher( flowPub := loop.NewFlowPublisher(
func(subject string, payload any) { _ = a.bus.Publish(context.Background(), subject, payload) }, func(subject string, payload any) { _ = a.bus.Publish(context.Background(), subject, payload) },
flowSessionAllowed, flowSessionAllowed,
// 每轮收尾落一条(**不是每 span 一条**):白名单由上面的 allow 把守
// (EndRound 对白名单外的会话直接返回),因此落盘出口不重复判白名单。
// 未接线时交出真 nil 接口 ⇒ 发布器走纯内存路径(F1 之前的行为)。
loop.WithRoundRecorder(a.flowRoundRecorder()),
loop.WithFlowLogger(a.log),
) )
// 同一个发布器也交给网关:`GET /api/v1/admin/flow/graph` 的只读出口 // 同一个发布器也交给网关:`GET /api/v1/admin/flow/graph` 的只读出口
// (契约 v0.71)就近读它每会话保留的最近一轮图 —— 写入面(这里)与查询面 // (契约 v0.71)就近读它每会话保留的最近一轮图 —— 写入面(这里)与查询面
+74 -20
View File
@@ -9,9 +9,13 @@ import { NodeDetail } from "./flow/NodeDetail";
import { LaneSummary } from "./flow/LaneSummary"; import { LaneSummary } from "./flow/LaneSummary";
import { FlowSnapshotHint } from "./flow/FlowSnapshotHint"; import { FlowSnapshotHint } from "./flow/FlowSnapshotHint";
import { useFlowSnapshot, type FlowGraphSource } from "./flow/useFlowSnapshot"; import { useFlowSnapshot, type FlowGraphSource } from "./flow/useFlowSnapshot";
import { useFlowTurns } from "./flow/useFlowTurns";
import { FlowHistoryHint, FlowTurnPicker } from "./flow/FlowTurnPicker";
import { LATEST_TURN, resolveShownGraph } from "./flow/flowHistory";
import { groupFlowLanes } from "./flow/lanes"; import { groupFlowLanes } from "./flow/lanes";
import { FLOW_TONE, STATUS_ORDER, statusOf } from "./flow/tone"; import { FLOW_TONE, STATUS_ORDER, statusOf } from "./flow/tone";
import { MAIN_SESSION_ID } from "../../store/sessionCache"; import { MAIN_SESSION_ID } from "../../store/sessionCache";
import type { AdminFlowTurn } from "../../lib/api";
/** 全链路流程页(design/19 §2):实时渲染 ws.graph_update 驱动的单轮图 —— /** 全链路流程页(design/19 §2):实时渲染 ws.graph_update 驱动的单轮图 ——
* 主干「输入 → 思考 → 工具 → 输出」+ 并行子代理泳道(子图容器), * 主干「输入 → 思考 → 工具 → 输出」+ 并行子代理泳道(子图容器),
@@ -27,55 +31,99 @@ import { MAIN_SESSION_ID } from "../../store/sessionCache";
export function FlowModule(): React.ReactNode { export function FlowModule(): React.ReactNode {
// 激活会话(没有会话时即主会话,与 store 的 currentGraph 口径一致)。 // 激活会话(没有会话时即主会话,与 store 的 currentGraph 口径一致)。
const activeSessionId = useAppStore((s) => s.activeSessionId); const activeSessionId = useAppStore((s) => s.activeSessionId);
const graphSource = useFlowSnapshot(activeSessionId || MAIN_SESSION_ID); const sessionId = activeSessionId || MAIN_SESSION_ID;
// 最新一轮:F1 的实时图 + 只读快照补拉(补拉会写进图桶 ⇒ 配合图桶持久化「刷新即刻有图」)。
const graphSource = useFlowSnapshot(sessionId);
const graph = useAppStore((s) => s.flowGraph); const graph = useAppStore((s) => s.flowGraph);
// 历史轮(契约 v0.73 多轮历史回放):已落盘轮次列表 + 选中轮的只读图。它**不写 store**,
// 因此翻历史绝不顶掉当前会话的实时图(裁决 16.4.2)。
const history = useFlowTurns(sessionId);
return ( return (
<FlowBody <FlowBody
graph={graph} graph={graph}
sessionId={activeSessionId || MAIN_SESSION_ID} sessionId={sessionId}
source={graphSource} source={graphSource}
turns={history.turns}
selectedTurnId={history.selectedTurnId}
onSelectTurn={history.selectTurn}
historyGraph={history.historyGraph}
historyLoading={history.historyLoading}
/> />
); );
} }
/** 有图就画整页、没图就诚实空态。props 驱动(不读 store),因此可被 /** 有图就画整页、没图就诚实空态。props 驱动(不读 store),因此可被
* 真实渲染测试直接喂两种输入。 */ * 真实渲染测试直接喂两种输入。
*
* **来源与轮次**(契约 v0.73):省略 `selectedTurnId`(= `LATEST_TURN`)时画
* 实时/快照那张图(F1);选了某一轮时画 `historyGraph`(从本地库读回的落盘轮)。
* 历史轮 UI 只在给了 `onSelectTurn` 时渲染 —— 渲染测试喂不到它,因此既有断言不受影响。 */
export function FlowBody({ export function FlowBody({
graph, graph,
sessionId, sessionId,
source, source,
turns = [],
selectedTurnId = LATEST_TURN,
onSelectTurn,
historyGraph = null,
historyLoading = false,
}: { }: {
graph: FlowGraph | null; graph: FlowGraph | null;
sessionId: string; sessionId: string;
source: FlowGraphSource; source: FlowGraphSource;
/** 该会话已落盘的轮次(最新在前)。 */
turns?: readonly AdminFlowTurn[];
/** 当前选择(`LATEST_TURN` = 最新一轮)。 */
selectedTurnId?: string;
/** 选中某一轮的回调;不给则不渲染历史轮选择器(渲染测试因此不必造它)。 */
onSelectTurn?: (turnId: string) => void;
/** 选中历史轮时拉回的图(只读)。 */
historyGraph?: FlowGraph | null;
/** 正在拉取选中的历史轮。 */
historyLoading?: boolean;
}): React.ReactNode { }): React.ReactNode {
// 屏幕上该画哪张图(纯函数,见 flowHistory.ts):选历史轮画落盘那张,否则画实时/快照那张。
const shown = resolveShownGraph(selectedTurnId, graph, historyGraph);
const showingHistory = selectedTurnId !== LATEST_TURN;
const [selectedId, setSelectedId] = useState<string | null>(null); const [selectedId, setSelectedId] = useState<string | null>(null);
const selected = graph?.nodes.find((n) => n.id === selectedId) ?? null; const selected = shown?.nodes.find((n) => n.id === selectedId) ?? null;
const grouping = graph ? groupFlowLanes(graph) : null; const grouping = shown ? groupFlowLanes(shown) : null;
const selectedLane = selected ? laneLabelFor(selectedId, grouping) : undefined; const selectedLane = selected ? laneLabelFor(selectedId, grouping) : undefined;
return ( return (
<div className="space-y-5"> <div className="space-y-5">
<PageHeader <PageHeader
title="全链路流程" title="全链路流程"
subtitle="实时 输入 → 思考 → 工具 → 输出 主干;并行子代理独立泳道;点节点看参数 / 结果 / 实时日志" subtitle="实时 输入 → 思考 → 工具 → 输出 主干;并行子代理独立泳道;点节点看参数 / 结果 / 实时日志;可按轮回放历史"
/> />
{graph ? ( {onSelectTurn ? (
<Surface className="space-y-2">
<FlowTurnPicker
turns={turns}
selectedTurnId={selectedTurnId}
onSelect={onSelectTurn}
loading={historyLoading}
/>
{showingHistory && shown ? <FlowHistoryHint turnId={shown.id} /> : null}
</Surface>
) : null}
{shown ? (
<> <>
<CardGrid> <CardGrid>
<StatCard <StatCard
icon={<Icon name="chat" size={18} />} icon={<Icon name="chat" size={18} />}
label="会话" label="会话"
value={<span className="text-base">{graph.sessionId ?? "web"}</span>} value={<span className="text-base">{shown.sessionId ?? "web"}</span>}
sub={graph.id} sub={shown.id}
tone="accent" tone="accent"
/> />
<StatCard <StatCard
icon={<Icon name="agent" size={18} />} icon={<Icon name="agent" size={18} />}
label="节点" label="节点"
value={graph.nodes.length} value={shown.nodes.length}
sub={`${graph.edges.length} 条连线`} sub={`${shown.edges.length} 条连线`}
tone="neutral" tone="neutral"
/> />
<StatCard <StatCard
@@ -88,24 +136,26 @@ export function FlowBody({
<StatCard <StatCard
icon={<Icon name="clock" size={18} />} icon={<Icon name="clock" size={18} />}
label="耗时" label="耗时"
value={fmtDuration(graph)} value={fmtDuration(shown)}
sub={`开始 ${fmtStart(graph)}`} sub={`开始 ${fmtStart(shown)}`}
tone="ok" tone="ok"
/> />
</CardGrid> </CardGrid>
<Surface className="space-y-3"> <Surface className="space-y-3">
<StatusStrip graph={graph} /> <StatusStrip graph={shown} />
<FlowGraphView <FlowGraphView
graph={graph} graph={shown}
selectedId={selectedId} selectedId={selectedId}
onNodeClick={(n) => setSelectedId(n.id)} onNodeClick={(n) => setSelectedId(n.id)}
/> />
<LaneSummary lanes={grouping?.lanes ?? []} onSelect={setSelectedId} /> <LaneSummary lanes={grouping?.lanes ?? []} onSelect={setSelectedId} />
<FlowSnapshotHint {showingHistory ? null : (
sessionId={graph.sessionId ?? sessionId} <FlowSnapshotHint
source={source ?? "live"} sessionId={shown.sessionId ?? sessionId}
/> source={source ?? "live"}
/>
)}
</Surface> </Surface>
{selected ? ( {selected ? (
@@ -118,7 +168,11 @@ export function FlowBody({
</> </>
) : ( ) : (
<Notice> <Notice>
暂无流程 —— 接入 /ws/chat 并发起一次会话后,将实时出现「输入 → 思考 → 工具 → 输出」连通图;并行子代理会各自成一条泳道。 {showingHistory
? historyLoading
? `正在读取历史轮 ${selectedTurnId} 的链路图……`
: `历史轮 ${selectedTurnId} 没有图 —— 可能已过保留期,或该会话本就不在发布会话白名单内。`
: "暂无流程 —— 接入 /ws/chat 并发起一次会话后,将实时出现「输入 → 思考 → 工具 → 输出」连通图;并行子代理会各自成一条泳道。"}
</Notice> </Notice>
)} )}
</div> </div>
@@ -34,7 +34,8 @@ export function FlowSnapshotHint({
<code>{sessionId}</code>)的 <code>{sessionId}</code>)的
<span className="text-[var(--mu-text-mid)]">最近一轮</span> <span className="text-[var(--mu-text-mid)]">最近一轮</span>
链路图快照 —— 打开本页时只读拉取(<code>GET /api/v1/admin/flow/graph</code>)。 链路图快照 —— 打开本页时只读拉取(<code>GET /api/v1/admin/flow/graph</code>)。
它不一定是你要找的那一轮:后端每会话只保留最近一份图,多轮历史回放尚未实现(design/19 §4 P3)。 它不一定是你要找的那一轮 —— 这是**最新**的一份。要精确看某一轮,用上方的
「历史轮次」选择器(那些轮已落盘,刷新/重启后仍在;契约 v0.73)。
</> </>
) : ( ) : (
<> <>
@@ -0,0 +1,66 @@
import type { AdminFlowTurn } from "../../../lib/api";
import { LATEST_TURN, turnOptionLabel } from "./flowHistory";
/**
* 历史轮次选择器(切片 F2;design/19 §4 P3;契约 v0.73)。props 驱动(不读 store),
* 因此可被真实渲染测试直接喂输入。
*
* 它把「图有本地数据」变成可点的东西:省略 turnId = 最新一轮(后端先内存、miss 才落盘),
* 选某一轮 = 只读拉该轮已落盘的图。没有已落盘轮次时的说明必须**诚实**:白名单外的会话
* (群聊 / 陌生人)永远没有图,这不是缺陷。
*/
export function FlowTurnPicker({
turns,
selectedTurnId,
onSelect,
loading,
}: {
turns: readonly AdminFlowTurn[];
selectedTurnId: string;
onSelect: (turnId: string) => void;
loading: boolean;
}): React.ReactNode {
return (
<div className="space-y-1.5">
<div className="flex flex-wrap items-center gap-2 text-xs text-[var(--mu-text-lo)]">
<label htmlFor="flow-turn-select" className="font-medium">
历史轮次
</label>
<select
id="flow-turn-select"
value={selectedTurnId}
onChange={(e) => onSelect(e.target.value)}
className="rounded-[var(--mu-radius-sm)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-2)] px-2 py-1 text-xs text-[var(--mu-text-mid)]"
>
<option value={LATEST_TURN}>最新一轮(实时 / 快照)</option>
{turns.map((t) => (
<option key={t.turnId} value={t.turnId}>
{turnOptionLabel(t)}
</option>
))}
</select>
<span className="font-mono">共 {turns.length} 轮已落盘</span>
{loading ? <span className="mu-blink text-[var(--mu-accent)]">加载中…</span> : null}
</div>
{turns.length === 0 ? (
<p className="text-[11px] text-[var(--mu-text-lo)]">
还没有已落盘的轮次 —— 每轮收尾时自动落盘,保留期内可回看。
发布会话白名单之外的会话(群聊 / 陌生人)永远没有图,这不是缺陷。
</p>
) : null}
</div>
);
}
/**
* 选中历史轮时图上的**诚实说明条**:这一张是从**本地落盘**读回的那一轮,不是实时图。
* 与 `FlowSnapshotHint`(讲最新一轮的来源)互补 —— 两者不会同时出现。
*/
export function FlowHistoryHint({ turnId }: { turnId: string }): React.ReactNode {
return (
<div className="rounded-[var(--mu-radius)] border border-dashed border-[var(--mu-glass-border)] px-3 py-2 text-xs text-[var(--mu-text-lo)]">
以上是 <code>{turnId}</code> 的链路图 —— 从<b>本地落盘</b>读回的历史轮(只读,契约 v0.73)。
它不是实时图;刷新或重启后它仍在(保留期内)。要回到正在跑 / 刚跑完的那一轮,把上方选回「最新一轮」。
</div>
);
}
@@ -2,7 +2,9 @@ import type { FlowNode } from "../../../lib/types";
import { Surface, Tag } from "../../../components/primitives"; import { Surface, Tag } from "../../../components/primitives";
import { Icon } from "../../../components/Icon"; import { Icon } from "../../../components/Icon";
import { Notice } from "../../components"; import { Notice } from "../../components";
import { useTopAnchoredScroll } from "../../logScroll";
import { FLOW_TONE, statusOf } from "./tone"; import { FLOW_TONE, statusOf } from "./tone";
import { headLogKey, newestFirstLog } from "./nodeLogOrder";
/** NodeDetail shows one selected node: its status, its lane (when it belongs to /** NodeDetail shows one selected node: its status, its lane (when it belongs to
* a sub-agent branch), the tool argument summary (design/19 §1.1), the * a sub-agent branch), the tool argument summary (design/19 §1.1), the
@@ -109,9 +111,10 @@ function toRecord(value: unknown): Record<string, unknown> {
return value as Record<string, unknown>; return value as Record<string, unknown>;
} }
/** LogList renders the node's accumulated span events, newest last, and is /** LogList 渲染该节点累积的 span 事件,**最新在上**(L1「日志倒序统一」;切片 F2 顺带
* explicit about why an empty list is empty instead of pretending to have * 收口)。span 事件是 append-only 的,显示倒序后新事件落在顶部;配套 `useTopAnchoredScroll`
* content. */ * 维持阅读位置(贴顶就跟最新,已下读则按新增高度补偿,新条目不会把用户顶跑)。
* 空态如实说明为什么空,而不是假装有内容。 */
function LogList({ function LogList({
node, node,
log, log,
@@ -120,6 +123,9 @@ function LogList({
log: NonNullable<FlowNode["log"]>; log: NonNullable<FlowNode["log"]>;
}): React.ReactNode { }): React.ReactNode {
const running = statusOf(node) === "running"; const running = statusOf(node) === "running";
// 顺序只在这里定一次(口径见 nodeLogOrder.ts);滚动锚定按「首条键 + 条数」触发。
const ordered = newestFirstLog(log);
const scrollRef = useTopAnchoredScroll(headLogKey(ordered), ordered.length);
if (log.length === 0) { if (log.length === 0) {
return ( return (
<div className="rounded-[var(--mu-radius-sm)] border border-[var(--mu-glass-border)] px-3 py-2 text-xs text-[var(--mu-text-lo)]"> <div className="rounded-[var(--mu-radius-sm)] border border-[var(--mu-glass-border)] px-3 py-2 text-xs text-[var(--mu-text-lo)]">
@@ -133,10 +139,11 @@ function LogList({
<h4 className="flex items-center gap-2 text-xs font-medium text-[var(--mu-text-lo)]"> <h4 className="flex items-center gap-2 text-xs font-medium text-[var(--mu-text-lo)]">
实时日志(span 事件累积) 实时日志(span 事件累积)
<span className="font-mono">{log.length}</span> <span className="font-mono">{log.length}</span>
<span>最新在上</span>
{running ? <span className="mu-blink text-[var(--mu-accent)]">● 运行中</span> : null} {running ? <span className="mu-blink text-[var(--mu-accent)]">● 运行中</span> : null}
</h4> </h4>
<div className="max-h-64 space-y-1.5 overflow-auto"> <div ref={scrollRef} className="max-h-64 space-y-1.5 overflow-auto">
{log.map((ev, i) => ( {ordered.map((ev, i) => (
<div <div
key={`${ev.time}-${ev.type}-${i}`} key={`${ev.time}-${ev.type}-${i}`}
className="flex flex-wrap items-start gap-2 rounded-[var(--mu-radius-sm)] bg-[var(--mu-surface-2)]/60 px-3 py-2 text-xs" className="flex flex-wrap items-start gap-2 rounded-[var(--mu-radius-sm)] bg-[var(--mu-surface-2)]/60 px-3 py-2 text-xs"
@@ -0,0 +1,136 @@
// 多轮历史回放 UI 的**真实渲染**测试(切片 F2;契约 v0.73;design/19 §4 P3)。
//
// 逻辑测试(flowHistory.test.ts)钉住「选哪一轮 ⇒ 画哪张图」;这里钉住「界面真的把它画
// 出来」—— 选择器、诚实说明条、以及「选历史轮时画的是落盘那张、不混入实时图」。
//
// 本项目没有 jsdom,渲染测试对 **props 驱动**的组件用 react-dom/server(见
// flowSnapshot.render.test.ts 头注释的同一理由)。
//
// 跑法:
//
// cd frontend/web \
// && ./node_modules/.bin/esbuild src/admin/modules/flow/flowHistory.render.test.ts \
// --bundle --platform=node --format=cjs \
// --outfile=/tmp/cyrene-flow-history-model.test.cjs \
// && node /tmp/cyrene-flow-history-model.test.cjs
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import type { AdminFlowTurn } from "../../../lib/api";
import type { FlowGraph } from "../../../lib/types";
import { MAIN_SESSION_ID } from "../../../store/sessionCache";
import { FlowBody } from "../Flow";
import { FlowHistoryHint, FlowTurnPicker } from "./FlowTurnPicker";
import { LATEST_TURN } from "./flowHistory";
let checks = 0;
function ok(cond: boolean, what: string): void {
checks += 1;
if (!cond) throw new Error(`FAILED: ${what}`);
}
function has(haystack: string, needle: string): boolean {
return haystack.includes(needle);
}
const turns: AdminFlowTurn[] = [
{ turnId: "web-3", createdAt: "2026-10-05T11:00:00Z" },
{ turnId: "web-2", createdAt: "2026-10-05T10:00:00Z" },
];
const latestGraph: FlowGraph = {
id: "web-9",
sessionId: MAIN_SESSION_ID,
nodes: [{ id: "input", label: "实时哨兵", kind: "input", status: "done" }],
edges: [],
};
const historyGraph: FlowGraph = {
id: "web-3",
sessionId: MAIN_SESSION_ID,
nodes: [{ id: "input", label: "历史哨兵", kind: "input", status: "done" }],
edges: [],
};
// ── ① 选择器:默认「最新一轮」+ 每个落盘轮 + 计数 ─────────────────────────────
const picker = renderToStaticMarkup(
createElement(FlowTurnPicker, {
turns,
selectedTurnId: LATEST_TURN,
onSelect: () => {},
loading: false,
})
);
ok(has(picker, "最新一轮"), "选择器必须有「最新一轮」选项");
ok(has(picker, "web-3") && has(picker, "web-2"), "选择器必须列出每个已落盘轮次");
ok(has(picker, "共 2 轮已落盘"), "选择器必须如实给出已落盘轮数");
const pickerLoading = renderToStaticMarkup(
createElement(FlowTurnPicker, {
turns,
selectedTurnId: "web-3",
onSelect: () => {},
loading: true,
})
);
ok(has(pickerLoading, "加载中"), "拉取历史轮时选择器要显示加载态");
// ── ② 没有落盘轮次时的诚实说明(白名单外会话永远没有图) ──────────────────────
const pickerEmpty = renderToStaticMarkup(
createElement(FlowTurnPicker, {
turns: [],
selectedTurnId: LATEST_TURN,
onSelect: () => {},
loading: false,
})
);
ok(has(pickerEmpty, "还没有已落盘的轮次"), "无历史轮时必须如实说明,而不是空着");
ok(has(pickerEmpty, "群聊"), "无历史轮时必须点明白名单外会话(群聊/陌生人)永远没有图");
// ── ③ 诚实说明条:历史轮来自本地落盘 ────────────────────────────────────────
const hint = renderToStaticMarkup(createElement(FlowHistoryHint, { turnId: "web-3" }));
ok(has(hint, "本地落盘"), "历史轮说明条必须写明来自本地落盘");
ok(has(hint, "web-3"), "历史轮说明条必须露出轮次 id");
// ── ④ FlowBody:选历史轮 ⇒ 画落盘那张、不混入实时图、给历史说明条 ────────────
const withHistory = renderToStaticMarkup(
createElement(FlowBody, {
graph: latestGraph,
sessionId: MAIN_SESSION_ID,
source: "live",
turns,
selectedTurnId: "web-3",
onSelectTurn: () => {},
historyGraph,
historyLoading: false,
})
);
ok(has(withHistory, "历史哨兵"), "选了历史轮 ⇒ 必须画出该轮落盘的图");
ok(!has(withHistory, "实时哨兵"), "选了历史轮时绝不把实时图混进来");
ok(has(withHistory, "本地落盘"), "选了历史轮时必须给出来源说明(本地落盘)");
ok(has(withHistory, "最新一轮"), "选择器仍在,可切回最新一轮");
// ── ⑤ 选了历史轮但还没拉到:是「正在读取」,不是「暂无流程」 ─────────────────
const loadingHistory = renderToStaticMarkup(
createElement(FlowBody, {
graph: latestGraph,
sessionId: MAIN_SESSION_ID,
source: "live",
turns,
selectedTurnId: "web-3",
onSelectTurn: () => {},
historyGraph: null,
historyLoading: true,
})
);
ok(has(loadingHistory, "正在读取历史轮"), "加载中必须显示读取提示");
ok(!has(loadingHistory, "暂无流程"), "加载中不是空态");
// ── ⑥ 没给 onSelectTurn(渲染测试的旧调用形态)⇒ 不渲染选择器,画实时图 ──────
const plain = renderToStaticMarkup(
createElement(FlowBody, { graph: latestGraph, sessionId: MAIN_SESSION_ID, source: "live" })
);
ok(!has(plain, 'id="flow-turn-select"'), "没有 onSelectTurn 时不渲染历史轮选择器");
ok(has(plain, "实时哨兵"), "默认形态照常画实时/快照图(F1 行为不回退)");
console.log(`flow history render test: ${checks} checks passed`);
@@ -0,0 +1,84 @@
// 「多轮历史回放」的**纯逻辑**测试(切片 F2;契约 v0.73;design/19 §4 P3)。
//
// 本项目没有前端测试运行器、也没有 jsdom,因此这是无依赖的纯 node 检查:hook(取数)
// 在本项目无法执行,但「选哪一轮 ⇒ 画哪张图、标什么来源、列表怎么归一」全是纯函数,
// 直接钉住即可(与 useFlowSnapshot.test.ts 同一手法)。
//
// 跑法:
//
// cd frontend/web \
// && ./node_modules/.bin/esbuild src/admin/modules/flow/flowHistory.test.ts \
// --bundle --platform=node --format=esm \
// --outfile=/tmp/cyrene-flow-history.test.mjs \
// && node /tmp/cyrene-flow-history.test.mjs
//
// `tsc --noEmit` 会对本文件做类型检查,因此它不 import node:*。
import type { AdminFlowTurn } from "../../../lib/api";
import type { FlowGraph } from "../../../lib/types";
import {
LATEST_TURN,
normalizeTurns,
resolveShownGraph,
shownGraphSource,
turnOptionLabel,
} from "./flowHistory";
let checks = 0;
function ok(cond: boolean, what: string): void {
checks += 1;
if (!cond) throw new Error(`FAILED: ${what}`);
}
function same<T>(got: T, want: T, what: string): void {
checks += 1;
if (got !== want) throw new Error(`FAILED: ${what} — got ${String(got)}, want ${String(want)}`);
}
const turn = (turnId: string, createdAt: string): AdminFlowTurn => ({ turnId, createdAt });
const graph = (id: string): FlowGraph => ({ id, nodes: [], edges: [] });
// ── ① 「最新一轮」的取值就是空串(= 不给后端 turnId) ───────────────────────
same(LATEST_TURN, "", "「最新一轮」必须用空串表示(与不传 turnId 同义)");
// ── ② 轮次列表归一:去重 + 最新在前(后端已排序,这里做防御性归一) ─────────
same(normalizeTurns(null).length, 0, "null 列表归一为空");
same(normalizeTurns(undefined).length, 0, "undefined 列表归一为空");
same(normalizeTurns([]).length, 0, "空列表归一为空");
const normalized = normalizeTurns([
turn("web-1", "2026-10-05T09:00:00Z"),
turn("web-3", "2026-10-05T11:00:00Z"),
turn("web-2", "2026-10-05T10:00:00Z"),
turn("web-3", "2026-10-05T11:05:00Z"), // 同 id 重复:只留一条
]);
same(normalized.length, 3, "同 turnId 去重后只剩三条");
same(normalized[0].turnId, "web-3", "最新在前");
same(normalized[1].turnId, "web-2", "次新第二");
same(normalized[2].turnId, "web-1", "最旧在最后");
// 时间不可解析的项排在最后(读数按 0 处理,不算 NaN、不伪造时间)。
const withBad = normalizeTurns([turn("web-x", "不是时间"), turn("web-9", "2026-10-05T10:00:00Z")]);
same(withBad[0].turnId, "web-9", "可解析时间的最新在前,不可解析的垫底");
// ── ③ 选择项文案:有时间的显示 id + 本地时间;没时间就只显示 id ─────────────
const labeled = turnOptionLabel(turn("web-7", "2026-10-05T10:00:00Z"));
ok(labeled.includes("web-7"), `文案必须含轮次 id:${labeled}`);
same(turnOptionLabel(turn("web-bad", "坏")).includes("web-bad"), true, "时间坏掉时仍给出 id");
same(turnOptionLabel(turn("web-bad", "坏")), "web-bad", "时间坏掉时不编造时间,只给 id");
// ── ④ 该画哪张图:最新 = 实时/快照;选历史轮 = 落盘那张 ─────────────────────
same(resolveShownGraph(LATEST_TURN, graph("web-9"), graph("web-1"))?.id, "web-9", "最新一轮画实时/快照那张");
same(resolveShownGraph("web-1", graph("web-9"), graph("web-1"))?.id, "web-1", "选历史轮画落盘那张");
same(resolveShownGraph("web-1", graph("web-9"), null), null, "选了历史轮但还没拉到 ⇒ 不拿实时图顶替");
same(resolveShownGraph(LATEST_TURN, null, graph("web-1")), null, "最新一轮没有图 ⇒ 不拿历史图顶替");
same(resolveShownGraph("web-1", undefined, undefined), null, "都没有图 ⇒ null");
// ── ⑤ 来源标签:选历史轮一定是落盘;否则沿用 F1 的 live/snapshot ────────────
same(shownGraphSource(LATEST_TURN, "live"), "live", "最新一轮沿用实时来源");
same(shownGraphSource(LATEST_TURN, "snapshot"), "snapshot", "最新一轮沿用快照来源");
same(shownGraphSource(LATEST_TURN, null), null, "没有图就没有来源");
same(shownGraphSource("web-1", "live"), "history", "选历史轮 ⇒ 来源一定是落盘的历史轮");
console.log(`flow history logic test: ${checks} checks passed`);
@@ -0,0 +1,67 @@
import type { AdminFlowTurn } from "../../../lib/api";
import type { FlowGraph } from "../../../lib/types";
/**
* 多轮历史回放的**纯逻辑**(切片 F2;design/19 §4 P3;契约 v0.73)。
*
* 从组件里抽出来(AGENTS §10:本项目没有 jsdom,effect 跑不了)—— 选择器「选哪一轮 ⇒
* 画哪张图、标什么来源」的判定在这里是纯函数,逻辑测试可直接钉住;hook 只负责取数。
*
* 诚实边界(与后端 §4.11 逐条对齐):
* - 选了历史轮 ⇒ 画的是**落盘**的那一轮(不是实时图);
* - 「最新一轮」= 不给 turnId,走后端「先内存、miss 才落盘」那条路;
* - 没有图就是没有图(空态由文案负责,绝不给来源标签,绝不补画)。
*/
/** 历史轮选择器里「最新一轮」的取值(= 不给后端 turnId)。刻意是空串:它与「没选历史轮」
* 同义,也与 `adminFlowGraph` 对空 turnId 的行为一致(不传参)。 */
export const LATEST_TURN = "";
/** 归一历史轮列表:按 turnId 去重、按 createdAt 倒序(最新在前)。
*
* 后端已按 created_at DESC 返回,这里仍归一一遍,是为了让**选择器**在任何来源下都只有
* 一条同轮记录、且顺序稳定(渲染/将来合并都不必各自排序)。时间不可解析的按 0 处理
* (不伪造时间、也不让比较器算出 NaN)。 */
export function normalizeTurns(turns: readonly AdminFlowTurn[] | null | undefined): AdminFlowTurn[] {
if (!turns || turns.length === 0) return [];
const byId = new Map<string, AdminFlowTurn>();
for (const t of turns) {
if (!t || !t.turnId) continue;
if (!byId.has(t.turnId)) byId.set(t.turnId, t);
}
return Array.from(byId.values()).sort((a, b) => timeOf(b.createdAt) - timeOf(a.createdAt));
}
/** 时间读数:不可解析的值一律算 0(不伪造时间,也不让比较器算出 NaN)。 */
function timeOf(iso: string): number {
const t = Date.parse(iso);
return Number.isFinite(t) ? t : 0;
}
/** 选择器某一项的文案:`轮次 id · 本地时间`。时间不可解析时只留 id(不编造时间)。 */
export function turnOptionLabel(turn: AdminFlowTurn): string {
const t = Date.parse(turn.createdAt);
if (!Number.isFinite(t)) return turn.turnId;
return `${turn.turnId} · ${new Date(t).toLocaleString()}`;
}
/** 屏幕上该画哪张图:选了历史轮 ⇒ 画拉回来的那一张(可能仍在加载 ⇒ null);
* 否则 ⇒ 画实时/快照那张(F1 的 `flowGraph`)。 */
export function resolveShownGraph(
selectedTurnId: string,
latestGraph: FlowGraph | null | undefined,
historyGraph: FlowGraph | null | undefined
): FlowGraph | null {
if (selectedTurnId !== LATEST_TURN) return historyGraph ?? null;
return latestGraph ?? null;
}
/** 来源标签(诚实来源)。选了历史轮 ⇒ 一定是**落盘**的那一轮;否则沿用 F1 的
* `live`(实时帧)/ `snapshot`(打开页面时拉回的最近一轮)。空图时返回 null。 */
export function shownGraphSource(
selectedTurnId: string,
baseSource: "live" | "snapshot" | null
): "live" | "snapshot" | "history" | null {
if (selectedTurnId !== LATEST_TURN) return "history";
return baseSource;
}
@@ -138,8 +138,8 @@ ok(
"快照来源必须写明它是只读拉取(GET /api/v1/admin/flow/graph)" "快照来源必须写明它是只读拉取(GET /api/v1/admin/flow/graph)"
); );
ok( ok(
has(snapshotHint, "多轮历史回放尚未实现"), has(snapshotHint, "历史轮次"),
"快照来源必须诚实标注「多轮历史回放尚未实现」,不得让用户以为每行都有自己的图" "快照来源必须指向「历史轮次」选择器(v0.73 起多轮历史回放已实现),不得让用户以为这一张就是他要的那一轮"
); );
const liveHint = renderToStaticMarkup( const liveHint = renderToStaticMarkup(
@@ -147,8 +147,8 @@ const liveHint = renderToStaticMarkup(
); );
ok(has(liveHint, "实时"), "实时来源必须标注为实时收到"); ok(has(liveHint, "实时"), "实时来源必须标注为实时收到");
ok( ok(
!has(liveHint, "多轮历史回放尚未实现"), !has(liveHint, "历史轮次"),
"实时图不是快照,不该出现快照专属的免责说明" "实时图不是快照,不该出现快照专属的历史轮说明"
); );
// 后台思考会话的人话标签必须认得出(图上要写清是哪个会话的图)。 // 后台思考会话的人话标签必须认得出(图上要写清是哪个会话的图)。
@@ -0,0 +1,51 @@
// 节点「实时日志」排序口径的**纯逻辑**测试(L1 顺带收口;切片 F2)。
//
// 本项目没有 jsdom、effect 不执行,因此渲染层只测纯函数(口径唯一出处见 nodeLogOrder.ts)。
//
// 跑法:
//
// cd frontend/web \
// && ./node_modules/.bin/esbuild src/admin/modules/flow/nodeLogOrder.test.ts \
// --bundle --platform=node --format=esm \
// --outfile=/tmp/cyrene-node-log-order.test.mjs \
// && node /tmp/cyrene-node-log-order.test.mjs
import type { TraceEvent } from "../../../lib/types";
import { headLogKey, newestFirstLog } from "./nodeLogOrder";
let checks = 0;
function ok(cond: boolean, what: string): void {
checks += 1;
if (!cond) throw new Error(`FAILED: ${what}`);
}
function same<T>(got: T, want: T, what: string): void {
checks += 1;
if (got !== want) throw new Error(`FAILED: ${what} — got ${String(got)}, want ${String(want)}`);
}
const ev = (time: string, type: string): TraceEvent => ({ time, type });
const log: TraceEvent[] = [
ev("2026-10-05T10:00:00Z", "span.start"),
ev("2026-10-05T10:00:01Z", "span.progress"),
ev("2026-10-05T10:00:02Z", "span.done"),
];
// ── ① 最新在上:顺序必须是原顺序的镜像 ──────────────────────────────────────
const ordered = newestFirstLog(log);
same(ordered.length, 3, "倒序不丢条目");
same(ordered[0].type, "span.done", "首条是最新一条(最新在上)");
same(ordered[2].type, "span.start", "末条是最旧一条");
// ── ② 不改动入参(store 里的日志仍按发生顺序存) ────────────────────────────
same(log[0].type, "span.start", "原数组顺序未被改动(返回的是副本)");
ok(ordered !== log, "返回的是新数组而不是同一个引用");
// ── ③ 空列表:倒序仍为空,首条键为 null(滚动锚定据此判定「还没加载」) ──────
same(newestFirstLog([]).length, 0, "空列表倒序仍为空");
same(headLogKey([]), null, "空列表的首条键是 null");
same(headLogKey(ordered), "2026-10-05T10:00:02Z~span.done", "首条键 = 最新一条的 time~type");
console.log(`node log order test: ${checks} checks passed`);
@@ -0,0 +1,25 @@
import type { TraceEvent } from "../../../lib/types";
/**
* 节点「实时日志」的排序口径:**最新在上**(L1「日志倒序统一」的顺带收口;切片 F2)。
*
* 为什么需要它:span 事件是 append-only 的累积日志 —— 后端按发生顺序追加,旧的在前。
* 界面按「最新在上」显示才符合直觉(与渠道消息日志三面同一口径,见
* `admin/modules/channels/channelLogOrder.ts`),并与 `admin/logScroll.ts` 的
* `useTopAnchoredScroll` 配套:新条目落在顶部时,用户正在读的旧条目不被打断。
*
* 刻意**不倒序**的例外:用户端聊天页(读对话,正序 + 自动滚底才自然),与本口径无关。
*/
/** 返回一份「最新在上」的副本。**不改动入参**(store 里的日志仍是发生顺序)。 */
export function newestFirstLog(log: readonly TraceEvent[]): TraceEvent[] {
return [...log].reverse();
}
/** 列表首条(最新一条)的稳定键:`useTopAnchoredScroll` 靠它判断「顶部前插了」。
* 空列表返回 null(还没加载 / 没有事件)。 */
export function headLogKey(log: readonly TraceEvent[]): string | null {
const head = log[0];
if (!head) return null;
return `${head.time}~${head.type}`;
}
@@ -0,0 +1,88 @@
import { useCallback, useEffect, useState } from "react";
import { adminFlowGraph, type AdminFlowTurn } from "../../../lib/api";
import type { FlowGraph } from "../../../lib/types";
import { LATEST_TURN, normalizeTurns } from "./flowHistory";
/**
* 历史轮的取数 hook(切片 F2;design/19 §4 P3;契约 v0.73)。
*
* 两件事:
* ① 会话确定后拉一次该会话的**已落盘轮次列表**(选择器用);
* ② 用户选了某一轮时,只读拉那一轮的图(`?turnId=`)。
*
* 与 `useFlowSnapshot`(F1)的分工:那个 hook 负责**最新一轮**的冷启动补拉并把图写进
* store(刷新即刻有图靠它 + `persistOptions` 持久化);本 hook 只负责**历史轮**,绝不写
* store —— 历史轮是「看过去」,把它塞进当前会话的图桶会把实时图顶掉(裁决 16.4.2)。
*
* 失败一律静默(历史回放是只读增强,失败不该改变界面):列表拉不到 → 空列表;某一轮
* 拉不到 → 该轮没有图(空态文案如实说明)。`sessionId` 为空时是空操作。
*/
export interface FlowTurnsState {
/** 该会话已落盘的轮次(最新在前)。 */
turns: AdminFlowTurn[];
/** 当前选择(`LATEST_TURN` = 最新一轮)。 */
selectedTurnId: string;
/** 选中历史轮时拉回的图(未选 / 加载中 / 拉不到时为 null)。 */
historyGraph: FlowGraph | null;
/** 正在拉取选中的历史轮。 */
historyLoading: boolean;
/** 选择某一轮(空串 = 回到「最新一轮」)。 */
selectTurn: (turnId: string) => void;
}
export function useFlowTurns(sessionId: string): FlowTurnsState {
const [turns, setTurns] = useState<AdminFlowTurn[]>([]);
const [selectedTurnId, setSelectedTurnId] = useState<string>(LATEST_TURN);
const [historyGraph, setHistoryGraph] = useState<FlowGraph | null>(null);
const [historyLoading, setHistoryLoading] = useState(false);
// 会话变了 ⇒ 复位选择并拉该会话的轮次列表。
useEffect(() => {
if (!sessionId) {
setTurns([]);
return;
}
let cancelled = false;
setSelectedTurnId(LATEST_TURN);
setHistoryGraph(null);
adminFlowGraph(sessionId)
.then((resp) => {
if (!cancelled) setTurns(normalizeTurns(resp.turns));
})
.catch(() => {
if (!cancelled) setTurns([]);
});
return () => {
cancelled = true;
};
}, [sessionId]);
// 选了历史轮 ⇒ 只读拉那一轮(最新一轮走 F1 的 useFlowSnapshot,不在这里重复拉)。
useEffect(() => {
if (!sessionId || selectedTurnId === LATEST_TURN) {
setHistoryGraph(null);
setHistoryLoading(false);
return;
}
let cancelled = false;
setHistoryLoading(true);
adminFlowGraph(sessionId, selectedTurnId)
.then((resp) => {
if (cancelled) return;
setHistoryGraph(resp.graph);
setHistoryLoading(false);
})
.catch(() => {
if (cancelled) return;
setHistoryGraph(null);
setHistoryLoading(false);
});
return () => {
cancelled = true;
};
}, [sessionId, selectedTurnId]);
const selectTurn = useCallback((turnId: string) => setSelectedTurnId(turnId || LATEST_TURN), []);
return { turns, selectedTurnId, historyGraph, historyLoading, selectTurn };
}
+1 -1
View File
@@ -129,4 +129,4 @@ export {
adminSystemRotateJWT, adminSystemRotateJWT,
adminSystemStorage, adminSystemStorage,
} from "./api/adminSystem"; } from "./api/adminSystem";
export type { AdminFlowGraphResponse } from "./api/adminSystem"; export type { AdminFlowGraphResponse, AdminFlowTurn } from "./api/adminSystem";
+30 -15
View File
@@ -47,28 +47,43 @@ export function adminBackthink(limit?: number): Promise<BackthinkItem[]> {
return request<BackthinkItem[]>(`/admin/backthink${limitQuery(limit)}`); return request<BackthinkItem[]>(`/admin/backthink${limitQuery(limit)}`);
} }
/** 最近一轮链路图快照的后端响应(契约 v0.71 §4.11(a))。`graph` **恒在** /** 一条**已落盘轮次**的摘要(契约 v0.73 §4.11 的 `turns[]`)。面板用它渲染历史轮
* (`null` 而不是缺席):`null` = 该会话**确实**还没有图,客户端必须据此 * 选择器:把 `turnId` 回带给 `adminFlowGraph` 即可精确取回那一轮。 */
* 渲染空态,绝不允许补画任何节点或假图。 */ export interface AdminFlowTurn {
export interface AdminFlowGraphResponse { turnId: string;
sessionId: string; createdAt: string;
graph: FlowGraph | null;
} }
/** GET /api/v1/admin/flow/graph?sessionId= — **最近一轮**链路图快照(契约 /** 链路图端点的后端响应(契约 v0.71 起;**v0.73 新增 `turnId`/`source`/`turns`**)。
* v0.71 §4.11,只读、不审计,owner/admin 档)。
* *
* 为什么需要它:`graph_update` 是**纯实时**帧、从不重放,且前端图桶 * `graph` **恒在**(`null` 而不是缺席):`null` = 该轮**确实**没有图,客户端必须据此
* (`flowGraphs`)**不持久化** ⇒ 「轮次结束后才打开页面」或「刷新过页面」 * 渲染空态,绝不允许补画任何节点或假图。
* 此前只能看到空态。打开页面时拉一次即可补上最近一轮。
* *
* ⚠️ 它只回**每会话最近一轮**:后台思考行上点开的图可能不是该行那一轮 * `source` 告诉客户端这张图是哪来的:`live` = 内存里最近一轮(实时图);`persisted` =
* (多轮历史回放仍未实现,`design/19 §4` P3)⇒ 面板必须在图上说明。 * 从本地库读回的历史轮;`none` = 没有图。`turns` 是该会话已落盘的轮次摘要(最新在前),
* 没有时为 `[]`(后端保证非 null 数组)。 */
export interface AdminFlowGraphResponse {
sessionId: string;
turnId: string;
source: "live" | "persisted" | "none";
graph: FlowGraph | null;
turns: AdminFlowTurn[];
}
/** GET /api/v1/admin/flow/graph?sessionId=&turnId= — **链路图快照 + 多轮历史回放**(契约
* v0.71 起,v0.73 增量;只读、不审计,owner/admin 档)。
* *
* `sessionId` 省略时后端默认主会话 `web`。 */ * 为什么需要它:`graph_update` 是**纯实时**帧、从不重放。v0.71 补了「打开页面拉最近一轮
export function adminFlowGraph(sessionId: string): Promise<AdminFlowGraphResponse> { * 快照」;v0.73 把图**每轮落盘**,于是可以:
* - **省略 `turnId`** ⇒ 最近一轮(后端**先查内存、miss 才落盘** ⇒ 最新一轮永远最新);
* - **给了 `turnId`** ⇒ 精确取该会话已落盘的某一轮(历史回放)。
*
* `turns`(同一响应里)就是给选择器用的历史轮列表。⚠️ 白名单之外的会话(群聊/陌生人)
* 从不建图、也从不落盘 ⇒ 它们**永远**是空态,这不是缺陷。 */
export function adminFlowGraph(sessionId: string, turnId?: string): Promise<AdminFlowGraphResponse> {
const qs = new URLSearchParams(); const qs = new URLSearchParams();
if (sessionId) qs.set("sessionId", sessionId); if (sessionId) qs.set("sessionId", sessionId);
if (turnId) qs.set("turnId", turnId);
const q = qs.toString(); const q = qs.toString();
return request<AdminFlowGraphResponse>(`/admin/flow/graph${q ? `?${q}` : ""}`); return request<AdminFlowGraphResponse>(`/admin/flow/graph${q ? `?${q}` : ""}`);
} }
@@ -0,0 +1,72 @@
// 前端 store 持久化的**纯逻辑**测试(切片 F2;主人要求「每次重载之后页面就没数据」)。
//
// 只钉一件事:`flowGraphs`(按会话分的链路图桶)**必须**在 zustand persist 的
// `partialize` 里 —— 否则刷新页面时图桶被抹掉,「刷新即刻有图」就不成立。同时钉住
// `migrate` 对旧版本(v3 之前没有图桶)给出空桶默认。
//
// 跑法:
//
// cd frontend/web \
// && ./node_modules/.bin/esbuild src/store/persistOptions.test.ts \
// --bundle --platform=node --format=esm \
// --outfile=/tmp/cyrene-persist-options.test.mjs \
// && node /tmp/cyrene-persist-options.test.mjs
//
// `tsc --noEmit` 会对本文件做类型检查,因此它不 import node:*;只类型引入 AppStore
// (编译后被擦除,不触发 useAppStore 的运行时副作用)。
import type { AppStore } from "./useAppStore";
import type { FlowGraph } from "../lib/types";
import { persistOptions } from "./persistOptions";
let checks = 0;
function ok(cond: boolean, what: string): void {
checks += 1;
if (!cond) throw new Error(`FAILED: ${what}`);
}
function same<T>(got: T, want: T, what: string): void {
checks += 1;
if (got !== want) throw new Error(`FAILED: ${what} — got ${String(got)}, want ${String(want)}`);
}
const graph: FlowGraph = {
id: "web-1",
sessionId: "web",
nodes: [{ id: "input", label: "你好", kind: "input", status: "done" }],
edges: [],
};
const state = {
activeSessionId: "web",
sessions: { web: { id: "web" } },
personas: [],
moods: { cyrene: "开心" },
context: {},
backthink: [],
flowGraphs: { web: graph },
} as unknown as AppStore;
// ── ① partialize 必须含 flowGraphs(刷新即刻有图的内核) ─────────────────────
const persisted = persistOptions.partialize(state) as unknown as Record<string, unknown>;
ok("flowGraphs" in persisted, "partialize 必须包含 flowGraphs —— 否则刷新后图桶被抹掉");
same(persisted.flowGraphs, state.flowGraphs, "持久化的图桶必须是当前 store 里的那份");
// ── ② 版本号已升到 4(v3 之前没有图桶,迁移时必须补空桶) ─────────────────────
same(persistOptions.version, 4, "引入 flowGraphs 持久化后版本应为 4");
// ── ③ migrate:旧版本(没有图桶)⇒ 补一个空桶;已存在的桶保留 ────────────────
const migratedOld = persistOptions.migrate({}, 3) as unknown as Record<string, unknown>;
same(JSON.stringify(migratedOld.flowGraphs), "{}", "v3 旧状态迁移后图桶是空对象(随后由快照补)");
const migratedKeep = persistOptions.migrate({ flowGraphs: { web: graph } }, 3) as unknown as Record<
string,
unknown
>;
ok(
(migratedKeep.flowGraphs as Record<string, FlowGraph>).web?.id === "web-1",
"迁移必须保留已有的图桶"
);
console.log(`persist options test: ${checks} checks passed`);
+10 -1
View File
@@ -10,7 +10,7 @@ import type { AppStore } from "./useAppStore";
*/ */
export const persistOptions = { export const persistOptions = {
name: "cyrene.app", name: "cyrene.app",
version: 3, version: 4,
// v1 persisted the whole chat (`messages` + a single `sessionId`); v2 added // v1 persisted the whole chat (`messages` + a single `sessionId`); v2 added
// one resolved `currentPersona`, which can no longer describe the active role // one resolved `currentPersona`, which can no longer describe the active role
// now that the role belongs to a *session*. Both shapes are dropped: // now that the role belongs to a *session*. Both shapes are dropped:
@@ -18,6 +18,8 @@ export const persistOptions = {
// the session list. `moods` is transient by nature (a restart resets a role's // the session list. `moods` is transient by nature (a restart resets a role's
// mood), so it is not carried across either. A v1 state was never trusted for // mood), so it is not carried across either. A v1 state was never trusted for
// the session metadata, so those users simply restart on the main session. // the session metadata, so those users simply restart on the main session.
// v4 (切片 F2) added `flowGraphs` (the per-session flow-graph buckets): a v3
// state has none, so it starts empty and is refilled by the snapshot hook.
migrate: (persisted: unknown, version: number) => { migrate: (persisted: unknown, version: number) => {
const p = (persisted ?? {}) as Partial<AppStore>; const p = (persisted ?? {}) as Partial<AppStore>;
const fromV1 = version < 2; const fromV1 = version < 2;
@@ -28,6 +30,7 @@ export const persistOptions = {
moods: {}, moods: {},
context: p.context ?? {}, context: p.context ?? {},
backthink: p.backthink ?? [], backthink: p.backthink ?? [],
flowGraphs: p.flowGraphs ?? {},
}; };
}, },
partialize: (s: AppStore) => ({ partialize: (s: AppStore) => ({
@@ -43,5 +46,11 @@ export const persistOptions = {
moods: s.moods, moods: s.moods,
context: s.context, context: s.context,
backthink: s.backthink, backthink: s.backthink,
// v4 (切片 F2): the per-session flow-graph buckets are persisted too, so a
// refresh paints the last-seen graph immediately instead of flashing the
// empty state until the snapshot fetch returns (主人 2026-10-05「每次重载
// 之后页面就没数据了」). Bounded by `MAX_GRAPHS` (store/sessionCache.ts);
// the backend stays the truth and the snapshot/history endpoints refill it.
flowGraphs: s.flowGraphs,
}), }),
}; };
+135
View File
@@ -0,0 +1,135 @@
package db
import (
"context"
"testing"
)
// flowGraphsMigrationID 是链路图落盘表的迁移 id(design/19 §4 P3;切片 F2)。
const flowGraphsMigrationID = "0022_flow_graphs"
// TestMigrateFlowGraphsReversible 钉住 0022(flow_graphs,切片 F2):up 建表与索引、
// 重复 up 幂等、down 整表消失、再 up 恢复端到端可用。它用自己的内存库,绝不碰
// data/cyrene.db。
//
// 断言的是**库里的结构事实**(表/列/索引在不在、默认值读出来是什么),不按日志文本
// 断言 —— 与 migrate_channel_events_test.go 同一口径。
func TestMigrateFlowGraphsReversible(t *testing.T) {
t.Parallel()
ctx := context.Background()
conn := openMemory(t)
if err := MigrateUp(ctx, conn, testLogger()); err != nil {
t.Fatalf("MigrateUp: %v", err)
}
if !schemaObjectExists(t, ctx, conn, "table", "flow_graphs") {
t.Fatal("flow_graphs should exist after MigrateUp")
}
// 列名错一个,写入面就会静默写错地方。
for _, col := range []string{"id", "session_id", "turn_id", "graph_json", "created_at", "updated_at"} {
if !columnExists(t, ctx, conn, "flow_graphs", col) {
t.Fatalf("flow_graphs.%s should exist after MigrateUp", col)
}
}
// 查询/GC 与 upsert 依赖的两个索引。
for _, idx := range []string{"idx_flow_graphs_session_id_turn_id", "idx_flow_graphs_created_at"} {
if !schemaObjectExists(t, ctx, conn, "index", idx) {
t.Fatalf("%s should exist after MigrateUp", idx)
}
}
// 默认值:只写必填列也能落行,时间戳由 SQLite 默认填。
if _, err := conn.ExecContext(ctx,
`INSERT INTO flow_graphs (session_id, turn_id, graph_json) VALUES (?, ?, ?)`,
"web", "web-1", `{"id":"web-1"}`); err != nil {
t.Fatalf("insert minimal flow graph: %v", err)
}
var createdAt string
if err := conn.QueryRowContext(ctx,
`SELECT created_at FROM flow_graphs WHERE session_id = ?`, "web").Scan(&createdAt); err != nil {
t.Fatalf("read minimal flow graph: %v", err)
}
if createdAt == "" {
t.Fatal("created_at should be filled by the migration default")
}
// 重复 up:已记录的迁移被跳过,结构仍在且不报错(幂等)。
if err := MigrateUp(ctx, conn, testLogger()); err != nil {
t.Fatalf("MigrateUp second call: %v", err)
}
if !schemaObjectExists(t, ctx, conn, "table", "flow_graphs") {
t.Fatal("flow_graphs should still exist after idempotent re-run")
}
// 恰好回滚到 0022(含它之后新加的迁移),日后再加迁移也不会让本测试失效。
ids := upMigrationIDs(t)
idx := -1
for i, id := range ids {
if id == flowGraphsMigrationID {
idx = i
}
}
if idx < 0 {
t.Fatalf("%s not found in %v", flowGraphsMigrationID, ids)
}
if err := MigrateDown(ctx, conn, len(ids)-idx, testLogger()); err != nil {
t.Fatalf("MigrateDown(%d): %v", len(ids)-idx, err)
}
if schemaObjectExists(t, ctx, conn, "table", "flow_graphs") {
t.Fatal("flow_graphs should be dropped by the 0022 down")
}
// 重新 up 恢复端到端可用。
if err := MigrateUp(ctx, conn, testLogger()); err != nil {
t.Fatalf("re-MigrateUp: %v", err)
}
if !schemaObjectExists(t, ctx, conn, "table", "flow_graphs") {
t.Fatal("flow_graphs should exist again after re-MigrateUp")
}
}
// TestMigrate0022UpsertSameTurn_正常 钉住唯一键的**行为**(不只是「索引存在」):
// 同一 `(session_id, turn_id)` 二次写入必须更新**一行**,而不是插出两行「同一轮」
// —— 否则多轮列表会出现重复,且「第 N 轮」到底存的是哪份图就说不清了。
func TestMigrate0022UpsertSameTurn_正常(t *testing.T) {
t.Parallel()
ctx := context.Background()
conn := openMemory(t)
if err := MigrateUp(ctx, conn, testLogger()); err != nil {
t.Fatalf("MigrateUp: %v", err)
}
const upsert = `INSERT INTO flow_graphs (session_id, turn_id, graph_json, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(session_id, turn_id) DO UPDATE SET
graph_json = excluded.graph_json,
updated_at = excluded.updated_at`
write := func(payload string) {
t.Helper()
if _, err := conn.ExecContext(ctx, upsert, "web", "web-1", payload,
"2026-10-05T00:00:00.000Z", "2026-10-05T00:00:00.000Z"); err != nil {
t.Fatalf("upsert flow graph: %v", err)
}
}
write(`{"id":"web-1","nodes":[]}`)
write(`{"id":"web-1","nodes":[{"id":"input"}]}`)
var rows int
if err := conn.QueryRowContext(ctx,
`SELECT count(*) FROM flow_graphs WHERE session_id = ? AND turn_id = ?`, "web", "web-1").Scan(&rows); err != nil {
t.Fatalf("count flow graphs: %v", err)
}
if rows != 1 {
t.Fatalf("flow_graphs rows = %d, want 1 (unique key must merge, not duplicate)", rows)
}
var payload string
if err := conn.QueryRowContext(ctx,
`SELECT graph_json FROM flow_graphs WHERE session_id = ? AND turn_id = ?`, "web", "web-1").Scan(&payload); err != nil {
t.Fatalf("read graph_json: %v", err)
}
if payload != `{"id":"web-1","nodes":[{"id":"input"}]}` {
t.Fatalf("graph_json = %q, want the second (latest) payload", payload)
}
}
@@ -0,0 +1,5 @@
-- 0022 的回滚:先删索引、再删表(与 0021 的回滚同一顺序口径)。
-- 表是本迁移新建的,回滚不触碰任何既有表。
DROP INDEX IF EXISTS idx_flow_graphs_created_at;
DROP INDEX IF EXISTS idx_flow_graphs_session_id_turn_id;
DROP TABLE IF EXISTS flow_graphs;
@@ -0,0 +1,41 @@
-- 0022_flow_graphs: 链路图的**本地落盘**(design/19 §4 P3「多轮历史回放」;切片 F2)。
--
-- 动因(主人 2026-10-05 追加要求):「链路流程应该有本地数据,不然每次重载之后页面
-- 就没数据了」。F1 的只读快照(契约 v0.71)只修了「后端进程活着时刷新能看到最近一轮」;
-- 真正的本地数据要**落盘**:后端重启后仍在、能翻前几轮。
--
-- 与既有实现在语义上的分工(一句话):
-- - `loop.FlowPublisher` 的每会话内存图 = **正在跑 / 刚跑完的那一轮**(实时帧的唯一来源);
-- - 本表 = **每轮一行**的收尾存档(`EndRound` 时落),供「多轮历史回放」按 `turn_id` 取回。
-- 每轮一行、**不是每 span 一行**:span 级写放大纯属浪费,图本身在收尾时已完整。
--
-- 命名口径:表名复数、主键 id、时间戳 created_at/updated_at、索引 idx_{table}_{col}
-- (援引 AGENTS §10 已登记例外,与既有迁移一致)。时间戳沿用 0001 起的
-- `strftime('%Y-%m-%dT%H:%M:%fZ','now')`(UTC ISO-8601 毫秒),定宽 ⇒ 字典序比较
-- 等价于时间序,时间窗 GC 因此能直接吃上 idx_flow_graphs_created_at。
--
-- 写入范围只有**发布白名单内的会话**(`cmd/cyrene` 的 `flowSessionAllowed`):发布器
-- 的 `EndRound` 对白名单外的会话直接返回 ⇒ 从不调用落盘出口,本表因此与「哪些会话能
-- 产出链路图」同一判据,不存在第二份白名单。隔离渠道会话(群聊/陌生人)绝不入库。
--
-- 脱敏:`graph_json` 是 `trace.FlowGraph` 的 JSON,节点 `props`/`log` 出图前已按
-- design/19 §1.1 清洗(args 白名单 + trace.RedactText 的凭据形状清洗),label 也经
-- RedactText —— 因此本列**不会**承载原始凭据。图里可能含用户输入文本 ⇒ 日志侧一律
-- 不打印图内容(只在写入失败时记 session/turn 标识)。
CREATE TABLE IF NOT EXISTS flow_graphs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL, -- 归属会话(web / web:<uuid> / sess-backthink)
turn_id TEXT NOT NULL, -- 轮次标识 = trace.FlowGraph.ID("<session>-<round>")
graph_json TEXT NOT NULL, -- 该轮的整张图(trace.FlowGraph 的 JSON)
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
-- 唯一键承载 upsert(`ON CONFLICT(session_id, turn_id) DO UPDATE`):同一会话同一轮
-- 二次收尾(重放/重试)是同轮覆盖,而不是插出两行「同一轮」。
CREATE UNIQUE INDEX IF NOT EXISTS idx_flow_graphs_session_id_turn_id
ON flow_graphs (session_id, turn_id);
-- 保留期清扫(Sweep)的**唯一依赖**:按 created_at 删过期行。
CREATE INDEX IF NOT EXISTS idx_flow_graphs_created_at ON flow_graphs (created_at);
+324
View File
@@ -0,0 +1,324 @@
// Package flowgraph 是**链路图本地落盘**的存储地基(design/19 §4 P3「多轮历史回放」;
// 切片 F2)。它补的是主人 2026-10-05 追加要求的那句「链路流程应该有本地数据,不然
// 每次重载之后页面就没数据了」—— 在此之前图只活在 `loop.FlowPublisher` 的**内存**里,
// 后端一重启、或该会话还没跑新轮次,就什么都查不到。
//
// 只做一件事:把**每一轮的收尾图**(`trace.FlowGraph`)连会话与轮次标识落进
// `flow_graphs`(迁移 0022),并支持按轮次读回、按保留期清扫。
//
// 三条职责边界(别在这里越界):
//
// - **不判身份、不判白名单**:哪些会话能产出图由发布器的发布会话范围
// (`cmd/cyrene` 的 `flowSessionAllowed`,design/19 §1 裁决 01.4.5)决定 ——
// 本包只被**已经过白名单的** `EndRound` 调用,因此不再存一份白名单。
// - **写入时机 = 每轮一条**(`EndRound`),**不是每 span 一条**:span 级写放大是纯浪费,
// 图在收尾时已完整(`output` 节点已并入)。
// - **不是图的真源**:实时图仍由发布器持有(`graph_update` 帧的唯一来源);本包只是
// 一份**收尾存档**,供「打开页面后翻历史轮」。读写失败都**不许**影响会话收尾。
//
// 脱敏:`graph_json` 里是**出图前已清洗**的图(节点 props 走 design/19 §1.1 的放行表 +
// 凭据形状清洗,label 也经 `trace.RedactText`)。本包**只用结构化字段**(session_id /
// turn_id)打日志,**绝不打印图内容** —— 图里可能含用户输入文本。
package flowgraph
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"strings"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// 存储侧的哨兵错误。调用方一律用 `errors.Is` 分类,**绝不按错误文本匹配**。
var (
// ErrNilDB 报告构造 Store 时没有可用的数据库连接。宁可明确失败,也不给出
// 一个写不进去的替身(与 `events.NewStore` / `history.NewStore` 同一口径)。
ErrNilDB = errors.New("flowgraph: 缺少数据库连接")
// ErrNoGraphID 报告一条待落盘的图缺少会话 id 或轮次标识。两者是「这一行属于哪一轮」
// 的唯一坐标,缺一个就既查不到也归不了档 —— 这是调用方的编程错误,必须显式失败。
ErrNoGraphID = errors.New("flowgraph: 轮次图缺少会话或轮次标识")
// ErrBadTimestamp 报告读回的轮次行携带一个无法解析的时间戳。宁可如实报错,也不
// 静默给一个零值时间(那会让 GC 与列表排序基于假时刻做判断)。
ErrBadTimestamp = errors.New("flowgraph: 轮次行时间戳不可解析")
)
const (
// sqliteTimeLayout 与迁移 0022 的默认值 `strftime('%Y-%m-%dT%H:%M:%fZ','now')`
// 逐字同形(UTC、毫秒、字面量 Z)。写入与解析共用它,使「我们写进去的」与
// 「SQLite 默认生成的」可以直接比较(与 `events` / `documents` 同一手法)。
sqliteTimeLayout = "2006-01-02T15:04:05.000"
// recordTimeout 是**单次落盘**的时长上界。写入发生在 `EndRound`(会话收尾)上,
// 必须有一个界:一次库卡死绝不允许把收尾挂住。
recordTimeout = 5 * time.Second
// flowgraphLogSource 是全部日志的 source 标签(AGENTS §10:禁无来源裸日志)。
flowgraphLogSource = "runtime/flowgraph"
// DefaultTurnsLimit / MaxTurnsLimit 是轮次列表的缺省页大小与硬上界
// (超过即钳住,绝不静默返回全表)。多轮历史回放看的是「最近几轮」,
// 因此缺省比事件列表小。
DefaultTurnsLimit = 50
MaxTurnsLimit = 200
)
// Config 是链路图存储的接线参数。时间与开关口径由调用方(组合根)给出:本包**不读
// 环境变量**、不猜数据目录布局(与 `events.Config` / `documents.GCConfig` 同一理由)。
type Config struct {
// DB 是活连接(组合根注入既有 SQLite 连接池;测试注入临时库)。为 nil 时
// NewStore 返回 ErrNilDB。
DB *sql.DB
// Log 是基础 logger;nil 时替换为标记来源 runtime/flowgraph 的丢弃 logger。
Log *slog.Logger
// Now 是可注入时钟(测试用);nil 时用 time.Now。
Now func() time.Time
// GCInterval 是保留期清扫的间隔(`FLOW_GRAPH_GC_INTERVAL`)。**≤0 = 关闭 GC**
// (含启动期清扫),默认 DefaultGCInterval。
GCInterval time.Duration
// Retention 是 `flow_graphs` 的保留期(`FLOW_GRAPH_RETENTION`)。**≤0 = 关闭 GC**,
// 默认 DefaultRetention。0 刻意**不**解释成「立即清扫」:那会让一次笔误把刚落盘的
// 历史轮清空。
Retention time.Duration
}
// SQLStore 是基于 SQLite 的链路图存储实现。它可安全并发使用:连接池本身串行化写
// (`db.Open` 的 SetMaxOpenConns(1))。
type SQLStore struct {
db *sql.DB
log *slog.Logger
now func() time.Time
gcInterval time.Duration
retention time.Duration
gcEnabled bool
}
// Turn 是 `flow_graphs` 里一轮的**摘要**(供面板选历史轮),不含图本身。
type Turn struct {
// TurnID 是轮次标识(`trace.FlowGraph.ID`,形如 "web-7")。
TurnID string
// CreatedAt 是该轮落盘时刻(UTC)。
CreatedAt time.Time
}
// NewStore 构造链路图存储。唯一的失败是 DB 为 nil(ErrNilDB);GC 的关闭**不是**
// 构造失败,它是可见的正常状态(Enabled() 为 false + 一条记明原因的 WARN)。
func NewStore(cfg Config) (*SQLStore, error) {
if cfg.DB == nil {
return nil, fmt.Errorf("flowgraph: new store: %w", ErrNilDB)
}
log := sourcedLog(cfg.Log)
now := cfg.Now
if now == nil {
now = time.Now
}
s := &SQLStore{
db: cfg.DB,
log: log,
now: now,
gcInterval: cfg.GCInterval,
retention: cfg.Retention,
}
switch {
case cfg.GCInterval <= 0:
log.Warn("flow graph gc disabled: interval <= 0", "interval", cfg.GCInterval.String())
case cfg.Retention <= 0:
log.Warn("flow graph gc disabled: retention <= 0", "retention", cfg.Retention.String())
default:
s.gcEnabled = true
}
log.Info("flow graph store ready",
"gc_enabled", s.gcEnabled,
"gc_interval", s.gcInterval.String(),
"retention", s.retention.String())
return s, nil
}
// sourcedLog 把基础 logger 标上 source=runtime/flowgraph;nil 时给出丢弃 logger,
// 使调用方永远可以安全记日志(与 `events` / `documents` 同一手法)。
func sourcedLog(l *slog.Logger) *slog.Logger {
if l == nil {
l = slog.New(slog.NewTextHandler(io.Discard, nil))
}
return logger.WithSource(l, flowgraphLogSource)
}
// upsertRoundSQL 是落盘的唯一语句:列按迁移 0022 显式列出、值全部参数化。冲突键是
// `(session_id, turn_id)`(迁移里的唯一索引承载)—— 同一会话同一轮的二次收尾是同轮
// 覆盖(更新图与 updated_at,**不动 created_at**),而不是插出两行「同一轮」。
const upsertRoundSQL = `INSERT INTO flow_graphs
(session_id, turn_id, graph_json, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(session_id, turn_id) DO UPDATE SET
graph_json = excluded.graph_json,
updated_at = excluded.updated_at`
// RecordRound 把一条**已收尾轮次**的图落盘(每轮一行)。它是发布器 `EndRound` 的落盘
// 出口,因此签名与 `loop.RoundRecorder` 一致:**不接收 ctx、自己用一个有界的后台
// context**(会话收尾路径不该被库拖住)。
//
// 失败**上抛**给调用方(发布器记 WARN 后继续),绝不 panic、绝不改变图 —— 图是可视化
// 资产,落盘失败不该影响会话本身。
func (s *SQLStore) RecordRound(g *trace.FlowGraph) error {
if s == nil || s.db == nil {
return fmt.Errorf("flowgraph: record round: %w", ErrNilDB)
}
if g == nil || strings.TrimSpace(g.SessionID) == "" || strings.TrimSpace(g.ID) == "" {
return fmt.Errorf("flowgraph: record round(session=%q turn=%q): %w", sessionOf(g), idOf(g), ErrNoGraphID)
}
payload, err := json.Marshal(g)
if err != nil {
return fmt.Errorf("flowgraph: 编码轮次图(%s/%s): %w", g.SessionID, g.ID, err)
}
ctx, cancel := context.WithTimeout(context.Background(), recordTimeout)
defer cancel()
stamp := sqliteTimeStamp(s.now())
if _, err := s.db.ExecContext(ctx, upsertRoundSQL,
g.SessionID, g.ID, string(payload), stamp, stamp); err != nil {
return fmt.Errorf("flowgraph: 落轮次图(%s/%s): %w", g.SessionID, g.ID, err)
}
return nil
}
// Graph 按 `(sessionID, turnID)` 取回一轮的图。该轮不存在时返回 `(nil, nil)` —— 这是
// 一个**诚实的空态**(不是错误):客户端必须据此渲染空态,绝不允许补画任何节点/假图。
func (s *SQLStore) Graph(ctx context.Context, sessionID, turnID string) (*trace.FlowGraph, error) {
if s == nil || s.db == nil {
return nil, fmt.Errorf("flowgraph: query round: %w", ErrNilDB)
}
const query = `SELECT graph_json FROM flow_graphs WHERE session_id = ? AND turn_id = ?`
var payload string
err := s.db.QueryRowContext(ctx, query, sessionID, turnID).Scan(&payload)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("flowgraph: 查轮次图(%s/%s): %w", sessionID, turnID, err)
}
return decodeGraph(sessionID, turnID, payload)
}
// Latest 取该会话**最近一轮**已落盘的图(按 created_at 倒序,同毫秒用 id 定序)。
// 没有已落盘的轮次时返回 `(nil, nil)`(诚实空态)。
func (s *SQLStore) Latest(ctx context.Context, sessionID string) (*trace.FlowGraph, error) {
if s == nil || s.db == nil {
return nil, fmt.Errorf("flowgraph: query latest round: %w", ErrNilDB)
}
const query = `SELECT turn_id, graph_json FROM flow_graphs
WHERE session_id = ? ORDER BY created_at DESC, id DESC LIMIT 1`
var (
turnID string
payload string
)
err := s.db.QueryRowContext(ctx, query, sessionID).Scan(&turnID, &payload)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("flowgraph: 查最近轮次图(%s): %w", sessionID, err)
}
return decodeGraph(sessionID, turnID, payload)
}
// Turns 返回该会话**已落盘轮次的摘要**,最新在前(供面板选历史轮)。limit ≤0 取
// DefaultTurnsLimit,超过 MaxTurnsLimit 即钳住。
func (s *SQLStore) Turns(ctx context.Context, sessionID string, limit int) ([]Turn, error) {
if s == nil || s.db == nil {
return nil, fmt.Errorf("flowgraph: query turns: %w", ErrNilDB)
}
n := normalizeLimit(limit)
const query = `SELECT turn_id, created_at FROM flow_graphs
WHERE session_id = ? ORDER BY created_at DESC, id DESC LIMIT ?`
rows, err := s.db.QueryContext(ctx, query, sessionID, n)
if err != nil {
return nil, fmt.Errorf("flowgraph: 查轮次列表(%s): %w", sessionID, err)
}
defer func() { _ = rows.Close() }()
out := make([]Turn, 0, n)
for rows.Next() {
var (
turnID string
createdAt string
)
if err := rows.Scan(&turnID, &createdAt); err != nil {
return nil, fmt.Errorf("flowgraph: 读轮次行(%s): %w", sessionID, err)
}
at, ok := parseSQLiteTime(createdAt)
if !ok {
return nil, fmt.Errorf("flowgraph: 轮次 %s 的 created_at 不可解析(%q): %w", turnID, createdAt, ErrBadTimestamp)
}
out = append(out, Turn{TurnID: turnID, CreatedAt: at})
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("flowgraph: 遍历轮次行(%s): %w", sessionID, err)
}
return out, nil
}
// decodeGraph 把一行 `graph_json` 解析回 `trace.FlowGraph`(与 `graph_update` 帧同形)。
func decodeGraph(sessionID, turnID, payload string) (*trace.FlowGraph, error) {
var g trace.FlowGraph
if err := json.Unmarshal([]byte(payload), &g); err != nil {
return nil, fmt.Errorf("flowgraph: 解析轮次图 JSON(%s/%s): %w", sessionID, turnID, err)
}
return &g, nil
}
// normalizeLimit 把轮次列表页大小归一:≤0 取缺省、超过硬上界即钳住。
func normalizeLimit(limit int) int {
switch {
case limit <= 0:
return DefaultTurnsLimit
case limit > MaxTurnsLimit:
return MaxTurnsLimit
default:
return limit
}
}
// sqliteTimeStamp 把时刻格式化成库里的时间戳口径(UTC 毫秒 + 字面量 Z)。
func sqliteTimeStamp(t time.Time) string {
return t.UTC().Format(sqliteTimeLayout) + "Z"
}
// parseSQLiteTime 解析库里的时间戳列。它先试本包写出的口径,再退回 RFC3339
// (覆盖带偏移或非毫秒精度的历史值);两者都不成立时返回 false,调用方据此保守
// 处理(绝不把读不懂的时间当成一个真实时刻)。
func parseSQLiteTime(raw string) (time.Time, bool) {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return time.Time{}, false
}
if t, err := time.Parse(sqliteTimeLayout+"Z", trimmed); err == nil {
return t.UTC(), true
}
if t, err := time.Parse(time.RFC3339Nano, trimmed); err == nil {
return t.UTC(), true
}
return time.Time{}, false
}
// sessionOf / idOf 是为**错误信息**安全取标识的助手:g 为 nil 时也不 panic
// (RecordRound 的校验顺序里要先能报出「缺了哪个坐标」)。
func sessionOf(g *trace.FlowGraph) string {
if g == nil {
return ""
}
return g.SessionID
}
func idOf(g *trace.FlowGraph) string {
if g == nil {
return ""
}
return g.ID
}
@@ -0,0 +1,317 @@
package flowgraph
// 本文件是**链路图落盘存储**的验收(design/19 §4 P3;切片 F2)。断言的是**库里的事实**
// (行在不在、读回来的图是不是原图、每轮一行),不按日志文本断言 —— 与
// `internal/runtime/events` 的测试同一口径。
//
// 核心一条(主人原话的内核):「后端重启后仍能查回历史轮次的图」——
// `TestStore_SurvivesRestart_正常` 用**同一个文件库、两条连接、两个 store** 模拟
// 「进程重启」,第二个 store 内存里没有任何图,只能靠落盘读回。
import (
"context"
"database/sql"
"errors"
"path/filepath"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// testNow 是夹具的固定时钟(可复现:所有时间断言都基于它)。
func testNow() time.Time {
return time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC)
}
// newTestStore 建一套隔离夹具:一个跑过迁移的内存库 + 一个把时钟钉在 testNow 的
// 存储。默认 **关闭 GC**(存储用例与保留期无关,GC 由 gc_test.go 专门覆盖),
// tune 可覆盖任何配置项。第二个返回值是底层连接,供用例做结构级断言。
func newTestStore(t *testing.T, tune func(*Config)) (*SQLStore, *sql.DB) {
t.Helper()
ctx := context.Background()
conn, err := db.Open(ctx, ":memory:", nil)
if err != nil {
t.Fatalf("打开内存库: %v", err)
}
if err := db.MigrateUp(ctx, conn, nil); err != nil {
_ = conn.Close()
t.Fatalf("内存库跑迁移: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
cfg := Config{DB: conn, Now: testNow, GCInterval: 0, Retention: 0}
if tune != nil {
tune(&cfg)
}
st, err := NewStore(cfg)
if err != nil {
t.Fatalf("NewStore: %v", err)
}
return st, conn
}
// sampleGraph 造一轮最小图(input→llm→output),轮次标识 = "<session>-<round>",
// 与 `trace.RunGraph.Snapshot` 的命名逐字同形。
func sampleGraph(session string, round int) *trace.FlowGraph {
return &trace.FlowGraph{
ID: session + "-" + itoa(round),
SessionID: session,
StartedAt: testNow().Add(-time.Minute),
EndedAt: testNow(),
Nodes: []trace.FlowNode{
{ID: "input", Label: "你好", Kind: "input", Status: trace.FlowStatusDone},
{ID: "llm-1", Label: "思考", Kind: "llm", Status: trace.FlowStatusDone},
{ID: "output", Label: "回复", Kind: "output", Status: trace.FlowStatusDone},
},
Edges: []trace.FlowEdge{{From: "input", To: "llm-1"}, {From: "llm-1", To: "output"}},
}
}
// itoa 是个极小的十进制转换(避免为一个测试引入 fmt 的字符串依赖)。
func itoa(n int) string {
if n == 0 {
return "0"
}
digits := make([]byte, 0, 4)
for n > 0 {
digits = append([]byte{byte('0' + n%10)}, digits...)
n /= 10
}
return string(digits)
}
// TestNewStore_NilDB_异常:没有数据库就没有存储 —— 宁可明确失败,也不给一个写不进去
// 的替身(与 events / documents 同一口径)。
func TestNewStore_NilDB_异常(t *testing.T) {
t.Parallel()
if _, err := NewStore(Config{}); !errors.Is(err, ErrNilDB) {
t.Fatalf("NewStore(nil DB) err = %v, want ErrNilDB", err)
}
}
// TestRecordRound_ReadBack_正常 钉住「写进去什么、按轮次读回来就是什么」:Latest 回
// 最新一轮、Graph 按 turnId 精确取,且主干端点与边都完整往返。
func TestRecordRound_ReadBack_正常(t *testing.T) {
t.Parallel()
ctx := context.Background()
st, _ := newTestStore(t, nil)
for round := 1; round <= 3; round++ {
if err := st.RecordRound(sampleGraph("web", round)); err != nil {
t.Fatalf("RecordRound(round=%d): %v", round, err)
}
}
latest, err := st.Latest(ctx, "web")
if err != nil {
t.Fatalf("Latest: %v", err)
}
if latest == nil || latest.ID != "web-3" {
t.Fatalf("Latest = %+v, want web-3", latest)
}
got, err := st.Graph(ctx, "web", "web-2")
if err != nil {
t.Fatalf("Graph(web-2): %v", err)
}
if got == nil || got.ID != "web-2" {
t.Fatalf("Graph(web-2) = %+v, want web-2", got)
}
if len(got.Nodes) != 3 || len(got.Edges) != 2 {
t.Fatalf("round-trip lost structure: nodes=%d edges=%d", len(got.Nodes), len(got.Edges))
}
if got.SessionID != "web" {
t.Fatalf("SessionID = %q, want web(图自带会话,前端按它键入桶)", got.SessionID)
}
}
// TestGraph_MissingIsHonestNil_边界 钉住「该轮不存在」是**诚实的空**(nil, nil),
// 不是错误 —— 客户端据此渲染空态,绝不补画假图。
func TestGraph_MissingIsHonestNil_边界(t *testing.T) {
t.Parallel()
ctx := context.Background()
st, _ := newTestStore(t, nil)
if err := st.RecordRound(sampleGraph("web", 1)); err != nil {
t.Fatalf("RecordRound: %v", err)
}
for _, turn := range []string{"web-999", "sess-backthink-1"} {
got, err := st.Graph(ctx, "web", turn)
if err != nil {
t.Fatalf("Graph(%s) err = %v, want nil(缺席不是错误)", turn, err)
}
if got != nil {
t.Fatalf("Graph(%s) = %+v, want nil", turn, got)
}
}
latest, err := st.Latest(ctx, "sess-backthink")
if err != nil {
t.Fatalf("Latest(unknown session) err = %v, want nil", err)
}
if latest != nil {
t.Fatalf("Latest(unknown session) = %+v, want nil", latest)
}
}
// TestRecordRound_SameTurnMerges_正常 钉住唯一键:同一会话同一轮二次收尾是**同轮覆盖**
// (一行、取后者),绝不插出两行「同一轮」。
func TestRecordRound_SameTurnMerges_正常(t *testing.T) {
t.Parallel()
ctx := context.Background()
st, conn := newTestStore(t, nil)
first := sampleGraph("web", 1)
if err := st.RecordRound(first); err != nil {
t.Fatalf("record first: %v", err)
}
second := sampleGraph("web", 1)
second.Nodes = second.Nodes[:1] // 第二次少两个节点:读回来必须看到「后写的那份」
if err := st.RecordRound(second); err != nil {
t.Fatalf("record second: %v", err)
}
var rows int
if err := conn.QueryRowContext(ctx,
`SELECT count(*) FROM flow_graphs WHERE session_id = ? AND turn_id = ?`, "web", "web-1").Scan(&rows); err != nil {
t.Fatalf("count rows: %v", err)
}
if rows != 1 {
t.Fatalf("rows = %d, want 1 (unique key must merge, not duplicate)", rows)
}
got, err := st.Graph(ctx, "web", "web-1")
if err != nil {
t.Fatalf("Graph: %v", err)
}
if got == nil || len(got.Nodes) != 1 {
t.Fatalf("merged round = %+v, want the second (1 node)", got)
}
}
// TestRecordRound_MissingID_异常 钉住缺坐标是显式的编程错误(ErrNoGraphID),
// 绝不写一行「不知道是哪一轮」的记录进库。
func TestRecordRound_MissingID_异常(t *testing.T) {
t.Parallel()
st, _ := newTestStore(t, nil)
cases := map[string]*trace.FlowGraph{
"nil graph": nil,
"no session": {ID: "web-1"},
"no turn id": {SessionID: "web"},
"blank turn id": {SessionID: "web", ID: " "},
"blank session": {SessionID: " ", ID: "web-1"},
}
for name, g := range cases {
if err := st.RecordRound(g); !errors.Is(err, ErrNoGraphID) {
t.Fatalf("%s: err = %v, want ErrNoGraphID", name, err)
}
}
}
// TestTurns_NewestFirstAndClamped_正常 钉住轮次列表的排序口径(最新在前)与分页钳制:
// 面板据此选历史轮,顺序错了就等于把「最近几轮」讲反。
func TestTurns_NewestFirstAndClamped_正常(t *testing.T) {
t.Parallel()
ctx := context.Background()
// 时钟递增,保证 created_at 严格不同(排序可复现)。
base := testNow()
clock := base
st, _ := newTestStore(t, func(c *Config) { c.Now = func() time.Time { return clock } })
for round := 1; round <= 4; round++ {
clock = base.Add(time.Duration(round) * time.Minute)
if err := st.RecordRound(sampleGraph("web", round)); err != nil {
t.Fatalf("RecordRound(round=%d): %v", round, err)
}
}
turns, err := st.Turns(ctx, "web", 0)
if err != nil {
t.Fatalf("Turns: %v", err)
}
if len(turns) != 4 {
t.Fatalf("turns = %d, want 4", len(turns))
}
want := []string{"web-4", "web-3", "web-2", "web-1"}
for i, w := range want {
if turns[i].TurnID != w {
t.Fatalf("turns[%d] = %q, want %q(最新在前)", i, turns[i].TurnID, w)
}
}
// 负/零取缺省;超上限被钳住(这里给 2 取 2)。
limited, err := st.Turns(ctx, "web", 2)
if err != nil {
t.Fatalf("Turns(limit=2): %v", err)
}
if len(limited) != 2 || limited[0].TurnID != "web-4" {
t.Fatalf("Turns(limit=2) = %+v, want newest 2", limited)
}
if MaxTurnsLimit < 3 || normalizeLimit(MaxTurnsLimit+1000) != MaxTurnsLimit {
t.Fatalf("normalizeLimit must clamp to MaxTurnsLimit=%d", MaxTurnsLimit)
}
if normalizeLimit(0) != DefaultTurnsLimit || normalizeLimit(-5) != DefaultTurnsLimit {
t.Fatalf("normalizeLimit(≤0) must fall back to DefaultTurnsLimit=%d", DefaultTurnsLimit)
}
}
// TestStore_SurvivesRestart_正常 是**本切片的内核**(主人要求):后端重启后仍能查回
// 历史轮次的图。用同一个**文件库**、先后两条连接 + 两个 store 模拟进程重启 —— 第二个
// store 内存里没有任何图,唯一能拿到 web-3 的方式就是从库里读回。
func TestStore_SurvivesRestart_正常(t *testing.T) {
t.Parallel()
ctx := context.Background()
path := filepath.Join(t.TempDir(), "cyrene.db")
// 第一个「进程」:建库、迁移、落一轮。
conn1, err := db.Open(ctx, path, nil)
if err != nil {
t.Fatalf("open db (first): %v", err)
}
if err := db.MigrateUp(ctx, conn1, nil); err != nil {
_ = conn1.Close()
t.Fatalf("migrate (first): %v", err)
}
st1, err := NewStore(Config{DB: conn1, Now: testNow})
if err != nil {
_ = conn1.Close()
t.Fatalf("NewStore (first): %v", err)
}
if err := st1.RecordRound(sampleGraph("web", 3)); err != nil {
_ = conn1.Close()
t.Fatalf("RecordRound: %v", err)
}
_ = conn1.Close() // 模拟进程退出
// 第二个「进程」:新连接、新 store(内存里没有任何图),只能靠落盘。
conn2, err := db.Open(ctx, path, nil)
if err != nil {
t.Fatalf("open db (second): %v", err)
}
t.Cleanup(func() { _ = conn2.Close() })
if err := db.MigrateUp(ctx, conn2, nil); err != nil {
t.Fatalf("migrate (second): %v", err)
}
st2, err := NewStore(Config{DB: conn2, Now: testNow})
if err != nil {
t.Fatalf("NewStore (second): %v", err)
}
got, err := st2.Graph(ctx, "web", "web-3")
if err != nil {
t.Fatalf("Graph after restart: %v", err)
}
if got == nil || got.ID != "web-3" {
t.Fatalf("重启后查回 = %+v, want web-3(这正是主人要的「本地数据」)", got)
}
latest, err := st2.Latest(ctx, "web")
if err != nil {
t.Fatalf("Latest after restart: %v", err)
}
if latest == nil || latest.ID != "web-3" {
t.Fatalf("重启后 Latest = %+v, want web-3", latest)
}
turns, err := st2.Turns(ctx, "web", 0)
if err != nil {
t.Fatalf("Turns after restart: %v", err)
}
if len(turns) != 1 || turns[0].TurnID != "web-3" {
t.Fatalf("重启后 turns = %+v, want [web-3]", turns)
}
}
+119
View File
@@ -0,0 +1,119 @@
package flowgraph
// 本文件归属**保留期清扫(Sweep)**(切片 F2)。它逐字沿用 `internal/runtime/events/gc.go`
// 的既有 GC 范式(design/19 §4 P3 的落盘件照 `events` / `documents` 口径,不另造):
//
// 1. **启动期扫一次、此后按间隔定时扫**(间隔默认 DefaultGCInterval)。启动期那次
// 与定时循环都**不**阻塞启动、不绑在磁盘上 —— 常驻循环住在组合根
// (`cmd/cyrene/wire_flow_graphs.go` 逐字对齐 `wire_channel_events.go`),本包只提供
// `Sweep` 与它需要的三个口径(Enabled / Interval / Retention)。
// 2. **失败只记 WARN、绝不让进程退出**:`Sweep` 把查询/删除失败**上抛**给调用方
// (组合根记 WARN 后继续等下一轮);已经删掉的行不会因为后续批次失败而「回滚」,
// 未删的行也不会被清空 —— 一轮失败就只是「这一轮没扫干净」。
// 3. **≤0 = 关闭**(显式),关闭原因在 NewStore 里记进日志 —— 「配了却不生效」不存在
// (与文档/事件 GC 同口径)。
//
// 清理条件只有一条:`created_at < now − 保留期` 的 `flow_graphs` 行逐批删除。落盘件本身
// **没有**「不参与 GC」的例外:图是可视化资产,过期即可回收(没有它,库会无限增长)。
import (
"context"
"fmt"
"time"
)
const (
// DefaultGCInterval 是 `FLOW_GRAPH_GC_INTERVAL` 的默认值:每小时清扫一次。
DefaultGCInterval = time.Hour
// DefaultRetention 是 `FLOW_GRAPH_RETENTION` 的默认值:7 天(168h,与文档保留期同值)。
// 图的用途是「回看最近若干轮」,不是事后定案证据(后者是 channel_events 的 720h 口径)——
// 一周足以覆盖「翻前几轮」,也把每轮一份的图 JSON 挡在可行的体积内。
DefaultRetention = 168 * time.Hour
// sweepBatchSize 是单批删除的行数上限;maxSweepBatches 是一轮清扫的批数上限。
// 两者一起把「一轮清扫」的时长与单次内存占用**有界**(AGENTS §10 的压测纪律:
// 有界,不靠堆进程):积压时不是一次 DELETE 全表,而是每轮最多
// maxSweepBatches × sweepBatchSize 行,剩下的留给下一轮。
sweepBatchSize = 500
maxSweepBatches = 100
)
// SweepStats 是一轮清扫的汇总(成功件只记总数,绝不记任何行内容 / 图内容)。
type SweepStats struct {
// Batches 是实际执行的删除批数。
Batches int
// Deleted 是删除的过期行数。
Deleted int64
}
// Enabled 报告清扫是否真的会删行(保留期与间隔都 > 0)。
func (s *SQLStore) Enabled() bool { return s != nil && s.gcEnabled }
// Interval 返回定时清扫间隔(关闭时为 0)。
func (s *SQLStore) Interval() time.Duration {
if s == nil {
return 0
}
return s.gcInterval
}
// Retention 返回轮次图保留期(关闭时为 0)。
func (s *SQLStore) Retention() time.Duration {
if s == nil {
return 0
}
return s.retention
}
// sweepSQL 按时间戳删一批过期行。子查询带 `ORDER BY created_at LIMIT ?`,因此它
// 吃得上 `idx_flow_graphs_created_at`,且每批的扫描面有界。时间戳用定宽 UTC 字符串
// 比较:字典序等价于时间序(见 flowgraph.go 的口径注释)。
const sweepSQL = `DELETE FROM flow_graphs WHERE id IN (
SELECT id FROM flow_graphs WHERE created_at < ? ORDER BY created_at LIMIT ?)`
// Sweep 按保留期清扫 `flow_graphs` 的过期行,返回一轮汇总。
//
// 关闭时(Enabled() 为 false)是**空操作**:返回零值与 nil 错误、不碰任何一行。这与
// 「配了却不生效」是两回事 —— 关闭原因在 NewStore 里已记 WARN。
//
// 失败语义:查询/删除失败返回错误并**带上已完成的统计**(调用方据此知道这一轮走到了
// 哪一步,记 WARN 后等下一轮)。ctx 取消时立刻停下:**已删的保持删除、未删的绝不因此
// 被清空**。
func (s *SQLStore) Sweep(ctx context.Context) (SweepStats, error) {
if !s.Enabled() {
return SweepStats{}, nil
}
cutoff := sqliteTimeStamp(s.now().Add(-s.retention))
var stats SweepStats
for i := 0; i < maxSweepBatches; i++ {
if err := ctx.Err(); err != nil {
return stats, fmt.Errorf("flowgraph: sweep: %w", err)
}
deleted, err := s.sweepBatch(ctx, cutoff)
if err != nil {
return stats, err
}
stats.Batches++
stats.Deleted += deleted
if deleted < int64(sweepBatchSize) {
break
}
}
s.log.Info("flow graph sweep",
"deleted", stats.Deleted, "batches", stats.Batches, "retention", s.retention.String())
return stats, nil
}
// sweepBatch 删一批过期行并返回本批删掉的行数。
func (s *SQLStore) sweepBatch(ctx context.Context, cutoff string) (int64, error) {
res, err := s.db.ExecContext(ctx, sweepSQL, cutoff, sweepBatchSize)
if err != nil {
return 0, fmt.Errorf("flowgraph: sweep 删过期行: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return 0, fmt.Errorf("flowgraph: sweep 读取删除行数: %w", err)
}
return n, nil
}
+144
View File
@@ -0,0 +1,144 @@
package flowgraph
// 本文件是**保留期清扫**的验收(切片 F2)。断言的是库里的事实(哪些行还在),
// 与 `internal/runtime/events/gc_test.go` 同一口径。
import (
"context"
"testing"
"time"
)
// TestSweep_DisabledIsNoOp_边界 钉住「配了 ≤0 = 关闭 GC」:`Sweep` 是**空操作**,
// 一行都不删。0 刻意不解释成「立即清空」—— 那会让一次笔误抹掉刚落盘的历史轮。
func TestSweep_DisabledIsNoOp_边界(t *testing.T) {
t.Parallel()
ctx := context.Background()
for _, tc := range []struct {
name string
gcInterval, retain time.Duration
}{
{"interval=0", 0, time.Hour},
{"interval<0", -time.Hour, time.Hour},
{"retention=0", time.Hour, 0},
{"retention<0", time.Hour, -time.Hour},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
st, _ := newTestStore(t, func(c *Config) {
c.GCInterval = tc.gcInterval
c.Retention = tc.retain
})
if st.Enabled() {
t.Fatalf("Enabled() = true, want false (%s)", tc.name)
}
if err := st.RecordRound(sampleGraph("web", 1)); err != nil {
t.Fatalf("RecordRound: %v", err)
}
stats, err := st.Sweep(ctx)
if err != nil {
t.Fatalf("Sweep: %v", err)
}
if stats.Deleted != 0 || stats.Batches != 0 {
t.Fatalf("disabled Sweep = %+v, want zero", stats)
}
turns, err := st.Turns(ctx, "web", 0)
if err != nil {
t.Fatalf("Turns: %v", err)
}
if len(turns) != 1 {
t.Fatalf("行被删了(turns=%d),但 GC 关闭时必须是空操作", len(turns))
}
})
}
}
// TestSweep_DeletesOnlyExpired_正常 钉住「正数只删超期」:保留期内的行**必须**留下,
// 只有 `created_at < now − 保留期` 的行被回收。时钟注入让这条判定可复现。
func TestSweep_DeletesOnlyExpired_正常(t *testing.T) {
t.Parallel()
ctx := context.Background()
base := testNow()
clock := base
st, _ := newTestStore(t, func(c *Config) {
c.GCInterval = time.Hour
c.Retention = 24 * time.Hour
c.Now = func() time.Time { return clock }
})
if !st.Enabled() {
t.Fatal("Enabled() = false, want true(间隔与保留期都 > 0)")
}
// 旧轮:T0 落盘。
if err := st.RecordRound(sampleGraph("web", 1)); err != nil {
t.Fatalf("record old round: %v", err)
}
// 时钟拨到 T0+48h,落一个新轮。
clock = base.Add(48 * time.Hour)
if err := st.RecordRound(sampleGraph("web", 2)); err != nil {
t.Fatalf("record new round: %v", err)
}
// cutoff = (T0+48h) − 24h = T0+24h ⇒ web-1(T0)超期、web-2(T0+48h)留下。
stats, err := st.Sweep(ctx)
if err != nil {
t.Fatalf("Sweep: %v", err)
}
if stats.Deleted != 1 {
t.Fatalf("Deleted = %d, want 1(只删超期的那一轮)", stats.Deleted)
}
turns, err := st.Turns(ctx, "web", 0)
if err != nil {
t.Fatalf("Turns: %v", err)
}
if len(turns) != 1 || turns[0].TurnID != "web-2" {
t.Fatalf("turns after sweep = %+v, want [web-2](保留期内的那轮必须留下)", turns)
}
if _, err := st.Graph(ctx, "web", "web-1"); err != nil {
t.Fatalf("Graph(被删轮) err = %v, want nil(缺席不是错误)", err)
}
old, err := st.Graph(ctx, "web", "web-1")
if err != nil {
t.Fatalf("Graph(web-1): %v", err)
}
if old != nil {
t.Fatalf("web-1 应已被回收,却仍读得到: %+v", old)
}
}
// TestSweep_ScopedPerSession_正常 钉住清扫是**全表按时间**的(不是按会话):过期的
// 会话行照样回收,不会因为另一个会话还有新轮就整表留着。
func TestSweep_ScopedPerSession_正常(t *testing.T) {
t.Parallel()
ctx := context.Background()
base := testNow()
clock := base
st, _ := newTestStore(t, func(c *Config) {
c.GCInterval = time.Hour
c.Retention = time.Hour
c.Now = func() time.Time { return clock }
})
if err := st.RecordRound(sampleGraph("web", 1)); err != nil {
t.Fatalf("record web: %v", err)
}
clock = base.Add(3 * time.Hour)
if err := st.RecordRound(sampleGraph("sess-backthink", 1)); err != nil {
t.Fatalf("record backthink: %v", err)
}
if _, err := st.Sweep(ctx); err != nil {
t.Fatalf("Sweep: %v", err)
}
webTurns, err := st.Turns(ctx, "web", 0)
if err != nil {
t.Fatalf("Turns(web): %v", err)
}
if len(webTurns) != 0 {
t.Fatalf("web turns = %+v, want empty(3h 前的轮已超 1h 保留期)", webTurns)
}
btTurns, err := st.Turns(ctx, "sess-backthink", 0)
if err != nil {
t.Fatalf("Turns(backthink): %v", err)
}
if len(btTurns) != 1 {
t.Fatalf("backthink turns = %+v, want [sess-backthink-1]", btTurns)
}
}
+74 -3
View File
@@ -1,9 +1,11 @@
package loop package loop
import ( import (
"log/slog"
"sync" "sync"
"time" "time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
) )
@@ -13,11 +15,41 @@ import (
// 作为 WS 事件类型 graph_update 转发(design/19 §1)。 // 作为 WS 事件类型 graph_update 转发(design/19 §1)。
const graphUpdateSubject = "ws.graph_update" const graphUpdateSubject = "ws.graph_update"
// flowLogSource 是发布器日志的 source 标签(落盘失败记 WARN;AGENTS §10 禁裸日志)。
const flowLogSource = "runtime/loop/flow"
// allowedSessions 是用于控制哪些会话可发布 // allowedSessions 是用于控制哪些会话可发布
// flow 图的谓词签名(主人的 web 会话),使 IM/陌生人/子代理轮次绝不 // flow 图的谓词签名(主人的 web 会话),使 IM/陌生人/子代理轮次绝不
// 泄露进管理面板。谓词为 nil 时允许所有会话。 // 泄露进管理面板。谓词为 nil 时允许所有会话。
type allowedSessions func(sessionID string) bool type allowedSessions func(sessionID string) bool
// RoundRecorder 把一条**已收尾轮次**的图落盘(design/19 §4 的 P3「多轮历史回放」;
// 切片 F2)。生产实现是 `internal/runtime/flowgraph` 的 `*SQLStore`。
//
// 语义约定(实现方必须遵守):**每轮一条**(只在 EndRound 里调用,绝不在每个 span
// 上调用),失败**上抛**给发布器(发布器记 WARN 后继续)—— 图是可视化资产,落盘失败
// 绝不允许影响会话收尾,也绝不改变实时帧。
//
// 白名单不在实现方:EndRound 对发布会话范围外的会话**直接返回**,因此落盘出口天然
// 只被白名单内的会话触发(复用 flowSessionAllowed,不存在第二份白名单)。
type RoundRecorder interface {
RecordRound(g *trace.FlowGraph) error
}
// FlowPublisherOption 配置 FlowPublisher 的可选行为(落盘出口 + 日志)。
type FlowPublisherOption func(*FlowPublisher)
// WithRoundRecorder 挂一个「每轮收尾时落一条」的落盘出口。传 nil 等价于不落盘。
func WithRoundRecorder(rec RoundRecorder) FlowPublisherOption {
return func(p *FlowPublisher) { p.record = rec }
}
// WithFlowLogger 给发布器一个 logger:落盘失败要**可见**(记 WARN),但绝不把图内容
// 写进日志(图里可能含用户输入文本,只记 session/turn 标识)。
func WithFlowLogger(log *slog.Logger) FlowPublisherOption {
return func(p *FlowPublisher) { p.log = log }
}
// FlowPublisher 从循环的 trace span 累积每会话 flow 图, // FlowPublisher 从循环的 trace span 累积每会话 flow 图,
// 并在每个 span 开始/结束时发布 ws.graph_update,给前端 // 并在每个 span 开始/结束时发布 ws.graph_update,给前端
// 一张实时的 input→think→tool→output 图(design/19 §1)。它容忍 nil // 一张实时的 input→think→tool→output 图(design/19 §1)。它容忍 nil
@@ -34,19 +66,32 @@ type FlowPublisher struct {
allow allowedSessions allow allowedSessions
graphs map[string]*trace.RunGraph graphs map[string]*trace.RunGraph
rounds map[string]int rounds map[string]int
// record 是每轮收尾的落盘出口(design/19 §4 P3;切片 F2)。nil = 不落盘
// (保持 F1 之前的纯内存行为,且测试可只测发布)。
record RoundRecorder
// log 用于把落盘失败记成 WARN(带 source)。nil 时静默(测试/未接线)。
log *slog.Logger
} }
// NewFlowPublisher 构建一个 FlowPublisher。publish 非 nil 时,会在 // NewFlowPublisher 构建一个 FlowPublisher。publish 非 nil 时,会在
// 每次 span 状态迁移时以 graphUpdateSubject 与 *trace.FlowGraph // 每次 span 状态迁移时以 graphUpdateSubject 与 *trace.FlowGraph
// 快照调用。allow 非 nil 时,控制哪些 session id 可发布(返回 // 快照调用。allow 非 nil 时,控制哪些 session id 可发布(返回
// false 则整个会话被丢弃)。两者都可以为 nil(nil publish 为空操作)。 // false 则整个会话被丢弃)。两者都可以为 nil(nil publish 为空操作)。
func NewFlowPublisher(publish func(subject string, payload any), allow func(sessionID string) bool) *FlowPublisher { //
return &FlowPublisher{ // 可变项经 Functional Options 传入(不改变既有两参调用):落盘出口(F2)与日志。
func NewFlowPublisher(publish func(subject string, payload any), allow func(sessionID string) bool, opts ...FlowPublisherOption) *FlowPublisher {
p := &FlowPublisher{
publish: publish, publish: publish,
allow: allow, allow: allow,
graphs: make(map[string]*trace.RunGraph), graphs: make(map[string]*trace.RunGraph),
rounds: make(map[string]int), rounds: make(map[string]int),
} }
for _, opt := range opts {
if opt != nil {
opt(p)
}
}
return p
} }
// StartRound 为 sessionID 开始一张新图,用用户文本 // StartRound 为 sessionID 开始一张新图,用用户文本
@@ -72,18 +117,34 @@ func (p *FlowPublisher) StartRound(sessionID, inputText string) {
// **重放的出口是 `LastGraph`**(网关只读查询,契约 v0.71),而不是又发一帧: // **重放的出口是 `LastGraph`**(网关只读查询,契约 v0.71),而不是又发一帧:
// 实时帧向来只推给当时在线的连接,刷新/晚开的页面只能靠只读拉取补齐 // 实时帧向来只推给当时在线的连接,刷新/晚开的页面只能靠只读拉取补齐
// (`graph_update` 本身不重放)。 // (`graph_update` 本身不重放)。
//
// 收尾也是**落盘**的唯一时机(切片 F2;design/19 §4 的 P3「多轮历史回放」):每轮落
// **一条**(绝不是每 span 一条),且只在该会话处于发布会话白名单内时发生(本方法对
// 白名单外的会话直接返回 ⇒ 落盘出口天然只被白名单会话触发)。深拷贝与写库在**放锁
// 之后**做:一次库写绝不把其他会话的 span 发布挡住;失败只记 WARN,绝不影响本轮收尾。
func (p *FlowPublisher) EndRound(sessionID, outputText string) { func (p *FlowPublisher) EndRound(sessionID, outputText string) {
if sessionID == "" || !p.allowed(sessionID) { if sessionID == "" || !p.allowed(sessionID) {
return return
} }
p.mu.Lock() p.mu.Lock()
defer p.mu.Unlock()
g := p.graphs[sessionID] g := p.graphs[sessionID]
if g == nil { if g == nil {
p.mu.Unlock()
return return
} }
g.SeedOutput(outputText) g.SeedOutput(outputText)
p.publishLocked(g) p.publishLocked(g)
var snap *trace.FlowGraph
if p.record != nil {
snap = g.Snapshot().Clone()
}
p.mu.Unlock()
if snap != nil {
if err := p.record.RecordRound(snap); err != nil {
p.logPersistError(sessionID, snap.ID, err)
}
}
} }
// MarkInputSteered 给本轮的 input 节点打上 `steered:true` 标记并发布一帧 // MarkInputSteered 给本轮的 input 节点打上 `steered:true` 标记并发布一帧
@@ -188,6 +249,16 @@ func (p *FlowPublisher) allowed(sessionID string) bool {
return p.allow(sessionID) return p.allow(sessionID)
} }
// logPersistError 把一次落盘失败记成带来源的 WARN。它只记 session/turn 标识,
// **绝不打印图内容**(图里可能含用户输入文本);logger 未接线时静默。
func (p *FlowPublisher) logPersistError(sessionID, turnID string, err error) {
if p.log == nil {
return
}
logger.WithSource(p.log, flowLogSource).Warn("flow round persist failed",
"session", sessionID, "turn", turnID, "err", err)
}
// lastUserText 返回 messages 中最新用户消息的文本,用于 // lastUserText 返回 messages 中最新用户消息的文本,用于
// 标注该轮的 input 节点。没有用户文本时返回 ""。 // 标注该轮的 input 节点。没有用户文本时返回 ""。
func lastUserText(messages []model.Message) string { func lastUserText(messages []model.Message) string {
+138
View File
@@ -0,0 +1,138 @@
package loop
// 本文件钉住**每轮落一条**的落盘口径(切片 F2;design/19 §4 P3)。它是「写放大」
// 这条红线在代码里的落点:落盘只能发生在 EndRound 上(每轮一次),**绝不**在每个
// span 上发生;且只对发布会话白名单内的会话发生。用假 recorder 数调用次数,
// 不依赖任何数据库。
import (
"errors"
"sync"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// fakeRecorder 记录每次落盘调用(含图的深拷贝)并可选地报错。
type fakeRecorder struct {
mu sync.Mutex
graphs []*trace.FlowGraph
err error
}
func (f *fakeRecorder) RecordRound(g *trace.FlowGraph) error {
f.mu.Lock()
defer f.mu.Unlock()
f.graphs = append(f.graphs, g)
return f.err
}
func (f *fakeRecorder) count() int {
f.mu.Lock()
defer f.mu.Unlock()
return len(f.graphs)
}
func (f *fakeRecorder) last() *trace.FlowGraph {
f.mu.Lock()
defer f.mu.Unlock()
if len(f.graphs) == 0 {
return nil
}
return f.graphs[len(f.graphs)-1]
}
// runRoundWithSpans 跑一轮:起轮 → 三个 span(起止)→ 收轮。它不读任何全局状态,
// 发布/落盘的计数由各用例自己的闭包与 recorder 负责。
func runRoundWithSpans(p *FlowPublisher, session string) {
p.StartRound(session, "帮我查天气")
for i, name := range []string{"llm.call", "tool_call", "llm.call"} {
sp := &trace.Span{ID: "span-" + string(rune('a'+i)), Name: name, Kind: trace.SpanKindLLM, StartedAt: time.Now()}
p.SpanStarted(session, sp)
sp.Finish(trace.SpanStatusOK)
p.SpanFinished(session, sp)
}
p.EndRound(session, "今天晴,26 度。")
}
// TestFlowPublisher_EndRoundPersistsOnePerRound_正常 是本片的核心断言:无论这一轮
// 有多少个 span,**落盘恰好一次**(每轮一行),且落的是**收尾后**的图(含 output 节点)。
func TestFlowPublisher_EndRoundPersistsOnePerRound_正常(t *testing.T) {
rec := &fakeRecorder{}
p := NewFlowPublisher(func(string, any) {}, func(string) bool { return true },
WithRoundRecorder(rec))
runRoundWithSpans(p, "web")
if got := rec.count(); got != 1 {
t.Fatalf("落盘次数 = %d, want 1(每轮一行;每 span 一行是写放大,本片红线)", got)
}
g := rec.last()
if g == nil {
t.Fatal("recorder 没收到图")
}
if g.ID != "web-1" {
t.Fatalf("落盘 turn id = %q, want web-1", g.ID)
}
if g.SessionID != "web" {
t.Fatalf("落盘 session = %q, want web", g.SessionID)
}
kinds := map[string]bool{}
for _, n := range g.Nodes {
kinds[n.Kind] = true
}
if !kinds["input"] || !kinds["output"] {
t.Fatalf("落盘的图必须含主干端点 input/output: %+v", g.Nodes)
}
}
// TestFlowPublisher_EndRoundSkipsWhitelistOutside_正常 钉住落盘的**白名单复用之**:
// 发布会话范围外的会话(隔离渠道会话)连图都不建,EndRound 直接返回 ⇒ 落盘调用为零。
func TestFlowPublisher_EndRoundSkipsWhitelistOutside_正常(t *testing.T) {
rec := &fakeRecorder{}
allow := func(session string) bool { return session == "web" }
p := NewFlowPublisher(func(string, any) {}, allow, WithRoundRecorder(rec))
runRoundWithSpans(p, "sess-im-stranger")
if got := rec.count(); got != 0 {
t.Fatalf("白名单外会话落盘 %d 次, want 0(白名单是同一处判据,绝不放行)", got)
}
runRoundWithSpans(p, "web")
if got := rec.count(); got != 1 {
t.Fatalf("白名单内会话落盘 %d 次, want 1(反向证明上面的 0 不是整体坏了)", got)
}
}
// TestFlowPublisher_RecorderFailureDoesNotDropRound_异常 钉住「落盘失败不影响会话」:
// recorder 报错时 EndRound 照常发布收尾帧(图仍在内存、面板仍实时看得到),且不 panic。
func TestFlowPublisher_RecorderFailureDoesNotDropRound_异常(t *testing.T) {
frames := 0
rec := &fakeRecorder{err: errors.New("db down")}
p := NewFlowPublisher(func(string, any) { frames++ }, func(string) bool { return true },
WithRoundRecorder(rec))
runRoundWithSpans(p, "web")
if rec.count() != 1 {
t.Fatalf("recorder 应被调用一次(失败也要尝试)")
}
if frames == 0 {
t.Fatal("落盘失败不该吞掉实时帧 —— EndRound 仍须发布收尾帧")
}
// 实时图仍保留最近一轮(LastGraph 是收尾快照的出口)。
if snap := p.LastGraph("web"); snap == nil || len(snap.Nodes) == 0 {
t.Fatal("落盘失败后最近一轮图仍在内存(LastGraph 仍可读)")
}
}
// TestFlowPublisher_NoRecorderStillPublishes_边界 钉住 F2 是**纯增量**:没挂 recorder
// 时 EndRound 行为与 F1 之前逐字相同(只发布、不落盘、不 panic)。
func TestFlowPublisher_NoRecorderStillPublishes_边界(t *testing.T) {
frames := 0
p := NewFlowPublisher(func(string, any) { frames++ }, func(string) bool { return true })
runRoundWithSpans(p, "web")
if frames == 0 {
t.Fatal("没有 recorder 时 EndRound 仍须正常发布")
}
}
+130 -21
View File
@@ -1,11 +1,14 @@
package gateway package gateway
import ( import (
"context"
"net/http" "net/http"
"strings" "strings"
"time"
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors" apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
) )
// mainSessionID 是未指定 `?sessionId=` 时的默认会话 —— Web 主会话 // mainSessionID 是未指定 `?sessionId=` 时的默认会话 —— Web 主会话
@@ -19,33 +22,62 @@ const mainSessionID = history.MainSessionID
// usage_unavailable / channel_events_unavailable 同一口径。 // usage_unavailable / channel_events_unavailable 同一口径。
const flowUnavailableCode = "flow_unavailable" const flowUnavailableCode = "flow_unavailable"
// adminFlowGraphResponse 是 GET /api/v1/admin/flow/graph 的响应体(契约 v0.71)。 // 图上那张图**是哪来的**(契约 v0.73 §4.11):
// - live = 内存里**最近一轮**的实时图(`loop.FlowPublisher.LastGraph`);
// - persisted = 从本地库读回的**已落盘轮次**(历史回放);
// - none = 没有图(诚实空态)。
// //
// Graph 为 nil 表示「该会话确实还没有图」——这是一个**诚实的空态**,不是错误: // 前端据此渲染来源说明条(v0.73 起也可经它的 `source` 字段直接分辨,
// 客户端必须据此渲染空态,绝不允许补画任何节点或假图(§4.11(b)-3)。 // 不再只靠对象引用)。
const (
flowSourceLive = "live"
flowSourcePersisted = "persisted"
flowSourceNone = "none"
)
// flowTurnDTO 是「该会话已落盘轮次」的一条摘要(契约 v0.73 的 `turns[]`)。
// 它是给面板选历史轮用的:`turnId` 回带给本端点当 `?turnId=`,`createdAt` 供排序/展示。
type flowTurnDTO struct {
TurnID string `json:"turnId"`
CreatedAt string `json:"createdAt"`
}
// adminFlowGraphResponse 是 GET /api/v1/admin/flow/graph 的响应体
// (v0.71 起;**v0.73 起新增 `turnId`/`source`/`turns` 三键**,纯增量)。
//
// Graph 为 nil 表示「该轮确实没有图」——这是一个**诚实的空态**,不是错误:
// 客户端必须据此渲染空态,绝不允许补画任何节点或假图(§4.11(b))。
// 该键**恒在**(`null` 而不是缺席),使客户端不必区分「没有这个键」与 // 该键**恒在**(`null` 而不是缺席),使客户端不必区分「没有这个键」与
// 「这个键是空」。 // 「这个键是空」。
//
// Turns **恒为非 nil 切片**(没有轮次时是 `[]`),使客户端不必判空。
type adminFlowGraphResponse struct { type adminFlowGraphResponse struct {
SessionID string `json:"sessionId"` SessionID string `json:"sessionId"`
// Graph 是**最近一轮**的图快照,与 `graph_update` 帧的 data 逐字同形 // TurnID 是**实际返回的那一轮**的标识(`graph.id`);没有图时为空串。
TurnID string `json:"turnId"`
// Source ∈ live / persisted / none,见上方常量。
Source string `json:"source"`
// Graph 是**该轮**的图快照,与 `graph_update` 帧的 data 逐字同形
// (契约 §3.4.5 / §4.11(a))。字段以 `any` 承载,因为「没有图」(nil ⇒ // (契约 §3.4.5 / §4.11(a))。字段以 `any` 承载,因为「没有图」(nil ⇒
// 线上 JSON `null`)与「有图」的区分就是本字段的语义。 // 线上 JSON `null`)与「有图」的区分就是本字段的语义。
Graph any `json:"graph"` Graph any `json:"graph"`
// Turns 是该会话**已落盘轮次**的摘要,最新在前(契约 v0.73)。
Turns []flowTurnDTO `json:"turns"`
} }
// adminFlowGraph 是 `GET /api/v1/admin/flow/graph` 的处理器(契约 v0.71, // adminFlowGraph 是 `GET /api/v1/admin/flow/graph` 的处理器(契约 v0.71 起,
// design/19 §4 的 P3「多轮历史回放」的最小一步)。 // v0.73 增量;design/19 §4 的 P3「多轮历史回放」)。
// //
// 它回答的是主人第二次报的那个洞:`graph_update` **只走实时帧、从不重放**, // 它回答的是主人报的两个洞:① `graph_update` **只走实时帧、从不重放**(v0.71 修);
// 而前端图桶(`flowGraphs`)**不持久化** ⇒ 「轮次结束后才打开页面」或 // ② 图只活在进程内存、刷新/重启/翻更早轮次就没了 ⇒ 现在每轮收尾**落盘**,本端点可按
// 「刷新过页面」永远空着。本端点让打开页面时能**只读拉一次**最近一轮的图。 // `turnId` 取历史轮、并给出可选轮次列表(v0.73)。三条硬约束:
// //
// 三条硬约束(与 §4.11(b) 逐条对应): // 1. **只读**:经 `FlowGraphSource` / `FlowGraphStore` 拿快照,网关不发布、不改图、
// 1. **只读**:经 `FlowGraphSource` 拿快照,网关不发布、不改图、不动任何会话; // 不动任何会话、也不落盘;
// 2. **不画假图**:该会话没有图 ⇒ `graph:null`;图源未接线 ⇒ `500 flow_unavailable` // 2. **不画假图**:没有该轮 ⇒ `graph:null`;图源未接线(内存 + 落盘都没接)⇒
// (**不是** graph:null,见 flowUnavailableCode); // `500 flow_unavailable`(**不是** graph:null,见 flowUnavailableCode);
// 3. **只回最近一轮**:更早轮次的历史回放仍未实现(design/19 §4 P3)—— // 3. **最近一轮优先内存,miss 才落盘**:省略 `turnId` 时先查内存 `LastGraph`(正在跑/
// 因此面板必须在图上说明「这不是你要的那一行那轮的图」。 // 刚跑完的那一轮,最新),取不到才回退落盘最新 —— 「最新一轮永远是最新的」。
// //
// 角色门:`writeAuth`(owner|admin),与 design/19 §3「flow 可视化仅 owner/admin」 // 角色门:`writeAuth`(owner|admin),与 design/19 §3「flow 可视化仅 owner/admin」
// 一致;它与同一张图的 WS 下行帧(`graph_update` 落 frameReachesAll 的 fail-closed // 一致;它与同一张图的 WS 下行帧(`graph_update` 落 frameReachesAll 的 fail-closed
@@ -56,15 +88,92 @@ func (s *Server) adminFlowGraph(w http.ResponseWriter, r *http.Request) {
if sessionID == "" { if sessionID == "" {
sessionID = mainSessionID sessionID = mainSessionID
} }
if s.flow == nil { turnID := strings.TrimSpace(r.URL.Query().Get("turnId"))
if s.flow == nil && s.flowGraphs == nil {
s.log.Warn("flow graph source not wired", "session", sessionID) s.log.Warn("flow graph source not wired", "session", sessionID)
writeError(w, apperr.Wrap(apperr.ErrInternal, flowUnavailableCode, "flow graph source is not wired")) writeError(w, apperr.Wrap(apperr.ErrInternal, flowUnavailableCode, "flow graph source is not wired"))
return return
} }
// Graph 恒在响应里(没有图时是 `null`),见 adminFlowGraphResponse 的注释。 ctx := r.Context()
var graph any graph, source, err := s.resolveFlowGraph(ctx, sessionID, turnID)
if snap := s.flow.LastGraph(sessionID); snap != nil { if err != nil {
graph = snap // 原始错误(可能带 SQL/路径)只进日志;响应只给稳定 code + 中文说明。
s.log.Warn("flow graph read failed", "session", sessionID, "turn", turnID, "err", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, flowUnavailableCode, "读取链路图失败"))
return
} }
writeJSON(w, http.StatusOK, adminFlowGraphResponse{SessionID: sessionID, Graph: graph}) turns, err := s.flowTurns(ctx, sessionID)
if err != nil {
s.log.Warn("flow turns read failed", "session", sessionID, "err", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, flowUnavailableCode, "读取轮次列表失败"))
return
}
// Graph 恒在响应里(没有图时是 `null`),见 adminFlowGraphResponse 的注释。
var graphAny any
resolvedTurn := ""
if graph != nil {
graphAny = graph
resolvedTurn = graph.ID
}
writeJSON(w, http.StatusOK, adminFlowGraphResponse{
SessionID: sessionID,
TurnID: resolvedTurn,
Source: source,
Graph: graphAny,
Turns: turns,
})
}
// resolveFlowGraph 解析「这一轮」的图:**先内存、miss 才落盘**(契约 v0.73 §4.11(b))。
//
// 判定顺序(三条都必要):
// - 内存里有图,且(省略 turnId **或** turnId 正是内存那一轮)⇒ 回内存图
// (`source:"live"`)—— 最新一轮永远最新;
// - 否则若给了 turnId ⇒ 精确读落盘那一轮(`source:"persisted"`);
// - 否则(省略 turnId、内存 miss)⇒ 读落盘**最新**一轮(`source:"persisted"`)。
//
// 落盘查询失败**上抛**(调用方回 500):把「读库失败」伪装成「没有这一轮」是
// 项目明令禁止的那类静默降级。该轮确实缺席返回 (nil, "none", nil)。
func (s *Server) resolveFlowGraph(ctx context.Context, sessionID, turnID string) (*trace.FlowGraph, string, error) {
if s.flow != nil {
if snap := s.flow.LastGraph(sessionID); snap != nil && (turnID == "" || snap.ID == turnID) {
return snap, flowSourceLive, nil
}
}
if s.flowGraphs == nil {
return nil, flowSourceNone, nil
}
var (
graph *trace.FlowGraph
err error
)
if turnID == "" {
graph, err = s.flowGraphs.Latest(ctx, sessionID)
} else {
graph, err = s.flowGraphs.Graph(ctx, sessionID, turnID)
}
if err != nil {
return nil, flowSourceNone, err
}
if graph == nil {
return nil, flowSourceNone, nil
}
return graph, flowSourcePersisted, nil
}
// flowTurns 取该会话已落盘轮次的摘要(最新在前)。落盘未接线时回**空切片**
// (不是错误):端点在「只有内存」的形态下照常可用(v0.71 口径),只是没有历史轮可选。
func (s *Server) flowTurns(ctx context.Context, sessionID string) ([]flowTurnDTO, error) {
out := make([]flowTurnDTO, 0)
if s.flowGraphs == nil {
return out, nil
}
turns, err := s.flowGraphs.Turns(ctx, sessionID, 0)
if err != nil {
return nil, err
}
for _, t := range turns {
out = append(out, flowTurnDTO{TurnID: t.TurnID, CreatedAt: t.CreatedAt.UTC().Format(time.RFC3339)})
}
return out, nil
} }
@@ -0,0 +1,345 @@
package gateway
// 本文件是链路图**落盘持久化 + 多轮历史回放**的跨层闭环(切片 F2;design/19 §4 P3)。
// 它用真的写入路径(`loop.FlowPublisher.EndRound` → `flowgraph.SQLStore.RecordRound`
// → 真 SQLite + 迁移 0022),再经**真的** HTTP handler 读回来,钉住三件只有端到端
// 才成立、也正是主人要的事:
//
// - **后端重启后仍能查回历史轮次的图**(本切片的内核):换一套「进程」(新连接、
// 新 store、空内存的发布器),图仍从库里读得回来;
// - **最近一轮优先内存、miss 才落盘**:内存里有更新的那轮时,省略 turnId 不回落盘旧轮;
// - **白名单外会话既不落盘也查不到**:隔离渠道会话连图都不建,`turns` 恒空、`graph` 恒 null。
//
// 参数翻译与语义在 `admin_flow_graph_test.go`(v0.71 那批)与本文件互补。
import (
"context"
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/flowgraph"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// flowGraphResponseDTO 是 v0.73 响应体的线上投影(含新增三键)。
type flowGraphResponseDTO struct {
SessionID string `json:"sessionId"`
TurnID string `json:"turnId"`
Source string `json:"source"`
Graph *struct {
ID string `json:"id"`
SessionID string `json:"sessionId"`
Nodes []struct {
Kind string `json:"kind"`
} `json:"nodes"`
} `json:"graph"`
Turns []struct {
TurnID string `json:"turnId"`
CreatedAt string `json:"createdAt"`
} `json:"turns"`
}
func decodeFlowGraphResp(t *testing.T, body []byte) flowGraphResponseDTO {
t.Helper()
var out flowGraphResponseDTO
if err := json.Unmarshal(decodeBody(t, body)["data"], &out); err != nil {
t.Fatalf("decode flow graph data: %v (body=%s)", err, body)
}
return out
}
// openFlowGraphStore 打开一个真实的 SQLite 文件库、跑迁移 0022,返回连接与存储句柄。
// caller 可显式 Close 连接以模拟进程退出(`t.Cleanup` 会兜底再关一次,无害)。
func openFlowGraphStore(t *testing.T, path string) (*sql.DB, *flowgraph.SQLStore) {
t.Helper()
ctx := context.Background()
conn, err := db.Open(ctx, path, gwTestLogger())
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.MigrateUp(ctx, conn, gwTestLogger()); err != nil {
_ = conn.Close()
t.Fatalf("migrate: %v", err)
}
store, err := flowgraph.NewStore(flowgraph.Config{DB: conn, Log: gwTestLogger()})
if err != nil {
_ = conn.Close()
t.Fatalf("new flowgraph store: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
return conn, store
}
// flowPublisherWithStore 造一个把每轮收尾落进 store 的发布器(生产那条路径)。
func flowPublisherWithStore(store *flowgraph.SQLStore, allow func(string) bool) *loop.FlowPublisher {
return loop.NewFlowPublisher(func(string, any) {}, allow,
loop.WithRoundRecorder(store), loop.WithFlowLogger(gwTestLogger()))
}
// runFlowRound 让发布器跑完一整轮(起轮 → 一个 llm span 起止 → 收轮)。
func runFlowRound(p *loop.FlowPublisher, session, input, output string) {
p.StartRound(session, input)
sp := &trace.Span{ID: "span-llm", Name: "llm.call", Kind: trace.SpanKindLLM, StartedAt: time.Now()}
p.SpanStarted(session, sp)
sp.Finish(trace.SpanStatusOK)
p.SpanFinished(session, sp)
p.EndRound(session, output)
}
// mkFlowGraph 造一轮最小图,供直接写库(不经发布器)的用例使用。
func mkFlowGraph(session string, round int) *trace.FlowGraph {
return &trace.FlowGraph{
ID: session + "-" + itoa(round),
SessionID: session,
Nodes: []trace.FlowNode{
{ID: "input", Label: "你好", Kind: "input", Status: trace.FlowStatusDone},
{ID: "output", Label: "回复", Kind: "output", Status: trace.FlowStatusDone},
},
Edges: []trace.FlowEdge{{From: "input", To: "output"}},
}
}
func itoa(n int) string {
if n == 0 {
return "0"
}
digits := make([]byte, 0, 4)
for n > 0 {
digits = append([]byte{byte('0' + n%10)}, digits...)
n /= 10
}
return string(digits)
}
// flowURL 拼出该端点的 URL(带可选 turnId)。
func flowURL(ts *httptest.Server, session, turn string) string {
u := ts.URL + "/api/v1/admin/flow/graph?sessionId=" + session
if turn != "" {
u += "&turnId=" + turn
}
return u
}
// TestAdminFlowGraph_PersistedHistoryReadBack_正常:省略 turnId 回内存最近一轮
// (source live),带 turnId 回该历史轮(source persisted),且 turns 列全三轮、最新在前。
func TestAdminFlowGraph_PersistedHistoryReadBack_正常(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "cyrene.db")
_, store := openFlowGraphStore(t, path)
pub := flowPublisherWithStore(store, func(string) bool { return true })
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Flow: pub, FlowGraphs: store})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
for _, io := range []struct{ in, out string }{
{"第一轮", "答一"}, {"第二轮", "答二"}, {"第三轮", "答三"},
} {
runFlowRound(pub, "web", io.in, io.out)
}
resp, body := doReq(t, http.MethodGet, flowURL(ts, "web", ""), nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("latest status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
latest := decodeFlowGraphResp(t, body)
if latest.Source != "live" {
t.Fatalf("最新一轮 source = %q, want live(先查内存)", latest.Source)
}
if latest.TurnID != "web-3" {
t.Fatalf("最新一轮 turnId = %q, want web-3", latest.TurnID)
}
if len(latest.Turns) != 3 {
t.Fatalf("turns = %d, want 3(每轮一行,不是每 span 一行)", len(latest.Turns))
}
for i, want := range []string{"web-3", "web-2", "web-1"} {
if latest.Turns[i].TurnID != want {
t.Fatalf("turns[%d] = %q, want %q(最新在前)", i, latest.Turns[i].TurnID, want)
}
}
resp, body = doReq(t, http.MethodGet, flowURL(ts, "web", "web-1"), nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("history status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
hist := decodeFlowGraphResp(t, body)
if hist.Source != "persisted" {
t.Fatalf("历史轮 source = %q, want persisted(内存里没有 web-1)", hist.Source)
}
if hist.Graph == nil || hist.Graph.ID != "web-1" {
t.Fatalf("历史轮 graph = %+v, want web-1", hist.Graph)
}
kinds := map[string]bool{}
for _, n := range hist.Graph.Nodes {
kinds[n.Kind] = true
}
if !kinds["input"] || !kinds["output"] {
t.Fatalf("历史轮图缺主干端点: %+v", hist.Graph.Nodes)
}
}
// TestAdminFlowGraph_SurvivesRestart_正常 是本切片的**内核**(主人要求):后端重启后
// 仍能查回历史轮次。换一套「进程」(新连接 + 新 store + **空内存**的新发布器),
// 唯一能拿到图的方式就是从库读回。
func TestAdminFlowGraph_SurvivesRestart_正常(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "cyrene.db")
// 第一个「进程」:跑两轮,图进库。
conn1, store1 := openFlowGraphStore(t, path)
pub1 := flowPublisherWithStore(store1, func(string) bool { return true })
_, ts1, _ := buildEnv(t, cfgDefault(), Deps{Flow: pub1, FlowGraphs: store1})
runFlowRound(pub1, "web", "第一轮", "答一")
runFlowRound(pub1, "web", "第二轮", "答二")
ts1.Close()
_ = conn1.Close() // 模拟进程退出
// 第二个「进程」:空内存的新发布器 + 同一个文件库的新连接 / 新 store。
_, store2 := openFlowGraphStore(t, path)
pub2 := flowPublisherWithStore(store2, func(string) bool { return true })
_, ts2, jwt2 := buildEnv(t, cfgDefault(), Deps{Flow: pub2, FlowGraphs: store2})
tok := signAny(t, jwt2, "acct", auth.RoleOwner)
resp, body := doReq(t, http.MethodGet, flowURL(ts2, "web", ""), nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("after restart status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
got := decodeFlowGraphResp(t, body)
if got.Graph == nil {
t.Fatalf("重启后 graph = null,但该会话刚跑完两轮并已落盘(这正是主人要的「本地数据」): %s", body)
}
if got.Source != "persisted" || got.TurnID != "web-2" {
t.Fatalf("重启后 source/turnId = %q/%q, want persisted/web-2", got.Source, got.TurnID)
}
// 更早那一轮也能按 turnId 取回。
resp, body = doReq(t, http.MethodGet, flowURL(ts2, "web", "web-1"), nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("restart history status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
hist := decodeFlowGraphResp(t, body)
if hist.Graph == nil || hist.Graph.ID != "web-1" {
t.Fatalf("重启后 web-1 = %+v, want web-1", hist.Graph)
}
}
// TestAdminFlowGraph_LatestPrefersMemory_正常 钉住「最新一轮永远最新」:内存里有更新的
// 那轮(web-2)而库里的最新只有 web-1 时,省略 turnId **必须**回内存那轮。
func TestAdminFlowGraph_LatestPrefersMemory_正常(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "cyrene.db")
_, store := openFlowGraphStore(t, path)
if err := store.RecordRound(mkFlowGraph("web", 1)); err != nil {
t.Fatalf("seed persisted round: %v", err)
}
// 发布器**不挂 recorder**:内存跑两轮(web-2),但不再落盘 ⇒ 库里仍旧只有 web-1。
pub := loop.NewFlowPublisher(func(string, any) {}, func(string) bool { return true })
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Flow: pub, FlowGraphs: store})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
runFlowRound(pub, "web", "第一轮", "答一")
runFlowRound(pub, "web", "第二轮", "答二")
resp, body := doReq(t, http.MethodGet, flowURL(ts, "web", ""), nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
got := decodeFlowGraphResp(t, body)
if got.Source != "live" || got.TurnID != "web-2" {
t.Fatalf("source/turnId = %q/%q, want live/web-2(省略 turnId 时先查内存,绝不回落盘的旧轮)",
got.Source, got.TurnID)
}
}
// TestAdminFlowGraph_OutsideWhitelistNotPersisted_正常 钉住白名单复用:发布会话范围外
// 的会话(隔离渠道会话)连图都不建 ⇒ 既不落盘、也查不到,`turns` 恒空。
func TestAdminFlowGraph_OutsideWhitelistNotPersisted_正常(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "cyrene.db")
_, store := openFlowGraphStore(t, path)
pub := flowPublisherWithStore(store, func(session string) bool { return session == "web" })
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Flow: pub, FlowGraphs: store})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
runFlowRound(pub, "sess-im-stranger", "陌生人说话", "回应")
runFlowRound(pub, "web", "主人说话", "回应")
resp, body := doReq(t, http.MethodGet, flowURL(ts, "sess-im-stranger", ""), nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("stranger status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
stranger := decodeFlowGraphResp(t, body)
if stranger.Graph != nil {
t.Fatalf("白名单外会话不该有图,却拿到 %+v", stranger.Graph)
}
if stranger.Source != "none" {
t.Fatalf("source = %q, want none", stranger.Source)
}
if len(stranger.Turns) != 0 {
t.Fatalf("白名单外会话不该落盘(turns=%+v)", stranger.Turns)
}
// 反向:白名单内的会话照常可查、有落盘轮(保证上面的空不是端点整体坏了)。
_, body = doReq(t, http.MethodGet, flowURL(ts, "web", ""), nil, tok)
if inScope := decodeFlowGraphResp(t, body); inScope.Graph == nil || len(inScope.Turns) != 1 {
t.Fatalf("白名单内会话应有图且有一轮落盘: %s", body)
}
}
// TestAdminFlowGraph_UnknownTurnIsHonestNull_边界 钉住「该 turnId 不存在」是诚实空态:
// 200 + `graph:null` + `source:"none"`,绝不用别的轮次顶替。
func TestAdminFlowGraph_UnknownTurnIsHonestNull_边界(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "cyrene.db")
_, store := openFlowGraphStore(t, path)
pub := flowPublisherWithStore(store, func(string) bool { return true })
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Flow: pub, FlowGraphs: store})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
runFlowRound(pub, "web", "第一轮", "答一")
resp, body := doReq(t, http.MethodGet, flowURL(ts, "web", "web-999"), nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
got := decodeFlowGraphResp(t, body)
if got.Graph != nil {
t.Fatalf("不存在的轮次 graph = %+v, want null", got.Graph)
}
if got.Source != "none" || got.TurnID != "" {
t.Fatalf("source/turnId = %q/%q, want none/\"\"", got.Source, got.TurnID)
}
// `graph` 键必须恒在(客户端靠它区分「没有图」与「字段缺失」)。
var envelope map[string]json.RawMessage
if err := json.Unmarshal(decodeBody(t, body)["data"], &envelope); err != nil {
t.Fatalf("decode envelope: %v", err)
}
if _, present := envelope["graph"]; !present {
t.Fatalf("`graph` 键必须恒在(空时是 null): %s", body)
}
}
// TestAdminFlowGraph_EmptyTurnsSerialiseAsArray_边界 钉住线上空形状:没有落盘轮次时
// `turns` 是 `[]`(客户端不必在 null 与空数组之间分叉)。
func TestAdminFlowGraph_EmptyTurnsSerialiseAsArray_边界(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "cyrene.db")
_, store := openFlowGraphStore(t, path)
pub := flowPublisherWithStore(store, func(string) bool { return true })
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Flow: pub, FlowGraphs: store})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, body := doReq(t, http.MethodGet, flowURL(ts, "web", ""), nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
if !strings.Contains(string(body), `"turns":[]`) {
t.Fatalf("空 turns 必须序列化为 [] 而非 null: %s", body)
}
}
+20
View File
@@ -14,6 +14,7 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader" "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader"
personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry" personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/events" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/events"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/flowgraph"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/knowledge" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/knowledge"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
@@ -305,3 +306,22 @@ type FlowGraphSource interface {
// (从未跑过带 span 的轮次,或本就不在发布会话范围内)时返回 nil。 // (从未跑过带 span 的轮次,或本就不在发布会话范围内)时返回 nil。
LastGraph(sessionID string) *trace.FlowGraph LastGraph(sessionID string) *trace.FlowGraph
} }
// FlowGraphStore 是**已落盘轮次图**的只读出口(契约 v0.73 的
// `GET /api/v1/admin/flow/graph` 的 `?turnId=` 与 `turns[]`;design/19 §4 的 P3
// 「多轮历史回放」)。生产实现是 `internal/runtime/flowgraph` 的 `*SQLStore`,
// 组合根把**同一个**句柄交给发布器(写入面:EndRound 每轮落一条)与本查询面。
//
// 它刻意只声明三个**读**方法:写入(RecordRound)与保留期清扫(Sweep)是发布器与
// 组合根的职责,管理查询面拿不到、也不该拿到它们(最小权限)。
//
// 为 nil 时端点回退到**只有内存**的行为(v0.71 口径):`turns[]` 为空、`?turnId=`
// 只能命中内存里那一轮。内存与落盘**都没接线**时才回 500 `flow_unavailable`。
type FlowGraphStore interface {
// Graph 按 (sessionID, turnID) 取回一轮的图;该轮不存在时返回 (nil, nil)。
Graph(ctx context.Context, sessionID, turnID string) (*trace.FlowGraph, error)
// Latest 取该会话最近一轮已落盘的图;没有已落盘的轮次时返回 (nil, nil)。
Latest(ctx context.Context, sessionID string) (*trace.FlowGraph, error)
// Turns 返回该会话已落盘轮次的摘要(最新在前),供面板选历史轮。
Turns(ctx context.Context, sessionID string, limit int) ([]flowgraph.Turn, error)
}
+5
View File
@@ -160,4 +160,9 @@ type Deps struct {
// 可选;为 nil 时端点诚实作答 500 flow_unavailable,绝不回 // 可选;为 nil 时端点诚实作答 500 flow_unavailable,绝不回
// graph:null(那会把「图源没接线」伪装成「这一轮确实没有图」)。 // graph:null(那会把「图源没接线」伪装成「这一轮确实没有图」)。
Flow FlowGraphSource Flow FlowGraphSource
// FlowGraphs 是**已落盘轮次图**的只读出口(契约 v0.73;design/19 §4 的 P3
// 「多轮历史回放」)。组合根注入的是发布器**每轮收尾落一条**用的同一个
// `*flowgraph.SQLStore`。可选;为 nil 时端点回退到只有内存的行为
// (turns 为空、turnId 只能命中内存里那一轮),内存与落盘都没接线才 500。
FlowGraphs FlowGraphStore
} }
+4
View File
@@ -103,6 +103,9 @@ type Server struct {
// flow 是「最近一轮链路图」的只读出口(见 Deps.Flow / FlowGraphSource)。 // flow 是「最近一轮链路图」的只读出口(见 Deps.Flow / FlowGraphSource)。
// 为 nil 时 GET /api/v1/admin/flow/graph 回 500 flow_unavailable。 // 为 nil 时 GET /api/v1/admin/flow/graph 回 500 flow_unavailable。
flow FlowGraphSource flow FlowGraphSource
// flowGraphs 是**已落盘轮次图**的只读出口(契约 v0.73;见 Deps.FlowGraphs)。
// 为 nil 时该端点回退到只有内存的行为(turns 为空、turnId 只能命中内存那一轮)。
flowGraphs FlowGraphStore
// envResolver 解析会话每轮的 loop 环境(其角色)。见 // envResolver 解析会话每轮的 loop 环境(其角色)。见
// persona_env.go / design/22 §4.5。 // persona_env.go / design/22 §4.5。
envResolver turnEnvResolver envResolver turnEnvResolver
@@ -156,6 +159,7 @@ func New(cfg *config.Config, deps Deps) (*Server, error) {
log: sourcedLog(deps.Log), log: sourcedLog(deps.Log),
maxParallelTurns: deps.MaxParallelTurns, maxParallelTurns: deps.MaxParallelTurns,
flow: deps.Flow, flow: deps.Flow,
flowGraphs: deps.FlowGraphs,
} }
// 一个角色解析器由 Server 各处理器与 hub 共用,使每个面向 owner // 一个角色解析器由 Server 各处理器与 hub 共用,使每个面向 owner
// 的入口(REST、WS、语音)以同样方式解析会话角色(design/22 §4.5), // 的入口(REST、WS、语音)以同样方式解析会话角色(design/22 §4.5),