feat(admin): 时间轴只读权威键 + 自排筛选轴 + browser 面板图标(Y2+Y3+Y4 前端一并落)
- 时间轴:页首「已过保留期」的**唯一**判据改为 retentionBoundaryReached === true(truncated 只出
「未显示」);缺席(老后端)一句话都不说;删掉旧的 truncated+retentionEnabled+翻到底 启发式
- Backthink:筛选轴接上服务端 ?trigger=(客户端筛选保留作兜底)+ 自排闸用量行(三态文案)
- plugin/nav.ts 加 browser 图标
- 真机(隔离实例,46/46):翻页不重不漏、三种「必须 false」的权威键语义、**重启后 round 不重复**
⚠️ 未验:真机上没构造出权威键 true(需真有更早轮次被清扫)——别读成「真机验过 true」
This commit is contained in:
1 parent
e0a6bca501
commit
57d0e8c543
11 files changed
+490
-102
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user