From 57d0e8c54344048df57e760fdde55ecbb63175d9 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 6 Oct 2026 21:48:52 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E6=97=B6=E9=97=B4=E8=BD=B4?= =?UTF-8?q?=E5=8F=AA=E8=AF=BB=E6=9D=83=E5=A8=81=E9=94=AE=20+=20=E8=87=AA?= =?UTF-8?q?=E6=8E=92=E7=AD=9B=E9=80=89=E8=BD=B4=20+=20browser=20=E9=9D=A2?= =?UTF-8?q?=E6=9D=BF=E5=9B=BE=E6=A0=87=EF=BC=88Y2+Y3+Y4=20=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E4=B8=80=E5=B9=B6=E8=90=BD=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 时间轴:页首「已过保留期」的**唯一**判据改为 retentionBoundaryReached === true(truncated 只出 「未显示」);缺席(老后端)一句话都不说;删掉旧的 truncated+retentionEnabled+翻到底 启发式 - Backthink:筛选轴接上服务端 ?trigger=(客户端筛选保留作兜底)+ 自排闸用量行(三态文案) - plugin/nav.ts 加 browser 图标 - 真机(隔离实例,46/46):翻页不重不漏、三种「必须 false」的权威键语义、**重启后 round 不重复** ⚠️ 未验:真机上没构造出权威键 true(需真有更早轮次被清扫)——别读成「真机验过 true」 --- frontend/web/src/admin/modules/Backthink.tsx | 97 ++++++++++++++++++- .../src/admin/modules/flow/TimelineView.tsx | 10 +- .../modules/flow/flowTimeline.render.test.ts | 50 +++++++--- .../admin/modules/flow/flowTimeline.test.ts | 84 ++++++++++++---- .../src/admin/modules/flow/flowTimeline.ts | 78 ++++++++------- .../flow/flowTimelineWiring.render.test.ts | 82 +++++++++++++++- .../modules/flow/useFlowTimeline.test.ts | 58 ++++++++++- .../src/admin/modules/flow/useFlowTimeline.ts | 33 +++++-- frontend/web/src/admin/plugin/nav.ts | 4 + frontend/web/src/lib/api.ts | 3 + frontend/web/src/lib/api/adminSystem.ts | 93 +++++++++++++++--- 11 files changed, 490 insertions(+), 102 deletions(-) diff --git a/frontend/web/src/admin/modules/Backthink.tsx b/frontend/web/src/admin/modules/Backthink.tsx index 47977a3..f6bb9eb 100644 --- a/frontend/web/src/admin/modules/Backthink.tsx +++ b/frontend/web/src/admin/modules/Backthink.tsx @@ -6,7 +6,7 @@ import { Button, EmptyState, Surface, Tag } from "../../components/primitives"; import type { BackthinkItem, FlowGraph, WsStatus } from "../../lib/types"; import { Icon } from "../../components/Icon"; import { FlowGraphView } from "./FlowGraph"; -import { adminBackthink } from "../../lib/api"; +import { adminBackthink, adminSelfWakeStatus, type AdminSelfWakeStatus } from "../../lib/api"; import { useAsyncData } from "../../lib/useAsyncData"; import { BACKTHINK_SESSION_ID } from "../../store/sessionCache"; import { FlowSnapshotHint } from "./flow/FlowSnapshotHint"; @@ -41,6 +41,80 @@ export function triggerLabel(trigger: string): string { /** 触发源筛选轴。它与「打扰」筛选轴**取交集**,两轴语义互斥、不挤进同一个 enum。 */ export type TriggerFilter = "all" | "self_wake" | "heartbeat"; +/** 把触发源筛选轴映射成**服务端**参数(契约 v0.82 §4.10(e),切片 C2)。 + * `all` ⇒ `undefined`(省略参数 = 不过滤,后端行为与 v0.81 逐字相同); + * 其余取值原样下发 —— 它们都在后端白名单里(`backthink.TriggerFilterValues`), + * 白名单外的取值后端会**如实 400**(`invalid_trigger`)而不是静默返回全部。 */ +export function selfWakeTriggerParam(filter: TriggerFilter): string | undefined { + return filter === "all" ? undefined : filter; +} + +/** 自排闸用量的**文案**(纯函数:三态必须画出不同的话)。 + * + * 这是本模块最容易撒谎的一处:「看不到」与「已用 0 次」必须**长得不一样** + * (与 config-facts 事实面同一条诚实口径)。因此: + * - 读取出错 ⇒ 明说读不到,并点出**这不等于「已用 0 次」**; + * - `available:false` ⇒ 说清后端给不出快照的原因,不画 0/0; + * - 已接线但未运行 ⇒ 配置面数字照给(上限/窗口),但明说此刻排不出新的一轮。 */ +export function selfWakeUsageText(state: { + data: AdminSelfWakeStatus | null; + loading: boolean; + error: string | null; +}): string { + if (state.error) { + return `读不到用量(${state.error})—— 这不等于「已用 0 次」`; + } + if (!state.data) { + return state.loading ? "读取中…" : "用量未知(没有拿到快照)"; + } + const st = state.data; + if (!st.available) { + return `用量未知 —— ${st.reason ?? "本进程给不出快照"}`; + } + const g = st.gate; + if (!g) { + return "用量未知 —— 后端报了 available 却没给 gate(形状异常,不猜数)"; + } + const limitNote = g.limitIsDefault ? "默认值" : "已改"; + const windowNote = g.windowIsDefault ? "默认值" : "已改"; + const head = `本窗口已用 ${g.used} / ${g.limit}(窗口 ${g.windowSec}s;上限=${limitNote}、窗口=${windowNote})`; + if (!st.running) { + return `${head} · 未运行:${st.reason ?? "未启动或已停止"}`; + } + const left = Math.max(0, g.limit - g.used); + return `${head} · 剩余 ${left} 次 · 待跑槽:${g.pending ? "占用(有 1 轮排着队)" : "空闲"}`; +} + +/** 「自排闸」那一行(导出是为了让三态文案可被真实渲染钉住)。 + * + * 数据源是一次只读快照(`GET /api/v1/admin/backthink/self-wake`),在打开页面与 + * 每次新记录到达时各拉一次 —— **不是**逐秒时钟,因此它是一个**读数**而不是实时值。 + * ⚠️ 旋钮**面板不可改**(写路径未提供):要改就得动 `$DATA_DIR/config/backthink.json` + * 并重启(启动期读一次)—— 这条必须写在面板上,否则「可调」会变成一句假话。 */ +export function SelfWakeUsageLine({ + data, + loading, + error, +}: { + data: AdminSelfWakeStatus | null; + loading: boolean; + error: string | null; +}): React.ReactNode { + const g = data?.gate; + const exhausted = !!g && g.limit - g.used <= 0; + const healthy = !!data?.available && !!data?.running && !exhausted && !error; + return ( +
+ 自排闸 + {selfWakeUsageText({ data, loading, error })} + + 旋钮落点:$DATA_DIR/{data?.configFile ?? "config/backthink.json"} + (启动期读一次,改后需重启 —— 面板写路径尚未提供) + +
+ ); +} + /** 打扰筛选轴的取值。 */ export type DisturbFilter = "all" | "notify" | "silent"; @@ -181,7 +255,16 @@ export function BackthinkModule(): React.ReactNode { // WS `backthink` events already feeding useAppStore.backthink (head-inserted), so we // do NOT poll-replace the array (which would re-render the whole list). Rows are keyed // by id → React reconciles incrementally, appending only NEW records. - const rec = useAsyncData(() => adminBackthink(100), []); + // + // 切片 C2:触发源那一轴**下发服务端参数**(`?trigger=`)—— 历史页因此按服务端筛选 + // (`limit` 是**匹配**条数,不是「先取一页再筛」)。WS 实时帧始终是全量的,所以下面 + // 的客户端筛选**必须保留**:它兜住两件事 —— ①实时帧那一部分;②切换筛选轴后新页 + // 还没回来的那一瞬(旧页仍在内存里)。 + const serverTrigger = selfWakeTriggerParam(triggerFilter); + const rec = useAsyncData(() => adminBackthink(100, serverTrigger), [serverTrigger ?? ""]); + // 自排闸用量(只读快照):打开页面拉一次,之后每来一条新记录再拉一次 —— 自排轮 + // 落库正是「本窗口已用」会变的唯一时刻,因此不必轮询,也不必假装实时。 + const usage = useAsyncData(() => adminSelfWakeStatus(), [backthink.length]); const byId = new Map(); for (const b of backthink) if (b.id) byId.set(b.id, b); for (const b of rec.data ?? []) if (b.id) byId.set(b.id, b); @@ -214,6 +297,8 @@ export function BackthinkModule(): React.ReactNode { + +
打扰 @@ -227,7 +312,9 @@ export function BackthinkModule(): React.ReactNode { setTriggerFilter("self_wake")}>自我唤醒 setTriggerFilter("heartbeat")}>例行心跳 - 显示 {items.length} / {allItems.length} 条 · 两个筛选轴取交集,且只筛当前已拉取的这一页(不是全部历史) + 显示 {items.length} / {allItems.length} 条 · 「打扰」轴只筛本页;「触发源」轴已接服务端 + (历史按 ?trigger= 筛选,WS 实时帧仍全量)⇒ 两轴取交集时客户端筛选仍是兜底, + 「筛不到」不等于「没发生」
@@ -252,7 +339,9 @@ export function BackthinkModule(): React.ReactNode { 数据源:/ws/chat 下行 backthink / thinking 事件(实时,主驱动)+ 打开时一次拉取 GET /api/v1/admin/backthink 历史,两者按 id 去重、按时间倒序,新记录增量插入(不整页重渲)。「推送结果」的 route / detail / bypassed 两条路径同名同义:历史拉取取 AdminBackthinkItem 的同名键,实时帧取后端原始 PushResult 的 Route / Detail / Bypassed(解析器已读,见 lib/wsParse.ts#parseBackthinkItem,兼容 camelCase 变体),因此本次会话新到的行同样显示推送路由与推送说明,不再只显示「已推送/未推送」。链路导图取自后台思考会话(sess-backthink)的 graph_update 快照 —— 后端完整链路图始终发布,与管理端 Flow 页的「当前会话」图相互独立(裁决 16.4.2)。干预为后端 M10+ 提供,当前为只读展示。
- 触发源标签(契约 §C.6,形状不变、仅值域扩展):`self_wake` 显示为「自我唤醒」= 她**自己**用 `request_think` 给自己排起的一轮(不是心跳、也不是外部事件),`heartbeat` / `startup` 分别显示为「例行心跳」/「启动问候」;其余取值(`iot.device_update` / `reminder.trigger` / `system.event` 等事件主题)与将来新增的未知取值**一律原样显示**,绝不猜一个更好听的词。⚠️ 「触发源」筛选是**客户端**筛选,只作用于下表**已拉取的这一页**(历史最多 100 条 + 本次会话实时帧),因此「筛不到」不等于「没发生」;服务端 `?trigger=` 筛选与面板写路径属 C2,尚未提供。同理,「例行心跳」这一档只匹配 `heartbeat` 本身,`startup`(启动问候)只在「全部」下可见。 + 触发源标签(契约 §C.6,形状不变、仅值域扩展):`self_wake` 显示为「自我唤醒」= 她**自己**用 `request_think` 给自己排起的一轮(不是心跳、也不是外部事件),`heartbeat` / `startup` 分别显示为「例行心跳」/「启动问候」;其余取值(`iot.device_update` / `reminder.trigger` / `system.event` 等事件主题)与将来新增的未知取值**一律原样显示**,绝不猜一个更好听的词。⚠️ 触发源那一轴现在**同时下发服务端参数**(契约 v0.82 §4.10(e),切片 C2):选「自我唤醒 / 例行心跳」时历史拉取带 `?trigger=`,`limit` 因此是**匹配**条数(不是「先取一页再筛」);但 WS 实时帧始终是**全量**的,所以「两个筛选轴 + 客户端筛选」仍然保留 —— 它兜住实时帧,以及切换筛选轴后新页还没回来的那一瞬。因此「筛不到」仍不等于「没发生」。白名单外的取值后端会**如实 400**(`invalid_trigger`),不静默返回全部。「例行心跳」这一档只匹配 `heartbeat` 本身,`startup`(启动问候)只在「全部」下可见。 +
+ 自排闸用量(契约 v0.82,切片 C2):上方那行读数来自 `GET /api/v1/admin/backthink/self-wake`(只读、不审计)—— 它是「本窗口已用 N / 上限 M」的**只读快照**(读它不消耗额度),在打开页面与每次新记录到达时各拉一次,**不是**逐秒时钟。三态画得不一样:**看不到快照**(服务未接线 / 实现不提供读口 / 闸不在)说清原因、**不画 0/0**;**已接线但未运行**照给配置面数字并明说「此刻排不出新的一轮」;读取出错也明说读不到(「读不到」≠「已用 0 次」)。⚠️ 这两个旋钮(`selfWakePerWindow` / `selfWakeWindowSec`)**面板改不了**:要改就动 `$DATA_DIR/config/backthink.json` 并重启(启动期读一次,与 `QUIET_*` 同口径);面板写路径属 design/09 §5 的面板声明机制,尚未接线。
新增的「穿透安静时段 / 紧急度」标记(契约 v0.70):穿透 = 这一轮落在安静时段窗口内、仍然被放行发出(只有 high 及以上档位做得到);紧急度**三态画得不一样** —— 把下限当精确值、把未知当「例常」都是把猜当事实:① **精确档位**(「例常」/「紧急(高)」/「紧急·安全域」)= 该轮实际生效的档位,随记录落库;② **≥ 下限**(带 `urgencyLowerBound` 的旧行:该行没有存档档位,此值由安静时段窗口规则推导 —— 「≥ 高」只表示至少 high、可能更高);③ **档位未知** = 存档里没有这个事实,**不是**「例常」。⚠️ 实时帧带上记录里的精确档位(`backthink.Record` 自 X2a 起带 `urgency`),但**不带** `urgencyLowerBound` / `quietHoursBypassed`(后两者由网关按安静时段窗口推导)⇒ 本次会话新到的行先显示精确档位与推送结果,穿透标记与旧行的下限标记要等重开页面由历史拉取补齐。
diff --git a/frontend/web/src/admin/modules/flow/TimelineView.tsx b/frontend/web/src/admin/modules/flow/TimelineView.tsx index 9ccdbd5..c7bb39f 100644 --- a/frontend/web/src/admin/modules/flow/TimelineView.tsx +++ b/frontend/web/src/admin/modules/flow/TimelineView.tsx @@ -31,10 +31,12 @@ import { * - 每个回合一个**可折叠块**,标题 = 时间 + `round` + `turnId` 短标识 + 状态; * - 块内**复用 `LaneTracks`** 画该回合各路的**并列泳道**(真并行不止一张); * - 🔴 **块与块之间不画因果连线**(不臆造关系):只用时间与序号分隔,页首明写这一点; - * - 页首的三句话**各有判据**(`flowTimeline.ts#timelineHints`):`hasMore`(还有更早的可拉) - * ⇒ 「更早的轮次未显示」;**已翻到底**却仍不是全部且保留期启用 ⇒ 才说「更早的轮次已过 - * 保留期」;本页全来自内存 ⇒ 「只拿到最近一轮」。**不许**拿 `truncated` 直接当「已过保留 - * 期」(契约 v0.80 §4.11.1(b).3:那会对着一页仍可翻的历史说假话)。 + * - 页首的三句话**各有判据**(`flowTimeline.ts#timelineHints`):**后端权威键** + * `retentionBoundaryReached === true` ⇒ 才说「更早的轮次已过保留期」(契约 v0.81 + * §4.11.1(a));`truncated`(本次不是全部)或还有更早的可拉 ⇒ 只说「更早的轮次未显示」; + * 本页全来自内存(`source:"live"`)⇒ 「只拿到最近一轮」。🔴 **不许**再拿 `truncated` + + * `retentionEnabled` + 「翻到底了没」自己拼「已过保留期」(v0.81 修掉的那句假话);权威键 + * 缺席(老后端)⇒ 一律不说(最保守话术)。 * * 全部 **props 驱动**(不读 store、不取数):本项目没有 jsdom,渲染测试只能喂 props * (见 `flowSnapshot.render.test.ts` 头注释的同一理由)。改一个块的展开态由父层持有 diff --git a/frontend/web/src/admin/modules/flow/flowTimeline.render.test.ts b/frontend/web/src/admin/modules/flow/flowTimeline.render.test.ts index 5a2e1ef..21272c8 100644 --- a/frontend/web/src/admin/modules/flow/flowTimeline.render.test.ts +++ b/frontend/web/src/admin/modules/flow/flowTimeline.render.test.ts @@ -6,7 +6,8 @@ // ① **顺序** = 真实时间序(默认最新在上,可切); // ② **块与块之间不画任何东西**(不臆造因果)—— 从 SSR 出来的 HTML 里量两个块之间的 // 字节间隔,只允许是结构性标签; -// ③ **过期不许静默**(`truncated` ⇒ 「更早的轮次已过保留期」); +// ③ **过期不许静默**(**只有**后端权威键 `retentionBoundaryReached:true` ⇒ 「更早的轮次 +// 已过保留期」;没有它时只能说「更早的轮次未显示」——v0.81 起不许自己拿 `truncated` 拼); // ④ **老后端 ≠ 没有回合**(404 ⇒ 「当前只支持最近一轮」,不是空态文案)。 // // 本项目没有 jsdom ⇒ 渲染 **props 驱动**的组件(同 `flowHistory.render.test.ts`)。 @@ -100,6 +101,7 @@ function tlState( retentionHours: 168, retentionEnabled: null, truncated: false, + retentionBoundaryReached: null, source: null, loading: false, canLoadMore: false, @@ -230,11 +232,29 @@ for (const b of bs) { ); } -// ── ⑥ 过期口径:`truncated` 必须显式标注,不能静默少一块 ──────────────────── -const truncated = view(tlState(NEWEST_FIRST, { truncated: true })); -ok(has(truncated, "更早的轮次已过保留期"), "有过期轮次 ⇒ 页首如实标注"); -ok(!has(base, "更早的轮次已过保留期"), "没有过期轮次 ⇒ 不写这句(不虚报)"); +// ── ⑥ 过期口径:**只有权威键**能让「已过保留期」出现,且缺失不许静默少一块 ──── +// v0.81:`truncated:true` 只答「本次不是全部」⇒ 面板只能说「更早的轮次未显示」; +// 后端权威键 `retentionBoundaryReached:true` 才说「更早的轮次已过保留期」。 +const truncated = view(tlState(NEWEST_FIRST, { truncated: true, retentionBoundaryReached: false })); +ok(has(truncated, "更早的轮次未显示"), "truncated + 权威键 false ⇒ 页首如实说「未显示」(不静默少一块)"); +ok( + !has(truncated, "更早的轮次已过保留期"), + "权威键不是 true ⇒ **绝不**说「已过保留期」(哪怕这一页确实不是全部)" +); ok(has(truncated, "共 3 个回合"), "截断只影响「还有更早的」提示,不影响已拿到的回合数"); +const boundary = view(tlState(NEWEST_FIRST, { truncated: true, retentionBoundaryReached: true })); +ok(has(boundary, "更早的轮次已过保留期"), "权威键 true ⇒ 页首如实标注「已过保留期」"); +ok(!has(boundary, "更早的轮次未显示"), "权威键 true ⇒ 不挂「未显示」(两句话不许一起给)"); +ok(!has(base, "更早的轮次已过保留期"), "没有过期轮次 ⇒ 不写这句(不虚报)"); +// 老后端(v0.80 出口,没有权威键 = null):按最保守话术 —— 只说「未显示」。 +const noBoundaryKey = view( + tlState(NEWEST_FIRST, { truncated: true, retentionBoundaryReached: null, retentionEnabled: true }) +); +ok(has(noBoundaryKey, "更早的轮次未显示"), "权威键缺席(老后端)⇒ 只能说「更早的轮次未显示」"); +ok( + !has(noBoundaryKey, "更早的轮次已过保留期"), + "权威键缺席 ⇒ 一律不臆断「已过保留期」(哪怕 retentionEnabled=true)" +); // ── ⑦ 空态与降级:老后端 / 读取失败 / 确实为空,三件事分开说 ───────────────── const unsupported = view(tlState([], { unsupported: true })); @@ -367,10 +387,13 @@ const bodyUnwired = renderToStaticMarkup( ); ok(has(bodyUnwired, "时间轴取数未接线"), "切到时间轴但没喂数据 ⇒ 如实说未接线(不假装空态)"); -// ── ⑬ 「未显示」与「已过保留期」是**两件事**(契约 v0.80 §4.11.1(b).3) ───────── +// ── ⑬ 「未显示」与「已过保留期」是**两件事**(契约 v0.81 §4.11.1(a)) ────────── // 真机上 `truncated = hasMore || 落盘源未接线` ⇒ 只要还有更早的可拉它就是 true。拿它直接 -// 当「已过保留期」,面板就会对着一页**仍然可翻**的历史说假话 —— 这条是核出来的成对契约缺陷。 -const moreToLoad = view(tlState(NEWEST_FIRST, { truncated: true, canLoadMore: true })); +// 当「已过保留期」,面板就会对着一页**仍然可翻**的历史说假话 —— 这是 X1 修掉的成对契约缺陷; +// v0.81 起唯一的判据是后端权威键 `retentionBoundaryReached`。 +const moreToLoad = view( + tlState(NEWEST_FIRST, { truncated: true, canLoadMore: true, retentionBoundaryReached: false }) +); ok(has(moreToLoad, "更早的轮次未显示"), "还有更早的可拉 ⇒ 只能说「未显示」"); ok( !has(moreToLoad, "更早的轮次已过保留期"), @@ -379,7 +402,12 @@ ok( ok(has(moreToLoad, "加载更早的回合"), "同一份状态里翻页入口仍在(缺失不是静默的)"); const retentionOff = view( - tlState(NEWEST_FIRST, { truncated: true, retentionHours: 0, retentionEnabled: false }) + tlState(NEWEST_FIRST, { + truncated: true, + retentionHours: 0, + retentionEnabled: false, + retentionBoundaryReached: false, + }) ); ok(has(retentionOff, "更早的轮次未显示"), "保留期未启用 ⇒ 只能说「未显示」"); ok( @@ -388,10 +416,10 @@ ok( ); ok(has(retentionOff, "保留期未启用"), "「保留期未启用」这件事本身照实说"); -const expiredNow = view(tlState(NEWEST_FIRST, { truncated: true, canLoadMore: false })); +const expiredNow = view(tlState(NEWEST_FIRST, { retentionBoundaryReached: true })); ok( has(expiredNow, "更早的轮次已过保留期"), - "已翻到底仍不是全部 + 保留期启用 ⇒ 才可以说「已过保留期」(design/19 §7.1 的逐字文案)" + "后端权威键 true ⇒ 才可以说「已过保留期」(design/19 §7.1 的逐字文案)" ); ok(!has(expiredNow, "更早的轮次未显示"), "同一份状态不得两种说法都给"); diff --git a/frontend/web/src/admin/modules/flow/flowTimeline.test.ts b/frontend/web/src/admin/modules/flow/flowTimeline.test.ts index 7598c7b..a92370e 100644 --- a/frontend/web/src/admin/modules/flow/flowTimeline.test.ts +++ b/frontend/web/src/admin/modules/flow/flowTimeline.test.ts @@ -3,8 +3,11 @@ // 为什么需要它:时间轴的四条硬口径全是**判定**而非渲染 —— ① 顺序与分页游标(`before` // 恒向更早走,与展示顺序无关);② **不臆造关系**(去重只认后端给的回合标识,标识缺席 // 也不自己拼一个);③ 状态与文案必须把「无图 / 未收尾 / 完成」分开说;④ 老后端(404) -// 与「没有回合」必须分成两件事。本项目没有前端测试运行器,因此这是无依赖的纯 node 检查 -// (与 `flowHistory.test.ts` / `flowSession.test.ts` 同一手法)。 +// 与「没有回合」必须分成两件事。⑤(v0.81 追补)**「已过保留期」只认权威键** +// `retentionBoundaryReached === true`:`truncated` / `retentionEnabled` / 「翻到底了没」 +// 的任何组合都**不再是**判据,权威键缺席(老后端)⇒ 按最保守话术(一句也不说)。本项目 +// 没有前端测试运行器,因此这是无依赖的纯 node 检查(与 `flowHistory.test.ts` / +// `flowSession.test.ts` 同一手法)。 // // 跑法: // @@ -31,7 +34,6 @@ import { orderRounds, pageHasOlder, pageMayHaveOlder, - retentionClaimable, retentionText, roundExpanded, roundGraphs, @@ -250,14 +252,24 @@ const packed = timelinePageOf({ rounds: [r1], retentionHours: 168, truncated: true, + retentionBoundaryReached: true, }); same(packed.rounds.length, 1, "解包保留回合"); same(packed.retentionHours, 168, "解包保留期"); same(packed.truncated, true, "解包截断标记"); +same(packed.retentionBoundaryReached, true, "解包**权威键**(v0.81):不能丢、不能反相"); const packedEmpty = timelinePageOf(null); same(packedEmpty.rounds.length, 0, "没有响应 ⇒ 空回合"); same(packedEmpty.retentionHours, null, "没有响应 ⇒ 保留期未知(不是 0)"); -same(packedEmpty.truncated, false, "没有响应 ⇒ 不宣称「已过保留期」"); +same(packedEmpty.truncated, false, "没有响应 ⇒ 不宣称「本次不是全部」"); +same(packedEmpty.retentionBoundaryReached, null, "没有响应 ⇒ 权威键为**未知**(不冒充 false)"); +// 老后端(v0.80 出口,没有这个键):必须落成 `null`(不知道),**不许**用别的字段回推。 +const packedOld = timelinePageOf({ rounds: [r1], retentionHours: 168, truncated: true }); +same( + packedOld.retentionBoundaryReached, + null, + "老后端没给权威键 ⇒ 解包成 null(不知道),**不**退回启发式" +); // ── ⑫ 真跑 api 客户端:URL 形状 + 游标**原样**回传(不换算、不拼 id) ─────── type Call = { url: string; method: string }; @@ -355,18 +367,33 @@ same( ); same(timelineCursorOf({ nextBefore: null, rounds: [] }), null, "都没有 ⇒ 没有游标(不瞎凑)"); -// ── ⑭ 保留期能不能用来解释缺失(`retentionClaimable` 的三支) ──────────────── -ok(retentionClaimable({ retentionHours: 168, retentionEnabled: null }), "有小时数且 >0 ⇒ 可以解释"); -ok(!retentionClaimable({ retentionHours: 0, retentionEnabled: null }), "≤0 = 配置为不回收 ⇒ 不许说过期"); +// ── ⑭ 权威键缺席 = 不知道(老后端不得拿别的字段回推) ──────────────────────── +// v0.81 起「已过保留期」的判据**只有** `retentionBoundaryReached === true`:老后端(v0.80 +// 出口)没这个键 ⇒ 解包成 `null`,「保留期开着」这件事本身**不足以**支撑那句话。 +const oldBackendPage = timelinePageOf({ + rounds: [r1], + retentionHours: 168, + retentionEnabled: true, + truncated: true, +}); +same(oldBackendPage.retentionBoundaryReached, null, "老后端缺权威键 ⇒ 状态里是 null(不知道)"); +// 喂给判定的入参:与 `timelineHints` 的真实入参同形(`canLoadMore` 由取数层算,这里取最保守的 false)。 +const oldBackendHints = timelineHints({ + truncated: oldBackendPage.truncated, + canLoadMore: false, + retentionBoundaryReached: oldBackendPage.retentionBoundaryReached, + source: oldBackendPage.source, +}); ok( - !retentionClaimable({ retentionHours: 168, retentionEnabled: false }), - "后端显式说没启用 ⇒ **永远**不许说过期(哪怕小时数字段还带着 168)" + !oldBackendHints.expired, + "「保留期启用 + truncated」**不构成**过期的判据(v0.81 明令禁止自己拼)" ); -ok(retentionClaimable({ retentionHours: null, retentionEnabled: true }), "显式说启用了 ⇒ 可以解释"); -ok(!retentionClaimable({ retentionHours: null, retentionEnabled: null }), "两样都不知道 ⇒ 不说"); +ok(oldBackendHints.hidden, "缺权威键 + truncated ⇒ 只能挂「更早的轮次未显示」"); // ── ⑮ 页首三句话的判定(`timelineHints`):互不冒充 ───────────────────────── -const hintBase = { truncated: true, canLoadMore: false, retentionHours: 168, retentionEnabled: true, source: null }; +// 入参只认「后端给的键」:`truncated` / `canLoadMore` / `retentionBoundaryReached` / `source`。 +// ⚠️ `retentionHours` / `retentionEnabled` **已不再**是入参(它们去掉了判据资格)。 +const hintBase = { truncated: true, canLoadMore: false, retentionBoundaryReached: null, source: null }; /** 三支一起比(对象比较要逐字段,`same` 只比标量)。 */ function hintsOf(h: TimelineHints): string { return `${h.hidden ? "hidden" : "-"}/${h.expired ? "expired" : "-"}/${h.liveOnly ? "liveOnly" : "-"}`; @@ -378,19 +405,34 @@ same( "还有更早可拉 ⇒ 只能「未显示」(**绝不**说「已过保留期」)" ); same( - hintsOf(timelineHints(hintBase)), - "-/expired/-", - "翻到底仍不是全部 + 保留期启用 ⇒ 才说「已过保留期」" -); -same( - hintsOf(timelineHints({ ...hintBase, retentionHours: 0, retentionEnabled: false })), + hintsOf(timelineHints({ ...hintBase, retentionBoundaryReached: false })), "hidden/-/-", - "保留期未启用 ⇒ 只能说「未显示」" + "后端明确说 false(本次不是全部但没被清扫)⇒ 说「未显示」" ); same( - hintsOf(timelineHints({ ...hintBase, source: "live" })), + hintsOf(timelineHints({ ...hintBase, truncated: false, canLoadMore: false, retentionBoundaryReached: true })), + "-/expired/-", + "权威键 true ⇒ 才说「已过保留期」(哪怕这一页 truncated=false:已到最早一端)" +); +same( + hintsOf(timelineHints({ ...hintBase, retentionBoundaryReached: true })), + "-/expired/-", + "权威键 true 时**不再**同时挂「未显示」(一句话,不是两句)" +); +same( + hintsOf(timelineHints({ ...hintBase, source: "live", retentionBoundaryReached: false })), "hidden/-/liveOnly", - "本页全来自内存(落盘源未接线)⇒ 「只拿到最近一轮」,且**不**说成过期" + "本页全来自内存(落盘源未接线)⇒ 「只拿到最近一轮」" +); +same( + hintsOf(timelineHints({ ...hintBase, source: "live", retentionBoundaryReached: true })), + "hidden/-/liveOnly", + "本页全来自内存时**压住**过期声明(本页没有落盘对照,无从复核 ⇒ 少说)" +); +same( + hintsOf(timelineHints({ truncated: false, canLoadMore: false, retentionBoundaryReached: null, source: null })), + "-/-/-", + "一切未知 / 什么都没说 ⇒ 一句话都不说(最保守话术)" ); console.log(`flow timeline logic test: ${checks} checks passed`); diff --git a/frontend/web/src/admin/modules/flow/flowTimeline.ts b/frontend/web/src/admin/modules/flow/flowTimeline.ts index b7afbd9..a1f63ce 100644 --- a/frontend/web/src/admin/modules/flow/flowTimeline.ts +++ b/frontend/web/src/admin/modules/flow/flowTimeline.ts @@ -31,14 +31,16 @@ export type FlowTimelineOrder = "newest-first" | "oldest-first"; /** 「更早的轮次已过保留期」——**逐字**文案(`design/19 §7.1`:过期轮次不在返回值里时, * 必须由面板如实标注,**不许静默少一块**)。 * - * ⚠️ 这句话**有判据**(契约 v0.80 §4.11.1(b).3):只有「分页已到底(`hasMore` 为假) - * 却仍不是全部,且保留期**启用**」才可以说;`truncated` 本身只等于「本次不是全部」, - * 拿它直接当「已过保留期」会对着一页**仍然可翻**的历史说假话(本切片核出的成对契约缺陷)。 */ + * 🔴 这句话的**唯一**判据是后端的权威键 **`retentionBoundaryReached === true`** + * (契约 v0.81 §4.11.1(a):清扫已启用 + 已到现存最早一端 + 现存最早一轮的轮次号 > 1)。 + * ⚠️ **不许自己拼**:`truncated` + `retentionEnabled` + 「翻到底了没」三者的组合**不再是** + * 判据(那正是 v0.81 修掉的缺陷②)—— `truncated` 只答「本次是不是全部」,拿它当过期会说 + * 假话(对着一页**仍然可翻**的历史说「已过保留期」);键缺席(老后端)时**一句也不说**。 */ export const RETENTION_TRUNCATED_TEXT = "更早的轮次已过保留期"; -/** 「更早的轮次**未显示**」:本次不是全部,但原因**不是**保留期 —— 要么还有更早的可拉 - * (`hasMore`,翻页入口就在旁边),要么后端配置为**不回收**(`retentionEnabled:false`)。 - * 与「已过保留期」是**两件事**,不许互相冒充。 */ +/** 「更早的轮次**未显示**」:本次不是全部(`truncated` = 分页未到底 **或** 落盘源未接线), + * 但后端**没有**说那些轮次被保留期清扫过(`retentionBoundaryReached` 不是 true)。 + * 与「已过保留期」是**两件事**,不许互相冒充(契约 v0.81 §4.11.1(a)(b).3)。 */ export const MORE_HIDDEN_TEXT = "更早的轮次未显示"; /** 「只拿到内存里的最近一轮」:本页**全部**来自内存(`source:"live"`)= 落盘源未接线, @@ -300,35 +302,23 @@ export function classifyTimelineFailure(err: unknown): TimelineFailure { return isNotImplemented(err) ? "unsupported" : "failed"; } -/** 保留期是否**可以用来解释「更早的轮次不见了」**(`retentionText` 三支的同源判据): - * `retentionEnabled:false` ⇒ **永远不许**说过期;给了小时数就看它是否 `>0`;两者都缺 ⇒ - * 只有当 `retentionEnabled === true` 才认(否则**不知道**,就不说)。 */ -export function retentionClaimable(state: { - retentionHours: number | null | undefined; - retentionEnabled: boolean | null | undefined; -}): boolean { - if (state.retentionEnabled === false) return false; - if (typeof state.retentionHours === "number" && Number.isFinite(state.retentionHours)) { - return state.retentionHours > 0; - } - return state.retentionEnabled === true; -} - /** 时间轴页首要如实交代的三件事(**只看后端给的字段**,不猜、不美化)。 * * - `liveOnly`:本页**全部来自内存**(`source:"live"`)⇒ 说「只拿到最近一轮」—— 缺的那些 * 回合**不是**被保留期清扫掉的(落盘源未接线),因此**永不**并入「已过保留期」; - * - `expired`:**分页已到底**(`hasMore` 为假)却仍「不是全部」,且保留期**可用它来解释** - * ⇒ 才可以说「更早的轮次已过保留期」(`design/19 §7.1`/§7.4 的逐字文案); - * - `hidden`:仍不是全部,但**不能**用保留期解释(还有更早的可拉 / 落盘源未接线 / 后端 - * 配置为不回收)⇒ 说「更早的轮次未显示」,**绝不**冒充「已过保留期」 - * (契约 v0.80 §4.11.1(b).3)。 + * - `expired`:后端权威键 **`retentionBoundaryReached === true`** ⇒ 才可以说「更早的轮次 + * 已过保留期」(`design/19 §7.1`/§7.4 的逐字文案)。🔴 **不许自己拼**(契约 v0.81 + * §4.11.1(a)(b).3):`truncated` + `retentionEnabled` + 「翻到底了没」的**任何组合**都不 + * 构成这句话的判据;键**缺席**(老后端)⇒ 一律**不说**(最保守话术); + * - `hidden`:本次确实不是全部(`truncated`,或还有更早的可拉)但**后端没说**被清扫过 + * ⇒ 说「更早的轮次未显示」,**绝不**冒充「已过保留期」。 * - * 三者互不冒充;`truncated` 为假时三支都为假(没有可交代的缺失)。 */ + * 三者互不冒充:`expired` 为真时不再挂 `hidden`(两句话说的是同一件事的两面); + * `truncated` 为假且没有更早的可拉时,`hidden` / `expired` 都为假(没有可交代的缺失)。 */ export interface TimelineHints { - /** 「更早的轮次未显示」(不是保留期的锅)。 */ + /** 「更早的轮次未显示」(**不是**保留期的锅 —— 后端没说被清扫过)。 */ hidden: boolean; - /** 「更早的轮次已过保留期」(保留期启用 + 已翻到底 + 不是「只有最近一轮」)。 */ + /** 「更早的轮次已过保留期」(唯一判据 = 后端 `retentionBoundaryReached === true`)。 */ expired: boolean; /** 「只拿到内存里的最近一轮」。 */ liveOnly: boolean; @@ -338,18 +328,25 @@ export interface TimelineHints { export interface TimelineHintsInput { truncated: boolean; canLoadMore: boolean; - retentionHours: number | null | undefined; - retentionEnabled: boolean | null | undefined; + /** 🆕 v0.81 的**权威键**;缺席(老后端 / 未给)⇒ `undefined`/`null` = **不知道**。 */ + retentionBoundaryReached: boolean | null | undefined; source: TimelineSource | null | undefined; } -/** 页首那三句话的判定(纯函数,逻辑测试与渲染测试共用**唯一真源**)。 */ +/** 页首那三句话的判定(纯函数,逻辑测试与渲染测试共用**唯一真源**)。 + * + * 🔴 `expired` **只读** `retentionBoundaryReached`(契约 v0.81):它 `true` 才说「已过保留 + * 期」;它 `false`/缺席(老后端)一律不说 —— **少说也不说谎**(它 `false` 不等于「没被 + * 清扫」,三种盲区见契约 §4.11.1(a) 表)。`retentionHours` / `retentionEnabled` 从此只服务 + * 「保留期 X 小时」这句**呈报**,**不再**参与这句话的判定。 + * + * `liveOnly`(本页全来自内存)**恒压住** `expired`:本页根本没有落盘的对照,说「已过保留 + * 期」无从复核 ⇒ 只如实说「只拿到最近一轮」。 */ export function timelineHints(state: TimelineHintsInput): TimelineHints { const liveOnly = state.source === "live"; - if (!state.truncated) return { hidden: false, expired: false, liveOnly }; - if (liveOnly || state.canLoadMore) return { hidden: true, expired: false, liveOnly }; - const expired = retentionClaimable(state); - return { hidden: !expired, expired, liveOnly }; + const expired = !liveOnly && state.retentionBoundaryReached === true; + const hidden = !expired && (state.truncated || state.canLoadMore); + return { hidden, expired, liveOnly }; } /** 折叠默认值:**用户没点过**的块 ⇒ 只有**最新一回合**默认展开(打开页面即看到最近的 @@ -380,13 +377,18 @@ export function toggleRoundExpansion( export type TimelineSource = NonNullable; /** 时间轴响应 → 取数层状态用的**纯**解包(缺键一律按诚实缺省:没有回合就是空数组、 - * `truncated` 缺席当 false、保留期/游标/`hasMore` 缺席当**未知**(`null`),不冒充 0)。 */ + * `truncated` 缺席当 false、保留期/游标/`hasMore`/`retentionBoundaryReached` 缺席当 + * **未知**(`null`),不冒充 0,也不拿别的键回推)。 */ export interface TimelinePage { rounds: AdminFlowTimelineRound[]; retentionHours: number | null; - /** 保留期清扫**是否启用**;缺席 = 未知(由 `retentionHours` 推断,见 `retentionClaimable`)。 */ + /** 保留期清扫**是否启用**;缺席 = 未知(只用于**呈报**保留期口径,不参与过期判定)。 */ retentionEnabled: boolean | null; + /** 「本次不是全部」;缺席(老后端真给了这个出口却没给这个键)= false(不宣称缺)。 */ truncated: boolean; + /** 🆕 v0.81 的**权威键**:后端说「确有更早的轮次被保留期清扫掉了」;缺席 = **不知道** + * (`null`,按最保守话术处理 —— 不说过期)。 */ + retentionBoundaryReached: boolean | null; /** 后端说「还有更早的」;缺席 = 不知道(取数层退回满页启发式)。 */ hasMore: boolean | null; /** 后端给的下一页游标(本页最早一轮的开始时刻);缺席 / 空串 = 没有。 */ @@ -406,6 +408,8 @@ export function timelinePageOf( : null, retentionEnabled: typeof resp?.retentionEnabled === "boolean" ? resp.retentionEnabled : null, truncated: Boolean(resp?.truncated), + retentionBoundaryReached: + typeof resp?.retentionBoundaryReached === "boolean" ? resp.retentionBoundaryReached : null, hasMore: typeof resp?.hasMore === "boolean" ? resp.hasMore : null, nextBefore: resp?.nextBefore ? resp.nextBefore : null, source: readSource(resp?.source), diff --git a/frontend/web/src/admin/modules/flow/flowTimelineWiring.render.test.ts b/frontend/web/src/admin/modules/flow/flowTimelineWiring.render.test.ts index ad3087c..cb02cd9 100644 --- a/frontend/web/src/admin/modules/flow/flowTimelineWiring.render.test.ts +++ b/frontend/web/src/admin/modules/flow/flowTimelineWiring.render.test.ts @@ -3,13 +3,16 @@ // 为什么需要它:本项目没有 jsdom ⇒ 既跑不了 hook,也点不了界面。W2 的渲染测试是**替身级** // (手写 state 当 props),取数测试此前根本不存在。本文件把两头接起来: // -// 真 `fetch`(照契约的假后端:`before` 严格更早 / `hasMore` / `nextBefore` / `truncated`) +// 真 `fetch`(照契约的假后端:`before` 严格更早 / `hasMore` / `nextBefore` / `truncated` / +// `retentionBoundaryReached`) // → 真的取数状态机(`loadFirstPage` / `loadOlderPage`) // → 真的状态映射(`flowTimelineViewState`,就是 hook 返回的那个) // → 真的视图(`FlowTimelineView` 的 SSR 输出) -// ⇒ 对**三种真实状态**逐条断言界面说了什么、**没说**什么: +// ⇒ 对**几种真实状态**逐条断言界面说了什么、**没说**什么: // ① 有数据(多轮 + 分页到底)② 只有最近一轮(落盘源未接线)③ 读取失败(500) // ④ 附:老后端(404)—— 与 ③ 是两件事,必须分开说。 +// ⑤ 权威键在场且 `true`(v0.81,更早的轮次被保留期清扫)⇒ 说「已过保留期」 +// ⑥ 权威键**缺席**(老 v0.80 出口)⇒ 最保守话术:只说「更早的轮次未显示」 // // 跑法(渲染类必须 `--format=cjs`;异步用 IIFE,抛错 ⇒ unhandled rejection ⇒ 非零退出): // @@ -89,7 +92,7 @@ function wireRounds(): unknown[] { }); } -type Mode = "persisted" | "liveOnly" | "failed" | "oldBackend"; +type Mode = "persisted" | "liveOnly" | "failed" | "oldBackend" | "swept" | "v080"; function installBackend(mode: Mode): void { globalThis.fetch = ((url: string) => { @@ -112,6 +115,51 @@ function installBackend(mode: Mode): void { const all = wireRounds(); const q = new URLSearchParams(String(url).split("?")[1] ?? ""); const before = q.get("before") ?? ""; + // 「保留期边界已到」档:现存最早一轮是**第 3 轮**(1、2 轮已被清扫),本次已到最早一端 + // ⇒ 后端权威键 `retentionBoundaryReached:true`(这正是 contract §4.11.1(a) 判据 ③ 的取值)。 + if (mode === "swept") { + const swept: unknown[] = all.map((r, i) => ({ ...(r as Record), round: i + 3 })); + return Promise.resolve({ + ok: true, + status: 200, + headers: { get: () => "application/json" }, + json: () => + Promise.resolve({ + data: { + sessionId: MAIN_SESSION_ID, + source: "persisted", + rounds: swept, + nextBefore: (swept[0] as { startedAt: string }).startedAt, + hasMore: false, + truncated: false, + retentionBoundaryReached: true, + retentionHours: 168, + retentionEnabled: true, + }, + }), + }); + } + // 老 v0.80 出口:**没有**权威键(`retentionBoundaryReached` 缺席)⇒ 面板按最保守话术。 + if (mode === "v080") { + return Promise.resolve({ + ok: true, + status: 200, + headers: { get: () => "application/json" }, + json: () => + Promise.resolve({ + data: { + sessionId: MAIN_SESSION_ID, + source: "persisted", + rounds: all, + nextBefore: (all[0] as { startedAt: string }).startedAt, + hasMore: false, + truncated: true, + retentionHours: 168, + retentionEnabled: true, + }, + }), + }); + } if (mode === "liveOnly") { return Promise.resolve({ ok: true, @@ -126,6 +174,7 @@ function installBackend(mode: Mode): void { nextBefore: "", hasMore: false, truncated: true, + retentionBoundaryReached: false, retentionHours: 168, retentionEnabled: true, }, @@ -148,6 +197,7 @@ function installBackend(mode: Mode): void { nextBefore: (page[0] as { startedAt: string } | undefined)?.startedAt ?? "", hasMore, truncated: hasMore, + retentionBoundaryReached: false, retentionHours: 168, retentionEnabled: true, }, @@ -215,7 +265,8 @@ void (async () => { ); ok(has(filledHtml, "共 3 个回合"), "页首的回合数来自真取数结果"); ok(has(filledHtml, "保留期 168 小时"), "页首如实转达后端给的保留期"); - ok(!has(filledHtml, "更早的轮次已过保留期"), "翻到底 + 后端没给清扫信号 ⇒ **不**说「已过保留期」"); + ok(!has(filledHtml, "更早的轮次已过保留期"), "翻到底 + 后端说未清扫(权威键 false)⇒ **不**说「已过保留期」"); + same(filled.get().retentionBoundaryReached, false, "权威键 false 也原样落进真取数状态"); ok(!has(filledHtml, "更早的轮次未显示"), "翻到底 ⇒ 也不挂「未显示」"); ok(has(filledHtml, "没有更早的回合了"), "翻到底 ⇒ 如实说没有更早的(不是静默消失)"); ok(has(filledHtml, "2 路 · "), "真并行的那一轮两路都在(块标题如实报路数)"); @@ -257,6 +308,29 @@ void (async () => { ok(has(oldHtml, "当前只支持最近一轮"), "老后端 ⇒ 明确说「当前只支持最近一轮」"); ok(!has(oldHtml, "暂时读不到"), "老后端 ≠ 读取失败"); + // ── ⑤ 权威键在场且 true(v0.81):真 fetch → 真状态 → 界面说「已过保留期」 ────── + installBackend("swept"); + const swept = host(); + await loadFirstPage(MAIN_SESSION_ID, swept.send); + same(swept.get().retentionBoundaryReached, true, "权威键原样落进真取数状态"); + const sweptHtml = render(swept); + ok(has(sweptHtml, "更早的轮次已过保留期"), "后端确认清扫过 ⇒ 界面如实说「已过保留期」"); + ok(!has(sweptHtml, "更早的轮次未显示"), "同一份状态不得两种说法都给"); + ok(!has(sweptHtml, "只拿到内存里的最近一轮"), "本页来自落盘 ⇒ 不说「只有最近一轮」"); + ok( + (sweptHtml.match(/data-flow-timeline-round="/g) ?? []).length === 3, + "被清扫后的现存 3 轮照旧画出来(不是空态)" + ); + + // ── ⑥ 老 v0.80 出口(没有权威键):按最保守话术,不臆断 ──────────────────── + installBackend("v080"); + const legacy = host(); + await loadFirstPage(MAIN_SESSION_ID, legacy.send); + same(legacy.get().retentionBoundaryReached, null, "缺权威键 ⇒ 状态里是 null(不知道)"); + const legacyHtml = render(legacy); + ok(has(legacyHtml, "更早的轮次未显示"), "缺权威键 + truncated ⇒ 只能说「更早的轮次未显示」"); + ok(!has(legacyHtml, "更早的轮次已过保留期"), "缺权威键 ⇒ **绝不**臆断「已过保留期」(哪怕保留期开着)"); + globalThis.localStorage = originalLocalStorage; console.log(`flow timeline wiring test: ${checks} checks passed`); })(); diff --git a/frontend/web/src/admin/modules/flow/useFlowTimeline.test.ts b/frontend/web/src/admin/modules/flow/useFlowTimeline.test.ts index a81ab17..4083d8b 100644 --- a/frontend/web/src/admin/modules/flow/useFlowTimeline.test.ts +++ b/frontend/web/src/admin/modules/flow/useFlowTimeline.test.ts @@ -126,7 +126,10 @@ const calls: string[] = []; /** 真跑 `fetch`:把假后端接在 `globalThis.fetch` 上(URL 逐条记下,供形状断言)。 * * 语义**照契约**(§4.11.1):`before` = 开始时刻**严格早于**它;`hasMore` = 库里还有更早的; - * `nextBefore` = 本页最早一轮的开始时刻;`truncated` = `hasMore || 落盘源未接线`。 + * `nextBefore` = 本页最早一轮的开始时刻;`truncated` = `hasMore || 落盘源未接线`; + * `retentionBoundaryReached` = 「确有更早的轮次被保留期清扫」——本假后端的数据是第 1~5 轮 + * **完整**的,因此它**恒为 false**(现存最早一轮就是第 1 轮 ⇒ 没有更早的被清扫掉;这正是 + * 后端判据 ③ 的真实取值)。 * `stuck` 档故意**忽略游标**并永远说 `hasMore`(模拟坏后端),用来钉住「零新增即停」。 */ function installBackend(mode: Partial = {}): ServerMode { const cfg: ServerMode = { pageSize: 2, kind: "persisted", ...mode }; @@ -152,6 +155,7 @@ function installBackend(mode: Partial = {}): ServerMode { nextBefore: "", hasMore: false, truncated: true, + retentionBoundaryReached: false, retentionHours: 168, retentionEnabled: true, } @@ -162,6 +166,7 @@ function installBackend(mode: Partial = {}): ServerMode { nextBefore: page.length > 0 ? page[0].startedAt : "", hasMore, truncated: hasMore, + retentionBoundaryReached: false, retentionHours: 168, retentionEnabled: true, }; @@ -233,6 +238,11 @@ same(h.get().rounds.length, 2, "假后端一页 2 条 ⇒ 状态里 2 个回合" same(h.get().rounds[0].turnId, "web-5#turn-e", "状态里**最新在前**(渲染顺序与分页游标解耦)"); same(h.get().canLoadMore, true, "后端说 hasMore ⇒ 还能继续翻"); same(h.get().truncated, true, "后端说 truncated ⇒ 原样落进状态(取数层不替它解释)"); +same( + h.get().retentionBoundaryReached, + false, + "后端权威键(v0.81)也原样落进状态 —— **不许**在取数层自己拼一个" +); same(h.get().retentionHours, 168, "保留期原样落进状态"); same(h.get().retentionEnabled, true, "保留期启用标记原样落进状态"); same(h.get().source, "persisted", "数据源原样落进状态"); @@ -367,6 +377,52 @@ const endedHints = timelineHints(paging.get()); same(paging.get().truncated, false, "翻到底后 `truncated` 逐页覆盖成 false(不累积成「历史上曾经缺过」)"); ok(!endedHints.expired, "翻到底后**不再**说「已过保留期」(后端压根没给过清扫信号)"); ok(!endedHints.hidden, "翻到底后也不再挂「更早的轮次未显示」——手上已经是保留期内的全部"); +// ── ⑧b 🔴 权威键(v0.81):在场读它、缺席不臆断 ───────────────────────────── +// 「这页只剩第 3~5 轮 ⇒ 更早的 1、2 轮已被保留期清扫」这种**后端确认过**的局面: +// 响应给 `retentionBoundaryReached:true` ⇒ 取数状态照收 ⇒ 才允许说「已过保留期」。 +const sweptRounds = dataset().slice(2); +function installSwept(withKey: boolean): void { + globalThis.fetch = ((url: string) => { + calls.push(String(url)); + const data: Record = { + sessionId: MAIN_SESSION_ID, + source: "persisted", + rounds: sweptRounds, + nextBefore: "", + hasMore: false, + truncated: false, + retentionHours: 168, + retentionEnabled: true, + }; + if (withKey) data.retentionBoundaryReached = true; + return Promise.resolve({ + ok: true, + status: 200, + headers: { get: () => "application/json" }, + json: () => Promise.resolve({ data }), + }); + }) as unknown as typeof globalThis.fetch; +} + +installSwept(true); +const swept = host(MAIN_SESSION_ID); +await loadFirstPage(MAIN_SESSION_ID, swept.send); +same(swept.get().retentionBoundaryReached, true, "权威键在场且 true ⇒ 原样落进取数状态"); +same(swept.get().rounds.length, 3, "只剩第 3~5 轮(更早的两轮不在返回值里)"); +const sweptHints = timelineHints(swept.get()); +ok(sweptHints.expired, "后端说清扫过 ⇒ 才说「更早的轮次已过保留期」"); +ok(!sweptHints.hidden, "权威键 true ⇒ 不挂「更早的轮次未显示」(两句话不一起给)"); +ok(!sweptHints.liveOnly, "本页来自落盘 ⇒ 不是「只有最近一轮」"); + +// 老后端(v0.80 出口):**没有**这个键 —— 同一份数据也不许推断出「已过保留期」。 +installSwept(false); +const noKey = host(MAIN_SESSION_ID); +await loadFirstPage(MAIN_SESSION_ID, noKey.send); +same(noKey.get().retentionBoundaryReached, null, "权威键缺席 ⇒ 状态里是 null(不知道)"); +const noKeyHints = timelineHints(noKey.get()); +ok(!noKeyHints.expired, "权威键缺席 ⇒ **绝不**臆断「已过保留期」(最保守话术)"); +ok(!noKeyHints.hidden, "本页 truncated=false 且没有更早的可拉 ⇒ 也不挂「未显示」(没缺什么)"); + // ── ⑨ 网络失败:仍然只是「这次没读到」(不伪装成空态 / 老后端) ───────────── globalThis.fetch = (() => Promise.reject(new Error("offline"))) as unknown as typeof globalThis.fetch; const offline = host(MAIN_SESSION_ID); diff --git a/frontend/web/src/admin/modules/flow/useFlowTimeline.ts b/frontend/web/src/admin/modules/flow/useFlowTimeline.ts index ca39d1c..fb8ef85 100644 --- a/frontend/web/src/admin/modules/flow/useFlowTimeline.ts +++ b/frontend/web/src/admin/modules/flow/useFlowTimeline.ts @@ -30,9 +30,12 @@ import type { TimelineSource } from "./flowTimeline"; * **绝不**把「没有这个出口」说成「这段时间没有回合」; * 4. **失败不伪装**:首屏失败 ⇒ `failed` + 可重试;翻页失败只记在 `moreFailed`, * 已拿到的回合**照旧显示**(不回退成空态); - * 5. **保留期口径原样转达**:`retentionHours` / `retentionEnabled` / `truncated` / `source` - * 四个字段**逐字**落进状态,由 `flowTimeline.ts#timelineHints` 判定「未显示」与 - * 「已过保留期」—— 取数层**不替**后端解释(那正是「已过保留期」说假话的来路)。 + * 5. **保留期口径原样转达**:`retentionHours` / `retentionEnabled` / `truncated` / + * `retentionBoundaryReached` / `source` 五个字段**逐字**落进状态,由 + * `flowTimeline.ts#timelineHints` 判定「未显示」与「已过保留期」—— 取数层**不替**后端 + * 解释(那正是「已过保留期」说假话的来路)。🔴 **v0.81 起「已过保留期」的判据只有** + * `retentionBoundaryReached === true`(`truncated` 只答「本次是不是全部」,`retentionEnabled` + * 只答「清扫开着没」)⇒ 取数层**逐页覆盖**它,**绝不**自己拼。 * * 取数体的两个函数(`loadFirstPage` / `loadOlderPage`)**导出**是为了能在无 jsdom 的项目里 * 被真跑:测试驱动**真的** `lib/api` 客户端(request → fetch)+ **真的**这几个状态机函数 @@ -43,13 +46,18 @@ export interface FlowTimelineState { rounds: AdminFlowTimelineRound[]; /** 全量回合数(= `rounds.length`;单独给一个名字,避免调用方各自取长度)。 */ total: number; - /** 保留期(小时);后端没给 ⇒ `null`(面板显示「未知」,不编造)。 */ + /** 保留期(小时);后端没给 ⇒ `null`(面板显示「未知」,不编造)。只用于**呈报**。 */ retentionHours: number | null; - /** 保留期清扫**是否启用**;缺失 ⇒ `null`(由小时数推断,见 `retentionClaimable`)。 */ + /** 保留期清扫**是否启用**;缺失 ⇒ `null`。⚠️ 它只说明「清扫开着」,**不说明发生过清扫** + * —— 过期判定不看它(见下一行)。 */ retentionEnabled: boolean | null; /** 后端**最近这次响应**说「本次不是全部」(`hasMore` 或落盘源未接线)—— 面板据此如实 - * 标注「更早的轮次未显示」;**不是**「已过保留期」(判据见 `timelineHints`)。 */ + * 标注「更早的轮次未显示」;**不是**「已过保留期」。 */ truncated: boolean; + /** 🆕 **v0.81 的权威键**(契约 §4.11.1(a)):`true` = 确有更早的轮次被保留期清扫掉了、 + * 且本次已取到现存最早一端 ⇒ **只有它**能支撑「更早的轮次已过保留期」这句话;`false` + * **不等于**「没被清扫」(三种盲区见契约);`null` = 后端没给(老后端)⇒ 最保守话术。 */ + retentionBoundaryReached: boolean | null; /** 本页数据源:`persisted` / `live`(只拿到内存里的最近一轮)/ `none`;缺失 ⇒ `null`。 */ source: TimelineSource | null; /** 首屏加载中(已有数据后翻页不算)。 */ @@ -77,6 +85,7 @@ export interface TimelineData { retentionHours: number | null; retentionEnabled: boolean | null; truncated: boolean; + retentionBoundaryReached: boolean | null; source: TimelineSource | null; /** 后端给的下一页游标(本页最早一轮的开始时刻);`null` = 没有 / 老后端没给。 */ nextBefore: string | null; @@ -96,6 +105,7 @@ export function emptyState(sessionId: string): TimelineData { retentionHours: null, retentionEnabled: null, truncated: false, + retentionBoundaryReached: null, source: null, nextBefore: null, loading: false, @@ -183,6 +193,7 @@ export function flowTimelineViewState( retentionHours: data.retentionHours, retentionEnabled: data.retentionEnabled, truncated: data.truncated, + retentionBoundaryReached: data.retentionBoundaryReached, source: data.source, loading: data.loading, canLoadMore: data.canLoadMore, @@ -214,6 +225,7 @@ export async function loadFirstPage(target: string, send: Send): Promise { retentionHours: page.retentionHours, retentionEnabled: page.retentionEnabled, truncated: page.truncated, + retentionBoundaryReached: page.retentionBoundaryReached, source: page.source, nextBefore: page.nextBefore, canLoadMore: pageHasOlder(page.hasMore, page.rounds.length, TIMELINE_PAGE_SIZE), @@ -231,9 +243,11 @@ export async function loadFirstPage(target: string, send: Send): Promise { /** 取更早的一页:合并去重、保留期取「已拿到的优先」;失败只影响翻页,**已拿到的回合照旧 * 显示**。 * - * ⚠️ `truncated` **逐页覆盖,不累积** —— 它描述的是**最近这次响应**「是不是全部」, - * 而不是「历史上曾经缺过」(翻到底之后它就该消失;累积会让面板在翻到底后仍说 - * 「更早的轮次已过保留期」)。 + * ⚠️ `truncated` 与 `retentionBoundaryReached` **逐页覆盖,不累积** —— 两者描述的都是 + * **最近这次响应**(前者:「本次是不是全部」;后者:「到没到保留期边界」),而不是「历史上 + * 曾经缺过」。覆盖才对:一页没拉完时后端说 `retentionBoundaryReached:false`,翻到最早那 + * 一页才会变 `true`;反过来累积会让面板在还没翻到底时就说「已过保留期」(v0.81 修掉的 + * 那句假话)。 * * `cursor` 由 `timelineCursorOf` 给出(优先后端的 `nextBefore`)。 */ export async function loadOlderPage(sid: string, cursor: string, send: Send): Promise { @@ -250,6 +264,7 @@ export async function loadOlderPage(sid: string, cursor: string, send: Send): Pr retentionHours: page.retentionHours ?? s.retentionHours, retentionEnabled: page.retentionEnabled ?? s.retentionEnabled, truncated: page.truncated, + retentionBoundaryReached: page.retentionBoundaryReached, source: page.source ?? s.source, nextBefore: page.nextBefore ?? s.nextBefore, loadingMore: false, diff --git a/frontend/web/src/admin/plugin/nav.ts b/frontend/web/src/admin/plugin/nav.ts index 14795d3..716338c 100644 --- a/frontend/web/src/admin/plugin/nav.ts +++ b/frontend/web/src/admin/plugin/nav.ts @@ -30,6 +30,10 @@ const ICON_ALLOW: Readonly> = { chat: "chat", docs: "docs", channel: "channel", + // browser:`browser` 插件(design/28 的浏览器与截图面板)声明的图标名。 + // 图标表里没有专门的浏览器图标(`Icon.tsx` 的闭集),用最贴近的 `image`(🖼 页面截图) + // 而不是回落到 `plugin` —— 回落会让「浏览器与截图」页在侧边栏里看不出是什么。 + browser: "image", device: "device", robot: "robot", clock: "clock", diff --git a/frontend/web/src/lib/api.ts b/frontend/web/src/lib/api.ts index 46363f6..ea27586 100644 --- a/frontend/web/src/lib/api.ts +++ b/frontend/web/src/lib/api.ts @@ -125,6 +125,7 @@ export { adminObservabilityLogs, adminObservabilityMetrics, adminObservabilityTrace, + adminSelfWakeStatus, adminSystemBackup, adminSystemRestore, adminSystemRevokeKeys, @@ -137,4 +138,6 @@ export type { AdminFlowTimelineResponse, AdminFlowTimelineRound, AdminFlowTurn, + AdminSelfWakeGate, + AdminSelfWakeStatus, } from "./api/adminSystem"; diff --git a/frontend/web/src/lib/api/adminSystem.ts b/frontend/web/src/lib/api/adminSystem.ts index 314b68e..1d90348 100644 --- a/frontend/web/src/lib/api/adminSystem.ts +++ b/frontend/web/src/lib/api/adminSystem.ts @@ -43,9 +43,69 @@ export function adminObservabilityLogs( return request(`/admin/observability/logs${q ? `?${q}` : ""}`); } -/** GET /api/v1/admin/backthink — persisted background-think records (newest first). */ -export function adminBackthink(limit?: number): Promise { - return request(`/admin/backthink${limitQuery(limit)}`); +/** GET /api/v1/admin/backthink?limit=&trigger= — persisted background-think records + * (newest first). + * + * `trigger` 是**服务端**筛选(契约 v0.82,切片 C2;`design/12 §C.6`):取值域 = + * 既有触发源 + `self_wake`(`heartbeat` / `startup` / `self_wake` / 事件主题 + * `iot.device_update` / `reminder.trigger` / `system.event`)。⚠️ 后端对白名单外的 + * 取值**如实 400**(`error.code = "invalid_trigger"`),**不静默忽略** —— 因此调用方 + * 只能传自己确实认识的值;省略 = 不过滤(返回全部,老行为逐字不变)。 + * + * ⚠️ 历史拉取的筛选结果**不含**实时帧:WS 的 `backthink` 事件始终是全量的, + * 面板必须**保留客户端筛选**兜住那一部分(见 Backthink.tsx)。 */ +export function adminBackthink(limit?: number, trigger?: string): Promise { + const qs = new URLSearchParams(); + if (limit && limit > 0) qs.set("limit", String(limit)); + if (trigger) qs.set("trigger", trigger); + const q = qs.toString(); + return request(`/admin/backthink${q ? `?${q}` : ""}`); +} + +/** 自排闸的**进程生效**口径与代码默认值(契约 v0.82 §4.10(e))。 + * + * ⚠️ `limitIsDefault` / `windowIsDefault` 只表示「生效值**恰好等于**代码默认值」, + * **不是**「`backthink.json` 里没写这个键」的断言(网关不读那个文件)。 */ +export interface AdminSelfWakeGate { + /** 容量 1 的待跑槽是否被占用(有一轮自排排着队还没跑)。 */ + pending: boolean; + /** 本窗口已用额度(与工具收据的 `used` 同一口径)。 */ + used: number; + limit: number; + windowSec: number; + defaultLimit: number; + defaultWindowSec: number; + limitIsDefault: boolean; + windowIsDefault: boolean; +} + +/** GET /api/v1/admin/backthink/self-wake 的响应(契约 v0.82 新增;只读、不审计, + * owner/admin 档)。 + * + * 三态**必须分开显示**(把「看不到」画成「已用 0 / 上限 0」就是把猜当事实): + * - `available:false` ⇒ 本进程给不出快照(服务未接线 / 实现不提供读口 / 闸不在): + * `gate` **缺席**、`reason` 说明原因; + * - `available:true, running:false` ⇒ 已接线但不在运行(未启动或已停止):`gate` + * 仍带配置面数字(上限/窗口/默认值),但此刻排不出新的一轮 —— `reason` 说明; + * - `available:true, running:true` ⇒ 正常态。 + * + * ⚠️ 它**不是写口**:调这两个旋钮要改 `$DATA_DIR/config/backthink.json` + 重启 + * (启动期读一次);`configFile` 只是相对落点(`config/backthink.json`)。 */ +export interface AdminSelfWakeStatus { + available: boolean; + running: boolean; + reason?: string; + configFile?: string; + gate?: AdminSelfWakeGate; +} + +/** GET /api/v1/admin/backthink/self-wake — 自排闸的只读快照(本窗口已用 N / 上限 M)。 + * + * 为什么有它:这个数字此前只出现在被拒那一轮的审计文案里(`backthink.self_wake.capped` + * 的 result),也就是「已经被拒过一次」才第一次看得见。它是纯读:不写库、不落审计、 + * 读它不改变闸的状态。 */ +export function adminSelfWakeStatus(): Promise { + return request("/admin/backthink/self-wake"); } /** 一条**已落盘轮次**的摘要(契约 v0.73 §4.11 的 `turns[]`)。面板用它渲染历史轮 @@ -116,13 +176,13 @@ export interface AdminFlowTimelineRound { lanes: AdminFlowLane[]; } -/** 链路**时间轴**端点的响应(契约 v0.80 §4.11.1;W1 的新出口)。 +/** 链路**时间轴**端点的响应(契约 v0.80 §4.11.1;v0.81 增量)。 * - * 🔴 **`truncated` 不等于「已过保留期」**(契约 §4.11.1(b).3 的原话):它的含义是 - * **「本次不是全部」** = `hasMore`(分页未到底)**或** 落盘源未接线(只剩内存最近一轮)。 - * 「更早的回合已过保留期」这句话的判据只能是 `retentionEnabled` + `retentionHours` + - * `truncated` **三者一起读**,**不得**因为「返回的回合比想象中少」就断言被清扫了 - * —— 否则面板会对着一页**仍然可翻**的历史说「已过保留期」(本切片核出的成对契约缺陷)。 */ + * 🔴 **`truncated` 不等于「已过保留期」**(契约 v0.81 §4.11.1(a) 的定稿口径):它的含义 + * 是 **「本次不是全部」** = `hasMore`(分页未到底)**或** 落盘源未接线(只剩内存最近一轮)。 + * 「更早的回合已过保留期」这句话的判据**只能是** `retentionBoundaryReached === true` + * ——🔴 **不得**再拿 `truncated` + `retentionEnabled` + 「翻到底了没」三者自己拼(那正是 + * v0.81 修掉的缺陷:一页还没拉完时面板会对着**仍然可翻**的历史说「已过保留期」)。 */ export interface AdminFlowTimelineResponse { /** 解析后的会话(回显)。取数只认调用时给的 `sessionId`,不依赖这一键。 */ sessionId?: string; @@ -137,11 +197,22 @@ export interface AdminFlowTimelineResponse { /** 库里**还有更早的回合**没被本次返回(= 还能继续翻页)。**这是唯一权威的翻页判据**; * 面板不要用「本页是否满页」去猜(满页的最后一页会被猜成「还有更早」,白打一次请求)。 */ hasMore?: boolean; - /** 本次**不是全部**:`hasMore`(分页未到底)或落盘源未接线。**不是**「已过保留期」。 */ + /** 本次**不是全部**:`hasMore`(分页未到底)或落盘源未接线。**不是**「已过保留期」 + * —— 面板据此标注「更早的轮次**未显示**」,说「已过保留期」请看下一行。 */ truncated: boolean; + /** 🆕 **v0.81**(契约 §4.11.1(a)):**该会话确有更早的轮次已被保留期清扫删掉**, + * 且本次已取到它现存最早的一端。**只有它为 `true` 时**才可以说「更早的轮次已过保留期」, + * 其余情形(含它 `false`)一律说「更早的轮次未显示」(`truncated`)或「只拿到最近一轮」 + * (`source:"live"`)。 + * + * ⚠️ 缺席(老后端)= **不知道** —— 一律按最保守话术处理(不说过期),**不**拿别的键回推。 + * ⚠️ 它**少说也不说谎**:`false` **不等于**「没被清扫」(整段历史都被清扫 / 有轮次号未知 + * 的老行 / 最早那一轮落盘失败,三种盲区见契约 §4.11.1(a) 表)。 */ + retentionBoundaryReached?: boolean; /** `flow_graphs` 的保留期(小时,**下取整**)。`≤0` = 后端配置为不回收。 */ retentionHours: number; - /** 保留期清扫**是否启用**。`false` ⇒ 「更早的回合」**不是**被清扫掉的,别说成过期。 */ + /** 保留期清扫**是否启用**。`false` ⇒ 「更早的回合」**不是**被清扫掉的,别说成过期。 + * ⚠️ 它只说明「清扫开着」,**不说明「发生过清扫」**(判据见 `retentionBoundaryReached`)。 */ retentionEnabled?: boolean; }