feat(admin): 时间轴只读权威键 + 自排筛选轴 + browser 面板图标(Y2+Y3+Y4 前端一并落)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 时间轴:页首「已过保留期」的**唯一**判据改为 retentionBoundaryReached === true(truncated 只出
  「未显示」);缺席(老后端)一句话都不说;删掉旧的 truncated+retentionEnabled+翻到底 启发式
- Backthink:筛选轴接上服务端 ?trigger=(客户端筛选保留作兜底)+ 自排闸用量行(三态文案)
- plugin/nav.ts 加 browser 图标
- 真机(隔离实例,46/46):翻页不重不漏、三种「必须 false」的权威键语义、**重启后 round 不重复**
⚠️ 未验:真机上没构造出权威键 true(需真有更早轮次被清扫)——别读成「真机验过 true」
This commit is contained in:
AskaEth committed 2026-10-06 21:48:52 +08:00
1 parent e0a6bca501
commit 57d0e8c543
11 files changed
+490 -102

No files matched your search

+93 -4
View File
@@ -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 (
<div className="flex flex-wrap items-center gap-2 text-xs text-[var(--mu-text-lo)]">
<Tag tone={error || exhausted ? "warn" : healthy ? "ok" : "neutral"}>自排闸</Tag>
<span>{selfWakeUsageText({ data, loading, error })}</span>
<span>
旋钮落点:<code>$DATA_DIR/{data?.configFile ?? "config/backthink.json"}</code>
(启动期读一次,改后需重启 —— 面板写路径尚未提供)
</span>
</div>
);
}
/** 打扰筛选轴的取值。 */
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<string, BackthinkItem>();
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 {
<QuietHoursFactsCard />
<SelfWakeUsageLine data={usage.data} loading={usage.loading} error={usage.error} />
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="w-12 shrink-0 text-xs text-[var(--mu-text-lo)]">打扰</span>
@@ -227,7 +312,9 @@ export function BackthinkModule(): React.ReactNode {
<FilterPill active={triggerFilter === "self_wake"} onClick={() => setTriggerFilter("self_wake")}>自我唤醒</FilterPill>
<FilterPill active={triggerFilter === "heartbeat"} onClick={() => setTriggerFilter("heartbeat")}>例行心跳</FilterPill>
<span className="text-xs text-[var(--mu-text-lo)]">
显示 {items.length} / {allItems.length} 条 · 两个筛选轴取交集,且只筛当前已拉取的这一页(不是全部历史)
显示 {items.length} / {allItems.length} 条 · 「打扰」轴只筛本页;「触发源」轴已接服务端
(历史按 ?trigger= 筛选,WS 实时帧仍全量)⇒ 两轴取交集时客户端筛选仍是兜底,
「筛不到」不等于「没发生」
</span>
</div>
</div>
@@ -252,7 +339,9 @@ export function BackthinkModule(): React.ReactNode {
<Notice>数据源:/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+ 提供,当前为只读展示。
<br />
触发源标签(契约 §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`(启动问候)只在「全部」下可见。
<br />
自排闸用量(契约 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 的面板声明机制,尚未接线。
<br />
新增的「穿透安静时段 / 紧急度」标记(契约 v0.70):穿透 = 这一轮落在安静时段窗口内、仍然被放行发出(只有 high 及以上档位做得到);紧急度**三态画得不一样** —— 把下限当精确值、把未知当「例常」都是把猜当事实:① **精确档位**(「例常」/「紧急(高)」/「紧急·安全域」)= 该轮实际生效的档位,随记录落库;② **≥ 下限**(带 `urgencyLowerBound` 的旧行:该行没有存档档位,此值由安静时段窗口规则推导 —— 「≥ 高」只表示至少 high、可能更高);③ **档位未知** = 存档里没有这个事实,**不是**「例常」。⚠️ 实时帧带上记录里的精确档位(`backthink.Record` 自 X2a 起带 `urgency`),但**不带** `urgencyLowerBound` / `quietHoursBypassed`(后两者由网关按安静时段窗口推导)⇒ 本次会话新到的行先显示精确档位与推送结果,穿透标记与旧行的下限标记要等重开页面由历史拉取补齐。</Notice>
</div>
@@ -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` 头注释的同一理由)。改一个块的展开态由父层持有
@@ -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, "更早的轮次未显示"), "同一份状态不得两种说法都给");
@@ -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`);
@@ -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<AdminFlowTimelineResponse["source"]>;
/** 时间轴响应 → 取数层状态用的**纯**解包(缺键一律按诚实缺省:没有回合就是空数组、
* `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),
@@ -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<string, unknown>), 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`);
})();
@@ -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> = {}): ServerMode {
const cfg: ServerMode = { pageSize: 2, kind: "persisted", ...mode };
@@ -152,6 +155,7 @@ function installBackend(mode: Partial<ServerMode> = {}): ServerMode {
nextBefore: "",
hasMore: false,
truncated: true,
retentionBoundaryReached: false,
retentionHours: 168,
retentionEnabled: true,
}
@@ -162,6 +166,7 @@ function installBackend(mode: Partial<ServerMode> = {}): 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<string, unknown> = {
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);
@@ -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<void> {
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<void> {
/** 取更早的一页:合并去重、保留期取「已拿到的优先」;失败只影响翻页,**已拿到的回合照旧
* 显示**。
*
* ⚠️ `truncated` **逐页覆盖,不累积** —— 它描述的是**最近这次响应**「是不是全部」,
* 而不是「历史上曾经缺过」(翻到底之后它就该消失;累积会让面板在翻到底后仍说
* 「更早的轮次已过保留期」)。
* ⚠️ `truncated` 与 `retentionBoundaryReached` **逐页覆盖,不累积** —— 两者描述的都是
* **最近这次响应**(前者:「本次是不是全部」;后者:「到没到保留期边界」),而不是「历史上
* 曾经缺过」。覆盖才对:一页没拉完时后端说 `retentionBoundaryReached:false`,翻到最早那
* 一页才会变 `true`;反过来累积会让面板在还没翻到底时就说「已过保留期」(v0.81 修掉的
* 那句假话)。
*
* `cursor` 由 `timelineCursorOf` 给出(优先后端的 `nextBefore`)。 */
export async function loadOlderPage(sid: string, cursor: string, send: Send): Promise<void> {
@@ -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,
+4
View File
@@ -30,6 +30,10 @@ const ICON_ALLOW: Readonly<Record<string, IconName>> = {
chat: "chat",
docs: "docs",
channel: "channel",
// browser:`browser` 插件(design/28 的浏览器与截图面板)声明的图标名。
// 图标表里没有专门的浏览器图标(`Icon.tsx` 的闭集),用最贴近的 `image`(🖼 页面截图)
// 而不是回落到 `plugin` —— 回落会让「浏览器与截图」页在侧边栏里看不出是什么。
browser: "image",
device: "device",
robot: "robot",
clock: "clock",
+3
View File
@@ -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";
+82 -11
View File
@@ -43,9 +43,69 @@ export function adminObservabilityLogs(
return request<AdminLogLine[]>(`/admin/observability/logs${q ? `?${q}` : ""}`);
}
/** GET /api/v1/admin/backthink — persisted background-think records (newest first). */
export function adminBackthink(limit?: number): Promise<BackthinkItem[]> {
return request<BackthinkItem[]>(`/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<BackthinkItem[]> {
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<BackthinkItem[]>(`/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<AdminSelfWakeStatus> {
return request<AdminSelfWakeStatus>("/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;
}