feat(flow): 链路图本地落盘 + 多轮历史回放(design/19 §P3,契约 v0.73)
主人要求「链路流程应该有本地数据,不然每次重载之后页面就没数据了」: - 落盘:迁移 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:
1 parent
84c124f5a9
commit
a6cb44b8a8
38 files changed
+3047
-91
No files matched your search
@@ -70,6 +70,18 @@ CHANNEL_EVENT_RETENTION=720h
|
||||
CHANNEL_EVENT_GC_INTERVAL=1h
|
||||
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 落」) ----
|
||||
# 每次成功模型调用都会把一条**非敏感**用量样本(token 计数 / 缓存两档 / 模型名 /
|
||||
# 用途 / 耗时)异步落进 llm_usage 表,供 GET /api/v1/admin/llm/usage 与面板的
|
||||
|
||||
+37
-17
@@ -1,6 +1,6 @@
|
||||
# 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 桥接等**跨平台客户端开发者**。
|
||||
> **唯一真源 = 当前代码**(`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,后端全量字段级文档)。归档的原因是**结构性臃肿 + 历史条目并列 + 长期漂移**;本文档**不复制**那段历史,需要考古时去归档。
|
||||
@@ -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.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. 基础约定
|
||||
@@ -167,7 +169,7 @@
|
||||
| B10 | GET | `/api/v1/admin/sessions/{id}/export` | 完整会话导出 |
|
||||
| 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 |
|
||||
| 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 响应形状不变。
|
||||
|
||||
@@ -731,9 +733,9 @@
|
||||
- **下限推导(只对没有落库档位的行)**:该列为空的行 —— 该列出现之前的**旧行**,以及从未走到档位判定点的行(系统事件降级、思考失败)—— 才回落到 v0.68 的安静时段窗口推导,并**必须**带 `urgencyLowerBound:true`。⚠️ 旧行**不得**被显示成精确档位,未知也不得被显示成某个档位。
|
||||
- 每轮 `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) 请求与响应
|
||||
|
||||
@@ -741,23 +743,25 @@
|
||||
|---|---|
|
||||
| 方法 / 路径 | `GET /api/v1/admin/flow/graph` |
|
||||
| 鉴权档 | `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>`(次级会话) |
|
||||
| 响应体 | `{data:{sessionId:"<解析后的会话 id>", graph: <FlowGraph 对象>|null}}` |
|
||||
| 查询参数 | `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, 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 的脱敏口径) |
|
||||
| 归档 | **不审计**(只读查询,与同档的 `/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) 语义硬约束(客户端必须照这个读)
|
||||
|
||||
1. **它是「最近一轮」而不是「你要的那一轮」。** 后端只保留每个会话**最新**的一份图(`loop.FlowPublisher` 每会话一张,`StartRound` 重置)。因此后台思考行上点开的图**可能不是该行那一轮**——**面板文案必须说明这一点**(当前实现:`frontend/web/src/admin/modules/flow/FlowSnapshotHint.tsx`),不得让用户以为「每行都有自己那轮的图」。**多轮历史回放**仍是未实现项(`design/19 §4` 的 P3,见 §7.4-18)。
|
||||
2. **只读、不发布。** 拉快照**不产生** `graph_update` 帧、不改变发布白名单、不改变任何会话的实时图。
|
||||
3. **`graph:null` 是诚实的空。** 会话从未跑过有 span 的轮次、或图源里没有该会话(如发布白名单之外的隔离渠道会话 `sess-xxx` 从未建图)时返回 `null` ⇒ 客户端**必须**渲染空态,**绝不允许**补画任何节点/假图。
|
||||
4. **快照会晚于实时帧到达,反之亦然。** 客户端已有实时图时**不应**被快照覆盖(实时图更新;快照只是补冷启动的洞)——当前实现只在**本会话还没有任何图**时才拉。
|
||||
5. **不替代** `graph_update`。实时帧仍是「正在跑的那一轮」的唯一来源;本端点只补「已经跑完、帧已经错过」的那一轮。
|
||||
1. **省略 `turnId` = 「最近一轮」而不是「你要的那一轮」。** 省略或空串时后端**先**取内存里该会话最新的一张图(`loop.FlowPublisher.LastGraph`,即正在跑/刚跑完的那一轮),**取不到才**回退到落盘的最新一行 —— 因此「最新一轮永远是最新的」。**不给 `turnId` 就不要假定它等于你眼前那条记录那一轮**:后台思考行上点开的图可能不是该行那轮 ⇒ 面板**必须**说明(当前实现:`frontend/web/src/admin/modules/flow/FlowSnapshotHint.tsx`)。要精确取某轮就带上 `turns[].turnId`。
|
||||
2. **给了 `turnId` = 精确取那一轮。** 该 `turnId` 正好等于内存里最新一轮 ⇒ 回内存图(`source:"live"`,更实时);否则读落盘行(`source:"persisted"`)。**该轮不存在 ⇒ `graph:null` + `source:"none"`**(诚实空态),**绝不**用别的轮次顶替。
|
||||
3. **只读、不发布。** 拉快照**不产生** `graph_update` 帧、不改变发布白名单、不改变任何会话的实时图、也不落盘。
|
||||
4. **`graph:null` 是诚实的空。** 该会话从未跑过有 span 的轮次、或图源里没有该会话(如发布白名单之外的隔离渠道会话 `sess-xxx` 从未建图、也从不落盘)时返回 `null` ⇒ 客户端**必须**渲染空态,**绝不允许**补画任何节点/假图。
|
||||
5. **落盘只覆盖发布会话白名单内的会话,且每轮一行**(`EndRound` 时写入,**不是每 span 一行**):图里可能含用户输入文本 ⇒ 客户端按既有 §1.1 脱敏口径读即可,但**不得**把图当成「有全部原文」的凭据。落盘件按保留期清扫(`FLOW_GRAPH_RETENTION`,默认 168h),**过期轮次会被回收** ⇒ `turns[]` 只反映「仍在保留期内」的轮次。
|
||||
6. **快照会晚于实时帧到达,反之亦然。** 客户端已有实时图时**不应**被快照覆盖(实时图更新;快照只是补冷启动的洞)——当前实现只在**本会话还没有任何图**时才拉。
|
||||
7. **不替代** `graph_update`。实时帧仍是「正在跑的那一轮」的唯一来源;本端点补的是「已经跑完、帧已经错过」的那些轮次(最近一轮 + 可选的任意历史轮)。
|
||||
|
||||
#### (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_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)) |
|
||||
| `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)。
|
||||
> 每版一句话 + 三档标注。**三档定义见 [§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` 的镜像口径)。
|
||||
|
||||
@@ -1055,9 +1074,10 @@
|
||||
- **v0.70 更正**:每轮 `Import` **已落库** ⇒ `urgency` **默认就是精确档位**(含窗口之外/白天的轮次);只有与 `urgencyLowerBound:true` 同时出现时才是**下限**(旧行 / 从未走到档位判定点的行,值由安静时段窗口规则推导)。实时 `backthink` 帧也带 `urgency`(精确值),但**不带** `urgencyLowerBound` / `quietHoursBypassed`(见 §4.10(b) 更正)。
|
||||
- **客户端两条硬约束(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**。
|
||||
19. **(v0.71 登记)图源是**进程内内存**,进程重启即清空**:`loop.FlowPublisher` 的每会话最近一图**不落库**(design/19 §1 的实时口径)⇒ 后端重启后、该会话还没跑新轮次时,端点如实回 `graph:null`。这是**有意**的诚实空态,不是丢数据(图从来不是持久资产)。同时**登记既有裁决不改**:`graph_update` 的两路互顶(同会话并行两路时后发的那张图会顶掉先发的)仍**未修**,本版**不碰**它。
|
||||
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 登记 · 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/**` 与本文件。*
|
||||
@@ -102,6 +102,9 @@ func run() error {
|
||||
// 渠道事件留存维护(切片 E3+E4):保留期清扫一次 + 此后定时清扫,内存计数
|
||||
// 按 CHANNEL_EVENT_COUNTER_FLUSH 定时落盘(退出前最后一次落盘在循环里)。
|
||||
a.startChannelEvents(ctx)
|
||||
// 链路图留存维护(切片 F2):保留期清扫一次 + 此后按 FLOW_GRAPH_GC_INTERVAL 定时清扫。
|
||||
// 没有它,落盘的轮次图会**无限增长**(design/19 §4 P3 的 GC 口径)。
|
||||
a.startFlowGraphs(ctx)
|
||||
// LLM 用量落库与保留期清理(切片 U1):单写者 + 有界队列的异步写入器,
|
||||
// 加上按 LLM_USAGE_RETENTION 的过期样本清理。
|
||||
a.startUsage(ctx)
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"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/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/loop"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
|
||||
@@ -130,6 +131,14 @@ type app struct {
|
||||
// 发布器同时交给 loop/orchestration(写入面)与网关(查询面),因此查询到的
|
||||
// 就是实时发布的那张图,不会出现两份状态。
|
||||
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 运行时:打开数据库、迁移表结构,接线
|
||||
|
||||
@@ -81,12 +81,13 @@ func (a *app) newChannelEventStore(envFilePath string) *events.SQLStore {
|
||||
}
|
||||
|
||||
// durationEnv 读一个 duration 型 env 键(进程环境 > `.env` > def)。配了但读不懂时记
|
||||
// 一条带原始取值的 WARN 并回落 def —— 错的是那一个键,不该让整条留存面失效
|
||||
// (AGENTS §10:非法配置不许静默失效,但也不许一票否决)。
|
||||
// 一条带键名与原始取值的 WARN 并回落 def —— 错的是那一个键,不该让整条留存面失效
|
||||
// (AGENTS §10:非法配置不许静默失效,但也不许一票否决)。消息刻意保持中立(不带
|
||||
// 具体功能名),使渠道事件与链路图两处共用同一份读取口径(切片 E4 / F2)。
|
||||
func durationEnv(log *slog.Logger, envFilePath, key string, def time.Duration) time.Duration {
|
||||
value, err := config.LoadDurationEnv(envFilePath, key, def)
|
||||
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())
|
||||
}
|
||||
return value
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -89,6 +89,10 @@ func (a *app) wireFrontend() error {
|
||||
// loop/orchestration 用的**同一个** *loop.FlowPublisher,因此
|
||||
// GET /api/v1/admin/flow/graph 读到的就是实时发布的那张图。
|
||||
Flow: a.flow,
|
||||
// FlowGraphs 是**已落盘轮次图**的只读出口(契约 v0.73;切片 F2):注入的正是
|
||||
// 发布器每轮落盘用的**同一个**存储句柄,因此 `?turnId=` 与 `turns[]` 读到的就是
|
||||
// 收尾时写进去的历史轮。未接线时为真 nil(端点回退到只有内存的行为)。
|
||||
FlowGraphs: a.flowGraphStore(),
|
||||
// Usage 是 `llm_usage` 表的只读汇总出口(切片 U1):网关复用组合根已打开的
|
||||
// 连接池,自己不开池、不读环境变量。库未接时为 nil(端点回 500
|
||||
// usage_unavailable,不回假汇总)。
|
||||
|
||||
@@ -125,6 +125,10 @@ func (a *app) wireMemory(ctx context.Context) error {
|
||||
// 追踪、持久化与编排 scheduler。
|
||||
func (a *app) wireRuntime() error {
|
||||
a.attachToolPolicyStore()
|
||||
// 链路图本地落盘(design/19 §4 P3;切片 F2):先建存储,随后在下面交给发布器作
|
||||
// 每轮收尾的落盘出口。它的常驻清扫由 main 的 startFlowGraphs 拉起(见
|
||||
// wire_flow_graphs.go);网关的只读出口在 wireFrontend 注入**同一个**句柄。
|
||||
a.wireFlowGraphs()
|
||||
|
||||
// LLM 提供商栈可热插拔:它加载 models.json(或回落 .env),面板的保存端点会
|
||||
// 就地重载它而无需重启(design/04 §3.1)。a.modelAdapter 仍保持 Adapter 接口
|
||||
@@ -219,6 +223,11 @@ func (a *app) wireRuntime() error {
|
||||
flowPub := loop.NewFlowPublisher(
|
||||
func(subject string, payload any) { _ = a.bus.Publish(context.Background(), subject, payload) },
|
||||
flowSessionAllowed,
|
||||
// 每轮收尾落一条(**不是每 span 一条**):白名单由上面的 allow 把守
|
||||
// (EndRound 对白名单外的会话直接返回),因此落盘出口不重复判白名单。
|
||||
// 未接线时交出真 nil 接口 ⇒ 发布器走纯内存路径(F1 之前的行为)。
|
||||
loop.WithRoundRecorder(a.flowRoundRecorder()),
|
||||
loop.WithFlowLogger(a.log),
|
||||
)
|
||||
// 同一个发布器也交给网关:`GET /api/v1/admin/flow/graph` 的只读出口
|
||||
// (契约 v0.71)就近读它每会话保留的最近一轮图 —— 写入面(这里)与查询面
|
||||
|
||||
@@ -9,9 +9,13 @@ import { NodeDetail } from "./flow/NodeDetail";
|
||||
import { LaneSummary } from "./flow/LaneSummary";
|
||||
import { FlowSnapshotHint } from "./flow/FlowSnapshotHint";
|
||||
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 { FLOW_TONE, STATUS_ORDER, statusOf } from "./flow/tone";
|
||||
import { MAIN_SESSION_ID } from "../../store/sessionCache";
|
||||
import type { AdminFlowTurn } from "../../lib/api";
|
||||
|
||||
/** 全链路流程页(design/19 §2):实时渲染 ws.graph_update 驱动的单轮图 ——
|
||||
* 主干「输入 → 思考 → 工具 → 输出」+ 并行子代理泳道(子图容器),
|
||||
@@ -27,55 +31,99 @@ import { MAIN_SESSION_ID } from "../../store/sessionCache";
|
||||
export function FlowModule(): React.ReactNode {
|
||||
// 激活会话(没有会话时即主会话,与 store 的 currentGraph 口径一致)。
|
||||
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);
|
||||
// 历史轮(契约 v0.73 多轮历史回放):已落盘轮次列表 + 选中轮的只读图。它**不写 store**,
|
||||
// 因此翻历史绝不顶掉当前会话的实时图(裁决 16.4.2)。
|
||||
const history = useFlowTurns(sessionId);
|
||||
return (
|
||||
<FlowBody
|
||||
graph={graph}
|
||||
sessionId={activeSessionId || MAIN_SESSION_ID}
|
||||
sessionId={sessionId}
|
||||
source={graphSource}
|
||||
turns={history.turns}
|
||||
selectedTurnId={history.selectedTurnId}
|
||||
onSelectTurn={history.selectTurn}
|
||||
historyGraph={history.historyGraph}
|
||||
historyLoading={history.historyLoading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** 有图就画整页、没图就诚实空态。props 驱动(不读 store),因此可被
|
||||
* 真实渲染测试直接喂两种输入。 */
|
||||
* 真实渲染测试直接喂两种输入。
|
||||
*
|
||||
* **来源与轮次**(契约 v0.73):省略 `selectedTurnId`(= `LATEST_TURN`)时画
|
||||
* 实时/快照那张图(F1);选了某一轮时画 `historyGraph`(从本地库读回的落盘轮)。
|
||||
* 历史轮 UI 只在给了 `onSelectTurn` 时渲染 —— 渲染测试喂不到它,因此既有断言不受影响。 */
|
||||
export function FlowBody({
|
||||
graph,
|
||||
sessionId,
|
||||
source,
|
||||
turns = [],
|
||||
selectedTurnId = LATEST_TURN,
|
||||
onSelectTurn,
|
||||
historyGraph = null,
|
||||
historyLoading = false,
|
||||
}: {
|
||||
graph: FlowGraph | null;
|
||||
sessionId: string;
|
||||
source: FlowGraphSource;
|
||||
/** 该会话已落盘的轮次(最新在前)。 */
|
||||
turns?: readonly AdminFlowTurn[];
|
||||
/** 当前选择(`LATEST_TURN` = 最新一轮)。 */
|
||||
selectedTurnId?: string;
|
||||
/** 选中某一轮的回调;不给则不渲染历史轮选择器(渲染测试因此不必造它)。 */
|
||||
onSelectTurn?: (turnId: string) => void;
|
||||
/** 选中历史轮时拉回的图(只读)。 */
|
||||
historyGraph?: FlowGraph | null;
|
||||
/** 正在拉取选中的历史轮。 */
|
||||
historyLoading?: boolean;
|
||||
}): React.ReactNode {
|
||||
// 屏幕上该画哪张图(纯函数,见 flowHistory.ts):选历史轮画落盘那张,否则画实时/快照那张。
|
||||
const shown = resolveShownGraph(selectedTurnId, graph, historyGraph);
|
||||
const showingHistory = selectedTurnId !== LATEST_TURN;
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const selected = graph?.nodes.find((n) => n.id === selectedId) ?? null;
|
||||
const grouping = graph ? groupFlowLanes(graph) : null;
|
||||
const selected = shown?.nodes.find((n) => n.id === selectedId) ?? null;
|
||||
const grouping = shown ? groupFlowLanes(shown) : null;
|
||||
const selectedLane = selected ? laneLabelFor(selectedId, grouping) : undefined;
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<PageHeader
|
||||
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>
|
||||
<StatCard
|
||||
icon={<Icon name="chat" size={18} />}
|
||||
label="会话"
|
||||
value={<span className="text-base">{graph.sessionId ?? "web"}</span>}
|
||||
sub={graph.id}
|
||||
value={<span className="text-base">{shown.sessionId ?? "web"}</span>}
|
||||
sub={shown.id}
|
||||
tone="accent"
|
||||
/>
|
||||
<StatCard
|
||||
icon={<Icon name="agent" size={18} />}
|
||||
label="节点"
|
||||
value={graph.nodes.length}
|
||||
sub={`${graph.edges.length} 条连线`}
|
||||
value={shown.nodes.length}
|
||||
sub={`${shown.edges.length} 条连线`}
|
||||
tone="neutral"
|
||||
/>
|
||||
<StatCard
|
||||
@@ -88,24 +136,26 @@ export function FlowBody({
|
||||
<StatCard
|
||||
icon={<Icon name="clock" size={18} />}
|
||||
label="耗时"
|
||||
value={fmtDuration(graph)}
|
||||
sub={`开始 ${fmtStart(graph)}`}
|
||||
value={fmtDuration(shown)}
|
||||
sub={`开始 ${fmtStart(shown)}`}
|
||||
tone="ok"
|
||||
/>
|
||||
</CardGrid>
|
||||
|
||||
<Surface className="space-y-3">
|
||||
<StatusStrip graph={graph} />
|
||||
<StatusStrip graph={shown} />
|
||||
<FlowGraphView
|
||||
graph={graph}
|
||||
graph={shown}
|
||||
selectedId={selectedId}
|
||||
onNodeClick={(n) => setSelectedId(n.id)}
|
||||
/>
|
||||
<LaneSummary lanes={grouping?.lanes ?? []} onSelect={setSelectedId} />
|
||||
<FlowSnapshotHint
|
||||
sessionId={graph.sessionId ?? sessionId}
|
||||
source={source ?? "live"}
|
||||
/>
|
||||
{showingHistory ? null : (
|
||||
<FlowSnapshotHint
|
||||
sessionId={shown.sessionId ?? sessionId}
|
||||
source={source ?? "live"}
|
||||
/>
|
||||
)}
|
||||
</Surface>
|
||||
|
||||
{selected ? (
|
||||
@@ -118,7 +168,11 @@ export function FlowBody({
|
||||
</>
|
||||
) : (
|
||||
<Notice>
|
||||
暂无流程 —— 接入 /ws/chat 并发起一次会话后,将实时出现「输入 → 思考 → 工具 → 输出」连通图;并行子代理会各自成一条泳道。
|
||||
{showingHistory
|
||||
? historyLoading
|
||||
? `正在读取历史轮 ${selectedTurnId} 的链路图……`
|
||||
: `历史轮 ${selectedTurnId} 没有图 —— 可能已过保留期,或该会话本就不在发布会话白名单内。`
|
||||
: "暂无流程 —— 接入 /ws/chat 并发起一次会话后,将实时出现「输入 → 思考 → 工具 → 输出」连通图;并行子代理会各自成一条泳道。"}
|
||||
</Notice>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -34,7 +34,8 @@ export function FlowSnapshotHint({
|
||||
<code>{sessionId}</code>)的
|
||||
<span className="text-[var(--mu-text-mid)]">最近一轮</span>
|
||||
链路图快照 —— 打开本页时只读拉取(<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 { Icon } from "../../../components/Icon";
|
||||
import { Notice } from "../../components";
|
||||
import { useTopAnchoredScroll } from "../../logScroll";
|
||||
import { FLOW_TONE, statusOf } from "./tone";
|
||||
import { headLogKey, newestFirstLog } from "./nodeLogOrder";
|
||||
|
||||
/** 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
|
||||
@@ -109,9 +111,10 @@ function toRecord(value: unknown): Record<string, unknown> {
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** LogList renders the node's accumulated span events, newest last, and is
|
||||
* explicit about why an empty list is empty instead of pretending to have
|
||||
* content. */
|
||||
/** LogList 渲染该节点累积的 span 事件,**最新在上**(L1「日志倒序统一」;切片 F2 顺带
|
||||
* 收口)。span 事件是 append-only 的,显示倒序后新事件落在顶部;配套 `useTopAnchoredScroll`
|
||||
* 维持阅读位置(贴顶就跟最新,已下读则按新增高度补偿,新条目不会把用户顶跑)。
|
||||
* 空态如实说明为什么空,而不是假装有内容。 */
|
||||
function LogList({
|
||||
node,
|
||||
log,
|
||||
@@ -120,6 +123,9 @@ function LogList({
|
||||
log: NonNullable<FlowNode["log"]>;
|
||||
}): React.ReactNode {
|
||||
const running = statusOf(node) === "running";
|
||||
// 顺序只在这里定一次(口径见 nodeLogOrder.ts);滚动锚定按「首条键 + 条数」触发。
|
||||
const ordered = newestFirstLog(log);
|
||||
const scrollRef = useTopAnchoredScroll(headLogKey(ordered), ordered.length);
|
||||
if (log.length === 0) {
|
||||
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)]">
|
||||
@@ -133,10 +139,11 @@ function LogList({
|
||||
<h4 className="flex items-center gap-2 text-xs font-medium text-[var(--mu-text-lo)]">
|
||||
实时日志(span 事件累积)
|
||||
<span className="font-mono">{log.length}</span>
|
||||
<span>最新在上</span>
|
||||
{running ? <span className="mu-blink text-[var(--mu-accent)]">● 运行中</span> : null}
|
||||
</h4>
|
||||
<div className="max-h-64 space-y-1.5 overflow-auto">
|
||||
{log.map((ev, i) => (
|
||||
<div ref={scrollRef} className="max-h-64 space-y-1.5 overflow-auto">
|
||||
{ordered.map((ev, i) => (
|
||||
<div
|
||||
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"
|
||||
|
||||
@@ -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)"
|
||||
);
|
||||
ok(
|
||||
has(snapshotHint, "多轮历史回放尚未实现"),
|
||||
"快照来源必须诚实标注「多轮历史回放尚未实现」,不得让用户以为每行都有自己的图"
|
||||
has(snapshotHint, "历史轮次"),
|
||||
"快照来源必须指向「历史轮次」选择器(v0.73 起多轮历史回放已实现),不得让用户以为这一张就是他要的那一轮"
|
||||
);
|
||||
|
||||
const liveHint = renderToStaticMarkup(
|
||||
@@ -147,8 +147,8 @@ const liveHint = renderToStaticMarkup(
|
||||
);
|
||||
ok(has(liveHint, "实时"), "实时来源必须标注为实时收到");
|
||||
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 };
|
||||
}
|
||||
@@ -129,4 +129,4 @@ export {
|
||||
adminSystemRotateJWT,
|
||||
adminSystemStorage,
|
||||
} from "./api/adminSystem";
|
||||
export type { AdminFlowGraphResponse } from "./api/adminSystem";
|
||||
export type { AdminFlowGraphResponse, AdminFlowTurn } from "./api/adminSystem";
|
||||
@@ -47,28 +47,43 @@ export function adminBackthink(limit?: number): Promise<BackthinkItem[]> {
|
||||
return request<BackthinkItem[]>(`/admin/backthink${limitQuery(limit)}`);
|
||||
}
|
||||
|
||||
/** 最近一轮链路图快照的后端响应(契约 v0.71 §4.11(a))。`graph` **恒在**
|
||||
* (`null` 而不是缺席):`null` = 该会话**确实**还没有图,客户端必须据此
|
||||
* 渲染空态,绝不允许补画任何节点或假图。 */
|
||||
export interface AdminFlowGraphResponse {
|
||||
sessionId: string;
|
||||
graph: FlowGraph | null;
|
||||
/** 一条**已落盘轮次**的摘要(契约 v0.73 §4.11 的 `turns[]`)。面板用它渲染历史轮
|
||||
* 选择器:把 `turnId` 回带给 `adminFlowGraph` 即可精确取回那一轮。 */
|
||||
export interface AdminFlowTurn {
|
||||
turnId: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** GET /api/v1/admin/flow/graph?sessionId= — **最近一轮**链路图快照(契约
|
||||
* v0.71 §4.11,只读、不审计,owner/admin 档)。
|
||||
/** 链路图端点的后端响应(契约 v0.71 起;**v0.73 新增 `turnId`/`source`/`turns`**)。
|
||||
*
|
||||
* 为什么需要它:`graph_update` 是**纯实时**帧、从不重放,且前端图桶
|
||||
* (`flowGraphs`)**不持久化** ⇒ 「轮次结束后才打开页面」或「刷新过页面」
|
||||
* 此前只能看到空态。打开页面时拉一次即可补上最近一轮。
|
||||
* `graph` **恒在**(`null` 而不是缺席):`null` = 该轮**确实**没有图,客户端必须据此
|
||||
* 渲染空态,绝不允许补画任何节点或假图。
|
||||
*
|
||||
* ⚠️ 它只回**每会话最近一轮**:后台思考行上点开的图可能不是该行那一轮
|
||||
* (多轮历史回放仍未实现,`design/19 §4` P3)⇒ 面板必须在图上说明。
|
||||
* `source` 告诉客户端这张图是哪来的:`live` = 内存里最近一轮(实时图);`persisted` =
|
||||
* 从本地库读回的历史轮;`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`。 */
|
||||
export function adminFlowGraph(sessionId: string): Promise<AdminFlowGraphResponse> {
|
||||
* 为什么需要它:`graph_update` 是**纯实时**帧、从不重放。v0.71 补了「打开页面拉最近一轮
|
||||
* 快照」;v0.73 把图**每轮落盘**,于是可以:
|
||||
* - **省略 `turnId`** ⇒ 最近一轮(后端**先查内存、miss 才落盘** ⇒ 最新一轮永远最新);
|
||||
* - **给了 `turnId`** ⇒ 精确取该会话已落盘的某一轮(历史回放)。
|
||||
*
|
||||
* `turns`(同一响应里)就是给选择器用的历史轮列表。⚠️ 白名单之外的会话(群聊/陌生人)
|
||||
* 从不建图、也从不落盘 ⇒ 它们**永远**是空态,这不是缺陷。 */
|
||||
export function adminFlowGraph(sessionId: string, turnId?: string): Promise<AdminFlowGraphResponse> {
|
||||
const qs = new URLSearchParams();
|
||||
if (sessionId) qs.set("sessionId", sessionId);
|
||||
if (turnId) qs.set("turnId", turnId);
|
||||
const q = qs.toString();
|
||||
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,7 +10,7 @@ import type { AppStore } from "./useAppStore";
|
||||
*/
|
||||
export const persistOptions = {
|
||||
name: "cyrene.app",
|
||||
version: 3,
|
||||
version: 4,
|
||||
// v1 persisted the whole chat (`messages` + a single `sessionId`); v2 added
|
||||
// one resolved `currentPersona`, which can no longer describe the active role
|
||||
// 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
|
||||
// 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.
|
||||
// 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) => {
|
||||
const p = (persisted ?? {}) as Partial<AppStore>;
|
||||
const fromV1 = version < 2;
|
||||
@@ -28,6 +30,7 @@ export const persistOptions = {
|
||||
moods: {},
|
||||
context: p.context ?? {},
|
||||
backthink: p.backthink ?? [],
|
||||
flowGraphs: p.flowGraphs ?? {},
|
||||
};
|
||||
},
|
||||
partialize: (s: AppStore) => ({
|
||||
@@ -43,5 +46,11 @@ export const persistOptions = {
|
||||
moods: s.moods,
|
||||
context: s.context,
|
||||
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,
|
||||
}),
|
||||
};
|
||||
@@ -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);
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
package loop
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"sync"
|
||||
"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/trace"
|
||||
)
|
||||
@@ -13,11 +15,41 @@ import (
|
||||
// 作为 WS 事件类型 graph_update 转发(design/19 §1)。
|
||||
const graphUpdateSubject = "ws.graph_update"
|
||||
|
||||
// flowLogSource 是发布器日志的 source 标签(落盘失败记 WARN;AGENTS §10 禁裸日志)。
|
||||
const flowLogSource = "runtime/loop/flow"
|
||||
|
||||
// allowedSessions 是用于控制哪些会话可发布
|
||||
// flow 图的谓词签名(主人的 web 会话),使 IM/陌生人/子代理轮次绝不
|
||||
// 泄露进管理面板。谓词为 nil 时允许所有会话。
|
||||
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 图,
|
||||
// 并在每个 span 开始/结束时发布 ws.graph_update,给前端
|
||||
// 一张实时的 input→think→tool→output 图(design/19 §1)。它容忍 nil
|
||||
@@ -34,19 +66,32 @@ type FlowPublisher struct {
|
||||
allow allowedSessions
|
||||
graphs map[string]*trace.RunGraph
|
||||
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 时,会在
|
||||
// 每次 span 状态迁移时以 graphUpdateSubject 与 *trace.FlowGraph
|
||||
// 快照调用。allow 非 nil 时,控制哪些 session id 可发布(返回
|
||||
// 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,
|
||||
allow: allow,
|
||||
graphs: make(map[string]*trace.RunGraph),
|
||||
rounds: make(map[string]int),
|
||||
}
|
||||
for _, opt := range opts {
|
||||
if opt != nil {
|
||||
opt(p)
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// StartRound 为 sessionID 开始一张新图,用用户文本
|
||||
@@ -72,18 +117,34 @@ func (p *FlowPublisher) StartRound(sessionID, inputText string) {
|
||||
// **重放的出口是 `LastGraph`**(网关只读查询,契约 v0.71),而不是又发一帧:
|
||||
// 实时帧向来只推给当时在线的连接,刷新/晚开的页面只能靠只读拉取补齐
|
||||
// (`graph_update` 本身不重放)。
|
||||
//
|
||||
// 收尾也是**落盘**的唯一时机(切片 F2;design/19 §4 的 P3「多轮历史回放」):每轮落
|
||||
// **一条**(绝不是每 span 一条),且只在该会话处于发布会话白名单内时发生(本方法对
|
||||
// 白名单外的会话直接返回 ⇒ 落盘出口天然只被白名单会话触发)。深拷贝与写库在**放锁
|
||||
// 之后**做:一次库写绝不把其他会话的 span 发布挡住;失败只记 WARN,绝不影响本轮收尾。
|
||||
func (p *FlowPublisher) EndRound(sessionID, outputText string) {
|
||||
if sessionID == "" || !p.allowed(sessionID) {
|
||||
return
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
g := p.graphs[sessionID]
|
||||
if g == nil {
|
||||
p.mu.Unlock()
|
||||
return
|
||||
}
|
||||
g.SeedOutput(outputText)
|
||||
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` 标记并发布一帧
|
||||
@@ -188,6 +249,16 @@ func (p *FlowPublisher) allowed(sessionID string) bool {
|
||||
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 中最新用户消息的文本,用于
|
||||
// 标注该轮的 input 节点。没有用户文本时返回 ""。
|
||||
func lastUserText(messages []model.Message) string {
|
||||
|
||||
@@ -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 仍须正常发布")
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
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/trace"
|
||||
)
|
||||
|
||||
// mainSessionID 是未指定 `?sessionId=` 时的默认会话 —— Web 主会话
|
||||
@@ -19,33 +22,62 @@ const mainSessionID = history.MainSessionID
|
||||
// usage_unavailable / channel_events_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 表示「该会话确实还没有图」——这是一个**诚实的空态**,不是错误:
|
||||
// 客户端必须据此渲染空态,绝不允许补画任何节点或假图(§4.11(b)-3)。
|
||||
// 前端据此渲染来源说明条(v0.73 起也可经它的 `source` 字段直接分辨,
|
||||
// 不再只靠对象引用)。
|
||||
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` 而不是缺席),使客户端不必区分「没有这个键」与
|
||||
// 「这个键是空」。
|
||||
//
|
||||
// Turns **恒为非 nil 切片**(没有轮次时是 `[]`),使客户端不必判空。
|
||||
type adminFlowGraphResponse struct {
|
||||
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 ⇒
|
||||
// 线上 JSON `null`)与「有图」的区分就是本字段的语义。
|
||||
Graph any `json:"graph"`
|
||||
// Turns 是该会话**已落盘轮次**的摘要,最新在前(契约 v0.73)。
|
||||
Turns []flowTurnDTO `json:"turns"`
|
||||
}
|
||||
|
||||
// adminFlowGraph 是 `GET /api/v1/admin/flow/graph` 的处理器(契约 v0.71,
|
||||
// design/19 §4 的 P3「多轮历史回放」的最小一步)。
|
||||
// adminFlowGraph 是 `GET /api/v1/admin/flow/graph` 的处理器(契约 v0.71 起,
|
||||
// v0.73 增量;design/19 §4 的 P3「多轮历史回放」)。
|
||||
//
|
||||
// 它回答的是主人第二次报的那个洞:`graph_update` **只走实时帧、从不重放**,
|
||||
// 而前端图桶(`flowGraphs`)**不持久化** ⇒ 「轮次结束后才打开页面」或
|
||||
// 「刷新过页面」永远空着。本端点让打开页面时能**只读拉一次**最近一轮的图。
|
||||
// 它回答的是主人报的两个洞:① `graph_update` **只走实时帧、从不重放**(v0.71 修);
|
||||
// ② 图只活在进程内存、刷新/重启/翻更早轮次就没了 ⇒ 现在每轮收尾**落盘**,本端点可按
|
||||
// `turnId` 取历史轮、并给出可选轮次列表(v0.73)。三条硬约束:
|
||||
//
|
||||
// 三条硬约束(与 §4.11(b) 逐条对应):
|
||||
// 1. **只读**:经 `FlowGraphSource` 拿快照,网关不发布、不改图、不动任何会话;
|
||||
// 2. **不画假图**:该会话没有图 ⇒ `graph:null`;图源未接线 ⇒ `500 flow_unavailable`
|
||||
// (**不是** graph:null,见 flowUnavailableCode);
|
||||
// 3. **只回最近一轮**:更早轮次的历史回放仍未实现(design/19 §4 P3)——
|
||||
// 因此面板必须在图上说明「这不是你要的那一行那轮的图」。
|
||||
// 1. **只读**:经 `FlowGraphSource` / `FlowGraphStore` 拿快照,网关不发布、不改图、
|
||||
// 不动任何会话、也不落盘;
|
||||
// 2. **不画假图**:没有该轮 ⇒ `graph:null`;图源未接线(内存 + 落盘都没接)⇒
|
||||
// `500 flow_unavailable`(**不是** graph:null,见 flowUnavailableCode);
|
||||
// 3. **最近一轮优先内存,miss 才落盘**:省略 `turnId` 时先查内存 `LastGraph`(正在跑/
|
||||
// 刚跑完的那一轮,最新),取不到才回退落盘最新 —— 「最新一轮永远是最新的」。
|
||||
//
|
||||
// 角色门:`writeAuth`(owner|admin),与 design/19 §3「flow 可视化仅 owner/admin」
|
||||
// 一致;它与同一张图的 WS 下行帧(`graph_update` 落 frameReachesAll 的 fail-closed
|
||||
@@ -56,15 +88,92 @@ func (s *Server) adminFlowGraph(w http.ResponseWriter, r *http.Request) {
|
||||
if sessionID == "" {
|
||||
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)
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, flowUnavailableCode, "flow graph source is not wired"))
|
||||
return
|
||||
}
|
||||
// Graph 恒在响应里(没有图时是 `null`),见 adminFlowGraphResponse 的注释。
|
||||
var graph any
|
||||
if snap := s.flow.LastGraph(sessionID); snap != nil {
|
||||
graph = snap
|
||||
ctx := r.Context()
|
||||
graph, source, err := s.resolveFlowGraph(ctx, sessionID, turnID)
|
||||
if err != nil {
|
||||
// 原始错误(可能带 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)
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader"
|
||||
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/flowgraph"
|
||||
"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/loop"
|
||||
@@ -305,3 +306,22 @@ type FlowGraphSource interface {
|
||||
// (从未跑过带 span 的轮次,或本就不在发布会话范围内)时返回 nil。
|
||||
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)
|
||||
}
|
||||
@@ -160,4 +160,9 @@ type Deps struct {
|
||||
// 可选;为 nil 时端点诚实作答 500 flow_unavailable,绝不回
|
||||
// graph:null(那会把「图源没接线」伪装成「这一轮确实没有图」)。
|
||||
Flow FlowGraphSource
|
||||
// FlowGraphs 是**已落盘轮次图**的只读出口(契约 v0.73;design/19 §4 的 P3
|
||||
// 「多轮历史回放」)。组合根注入的是发布器**每轮收尾落一条**用的同一个
|
||||
// `*flowgraph.SQLStore`。可选;为 nil 时端点回退到只有内存的行为
|
||||
// (turns 为空、turnId 只能命中内存里那一轮),内存与落盘都没接线才 500。
|
||||
FlowGraphs FlowGraphStore
|
||||
}
|
||||
@@ -103,6 +103,9 @@ type Server struct {
|
||||
// flow 是「最近一轮链路图」的只读出口(见 Deps.Flow / FlowGraphSource)。
|
||||
// 为 nil 时 GET /api/v1/admin/flow/graph 回 500 flow_unavailable。
|
||||
flow FlowGraphSource
|
||||
// flowGraphs 是**已落盘轮次图**的只读出口(契约 v0.73;见 Deps.FlowGraphs)。
|
||||
// 为 nil 时该端点回退到只有内存的行为(turns 为空、turnId 只能命中内存那一轮)。
|
||||
flowGraphs FlowGraphStore
|
||||
// envResolver 解析会话每轮的 loop 环境(其角色)。见
|
||||
// persona_env.go / design/22 §4.5。
|
||||
envResolver turnEnvResolver
|
||||
@@ -156,6 +159,7 @@ func New(cfg *config.Config, deps Deps) (*Server, error) {
|
||||
log: sourcedLog(deps.Log),
|
||||
maxParallelTurns: deps.MaxParallelTurns,
|
||||
flow: deps.Flow,
|
||||
flowGraphs: deps.FlowGraphs,
|
||||
}
|
||||
// 一个角色解析器由 Server 各处理器与 hub 共用,使每个面向 owner
|
||||
// 的入口(REST、WS、语音)以同样方式解析会话角色(design/22 §4.5),
|
||||
|
||||
Reference in new issue
Block a user