Files
dsh_shenxian/dsh-server-docs/04-调整方案/77-回到页面自检与就地恢复-恢复过程可见化.md
T
admin 5ad755116e chore(docs): 文档库并入代码仓(R4 选 a)+ 索引/台账跟进
1) dsh-server-docs/ 从工作区(原 E:\...\aliyun-dsh-server\dsh-server-docs)**整体并入本仓**,
   保留目录名 ⇒ 仓库内 dsh-server-docs/... 的相对引用天然继续有效;旧目录(含其 .git)已归档到
   工作区 _中间产物_待清理/,未随本提交带入。
2) .gitattributes:新增 `dsh-server-docs/** -text` —— 原文档库是 `* -text` + autocrlf=false,
   必须保持纯 LF,否则会被本仓的 CRLF 规则翻掉。
3) 活引用里的绝对路径已全部改到新位置(docs 的 INDEX / README / scripts / skills + 用户级 skills
   + ~/.workbuddy/settings.json 的 hooks);历史档案(04-调整方案/、archive/)按「只增不改」未动。
   ⚠️ hooks 路径改动需「完全重启会话」才生效(配置是会话启动快照)。
4) 交接单/T08:新增 §16「生产整体切换执行记录」(形态 / 落地动作 / **4 个只有真上线才暴露的真 bug** /
   验收证据 / 回滚命令 / 残留项);台账 T08 行 → 已完成并归档;03-路线图 §二 登记 T08 收尾项。
5) 统一称谓:**「本机」只指跑 WorkBuddy 的开发机**,47 / 106 一律写「远程服务器」。
2026-09-15 18:47:13 +08:00

