What to build: 通过统一运行时服务接入设备状态推送。推送只更新受影响的点位和详情字段,断线后可重连,并保留当前选择上下文。
Blocked by: 09, 13
Status: resolved Labels: ready-for-agent
authenticate 帧(token 与 REST 同源),再发 cockpitSubscribe;authenticate 失败只把状态推送标为不可用,REST 快照照常显示。2026-09-24 真机 DOM 走查完成(http://127.0.0.1:8001/#/cockpit,维护者开启服务后实测):
A–F 六组逐项通过,逻辑闸门 45/45、19 个逻辑闸门全量回归绿。真机实测抓到并修掉第二个真 bug
(推送早于分页快照 → 翻页后旧状态复活),详见下文「对抗式自查抓到的真 bug」。
data-ws-state=open、文案「推送 · 状态推送已连接」。MP-001:OFFLINE(脚本推的值,修 bug 前这里停在 NORMAL)、
MP-002:ALARM;地图点位数 2 → 2 不变(只改已有实体)。unauthorized 时顶栏「推送 · 状态推送不可用(鉴权失败)· 快照数据照常显示」,
经真实服务实测只发了 authenticate(无 cockpitSubscribe、不重连),
同时 8 个面板仍有数据、2 个点位标记仍在 —— REST 快照照常显示。flaky 场景状态序列 connecting → authenticating → subscribing → open → retrying → connecting → … → open,
工厂建了 2 个连接(真重连,不是只改文案),重连后点位数不变。pageerror/console.error 全程为空)。cockpit-map-1)、
点位数不变、面板 id 列表逐位相同、滚动位置不变。环境备注:本机
@puppeteer/browsers是空目录,t15-websocket-walkthrough.mjs起不来 (与 t28 同一环境问题),故上述 A–F 用内建浏览器驱动同一批断言完成; 脚本已保留成可复跑形式。另:内建浏览器窗口被遮挡时requestAnimationFrame归零, 涉及相机飞行的走查必须先确认标签页可见(票 14 记过同一坑)。
monitoringPointCode 直接改写那个点位。
不由「设备 ALARM」推导「点位 ALARM」—— CONTEXT.md 第 31-32 行明确「点位状态由后端解析并发布,
前端只渲染、不重算」。后端没给点位码的帧只更新设备维度,不动任何点位。runtimeSocket 走的是与真实环境完全同一条代码路径(演示的不是另一套实现)。E:\pipe-ner-server 源码核实,不猜){"type":"authenticate","token":...} → {"type":"authenticated","success":bool,"msg":...}
(DeviceStatusWebSocketHandler.handleAuthenticate 第 220-240 行);成功才发
{"type":"cockpitSubscribe","scope":"all"}(handleCockpitSubscribe 第 344-357 行,scope 只认 all)。DeviceStatusRedisListener.cockpitDeltaMessage(第 187-202 行)——
deviceCode/device_code/monitoringPointCode 与 data.{status,statusTime,serverUpdatedAt,reason}
平级在顶层;状态键是 data.status(不是 DTO 的 monitoringState,两个都认)。cockpitSubscribed 回帧只带 scope 与 serverUpdatedAt,没有初始状态数组
—— 后端注释明确「REST 仍是初始快照,这里只接收后续状态增量」,前端按此实现,不假装它带了初始态。ALARM/OFFLINE/WORK_ORDER/NORMAL/EMPTY(resolveStatus 第 231-242 行),
与 domain/pointStatus.js 的后端六态吻合;未关联设备的 monitoringPointCode 显式为 null。services/runtimeSocket.js(新):连接/鉴权/订阅/断线/重连的唯一所有者。
状态机 idle → connecting → authenticating → subscribing → open,断线进 retrying
(退避 1/2/5/10/15 秒封顶),鉴权失败进 unauthorized 且不重连(token 不对时重试只是白撞)。domain/statusPush.js(新):增量合并的纯函数层 —— 按 serverUpdatedAt 按时刻(非字符串)
取舍、旧消息原样返回同一引用(调用方据此跳过重渲染)、点位状态只按后端指名的点位改写、
就地合并时未命中的元素保持同一引用。stores/runtimeStore.js(新,ADR 0004 要求的 runtimeStore):连接状态 + 合并后的状态表 +
把受影响的点位状态推给 mapStore/detailStore。不认识 socket、不认识帧类型。mocks/wsMock.js(新):脚本化假 socket,三种场景 success / unauthorized / flaky;
默认脚本里刻意放了一条更旧时间戳的负例(证明"旧消息被忽略"不是纸面承诺)。stores/mapStore.js:新增 applyPointStates() —— 只替换状态真变了的那几条,未命中的保持同一引用。stores/detailStore.js:新增 applyPushedStatus() —— 已打开详情只换状态字段,
不重新取数、不换层级、不动选择。services/runtimeApi.js:补 toDeviceStatusEvent 的 monitoringPointCode(原先漏了,点位联动就无从谈起)、
新增 toAuthenticatedEvent、补全 WS_MESSAGE。CockpitShell.vue:顶栏状态胶囊(data-ws-state 走查断言面)+ 挂载时启动、卸载时停止连接。verify/t15-websocket.mjs(新,43 项逻辑闸门)、verify/t15-websocket-walkthrough.mjs(新,真机走查,待执行)。new WebSocket/onmessage/send)、
H1(外壳不认识帧类型与 socket);连接与重连在 B/F 段实测(工厂建了 2 个连接 = 真的重连)。scope、
B5/B6/B7(失败不订阅、不重连、状态转 unauthorized)、H6(源码级:订阅只在 success===true 时发生)、
H7(运行时服务不认识任何数据 store —— 鉴权失败在结构上不可能牵连数据分区)。10:00+08:00 与 02:00Z 判为同一时刻)、时间认不出的不采纳、
丢弃时原样返回同一引用。status 字段,从不触碰这些状态。disabled,如实说"未配置")、F2(断线 → retrying → 重连 → open)、
B5(鉴权失败 → unauthorized)、D/E 组在走查脚本里对应真机断言。node .scratch/cockpit-shell-panel-layout/verify/t15-websocket.mjs → 45/45(八段:
协议与时序 / 握手与鉴权失败 / 旧消息丢弃 / 就地更新 / 选中与面板不受影响 / 四态可演示 / 退避与清理 / 静态守卫)。disconnect() 里的待重连取消 → G2 红(本轮抓到的真 bug 之一);
② 去掉时间比较(旧消息也采纳)→ C4 红;
③ 把 success === true 改成恒真(鉴权失败也订阅)→ B5/B6/H6 三红。http://127.0.0.1:8001/#/cockpit 实测通过(见上文)。yarn build(AGENTS.md)。node .scratch/cockpit-shell-panel-layout/verify/t15-websocket-walkthrough.mjs
A 顶栏 data-ws-state 走到 open;B 推送后点位状态真的变(MP-001→OFFLINE、MP-002→ALARM)
且地图点位数不变;C 旧消息负例被丢弃;D unauthorized 时REST 快照照常显示;
E flaky 时状态序列经过 retrying 并回到 open;F 全程无页面级报错。
(本机 @puppeteer/browsers 为空目录时脚本起不来,改用内建浏览器驱动同一批断言。)
applyPointStates() 只写当时已加载的对象。
于是先被推成 OFFLINE 的 MP-001,在第 1 页到达后又显示快照里的 NORMAL ——
真机上肉眼可见「推送没生效」。已修:mapStore 缓存收到的推送状态(pushedPointStates),
翻页合并后补写到新到的对象上(withPushedStatus);同时 refresh()(显式换快照)
丢弃缓存,让这一份快照说了算 —— 否则一条较旧的推送会盖掉刚取回的新状态。
新增 E7c/E7d 两条回归守住两个方向。disconnect() 漏取消待触发的重连定时器(G2 抓红):链路掉了之后 session 已经置空,
只剩一个等着重连的模块级定时器,而 disconnect() 只清了 session.timer ——
于是离开驾驶舱后连接还会继续重连。已修:显式取消 pendingTimer;顺带在重连触发时
清掉该引用、并在处理断线时显式关闭那个已死的 socket(释放它自己的待发定时器)。config.ws.token、并把 deviceStatus 与假 socket 工厂写进页面。
这不是守卫太严,而是层次被破坏了 —— 已改成外壳只说 runtimeStore.startWithConfig(config),
地址/凭据/mock-real 的连接方式全部下沉到 store,页面因此对凭据与假连接完全无知。
同时把 t15 的 H5 守卫写精确:组件零凭据字段,store 只允许把配置里的 token 转交给服务
(不得自己拼 Authorization/Bearer)。pushedPointStates 里,等该对象翻到(或出现在后续页)时补写。monitoringPointCode 的可用性依赖后端:票 25 记录该字段当前只在后端未提交工作树里,
且真实库点位关联近乎为空。所以真实环境下"点位联动"可能长期只落设备维度 ——
这是数据/交付缺口(票 25 的 BT-1/G4),不是本票的实现缺陷;本票按契约实现,并让缺点位码的帧
诚实地只更新设备维度。runtimeStore 的 statusState.seenAt 可供票 16 复用。ping/pong)未实现:后端有 pong 回应,但验收未要求,且连接活性已由 open/close/error 表达。状态推送接入完成:连接/鉴权/订阅/断线重连集中在运行时服务,增量按服务端更新时间就地合并, 旧消息丢弃,推送只改受影响点位与已打开详情、不重建视图,选中/面板/滚动保持不变。
E:\pipe-ner-server 源码核实):连接 → authenticate →
authenticated{success} → 成功才 cockpitSubscribe{scope:"all"} → cockpitSubscribed;
鉴权失败只把推送标为不可用(状态 unauthorized、不订阅、不重连),
在结构上不可能牵连数据分区(运行时服务不认识任何数据 store)。serverUpdatedAt 转 epoch 毫秒后比较,
跨偏移写法(+08:00 vs Z)判为同一时刻;时间认不出的帧不采纳;丢弃时原样返回同一引用。monitoringPointCode 改写,绝不由设备状态推导
(CONTEXT.md:前端只渲染、不重算);缺点位码的帧只更新设备维度。refresh() 换新快照时丢弃缓存,让快照说了算。disabled(未配置)/ connecting…open(正常)/ retrying…open(断线重连,
退避 1/2/5/10/15 秒封顶)/ unauthorized(鉴权失败),由脚本化假 socket 三场景驱动,
与真实环境同一条代码路径。验证:逻辑闸门 45/45 + 三个变异测试有牙 + 真机 A–F 六组逐项通过 + 19 个逻辑闸门全量回归绿。
真机抓出并修掉两个真 bug(推送早于快照导致翻页后旧状态复活;disconnect() 漏取消待重连定时器),
另修正了一处层次破坏(外壳读凭据/认识假连接)。
解锁:票 16(新鲜度与过期状态)的 Blocked by: 15 已满足,可开始。