feat(flow): 链路图本地落盘 + 多轮历史回放(design/19 §P3,契约 v0.73)
主人要求「链路流程应该有本地数据,不然每次重载之后页面就没数据了」: - 落盘:迁移 0022_flow_graphs(session_id/turn_id/graph_json/时间戳 + 唯一键 upsert)+ internal/runtime/flowgraph(RecordRound/Graph/Latest/Turns/Turn + 保留期 GC) - 写入:loop.EndRound **每轮一条**(锁外深拷贝后写;失败 WARN 且不打印图内容);只落发布会话白名单(复用 flowSessionAllowed) - 读回:端点加 turnId + source(live/persisted/none) + turns[](最新在前,空为 []);**内存优先、miss 才落盘**(最新一轮永远最新) - 前端:flowGraphs 纳入 persistOptions.partialize(**刷新即刻有图**,version 3→4 + migrate)+ 历史轮选择器 + 诚实文案;顺带收 L1 转交的 flow/NodeDetail.tsx 节点日志倒序 - 新 env 键:FLOW_GRAPH_RETENTION=168h / FLOW_GRAPH_GC_INTERVAL=1h - 变异实测:每 span 一行 / 保留期守卫失效 / partialize 去掉 flowGraphs / 去掉内存优先 / 白名单失效 ⇒ 全部打红 - 3 个禁动面文件的纯追加改动(Flow.tsx 挂选择器、FlowSnapshotHint 纠已成假话的文案、其渲染断言随之更新)由主会话裁定认可 - 登记存疑:图桶持久化无体积上限;useFlowTurns 的 effect 无自动化证据(项目无 jsdom)
This commit is contained in:
1 parent
84c124f5a9
commit
a6cb44b8a8
38 files changed
+3047
-91
No files matched your search
@@ -0,0 +1,72 @@
|
||||
// 前端 store 持久化的**纯逻辑**测试(切片 F2;主人要求「每次重载之后页面就没数据」)。
|
||||
//
|
||||
// 只钉一件事:`flowGraphs`(按会话分的链路图桶)**必须**在 zustand persist 的
|
||||
// `partialize` 里 —— 否则刷新页面时图桶被抹掉,「刷新即刻有图」就不成立。同时钉住
|
||||
// `migrate` 对旧版本(v3 之前没有图桶)给出空桶默认。
|
||||
//
|
||||
// 跑法:
|
||||
//
|
||||
// cd frontend/web \
|
||||
// && ./node_modules/.bin/esbuild src/store/persistOptions.test.ts \
|
||||
// --bundle --platform=node --format=esm \
|
||||
// --outfile=/tmp/cyrene-persist-options.test.mjs \
|
||||
// && node /tmp/cyrene-persist-options.test.mjs
|
||||
//
|
||||
// `tsc --noEmit` 会对本文件做类型检查,因此它不 import node:*;只类型引入 AppStore
|
||||
// (编译后被擦除,不触发 useAppStore 的运行时副作用)。
|
||||
|
||||
import type { AppStore } from "./useAppStore";
|
||||
import type { FlowGraph } from "../lib/types";
|
||||
import { persistOptions } from "./persistOptions";
|
||||
|
||||
let checks = 0;
|
||||
|
||||
function ok(cond: boolean, what: string): void {
|
||||
checks += 1;
|
||||
if (!cond) throw new Error(`FAILED: ${what}`);
|
||||
}
|
||||
|
||||
function same<T>(got: T, want: T, what: string): void {
|
||||
checks += 1;
|
||||
if (got !== want) throw new Error(`FAILED: ${what} — got ${String(got)}, want ${String(want)}`);
|
||||
}
|
||||
|
||||
const graph: FlowGraph = {
|
||||
id: "web-1",
|
||||
sessionId: "web",
|
||||
nodes: [{ id: "input", label: "你好", kind: "input", status: "done" }],
|
||||
edges: [],
|
||||
};
|
||||
|
||||
const state = {
|
||||
activeSessionId: "web",
|
||||
sessions: { web: { id: "web" } },
|
||||
personas: [],
|
||||
moods: { cyrene: "开心" },
|
||||
context: {},
|
||||
backthink: [],
|
||||
flowGraphs: { web: graph },
|
||||
} as unknown as AppStore;
|
||||
|
||||
// ── ① partialize 必须含 flowGraphs(刷新即刻有图的内核) ─────────────────────
|
||||
const persisted = persistOptions.partialize(state) as unknown as Record<string, unknown>;
|
||||
ok("flowGraphs" in persisted, "partialize 必须包含 flowGraphs —— 否则刷新后图桶被抹掉");
|
||||
same(persisted.flowGraphs, state.flowGraphs, "持久化的图桶必须是当前 store 里的那份");
|
||||
|
||||
// ── ② 版本号已升到 4(v3 之前没有图桶,迁移时必须补空桶) ─────────────────────
|
||||
same(persistOptions.version, 4, "引入 flowGraphs 持久化后版本应为 4");
|
||||
|
||||
// ── ③ migrate:旧版本(没有图桶)⇒ 补一个空桶;已存在的桶保留 ────────────────
|
||||
const migratedOld = persistOptions.migrate({}, 3) as unknown as Record<string, unknown>;
|
||||
same(JSON.stringify(migratedOld.flowGraphs), "{}", "v3 旧状态迁移后图桶是空对象(随后由快照补)");
|
||||
|
||||
const migratedKeep = persistOptions.migrate({ flowGraphs: { web: graph } }, 3) as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
ok(
|
||||
(migratedKeep.flowGraphs as Record<string, FlowGraph>).web?.id === "web-1",
|
||||
"迁移必须保留已有的图桶"
|
||||
);
|
||||
|
||||
console.log(`persist options test: ${checks} checks passed`);
|
||||
@@ -10,7 +10,7 @@ import type { AppStore } from "./useAppStore";
|
||||
*/
|
||||
export const persistOptions = {
|
||||
name: "cyrene.app",
|
||||
version: 3,
|
||||
version: 4,
|
||||
// v1 persisted the whole chat (`messages` + a single `sessionId`); v2 added
|
||||
// one resolved `currentPersona`, which can no longer describe the active role
|
||||
// now that the role belongs to a *session*. Both shapes are dropped:
|
||||
@@ -18,6 +18,8 @@ export const persistOptions = {
|
||||
// the session list. `moods` is transient by nature (a restart resets a role's
|
||||
// mood), so it is not carried across either. A v1 state was never trusted for
|
||||
// the session metadata, so those users simply restart on the main session.
|
||||
// v4 (切片 F2) added `flowGraphs` (the per-session flow-graph buckets): a v3
|
||||
// state has none, so it starts empty and is refilled by the snapshot hook.
|
||||
migrate: (persisted: unknown, version: number) => {
|
||||
const p = (persisted ?? {}) as Partial<AppStore>;
|
||||
const fromV1 = version < 2;
|
||||
@@ -28,6 +30,7 @@ export const persistOptions = {
|
||||
moods: {},
|
||||
context: p.context ?? {},
|
||||
backthink: p.backthink ?? [],
|
||||
flowGraphs: p.flowGraphs ?? {},
|
||||
};
|
||||
},
|
||||
partialize: (s: AppStore) => ({
|
||||
@@ -43,5 +46,11 @@ export const persistOptions = {
|
||||
moods: s.moods,
|
||||
context: s.context,
|
||||
backthink: s.backthink,
|
||||
// v4 (切片 F2): the per-session flow-graph buckets are persisted too, so a
|
||||
// refresh paints the last-seen graph immediately instead of flashing the
|
||||
// empty state until the snapshot fetch returns (主人 2026-10-05「每次重载
|
||||
// 之后页面就没数据了」). Bounded by `MAX_GRAPHS` (store/sessionCache.ts);
|
||||
// the backend stays the truth and the snapshot/history endpoints refill it.
|
||||
flowGraphs: s.flowGraphs,
|
||||
}),
|
||||
};
|
||||
Reference in new issue
Block a user