223 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 77 · 回到页面自检 + 就地恢复:把「恢复过程」做成看得见的
- 日期:2026-09-13
- 状态:✅ **已落地并验证**(服务器 `src/supervisor/proxy.ts` + `lib/` 已构建,`dshs` 已重启)
- 触发:用户「**不自然的地方是我回到页面根本没看到过拉起实例的提示**」(承接档案 72 的「回到页面没反应」)
- 结论一句话:**能检测,但判据选错了** —— 探活只问「进程在不在跑」,而平台把 `starting` 也算在跑、崩溃后新实例 **1 秒内**就起来 ⇒ 用户切回页面时几乎永远命中「没问题」,**既不提示也不恢复**。本次把判据换成「页面还能不能连上实例」,并把恢复过程做成**可见**的,同时**不再跳到门户域**。
- 关联:档案 72(回到页面自动唤醒)/49(导航过渡页)/50(注入脚本)/51(401 透明重放)/59(慢请求提示)/24(导航 401 恢复)
> **TL;DR**|**结论**:提示"看不到"不是没做,是**判据失效 + 提示只活 600ms**。
> **关键**:① 判据改为「实例侧探针」(`getting starting` 也必然超时 → 能被识别)② 顶部轻提示条 + 不自动消失的覆盖层 ③ 恢复**就地**,不再跳门户过渡页。
> **状态**:✅ 已部署(配置层面全绿;**浏览器观感待用户实测**)。
---
## 一、根因(三条,都从代码与生产日志取证)
| # | 根因 | 证据 |
|---|---|---|
| **主** | 注入脚本 `probe()` 只在 `j.running === false` 时恢复;而 `running` 来自 `src/web/routes/dsh.ts` 的 `alive(status)` = `status !== 'crashed' && !== 'stopped' && !== 'failed'` ⇒ **`starting`(正在启动)也算"活着"** | 源码;且实测每次崩溃后新 scope 在**同一秒或 1 秒内**起来(23:37:46→23:37:46、23:41:06→23:41:07、23:45:30、23:46:46、23:48:46、23:50:51)⇒ 用户切回标签页时实例几乎总是 `starting`/`running` ⇒ **判据永远不动手** |
| 次 | **即便触发,提示只活 600 ms** 就 `location.replace(wake.html)`;实例已在跑时过渡页的 `enter` **立即返回** ⇒ 过渡页一闪而过,肉眼看不到 | 旧 `recover()`;`dsh.ts` `/api/dsh/enter` 的"复用已运行实例"分支 |
| 次 | 旧路径 **跳门户域**,且 `recover()` 跳的 `wake.html` **不带 `next`** ⇒ 恢复后落回**根地址**、地址栏换域名、页面状态全丢 | 档案 72 §三 |
## 二、方案取舍
| 方案 | 做法 | 判定 |
|---|---|---|
| A 现状(只判进程) | 只在 `running:false` 恢复 | ❌ 判据与"页面能不能用"不等价 ⇒ 主场景不触发(本次要修的) |
| B 周期性轮询(如每 30 秒探活一次) | 定时器主动探 | ❌ 每个在线用户每分钟多次请求,且**用户在场却不操作时毫无收益**;档案 72 §五已否 |
| **C 探针 + 可见反馈 + 就地恢复(采用)** | 回到页面才探;门户口判进程 + **实例侧探针**判连通;提示条→覆盖层;恢复就地 | ✅ 只在"用户真的回到页面"这一个时机付出成本;失败即**可见**;不换域名、保留路径 |
| D 保活(页面可见即上报活跃,防被 LRU 挤掉) | 定时上报 | ⏸ **本次不做**:会改变常驻实例的抢占语义(`cap=4` 下会挤掉别人),需单独评估容量,另开档案 |
## 三、实现(唯一改动文件 `src/supervisor/proxy.ts`,改的都是注入脚本 `SESSION_RECOVERY_JS`)
| 改动 | 内容 |
|---|---|
| **新增探针 `poke()`** | 同源 `GET /`,`cache:'no-store'`、`redirect:'manual'`、**2.5 秒超时**(`AbortController`)。`200` → `ok`;`404/405` → **`unknown`(官方若改了入口形态就退回旧判据,绝不误报)**;其余 → `bad` |
| **判据升级** | 门户口 `status` 说 `running:false` → 直接恢复;否则再跑 `poke()`,`bad` 才恢复 |
| **提示可见** | 新增 `ensureBar/showBar/hideBar` → 顶部轻提示条 `#__dshConnBar`(不遮罩);**至少显示 900 ms** 再撤(否则一闪而过等于没提示) |
| **触发细分** | 只有"**离开 ≥20 秒**又回来"(`visibilitychange` + `leftAt`)才亮提示条;瞬时切换静默探活。`focus` 静默、`pageshow(persisted)` 带提示 |
| **就地恢复** | `recover()` 不再跳 `wake.html`,改为跨域 `POST /api/dsh/enter`(带 cookie)拿**带新 token** 的地址 → `withPath()` 保留 `pathname`/`hash` → `location.replace()`;取不到则回落 `location.reload()` |
| **文案去归因** | 「实例已休眠(**空闲回收**)」→「工作区已休眠,正在唤醒…」;「会话已过期(实例被回收重建)」→「正在重新连接你的工作区…」 |
**未新增**:后端端点、DB 字段、依赖;**未改动**:导航路径的 302 → `wake.html`(刷新/首次进入仍走过渡页,**行为不变**)、401 既有路径、`wake.html` 本体。
> 复用而非新建(U2):探针用**实例自己的 `GET /`**(平台无新端点);恢复复用既有 `/api/dsh/enter`。
## 四、验证
| 项 | 方式 | 结果 |
|---|---|---|
| 本地类型检查 | `npm run build`(tsc) | ✅ 退出码 0 |
| **注入脚本不变量** | 模板字符串内**反引号 = 0 / `${` = 0** | ✅(长度 6604 → 10634 字符) |
| **注入脚本语法** | 从编译产物抽 `SESSION_RECOVERY_JS`/`SESSION_ASSIST_JS` → `new Function()` | ✅ 双双通过 |
| **CORS 前提**(本方案命门) | `OPTIONS https://alotbuy.com/api/dsh/enter`,`Origin: https://admin.alotbuy.com` | ✅ `204` + `allow-origin: https://admin.alotbuy.com` + `allow-credentials: true` + `allow-headers: Content-Type`;无 cookie 的 POST → `401` **且带 CORS 头** |
| 双端一致性 | `md5sum` 本机 vs 服务器 + 服务器 `CR 行数 = 0` | ✅ `8c21a885…` 双向一致,LF 保持 |
| 服务 | `systemctl restart` → `is-active` / 门户 / 残留 scope | ✅ `active` / `200` / **0 个** |
| **注入落位** | 临时会话(`mksess.cjs`,用完即删)→ `enter` → 三件套 curl 取实例页 | ✅ `enter_http=200`、实例 `running`、页面 `200`;`__dshConnBar`/`function poke`/`withPath`/`rawFetch`/`leftAt`/「正在检查工作区连接」/「工作区正在恢复」**全部命中**;旧文案「会话已过期(实例被回收重建)」**= 0** |
| 清理 | 临时会话删除 | ✅ `deleted_sessions=2`(含历史残留) |
**未做(L5)**:浏览器端观感 —— 「切回标签页 → 顶部出现『正在检查工作区连接…』」、「实例确实不可用时 → 覆盖层 → 原地回到原路径」。**需用户实测**(本机无 Chromium,装它成本不成比例)。
### 验证顺带纠正的一条误报
`[inject-recovery] skip: content-encoding=gzip` 全历史 11 次、最近两次(09-12 22:40 / 23:26)**紧邻 `[proxy-auth-replay]`**,`remoteAddress` 一次是**服务器本机 IP**、一次是 CF 回源 ⇒ 是**我们自己的验证 curl**(默认 `Accept: */*`,不含 `text/html` ⇒ 代理不覆盖 `accept-encoding: identity` ⇒ 上游 gzip ⇒ 跳过注入)。**真实浏览器导航必带 `text/html`**(实测注入正常)⇒ **不是用户侧的问题,本次无需处理**。
## 五、边界(有意不做)
1. **恢复后不恢复页面内状态**(输入草稿 / 滚动位置)——同档案 72 §五,抓 DOM 状态会随官方前端升级而碎。
2. **不做周期性探活**(只在"回到页面"付出成本)。
3. **不做保活**(见 §二 方案 D,需单独评估容量)。
4. 探针 **2.5 秒超时**是取舍:把"实例在启动中"判成 `bad` 正是我们要的;代价是**网络抖动 + 实例侧同时慢**时可能多一次原地重载(`status` 走门户、`poke` 走实例,两条链路同时慢才可能命中)。
## 六、红线遵守
- **R8**:重启 `dshs` → 当时 **2 个**在线实例被停(约数秒);**执行前已获用户明确授权**("现在改")。
- **R2**:纯注入脚本,不改官方 dsh 主程序与缓存;不落盘。
- **R7**:只改 **1 个源文件**;改动前用 `md5`/`diff` 确认服务器版与本地版**完全一致**(排除了把在途改动带上生产的风险)。
- **R9**:抢锁失败时**停手并报告**;本次解锁是**用户明确点头**后执行(`--release-exec` + `FORCE=1 release univer-fork-deploy`),**未在本会话内自行判定对方死活**。
- 未 commit / 未 push。
## 七、回滚
```bash
cp -a /opt/dsh/backups/proxy.ts.bak-20260913-0012 /opt/dshs/src/supervisor/proxy.ts
cd /opt/dshs && npm run build && systemctl restart dshs
```
(`wake.html` 未被改动,导航路径行为不变,回滚面仅注入脚本本身。)
## 八、遗留(另行报告)
1. ~~**崩溃熔断可无限重来**:熔断后 `mains.delete` + `resetCrashState()`,而用户下次 `launch()`/`enter` 又 `resetCrashState()` ⇒ 计数清零、重新给足预算;且熔断**只写 stderr、无告警**。~~ → ✅ **2026-09-13 已由档案 78 处置**(跨轮熔断态 + 指数冷却 + 双通道告警;代码完成、本地全绿,**部署待 R8 窗口**)。
2. `INDEX.md §二` 的状态摘要("档案 72 份" + 各状态计数)与表格实际行数(76)**已不一致**(本次未改,属历史漂移)。
3. 实例被反复重启的**外部诱因**已查明(并行会话迭代插件版本),与本档无关。
## 九、补充(2026-09-13 07:0x):**浏览器端实测已完成**(关闭 §四 的 L5)
§四 写的「未做(L5):浏览器端观感 —— 需用户实测」**已补做**:本机真 Chrome(无头,`playwright-core` 驱动)+ **临时会话**(`mksess.cjs` 直插,用完即删)打开 `https://admin.alotbuy.com/`,用**真实事件**(`visibilitychange` / `pageshow(persisted)`)+ **网络层拦截**触发两条路径。**未重启服务、未停实例、未改任何源文件**。
| # | 场景 | 手段 | 结果 |
|---|---|---|---|
| 0 | 注入落位 | 直接加载实例页 | ✅ `__dshRecover=1`;`__dshConnBar` / `function poke` / `function withPath` / `LEFT_MS` **全部命中**;旧文案「空闲回收」= **0** |
| A | **离开 ≥20 s 又回来** | 覆写 `document.visibilityState` 后派发 `visibilitychange`(hidden),21 s 后派发 (visible) | ✅ 顶部条出现「**正在检查工作区连接…**」,**约 1 s 后自动撤除**;**全程未误亮覆盖层** |
| B | **页面与实例已脱节** | 拦截实例侧 `fetch /` 使其失败 → 派发 `pageshow(persisted)` | ✅ 覆盖层出现「工作区正在恢复,请稍候…」→ `POST /api/dsh/enter` **200** → 覆盖层撤除 → **仍在 `admin.alotbuy.com`(未跳门户域)** |
**证据截图**:`_patch77/shots/`(`A-顶部提示条.png`、`B-恢复覆盖层.png`、`B-恢复后.png`、`0-初始.png`)。
> **第一次跑出现两个「❌」,复盘后确认都是测量脚本的错,不是产品的错**:① 提示条等待器在 21 s 等待**之前**就启动,被自身 6 s 超时耗光 → 必然抓不到;② 网络过滤写成 `hostname.endsWith('.alotbuy.com')`,**漏掉裸域 `alotbuy.com`**(`/api/dsh/enter` 在门户裸域上)→ 误报「没调 enter」。修正后 **10/10 全绿**。
**结论**:档案 72/77 的「回到页面自检 + 就地恢复」链路在**真实浏览器**里行为符合设计(含「看得见」这一条);**§四 的 L5 关闭**。
---
## 追加(2026-09-13 08:1x · **浮层视觉升级**):「转圈」→「AI 核心」
**触发**:用户「工作区已释放正在恢复那个浮层的**转圈动画效果太简陋**,看看能否更科技化 AI 化」。
**旧实现**:一个 `border:3px solid rgba(255,255,255,.25); border-top-color:#fff` 的经典转圈(34×34 / 0.8s linear)—— 能看,但确实"工地"。
**新实现**(纯 CSS + `createElement`,零外部资源、零依赖):
| 元素 | 作用 |
|---|---|
| `.__dsh-ov` | 覆盖层:双层径向光晕 + `rgba(9,11,16,.9)` 深底 |
| `.__dsh-card` | 半透明卡片 + 渐变描边 + `backdrop-filter: blur(9px)` |
| `.__dsh-halo` | 呼吸光晕(2.6s 缩放/透明度) |
| **`.__dsh-arc`** | **conic 渐变扫描弧**(蓝 → 紫),用 `mask` 挖空成环、1.5s 旋转 —— 视觉主角 |
| `.__dsh-arc2` | 反向虚线环(7s,与主弧**反差速**,产生"仪器感") |
| `.__dsh-core` | 脉冲核心(1.6s 缩放 + 透明度) |
| `.__dsh-dots` | 三颗轨道粒子(各自 0.35s 延迟 + 三色) |
| `.__dsh-msg` | 文案:极弱流光扫过(**可读性下限 0.94 不透明度**,不会像灰掉) |
| `.__dsh-label` | `DSH · AUTO RECOVERY` 等宽小字 |
| `.__dsh-track` | 不定进度条(scan) |
**无障碍**:`@media (prefers-reduced-motion: reduce)` 关闭全部动画(已出 reduced-motion 截图确认不是"卡住")。动画只用 `transform` / `opacity` / `background-position`,不触发布局抖动。
**硬约束遵守**(注入脚本的三条红线):
1. 反引号 **0** / `${` **0**(CSS 里字体名改用单引号 `'Segoe UI'`,从而 JS 侧可用双引号字符串承载 CSS 各行);
2. **不用 `innerHTML`**(实例页可能启用 Trusted Types)→ 结构全部 `createElement`;
3. 样式集中在 `#__dshRecoverCss` 一次性注入;DOM 仍**只建一次**(否则每次改文案会重建元素、打断动画 —— 这是档案 77 原有教训,本次沿用)。
**验证**:
| 项 | 结果 |
|---|---|
| `npm run build`(tsc) | ✅ rc=0 |
| `npm test` | ✅ 45 pass / 0 fail / 1 skipped |
| 注入脚本不变量 | ✅ 编译产物抽 `SESSION_RECOVERY_JS`/`SESSION_ASSIST_JS` → `new Function()` **双通过**(反引号 0 / `${` 0) |
| **端到端**(临时会话 + 三件套 curl 取实例页) | ✅ `__dsh-ov` / `__dsh-orb` / `__dsh-arc2` / `__dsh-core` / `__dsh-track` / `conic-gradient` / `prefers-reduced-motion` / `DSH · auto recovery` **全部命中**;旧标记 `border-top-color` = **0**;临时会话已删 |
| 部署 | ✅ 备份 `proxy.ts.bak-overlay-20260913-0815` → 服务器 build rc=0 → 重启(PID 413817→**415625**,门户 200) |
**预览与证据**:`_patch77/overlay-preview.html`(**与注入样式同源** = `_patch77/overlay.css`,避免"预览和线上不一样")+ 截图 `_patch77/shots/overlay-{new,closeup,reduced-motion}.png`。
**本次未动**:顶部提示条 `#__dshConnBar` 的视觉(用户只点了浮层)—— 若要一起统一风格,另说。
---
## 追加(2026-09-13 08:2x · **现场故障 + 热修**):注入脚本被我自己写崩了
**用户报告**:「admin 的 dsh 主页面断开连接了,但页面没显示『工作区已休眠正在唤醒』浮层,模型一直显示连接异常」。
**根因(我引入的)**:08:15 那次浮层视觉升级里,我写了 `st.textContent = [...].join('\n');` ——
这段代码位于 **TS 模板字面量** `SESSION_RECOVERY_JS` 内部,`\n` 在**模板求值时**就被转成**真换行**,
注入到浏览器的那段 JS 变成 `].join('` + 裸换行 + `');` ⇒ **SyntaxError** ⇒
**整个注入脚本不执行** ⇒ 浮层 / 自检 / 自愈全部静默失效,页面只剩 dsh 自己的「连接异常」。
**为什么我的"校验"没拦住**:当时只做「从 `lib/` 抽**原始文本** → `new Function()`」——
**跳过了模板求值这一步**,所以是**假绿**。浏览器里 `window.__dshRecover` 为 `false`、注入 CSS 长度 0,才是真相。
**顺带挖出的第二个(更早存在、更严重的)缺陷**:同一文件里的 `SESSION_ASSIST_JS`(档案 56 的
「我的文件 / 能力」助手)**一直是坏的** —— 里面 8 处 `'\n'` 同样是单反斜杠写法 ⇒ 模板求值后裸换行 ⇒
**该脚本从来没在浏览器里跑过**。此前"验证"只 grep 了页面 HTML 里有没有 `__dshAssist` 字样,
**没有验"脚本跑不跑得起来"**(同一个假绿模式)。两个脚本今天一起修好。
**修复**:
1. 我引入的那处 → `.join(String.fromCharCode(10))`(不依赖任何转义);
2. 助手脚本的 8 处 → 按源文件补成 `\\n`(只补未转义的,已转义的保持原样);
3. ⛔ **新增常设校验 `scripts/verify-inject.cjs`**(已接入 `npm test`):**先把模板字面量求值成运行时字符串,再 `node --check`**,
并断言运行时串里不含反引号 / `${`。⇒ 这类"假绿"再也不可能溜过 `npm test`。
**同时补齐的触发面(这才是"页面没反应"的另一半)**:实测(见下)确认——**用户一直盯着页面、连接悄悄断掉时,原设计一个事件都不会来**(只有切标签/切回来、focus、pageshow 才探活),所以既不提示也不恢复。
- **① 心跳**:页面**可见**时每 25 秒静默探一次(不可见时零成本);
- **② 流断**:包装 `EventSource` / `WebSocket` 的 `error` / `close`,立即探一次(比心跳更早);
- 两者都走新的 **soft 模式:连续两次失败才恢复** —— 因为恢复 = 原地 `location.replace`,**页面内未保存的输入会丢**,一次网络抖动不该触发它。
- ⚠️ **这推翻了本文 §五 方案 B「不做周期性探活」**:当时的前提是"用户在场却不操作时毫无收益",**现场证明该前提不成立**。代价是每个可见页面每 25 秒 2 个轻请求(`/api/dsh/status` + 实例侧 `GET /`),量级可忽略。
**验证(真 Chrome + 临时会话,全部只读)**:
| 项 | 结果 |
|---|---|
| 修复后基线 | ✅ `window.__dshRecover = true`、`window.__dshAssist = true`、**语法类错误 0**(助手脚本首次真正运行) |
| 事件路径回归 | ✅ `pageshow(persisted)` → 浮层出现、`class=__dsh-ov`、新视觉节点(orb/track)齐 |
| **无操作自动恢复(本次核心验收)** | ✅ 只打挂实例侧探针、**全程不派发任何事件** → **47.4 秒后浮层自动出现**(= 两次心跳判定),文案「工作区正在恢复,请稍候…」,0 页面错误 |
| `npm test` | ✅ 45 pass / 0 fail / 1 skipped,**且新增的注入脚本运行时校验双脚本通过** |
| 部署 | ✅ 两轮热修(PID 415625→416910→**417427**),备份 `proxy.ts.bak-before-hotfix-0825` / `proxy.ts.bak-before-triggers-0835` |
**教训(已写进 `MEMORY.md` 与技能)**:**模板字面量里的转义会先被求值一次** ——
凡"把 JS 塞进模板字面量"的注入脚本,校验必须**先求值再验**,否则所有 grep/`new Function(原文)` 都是假绿。
---
## 追加(2026-09-13 10:0x · 视觉升级续):**导航过渡页 `wake.html` 也统一成「AI 核心」**
**触发**:用户报「admin(Edge)能自动弹浮层恢复,guest(Chrome)不行」→ 实测(真 Chrome 无头 + guest 临时会话)**guest 侧脚本、心跳、浮层全部正常**(46.2 s 自动出现)⇒ 差异不在浏览器,而在**落在了哪个界面**:
| 界面 | 有无注入脚本 | 加载态视觉 |
|---|---|---|
| **实例页** | ✅ 有 | 「AI 核心」浮层(档案 77 本次升级) |
| **过渡页 `wake.html`**(平台侧;刷新/首次进入且实例不在时落这里) | ❌ 无(`__dshRecover` 命中 0) | **仍是旧的 `border-top` 转圈** |
⇒ guest 实例今早崩过 5 次并被回滚,刷新时**多半落在过渡页**,所以"看不到浮层"。**这是结构使然,不是 bug**,但两套视觉确实割裂。
**改动**:`web/wake.html` 的加载态换成与浮层**同一套视觉语言**(conic 扫描弧 + 反向虚线环 + 脉冲核心 + 三轨道粒子 + 渐变进度条 + 等宽小字标签),**配色跟随平台主题**(`#534AB7` / `#8B7CF6`,不硬套暗色)。
**三条纪律**:
1. **JS 逻辑一行未动**(diff 只差档案 78 加的那段 `instance_circuit_open` 分支);
2. **行尾保持 LF**(本机 CRLF=0,与文件一致);
3. **静态页免重启** ⇒ scp 即生效(比浮层省一次重启窗口)。
**验证**:线上 `https://alotbuy.com/wake.html` 取回即为新版(`wk-orb` / `conic-gradient` / `prefers-reduced-motion` / `dsh · auto recovery` 命中,旧 `.wk-spin{width:34px…}` 已消失);截图 `_patch77/shots/wake-aicore.png`。回滚点 `/opt/dsh/backups/wake.html.bak-aicore-20260913`。
> **留在档案里的诊断口诀**:客户报"看不到浮层/没反应"时先分清是**哪个界面** ——
> 实例页(有脚本,出浮层)还是过渡页(平台侧,只有加载态);再看该标签页是不是**修复前打开的旧页面**(那种情况刷新一次即可)。