From 739b8c56f9e7c1702ec8519e587a6d8aba3001b8 Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 27 Sep 2026 07:05:05 +0000 Subject: [PATCH 1/2] fix(web): purge legacy demo sessionId residue from localStorage on read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 旧前端曾把 demo fallback 值 sess-001 持久化到 active_session;一次性 purge 版本 key 已写不再清理,读取侧无校验导致老浏览器 + 无可见会话 账号首屏 4x chat 404。提取共享 LEGACY_DEMO_SESSION_IDS 常量, loadActiveSessionId 命中即清除并返回 fallback(幂等),loadMessages 复用同一集合。 --- web/src/data/storage.ts | 18 +++++- web/src/data/storageActiveSession.test.ts | 69 +++++++++++++++++++++++ 2 files changed, 84 insertions(+), 3 deletions(-) create mode 100644 web/src/data/storageActiveSession.test.ts diff --git a/web/src/data/storage.ts b/web/src/data/storage.ts index e25c10f5..806345cd 100644 --- a/web/src/data/storage.ts +++ b/web/src/data/storage.ts @@ -104,10 +104,22 @@ export function saveSessions(sessions: ProjectSession[]): void { safeSetItem(STORAGE_KEYS.SESSIONS, sessions); } +// 旧版 demo/混聊 sessionId 集合:出现在任何持久化位置都视为残留,读取时丢弃。 +// 「一次性 purge 之后被旧代码写回」的值只能靠读取侧幂等校验兜底(ISSUE-20260927 残留路径)。 +export const LEGACY_DEMO_SESSION_IDS = new Set(['sess-cusa-001', 'sess-001', 'sess-002']); + export function loadActiveSessionId(fallback = ''): string { const resolvedFallback = fallback || NEUROPULSE_ACTIVE_SESSION_ID || ''; if (typeof window === 'undefined' || !window.localStorage) return resolvedFallback; - return window.localStorage.getItem(STORAGE_KEYS.ACTIVE_SESSION) || resolvedFallback; + const stored = window.localStorage.getItem(STORAGE_KEYS.ACTIVE_SESSION); + if (!stored) return resolvedFallback; + if (LEGACY_DEMO_SESSION_IDS.has(stored)) { + // 旧前端曾把 demo fallback 值持久化到本 key;命中即清除并返回 fallback, + // 避免对不存在的会话发出 window/unread/categories/dates 假请求。 + window.localStorage.removeItem(STORAGE_KEYS.ACTIVE_SESSION); + return resolvedFallback; + } + return stored; } export function saveActiveSessionId(sessionId: string): void { @@ -117,8 +129,8 @@ export function saveActiveSessionId(sessionId: string): void { export function loadMessages(): ChatMessage[] { const loaded = safeGetItem(STORAGE_KEYS.MESSAGES, DEFAULT_MESSAGES); - // 丢弃旧版混聊 sessionId,避免「有消息但当前私聊窗口对不上」 - const legacyIds = new Set(['sess-cusa-001', 'sess-001', 'sess-002']); + // 丢弃旧版混聊 sessionId,避免「有消息但当前私聊窗口对不上」(与 active_session 读取侧共用同一残留集合) + const legacyIds = LEGACY_DEMO_SESSION_IDS; const defaultMsgMap = new Map(DEFAULT_MESSAGES.map((m) => [m.id, m])); const cleaned = loaded .filter((m) => !legacyIds.has(m.sessionId)) diff --git a/web/src/data/storageActiveSession.test.ts b/web/src/data/storageActiveSession.test.ts new file mode 100644 index 00000000..9fa0f60a --- /dev/null +++ b/web/src/data/storageActiveSession.test.ts @@ -0,0 +1,69 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { LEGACY_DEMO_SESSION_IDS, STORAGE_KEYS, loadActiveSessionId, saveActiveSessionId } from './storage'; + +function installMemoryLocalStorage(): { restore: () => void } { + const store = new Map(); + const originalWindow = (globalThis as { window?: unknown }).window; + const localStorage = { + getItem(key: string) { + return store.has(key) ? store.get(key)! : null; + }, + setItem(key: string, value: string) { + store.set(key, value); + }, + removeItem(key: string) { + store.delete(key); + }, + }; + (globalThis as { window: unknown }).window = { localStorage }; + return { + restore: () => { + if (originalWindow === undefined) { + delete (globalThis as { window?: unknown }).window; + } else { + (globalThis as { window: unknown }).window = originalWindow; + } + }, + }; +} + +test('残留的 legacy demo sessionId 被读取侧清除并返回 fallback', () => { + for (const legacy of LEGACY_DEMO_SESSION_IDS) { + const { restore } = installMemoryLocalStorage(); + try { + saveActiveSessionId(legacy); + assert.equal(loadActiveSessionId(), ''); + // key 已被清除:再次读取仍是 fallback,且不会把 legacy 值写回 + assert.equal(window.localStorage.getItem(STORAGE_KEYS.ACTIVE_SESSION), null); + assert.equal(loadActiveSessionId('sess-real-1'), 'sess-real-1'); + } finally { + restore(); + } + } +}); + +test('真实 sessionId 不被误清', () => { + const { restore } = installMemoryLocalStorage(); + try { + saveActiveSessionId('sess-real-42'); + assert.equal(loadActiveSessionId(), 'sess-real-42'); + assert.equal(window.localStorage.getItem(STORAGE_KEYS.ACTIVE_SESSION), 'sess-real-42'); + // 显式 fallback 只在无值/残留时生效,不覆盖真实值 + assert.equal(loadActiveSessionId('sess-other'), 'sess-real-42'); + } finally { + restore(); + } +}); + +test('无 window 环境(SSR)安全返回 fallback', () => { + const originalWindow = (globalThis as { window?: unknown }).window; + delete (globalThis as { window?: unknown }).window; + try { + assert.equal(loadActiveSessionId(), ''); + } finally { + if (originalWindow !== undefined) { + (globalThis as { window: unknown }).window = originalWindow; + } + } +}); From 083c63bb9c2e7047b01644e59e373021199d1e88 Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 27 Sep 2026 07:05:09 +0000 Subject: [PATCH 2/2] docs(issue): register active-session residue issue and r3 task record MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 登记 ISSUE-20260927-active-session-residue(ISSUE-20260927-demo-fallback 修复残留路径);新增 r3 任务主记录(根因/同类排查/修复方案/验证证据); CURRENT 登记 r3c 任务行。 --- .doc/audit/ISSUES.md | 1 + .doc/ops/CURRENT.md | 1 + ...tend-demo-fallback-localstorage-residue.md | 108 ++++++++++++++++++ 3 files changed, 110 insertions(+) create mode 100644 .doc/ops/short-term/2026-09-27-frontend-demo-fallback-localstorage-residue.md diff --git a/.doc/audit/ISSUES.md b/.doc/audit/ISSUES.md index 897d31f9..83428d7d 100644 --- a/.doc/audit/ISSUES.md +++ b/.doc/audit/ISSUES.md @@ -30,3 +30,4 @@ | ISSUE-20260926-governance-multi-entry | docgov / 多意图 / 多入口 / Coordinator / OwnerMux / GovernanceEngine | docgov 三入口 + Coordinator ~40 case 与 intentflow OwnerMux 平行,鉴权/确认/审计易漂移 | high | deferred | pi | 2026-09-26;flag 漂移面已修(dual-flags-drift);入口合并是多日级重构,需独立任务授权与回归矩阵 | [主记录](../remediation/2026-09-26-system-code-review-fixes.md#6-find-qual-01-双套-flags-静默漂移issue-20260926-dual-flags-drift含残差) | 沿用 failclosed-bypass-contract | | ISSUE-20260926-server-pool-wiring | server / swboot_handlers / Runtime.Pool / 组合根越界 / FIND-ARCH-02 | server 持 Pool 直装 swboot repo/executor 并直查表,成为第二组合根(本轮未授权修复) | critical | open | pi | 2026-09-26;代码审证据齐(审计 FIND-ARCH-02),待修复授权 | [主记录](../audit/2026-09-26-system-code-review.md) | 待修复后补 | | ISSUE-20260927-demo-fallback | web / App.tsx / sess-001 / projectId fallback / demo seeds 关闭 | demo seeds 关闭模式下前端硬编码 fallback(激活 sess-001、项目 1)对真实后端发假请求,新登录用户首屏 9 个 404/403(chat 窗口被迫回退本地缓存) | medium | resolved | pi | 2026-09-27;dev 真实浏览器修复前后对照(author+member 双账号:9 假请求→0) | [主记录](../ops/short-term/2026-09-27-frontend-demo-fallback.md) | [经验小结](../ops/short-term/2026-09-27-frontend-demo-fallback.md#经验小结)(独立 lesson 待后续任务补登) | +| ISSUE-20260927-active-session-residue | web / storage.ts / loadActiveSessionId / localStorage 残留 / sess-001 / demo-seed-purge 写回窗口 | 旧前端把 demo fallback 值写回 localStorage active_session;一次性 purge 已执行不再清,读取侧无 legacy 校验,老浏览器 + 无可见会话账号首屏 4×chat 404(ISSUE-20260927-demo-fallback 修复残留路径) | medium | in_progress | pi | 2026-09-27;dev PG 只读 + 代码链路已证实;修复验证见主记录 | [主记录](../ops/short-term/2026-09-27-frontend-demo-fallback-localstorage-residue.md) | [经验小结](../ops/short-term/2026-09-27-frontend-demo-fallback-localstorage-residue.md#经验小结补充-issue-20260927-经验) | diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index ffa153ac..5b734e32 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -18,6 +18,7 @@ | owner | task | branch | worktree | mutex | status | |---|---|---|---|---|---| +| pi | frontend-demo-fallback-2026-09-27-r3c | fix/frontend-demo-fallback-2026-09-27-r3 | .worktrees/frontend-demo-fallback-2026-09-27-r3 | web-chat-fallback | in_progress | | pi | frontend-demo-fallback-2026-09-27-r2 | fix/frontend-demo-fallback-2026-09-27 | (已清理) | web-chat-fallback | done | | Cursor | full-system-browser-e2e-2026-09-26 | feature/full-system-browser-e2e-2026-09-26 | .worktrees/full-system-browser-e2e-2026-09-26 | env:dev+docs-test-report | done | | pi | sysadmin-maturity-refresh-2026-09-26 | feature/sysadmin-maturity-refresh-2026-09-26 | .worktrees/sysadmin-maturity-refresh-2026-09-26 | sysadmin-maturity | done | diff --git a/.doc/ops/short-term/2026-09-27-frontend-demo-fallback-localstorage-residue.md b/.doc/ops/short-term/2026-09-27-frontend-demo-fallback-localstorage-residue.md new file mode 100644 index 00000000..ba3b40a3 --- /dev/null +++ b/.doc/ops/short-term/2026-09-27-frontend-demo-fallback-localstorage-residue.md @@ -0,0 +1,108 @@ +--- +title: "localStorage 残留 legacy demo sessionId 导致首屏 chat 404(ISSUE-20260927 修复残留路径)" +task_id: "frontend-demo-fallback-2026-09-27-r3c" +status: in_progress +created: 2026-09-27 +updated: 2026-09-27 +owner: pi +task_type: implementation +task_tier: small +policy_revision: "agent-governance-2026-09-21-r1 @ dev 87db3621c62f8d9473b7191cce3a782cfbaf3d90" +branch: fix/frontend-demo-fallback-2026-09-27-r3 +worktree: .worktrees/frontend-demo-fallback-2026-09-27-r3 +base_dev_sha: "87db3621c62f8d9473b7191cce3a782cfbaf3d90" +write_scope: ["web/src", ".doc"] +allowed_environments: ["dev-readonly", "dev-browser-verify"] +head_sha: "" +mutex: "web-chat-fallback" +review_state: not_required # 小任务;沿用 ISSUE-20260927 修复线,验证契约前端范围全过 +integration_state: pending +deployment: pending +cleanup: pending +cleanup_owner: "独立集成/维护执行者" +cleanup_trigger: "owner 退出且 dev 合入+验收证据齐备" +remote_sync: not_requested +--- + +# localStorage 残留 legacy demo sessionId 导致首屏 chat 404 + +## 用户目标与授权 + +- 用户目标/授权来源:用户报告 dev(frp-mix.com:58880)控制台仍出现 4 个 `session_id=sess-001` 的 chat API 404(无 `projects/1` 假请求);本轮会话明确授权"按上轮任务同路径修复(worktree 修改 → 测试 → 合入 dev → dev 真实浏览器复验 → 清理)"。 +- 交付终点:修复合入 dev、dev 实际运行验证通过、worktree 清理。 +- 禁止项:不 push 远端;不动 test/main;不改后端。 +- 例外:无。本任务是 [ISSUE-20260927-demo-fallback](./2026-09-27-frontend-demo-fallback.md) 修复的残留缺口修正,沿用同一 issue 与 mutex。 + +## 问题事实与根因(2026-09-27 排查) + +- 现象:登录后首屏 `GET /api/v1/chat/messages/window|unread|categories|dates?session_id=sess-001` 4×404;**无** `projects/1` 假请求。 +- 运行态核实:dev vite :3100(主目录,base 87db3621,修复后代码)按需编译;`projects/1` 假请求消失证明上轮 fallback 常量修复已生效,本例 `sess-001` 来源不是 fallback 常量。 +- 代码核实: + - `web/src/data/storage.ts` `loadActiveSessionId()`:localStorage `smdf_agent_workbench_v6_active_session` **已存值优先于 fallback**(:110); + - 旧前端(上轮修复前)由 fallback 激活 `sess-001` 后,`useWorkbenchPersistence`(:44 `saveActiveSessionId`)把它**写回** localStorage; + - 一次性清理 `purgeDemoSeedsFromLocalStorage`(`demo-seed-purge-v1` 版本 key 幂等跳过)在旧代码写回**之前**已执行过,不再清; + - `loadMessages` 对 messages 有 `legacyIds` 防御(:121),`loadActiveSessionId` 对 active_session **没有**——同一机制缺口。 +- 数据核实(dev PG :5433 只读):`chat_sessions` 80 行无 `sess-001`;user_id=4(admin,报障账号)拥有 0 个会话 → 前端 `visibleSessions` 为空 → 会话纠正 effect(App.tsx:268)不触发 → `sess-001` 持续激活。 +- 根因:上轮修复只切断"生成"路径(fallback 常量),未覆盖"旧代码已把 demo 值持久化到用户浏览器"的读取路径;读取侧无 legacy 校验 + 一次性 purge 无法覆盖其后的写回窗口。 +- 为何上轮验证未拦:真实浏览器复验用"空 storage"登录(phantom=0),未覆盖"localStorage 已残留 sess-001 + 当前账号无可见会话"组合。 +- 功能影响:老浏览器(修复前访问过 dev 且当时激活 demo 会话 id)+ 无可见会话账号首屏 4×404 噪音、chat 回退本地缓存;无数据损坏。 + +## 同类排查 + +- grep 生产代码:`sess-001` 常量已无(仅 tests + storage.ts legacy 清理集合)。 +- 同 key 家族:`ACTIVE_SESSION` 是唯一"旧代码写回 demo 值 + 新代码读出不校验"路径;messages 已有 legacyIds 防御;sessions/deliverables/users/projects 走一次性 purge 且写回源为当前内存值(新代码不产生 demo 值)。 +- `activeDocId` 的 `doc-001`/5201/3 本地选择残留维持上轮结论(无 API 请求,另行清理,不属本 bug)。 +- ISSUES.md 开工检索命中 ISSUE-20260927-demo-fallback(本条即其残留路径);lessons 无新命中。 + +## 修复方案(A:读取侧幂等校验,共用机制) + +1. `storage.ts` 提取共享常量 `LEGACY_DEMO_SESSION_IDS = {'sess-cusa-001','sess-001','sess-002'}`(与 `loadMessages` 同一集合,单一来源)。 +2. `loadActiveSessionId` 读出 localStorage 值后命中该集合 → `removeItem` 该 key 并返回 fallback。每次启动幂等生效,覆盖任何历史/未来写回窗口,不依赖一次性 purge 版本升级。 +3. `loadMessages` 复用共享常量,行为不变。 + +## 步骤与验收 + +1. [x] 根因与同类排查 — 见上文。 +2. [x] 实施修复 — `web/src/data/storage.ts`(常量提取 + 读取侧清理);新增 `web/src/data/storageActiveSession.test.ts`(3 用例)。 +3. [x] 单测 — node:test 515 pass/0 fail(含新增 3);vitest 34 pass/0 fail。 +4. [x] 构建 — demo off/on 双模式 `npm run build` 均成功。 +5. [x] 真实浏览器验证(worktree vite :3101 + dev 后端 :8080,Playwright chromium headless): + - A 原例:admin(user 4)+ 预置 `active_session='sess-001'` + purge 版本 key 已写 → 37 API 全 2xx、phantom=0、4xx5xx=0、残留 key 被清除; + - B 变体:`sess-cusa-001` 同样清除、phantom=0; + - C 回归:author(有真实会话)+ 预置 `sess-002` → 56 API 全 2xx、正常激活真实会话 `sess-p6020-u60`(真实 id 不误清)。 +6. [ ] 合入 dev(candidate 流程)。 +7. [ ] dev 主入口(:3100)实际运行复验。 +8. [ ] 清理 worktree、收口(ISSUES.md 更新、任务终态)。 + +## 验证与证据 + +验证日期 2026-09-27;被测代码:worktree `fix/frontend-demo-fallback-2026-09-27-r3`(基准 dev `87db3621` + 本修复);环境:dev 后端 `127.0.0.1:8080`(ENV=dev 裸进程)+ worktree 独立 vite `:3101`(proxy /api → 8080)。账号:admin(本机 `.env`)与 author `e2e26_author_mui2xpg2`(来源 `2026-09-26-full-system-browser-e2e/audit-login-credentials.md`,凭证不入库)。 + +| 范围 | 命令与环境 | 预期 | 实际 / 判定 | 证据 | +|---|---|---|---|---| +| 单测(新增) | `npx tsx --test src/data/storageActiveSession.test.ts` | 3 pass | **PASS**:legacy 清除+返回 fallback、真实 id 不误清、SSR 安全 | 控制台输出(本节) | +| 单测(全量) | `npm test`(worktree) | 全绿 | **PASS**:node:test 515 pass/0 fail;vitest 34 pass/0 fail | 控制台输出 | +| 构建 | `npm run build`(off/on 双模式) | 成功 | **PASS**:66 组产物预压缩 ×2 | 控制台输出 | +| 原例(修复后) | 场景 A | 0 假请求 | **PASS**:37 API 全 2xx,phantom(sess-001)=0,key 被清除 | SUMMARY 输出(下附) | +| 同类变体 | 场景 B(sess-cusa-001) | 0 假请求 | **PASS**:37 API 全 2xx,phantom=0 | 同上 | +| 正常行为回归 | 场景 C(author,真实会话) | 不受损 | **PASS**:56 API 全 2xx,激活真实会话 | 同上 | + +```text +SUMMARY: [{"phantom":0,"bad":0,"total":37,"activeAfter":""}, // A: admin + sess-001 + {"phantom":0,"bad":0,"total":37,"activeAfter":""}, // B: admin + sess-cusa-001 + {"phantom":0,"bad":0,"total":56,"activeAfter":"sess-p6020-u60"}] // C: author 回归 +``` + +注:临时验证脚本未入库(/tmp 已删);worktree 独立 vite :3101 已停。 + +## 经验小结(补充 ISSUE-20260927 经验) + +- 触发条件:持久化读取路径存在"旧版本可写入、新版本不校验"的值域(本例 active_session)。 +- 机制:一次性 purge(版本 key 幂等)无法覆盖"purge 之后旧代码写回"的窗口;localStorage 已存值优先于 fallback,修掉 fallback 常量不影响已持久化的值。 +- 应做检查:修复"假 ID 产生请求"类问题时,区分生成路径与持久化路径;对读取侧加值域校验(与 messages 的 legacyIds 同源),验证必须覆盖"老浏览器残留值"状态而非仅空 storage。 +- 验证方法:预置残留 localStorage(含 purge 版本 key)+ 无可见会话账号登录,统计 Network 假请求。 + +## 收口与交接 + +- 剩余工作:合入 dev、dev 主入口复验、清理。 +- 状态分别记录:见 frontmatter。