From a6cb44b8a8f2950ffe2fa0ad173f0490065461c2 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 5 Oct 2026 16:19:32 +0800 Subject: [PATCH] =?UTF-8?q?feat(flow):=20=E9=93=BE=E8=B7=AF=E5=9B=BE?= =?UTF-8?q?=E6=9C=AC=E5=9C=B0=E8=90=BD=E7=9B=98=20+=20=E5=A4=9A=E8=BD=AE?= =?UTF-8?q?=E5=8E=86=E5=8F=B2=E5=9B=9E=E6=94=BE=EF=BC=88design/19=20=C2=A7?= =?UTF-8?q?P3=EF=BC=8C=E5=A5=91=E7=BA=A6=20v0.73=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主人要求「链路流程应该有本地数据,不然每次重载之后页面就没数据了」: - 落盘:迁移 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) --- .env.example | 12 + Docs/api-client.md | 54 ++- cmd/cyrene/main.go | 3 + cmd/cyrene/wire.go | 9 + cmd/cyrene/wire_channel_events.go | 7 +- cmd/cyrene/wire_flow_graphs.go | 156 ++++++++ cmd/cyrene/wire_flow_graphs_test.go | 290 +++++++++++++++ cmd/cyrene/wire_frontend.go | 4 + cmd/cyrene/wire_runtime.go | 9 + frontend/web/src/admin/modules/Flow.tsx | 94 ++++- .../admin/modules/flow/FlowSnapshotHint.tsx | 3 +- .../src/admin/modules/flow/FlowTurnPicker.tsx | 66 ++++ .../web/src/admin/modules/flow/NodeDetail.tsx | 17 +- .../modules/flow/flowHistory.render.test.ts | 136 +++++++ .../admin/modules/flow/flowHistory.test.ts | 84 +++++ .../web/src/admin/modules/flow/flowHistory.ts | 67 ++++ .../modules/flow/flowSnapshot.render.test.ts | 8 +- .../admin/modules/flow/nodeLogOrder.test.ts | 51 +++ .../src/admin/modules/flow/nodeLogOrder.ts | 25 ++ .../src/admin/modules/flow/useFlowTurns.ts | 88 +++++ frontend/web/src/lib/api.ts | 2 +- frontend/web/src/lib/api/adminSystem.ts | 45 ++- frontend/web/src/store/persistOptions.test.ts | 72 ++++ frontend/web/src/store/persistOptions.ts | 11 +- internal/db/migrate_flow_graphs_test.go | 135 +++++++ .../db/migrations/0022_flow_graphs.down.sql | 5 + .../db/migrations/0022_flow_graphs.up.sql | 41 +++ internal/runtime/flowgraph/flowgraph.go | 324 ++++++++++++++++ internal/runtime/flowgraph/flowgraph_test.go | 317 ++++++++++++++++ internal/runtime/flowgraph/gc.go | 119 ++++++ internal/runtime/flowgraph/gc_test.go | 144 ++++++++ internal/runtime/loop/flow.go | 77 +++- internal/runtime/loop/flow_persist_test.go | 138 +++++++ internal/services/gateway/admin_flow.go | 151 ++++++-- .../gateway/admin_flow_graph_persist_test.go | 345 ++++++++++++++++++ internal/services/gateway/contracts.go | 20 + internal/services/gateway/deps.go | 5 + internal/services/gateway/server.go | 4 + 38 files changed, 3047 insertions(+), 91 deletions(-) create mode 100644 cmd/cyrene/wire_flow_graphs.go create mode 100644 cmd/cyrene/wire_flow_graphs_test.go create mode 100644 frontend/web/src/admin/modules/flow/FlowTurnPicker.tsx create mode 100644 frontend/web/src/admin/modules/flow/flowHistory.render.test.ts create mode 100644 frontend/web/src/admin/modules/flow/flowHistory.test.ts create mode 100644 frontend/web/src/admin/modules/flow/flowHistory.ts create mode 100644 frontend/web/src/admin/modules/flow/nodeLogOrder.test.ts create mode 100644 frontend/web/src/admin/modules/flow/nodeLogOrder.ts create mode 100644 frontend/web/src/admin/modules/flow/useFlowTurns.ts create mode 100644 frontend/web/src/store/persistOptions.test.ts create mode 100644 internal/db/migrate_flow_graphs_test.go create mode 100644 internal/db/migrations/0022_flow_graphs.down.sql create mode 100644 internal/db/migrations/0022_flow_graphs.up.sql create mode 100644 internal/runtime/flowgraph/flowgraph.go create mode 100644 internal/runtime/flowgraph/flowgraph_test.go create mode 100644 internal/runtime/flowgraph/gc.go create mode 100644 internal/runtime/flowgraph/gc_test.go create mode 100644 internal/runtime/loop/flow_persist_test.go create mode 100644 internal/services/gateway/admin_flow_graph_persist_test.go diff --git a/.env.example b/.env.example index 3dd623f..40ac39a 100644 --- a/.env.example +++ b/.env.example @@ -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: / 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 与面板的 diff --git a/Docs/api-client.md b/Docs/api-client.md index b34814e..4f8bc0e 100644 --- a/Docs/api-client.md +++ b/Docs/api-client.md @@ -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:`(次级会话) | -| 响应体 | `{data:{sessionId:"<解析后的会话 id>", graph: |null}}` | +| 查询参数 | `sessionId`(**可选**)。省略 / 空串 ⇒ 主会话 `web`(`internal/runtime/history.MainSessionID`)。其它取值就是该会话 id,如 `sess-backthink`(后台思考,design/20 §2)、`web:`(次级会话)。**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:` / `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:` / `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:` / `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/**` 与本文件。* diff --git a/cmd/cyrene/main.go b/cmd/cyrene/main.go index eef82c7..892ce9a 100644 --- a/cmd/cyrene/main.go +++ b/cmd/cyrene/main.go @@ -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) diff --git a/cmd/cyrene/wire.go b/cmd/cyrene/wire.go index 1287914..e5fcab8 100644 --- a/cmd/cyrene/wire.go +++ b/cmd/cyrene/wire.go @@ -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 运行时:打开数据库、迁移表结构,接线 diff --git a/cmd/cyrene/wire_channel_events.go b/cmd/cyrene/wire_channel_events.go index 208a21d..4d5f92d 100644 --- a/cmd/cyrene/wire_channel_events.go +++ b/cmd/cyrene/wire_channel_events.go @@ -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 diff --git a/cmd/cyrene/wire_flow_graphs.go b/cmd/cyrene/wire_flow_graphs.go new file mode 100644 index 0000000..dc1e54f --- /dev/null +++ b/cmd/cyrene/wire_flow_graphs.go @@ -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) + } +} diff --git a/cmd/cyrene/wire_flow_graphs_test.go b/cmd/cyrene/wire_flow_graphs_test.go new file mode 100644 index 0000000..79f7402 --- /dev/null +++ b/cmd/cyrene/wire_flow_graphs_test.go @@ -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) + } + } +} diff --git a/cmd/cyrene/wire_frontend.go b/cmd/cyrene/wire_frontend.go index 6835bdd..d967831 100644 --- a/cmd/cyrene/wire_frontend.go +++ b/cmd/cyrene/wire_frontend.go @@ -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,不回假汇总)。 diff --git a/cmd/cyrene/wire_runtime.go b/cmd/cyrene/wire_runtime.go index c45b38d..8c76ecc 100644 --- a/cmd/cyrene/wire_runtime.go +++ b/cmd/cyrene/wire_runtime.go @@ -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)就近读它每会话保留的最近一轮图 —— 写入面(这里)与查询面 diff --git a/frontend/web/src/admin/modules/Flow.tsx b/frontend/web/src/admin/modules/Flow.tsx index 93dc8ab..0617805 100644 --- a/frontend/web/src/admin/modules/Flow.tsx +++ b/frontend/web/src/admin/modules/Flow.tsx @@ -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 ( ); } /** 有图就画整页、没图就诚实空态。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(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 (
- {graph ? ( + {onSelectTurn ? ( + + + {showingHistory && shown ? : null} + + ) : null} + + {shown ? ( <> } label="会话" - value={{graph.sessionId ?? "web"}} - sub={graph.id} + value={{shown.sessionId ?? "web"}} + sub={shown.id} tone="accent" /> } label="节点" - value={graph.nodes.length} - sub={`${graph.edges.length} 条连线`} + value={shown.nodes.length} + sub={`${shown.edges.length} 条连线`} tone="neutral" /> } label="耗时" - value={fmtDuration(graph)} - sub={`开始 ${fmtStart(graph)}`} + value={fmtDuration(shown)} + sub={`开始 ${fmtStart(shown)}`} tone="ok" /> - + setSelectedId(n.id)} /> - + {showingHistory ? null : ( + + )} {selected ? ( @@ -118,7 +168,11 @@ export function FlowBody({ ) : ( - 暂无流程 —— 接入 /ws/chat 并发起一次会话后,将实时出现「输入 → 思考 → 工具 → 输出」连通图;并行子代理会各自成一条泳道。 + {showingHistory + ? historyLoading + ? `正在读取历史轮 ${selectedTurnId} 的链路图……` + : `历史轮 ${selectedTurnId} 没有图 —— 可能已过保留期,或该会话本就不在发布会话白名单内。` + : "暂无流程 —— 接入 /ws/chat 并发起一次会话后,将实时出现「输入 → 思考 → 工具 → 输出」连通图;并行子代理会各自成一条泳道。"} )}
diff --git a/frontend/web/src/admin/modules/flow/FlowSnapshotHint.tsx b/frontend/web/src/admin/modules/flow/FlowSnapshotHint.tsx index 6ea7136..4f65e90 100644 --- a/frontend/web/src/admin/modules/flow/FlowSnapshotHint.tsx +++ b/frontend/web/src/admin/modules/flow/FlowSnapshotHint.tsx @@ -34,7 +34,8 @@ export function FlowSnapshotHint({ {sessionId})的 最近一轮 链路图快照 —— 打开本页时只读拉取(GET /api/v1/admin/flow/graph)。 - 它不一定是你要找的那一轮:后端每会话只保留最近一份图,多轮历史回放尚未实现(design/19 §4 P3)。 + 它不一定是你要找的那一轮 —— 这是**最新**的一份。要精确看某一轮,用上方的 + 「历史轮次」选择器(那些轮已落盘,刷新/重启后仍在;契约 v0.73)。 ) : ( <> diff --git a/frontend/web/src/admin/modules/flow/FlowTurnPicker.tsx b/frontend/web/src/admin/modules/flow/FlowTurnPicker.tsx new file mode 100644 index 0000000..88c50c1 --- /dev/null +++ b/frontend/web/src/admin/modules/flow/FlowTurnPicker.tsx @@ -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 ( +
+
+ + + 共 {turns.length} 轮已落盘 + {loading ? 加载中… : null} +
+ {turns.length === 0 ? ( +

+ 还没有已落盘的轮次 —— 每轮收尾时自动落盘,保留期内可回看。 + 发布会话白名单之外的会话(群聊 / 陌生人)永远没有图,这不是缺陷。 +

+ ) : null} +
+ ); +} + +/** + * 选中历史轮时图上的**诚实说明条**:这一张是从**本地落盘**读回的那一轮,不是实时图。 + * 与 `FlowSnapshotHint`(讲最新一轮的来源)互补 —— 两者不会同时出现。 + */ +export function FlowHistoryHint({ turnId }: { turnId: string }): React.ReactNode { + return ( +
+ 以上是 {turnId} 的链路图 —— 从本地落盘读回的历史轮(只读,契约 v0.73)。 + 它不是实时图;刷新或重启后它仍在(保留期内)。要回到正在跑 / 刚跑完的那一轮,把上方选回「最新一轮」。 +
+ ); +} diff --git a/frontend/web/src/admin/modules/flow/NodeDetail.tsx b/frontend/web/src/admin/modules/flow/NodeDetail.tsx index 65d65b9..30c42d2 100644 --- a/frontend/web/src/admin/modules/flow/NodeDetail.tsx +++ b/frontend/web/src/admin/modules/flow/NodeDetail.tsx @@ -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 { return value as Record; } -/** 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; }): 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 (
@@ -133,10 +139,11 @@ function LogList({

实时日志(span 事件累积) {log.length} + 最新在上 {running ? ● 运行中 : null}

-
- {log.map((ev, i) => ( +
+ {ordered.map((ev, i) => (
{}, + 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`); diff --git a/frontend/web/src/admin/modules/flow/flowHistory.test.ts b/frontend/web/src/admin/modules/flow/flowHistory.test.ts new file mode 100644 index 0000000..e5014dc --- /dev/null +++ b/frontend/web/src/admin/modules/flow/flowHistory.test.ts @@ -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(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`); diff --git a/frontend/web/src/admin/modules/flow/flowHistory.ts b/frontend/web/src/admin/modules/flow/flowHistory.ts new file mode 100644 index 0000000..f647515 --- /dev/null +++ b/frontend/web/src/admin/modules/flow/flowHistory.ts @@ -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(); + 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; +} diff --git a/frontend/web/src/admin/modules/flow/flowSnapshot.render.test.ts b/frontend/web/src/admin/modules/flow/flowSnapshot.render.test.ts index 21b98c1..de622e0 100644 --- a/frontend/web/src/admin/modules/flow/flowSnapshot.render.test.ts +++ b/frontend/web/src/admin/modules/flow/flowSnapshot.render.test.ts @@ -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, "历史轮次"), + "实时图不是快照,不该出现快照专属的历史轮说明" ); // 后台思考会话的人话标签必须认得出(图上要写清是哪个会话的图)。 diff --git a/frontend/web/src/admin/modules/flow/nodeLogOrder.test.ts b/frontend/web/src/admin/modules/flow/nodeLogOrder.test.ts new file mode 100644 index 0000000..b1f8670 --- /dev/null +++ b/frontend/web/src/admin/modules/flow/nodeLogOrder.test.ts @@ -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(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`); diff --git a/frontend/web/src/admin/modules/flow/nodeLogOrder.ts b/frontend/web/src/admin/modules/flow/nodeLogOrder.ts new file mode 100644 index 0000000..2cad8c1 --- /dev/null +++ b/frontend/web/src/admin/modules/flow/nodeLogOrder.ts @@ -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}`; +} diff --git a/frontend/web/src/admin/modules/flow/useFlowTurns.ts b/frontend/web/src/admin/modules/flow/useFlowTurns.ts new file mode 100644 index 0000000..2e7f3e9 --- /dev/null +++ b/frontend/web/src/admin/modules/flow/useFlowTurns.ts @@ -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([]); + const [selectedTurnId, setSelectedTurnId] = useState(LATEST_TURN); + const [historyGraph, setHistoryGraph] = useState(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 }; +} diff --git a/frontend/web/src/lib/api.ts b/frontend/web/src/lib/api.ts index 12e58cc..038e960 100644 --- a/frontend/web/src/lib/api.ts +++ b/frontend/web/src/lib/api.ts @@ -129,4 +129,4 @@ export { adminSystemRotateJWT, adminSystemStorage, } from "./api/adminSystem"; -export type { AdminFlowGraphResponse } from "./api/adminSystem"; +export type { AdminFlowGraphResponse, AdminFlowTurn } from "./api/adminSystem"; diff --git a/frontend/web/src/lib/api/adminSystem.ts b/frontend/web/src/lib/api/adminSystem.ts index 30f742c..9f9fc9c 100644 --- a/frontend/web/src/lib/api/adminSystem.ts +++ b/frontend/web/src/lib/api/adminSystem.ts @@ -47,28 +47,43 @@ export function adminBackthink(limit?: number): Promise { return request(`/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 { + * 为什么需要它:`graph_update` 是**纯实时**帧、从不重放。v0.71 补了「打开页面拉最近一轮 + * 快照」;v0.73 把图**每轮落盘**,于是可以: + * - **省略 `turnId`** ⇒ 最近一轮(后端**先查内存、miss 才落盘** ⇒ 最新一轮永远最新); + * - **给了 `turnId`** ⇒ 精确取该会话已落盘的某一轮(历史回放)。 + * + * `turns`(同一响应里)就是给选择器用的历史轮列表。⚠️ 白名单之外的会话(群聊/陌生人) + * 从不建图、也从不落盘 ⇒ 它们**永远**是空态,这不是缺陷。 */ +export function adminFlowGraph(sessionId: string, turnId?: string): Promise { const qs = new URLSearchParams(); if (sessionId) qs.set("sessionId", sessionId); + if (turnId) qs.set("turnId", turnId); const q = qs.toString(); return request(`/admin/flow/graph${q ? `?${q}` : ""}`); } diff --git a/frontend/web/src/store/persistOptions.test.ts b/frontend/web/src/store/persistOptions.test.ts new file mode 100644 index 0000000..1fd246f --- /dev/null +++ b/frontend/web/src/store/persistOptions.test.ts @@ -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(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; +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; +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).web?.id === "web-1", + "迁移必须保留已有的图桶" +); + +console.log(`persist options test: ${checks} checks passed`); diff --git a/frontend/web/src/store/persistOptions.ts b/frontend/web/src/store/persistOptions.ts index ed72b2a..19c47fa 100644 --- a/frontend/web/src/store/persistOptions.ts +++ b/frontend/web/src/store/persistOptions.ts @@ -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; 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, }), }; diff --git a/internal/db/migrate_flow_graphs_test.go b/internal/db/migrate_flow_graphs_test.go new file mode 100644 index 0000000..8cdb793 --- /dev/null +++ b/internal/db/migrate_flow_graphs_test.go @@ -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) + } +} diff --git a/internal/db/migrations/0022_flow_graphs.down.sql b/internal/db/migrations/0022_flow_graphs.down.sql new file mode 100644 index 0000000..ba4e4a2 --- /dev/null +++ b/internal/db/migrations/0022_flow_graphs.down.sql @@ -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; diff --git a/internal/db/migrations/0022_flow_graphs.up.sql b/internal/db/migrations/0022_flow_graphs.up.sql new file mode 100644 index 0000000..1fa904b --- /dev/null +++ b/internal/db/migrations/0022_flow_graphs.up.sql @@ -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: / sess-backthink) + turn_id TEXT NOT NULL, -- 轮次标识 = trace.FlowGraph.ID("-") + 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); diff --git a/internal/runtime/flowgraph/flowgraph.go b/internal/runtime/flowgraph/flowgraph.go new file mode 100644 index 0000000..e19c202 --- /dev/null +++ b/internal/runtime/flowgraph/flowgraph.go @@ -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 +} diff --git a/internal/runtime/flowgraph/flowgraph_test.go b/internal/runtime/flowgraph/flowgraph_test.go new file mode 100644 index 0000000..69103e5 --- /dev/null +++ b/internal/runtime/flowgraph/flowgraph_test.go @@ -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),轮次标识 = "-", +// 与 `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) + } +} diff --git a/internal/runtime/flowgraph/gc.go b/internal/runtime/flowgraph/gc.go new file mode 100644 index 0000000..d479a3b --- /dev/null +++ b/internal/runtime/flowgraph/gc.go @@ -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 +} diff --git a/internal/runtime/flowgraph/gc_test.go b/internal/runtime/flowgraph/gc_test.go new file mode 100644 index 0000000..19855ae --- /dev/null +++ b/internal/runtime/flowgraph/gc_test.go @@ -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) + } +} diff --git a/internal/runtime/loop/flow.go b/internal/runtime/loop/flow.go index 43db141..4ca660e 100644 --- a/internal/runtime/loop/flow.go +++ b/internal/runtime/loop/flow.go @@ -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 { diff --git a/internal/runtime/loop/flow_persist_test.go b/internal/runtime/loop/flow_persist_test.go new file mode 100644 index 0000000..587d821 --- /dev/null +++ b/internal/runtime/loop/flow_persist_test.go @@ -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 仍须正常发布") + } +} diff --git a/internal/services/gateway/admin_flow.go b/internal/services/gateway/admin_flow.go index c62b1e5..96c78d1 100644 --- a/internal/services/gateway/admin_flow.go +++ b/internal/services/gateway/admin_flow.go @@ -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 } diff --git a/internal/services/gateway/admin_flow_graph_persist_test.go b/internal/services/gateway/admin_flow_graph_persist_test.go new file mode 100644 index 0000000..a08dd97 --- /dev/null +++ b/internal/services/gateway/admin_flow_graph_persist_test.go @@ -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) + } +} diff --git a/internal/services/gateway/contracts.go b/internal/services/gateway/contracts.go index 813b051..b1f3cc3 100644 --- a/internal/services/gateway/contracts.go +++ b/internal/services/gateway/contracts.go @@ -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) +} diff --git a/internal/services/gateway/deps.go b/internal/services/gateway/deps.go index 6d3c58e..c8c65b5 100644 --- a/internal/services/gateway/deps.go +++ b/internal/services/gateway/deps.go @@ -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 } diff --git a/internal/services/gateway/server.go b/internal/services/gateway/server.go index 1d45a17..f084bfd 100644 --- a/internal/services/gateway/server.go +++ b/internal/services/gateway/server.go @@ -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),