Skip to content

feat(config): 配置管道治本——单一 schema + 暴露面收敛 + 一键重启 + 三元组 + 根因错误 + 保存即生效(A–G,含 #80) - #81

Merged
Windsander merged 7 commits into
mainfrom
feat/config-pipeline
Sep 23, 2026
Merged

Windsander merged 7 commits into
mainfrom
feat/config-pipeline

Conversation

@Windsander

Copy link
Copy Markdown
Owner

S1 概要

配置管道「治本 + 保存即生效」一轮(A–G,17 文件 +1260/−234,零 coregit diff origin/main..HEAD -- src 为空)。

#80 处理:本轮分支已 merge origin/feat/console-pending-restart(保留其全部提交与断言,未重写历史)。#80 的 8 条 pendingRestart 断言原样保留、只增不弱化;本 PR 合并后可直接关闭 #80(改合本 PR 即覆盖其内容)。

A 单一真源(治本)

packages/mcp/src/config-schema.mjs(纯数据)每字段 {path, kind, spec, default, requiresRestart, exposure, ui, read, effective, reason}驱动:① POST /admin/api/config 写校验 ② pendingRestart 判定(= #80 的 24 项,逐项对应)③ 三元组「实际值/未生效原因」④ 控制台编辑器元数据(/admin/api/settings.configSchema控制台不再重复声明字段)⑤ verify:config 矩阵。
防漂移断言(E1):schema.EDITABLE_PATHS == IA 渲染面schema.STATUS_ONLY_PATHS == IA 只读面(多/少即红)。

B 暴露面收敛(用户拍板:自动的不保留在 GUI)

path exposure requiresRestart 写入
network.enabled editable 可写
network.libp2p.listen editable 可写
sync.namespaces editable 可写
joinService.enabled editable 可写
mcp.http.port editable 可写
mcp.http.auth editable 可写
sync.antiEntropy.enabled editable 可写
sync.antiEntropy.intervalMs editable 可写
sync.antiEntropy.jitterRatio editable 可写
sync.snapshotThreshold editable 可写
sync.peerWhitelist editable 可写
sync.policyIssuers editable 可写
semantic.enabled editable 可写
semantic.minScore editable 可写
joinService.bind editable 可写
joinService.port editable 可写
mcp.http.host editable 可写
mcp.http.tls editable 可写
mcp.http.tlsKey editable 可写
mcp.http.tlsCert editable 可写
path exposure requiresRestart 写入
network.libp2p.relayServers status-only 可写
sync.autoSync status-only 可写
sync.pushOnWrite status-only 可写
status.lan.discovery status-only 可写
status.relay.role status-only 可写
status.relay.bridge status-only 可写
status.nat.holepunch status-only 可写
status.net.broadcast status-only 可写
status.join.endpoint status-only 可写
status.tls status-only 可写
status.invite.grantTtl status-only 可写
path exposure requiresRestart 写入
network.libp2p.relayUnlimited internal 可写
network.libp2p.relayServer internal 可写
network.libp2p.relayPolicy internal 受保护
network.lan.enabled internal 可写
network.lan.autoDial internal 可写
network.lan.defaultFactory internal 可写
network.relayService internal 可写
network.broadcast.mode internal 可写
network.broadcast.ttlMs internal 可写
network.nat.autonat internal 可写
network.nat.dcutr internal 可写
network.endpointStore internal 受保护
network.autoConnect internal 可写
network.peers internal 受保护
sync.pushOnWriteThrottleMs internal 可写
sync.peerNamespacePolicy internal 受保护
sync.syncTimeout internal 可写
sync.syncStatePath internal 受保护
semantic.model internal 受保护
semantic.cacheDir internal 受保护
storagePath internal 受保护
storageAdapter internal 受保护
deviceId internal 受保护
deviceName internal 受保护
identity.mode internal 受保护
encryption.level internal 受保护
encryption.passphraseEnv internal 受保护
mcp.http.tokensFile internal 受保护
  • network.libp2p.relayServers:editable → status-only(只读「自动 relay 池」= config seeds ∪ 令牌 hints ∪ 地址簿学习)
  • network.libp2p.relayUnlimited:editable → internal(GUI 不渲染;保留历史可写能力 writable:true
  • C5–C7 新项(lan.*/relayService/broadcast.*/nat.*/endpointStore…)→ internal(只入 RUNBOOK §13)
  • 敏感 internal(storagePath/deviceId/encryption/tokensFile…)显式 writable:falseH4「敏感字段不可写」不回归
  • 高级面 16 → 14;可编辑 = 常用 6 + 高级 14 = 20

C 一键重启(修正 F-UI-1:mebular service restart 之前不存在)

  • packages/servicerestartPlanFor/restartService:launchd kickstart -k gui/<uid>/<label> · systemd systemctl --user restart mebular-<kind>.service · win32 schtasks /End+/Run未注册只给手动指引(绝不盲发);命令可注入
  • mebular service restart 接上;设置卡 footer 文案与实际命令一致
  • GUI POST /admin/api/restart(memory.admin + CSRF + {confirm:true}):先返回 202 再分离式触发;前台运行 → 409 + 手动指引(命令 + .bak 路径);MEBULAR_RESTART_DRY_RUN=1 可注入
  • 待重启横幅接「一键重启」按钮

D 三元组(补 #80 的双值)

已配置 X / 实际 Y / **未生效原因**(服务端计算,前端只渲染):待重启 · 环境变量优先 · join 端口被占 · LAN/回环不可达 · 桥未提供 · 缺证书 · 缺可选依赖 · 热生效 · 已生效。覆盖 editable 20 + status-only 11。

E 启动失败根因化

createJoinServer 抛错 → 启动错误即「joinService.port <p> 被占(bind=…):改端口(如 0)或释放占用」+ 落 join.error.json`(不再笼统「serve 起不来」)。

F/G verify:config(45 断言全绿)+ 保存即生效

  • 保存路径:含 requiresRestart 项 → {restarting:true} 自动重启(防抖 800ms 合并多次保存);全即时项(MEBULAR_CONFIG_HOT_PATHS)→ {effectiveImmediately:true} 就地生效
  • 生效校验:新实例装配完成写 serve-ready.json + 用自身运行时逐字段比对 → 绿「已生效(字段清单)」/红「未按预期生效:<字段>(已配置 X / 实际 Y)」
  • 回滚兜底:分离监督进程(apply-supervisor.mjs)等新实例健康(serve-ready 新 pid + 端口连通;MEBULAR_APPLY_TIMEOUT_MS 可调),不健康 → 还原 config.json.bak + 再重启 → status:'rolled-back'(含根因);pending.rollingBack 标记防回滚实例误写「已生效」;前台 nohup → 手动命令 + .bak 路径
  • 自锁防护(G4):改动命中 mcp.http.auth/host/tls → 未带 {confirm:true} 返回 409 needsConfirmation不写盘、不重启
  • UI:按钮区分「保存并重启 / 保存」;保存中「正在重启…重连中…」;恢复后绿「已生效」/红「已回滚」;诊断页「最近一次应用结果」

红→绿实证(≥4 A + ≥3 G,各自必红)

# 拆掉什么 变红的断言(原文)
A1 删三元组原因 ✗ S4 实际生效:intervalMs=保存值 且原因=已生效(actual=123456)✗ S6 热路径不进待重启横幅(原因=已声明为热生效)(reason=null)
A2 删 service restart ✕ runServiceCli restart:已注册→0 且输出命令;usage 含 restart
A3 删防漂移断言 + 渲染漂移 ✗ B 暴露面收敛:relayServers 退出编辑面…(advanced=15 残留=network.libp2p.relayUnlimited)✗ IA 不丢项…(editorPaths=21…)
A4 schema 与渲染漂移 ✗ schema↔IA 防漂移:editable 集合逐项一致(各 20)(schema=20 ia=21 差异=network.libp2p.relayUnlimited)
G1 拆掉自动重启 ✗ S4 保存需重启项 → 200 + restarting:true + restart.mode=service(status=200 mode=foreground)✗ S4 lastApply=applied 且逐字段生效校验全通过(verify=[])
G2 拆掉回滚 ✗ S7 已回滚:config.json 恢复为备份(joinService.enabled=false)(enabled=true)✗ S7 回滚后旧配置实例恢复健康(serve-ready = 新 pid)(pid=undefined)
G3 拆掉生效校验 ✗ S4 lastApply=applied 且逐字段生效校验全通过(verify=[…:null,…])

我的复验命令

npm run build && npm run lint          # 0 / 0
npm test                               # 849 passed(119 suites;+4 restart 单测)
npm run verify:config                  # 45/45
npm run verify:console                 # E1 含 #80 8 条 + G 收尾断言
npm run verify:connect / lan / invite / nat
npm run verify:daemon / daemon:cluster
npm run verify:fleet:all               # 163/163
npm run check:docs / surface-parity / cleanliness / deps

手动复验「造启动失败再回滚」

T=$(mktemp -d) && mkdir -p $T/home && node -e "require('net').createServer().listen(4002,'127.0.0.1')" &
# 另开:把 config.json 的 joinService 设 {enabled:true, bind:'127.0.0.1', port:4002},
# 以服务方式启动(MEBULAR_SERVICE_KIND + MEBULAR_RESTART_CMD 注入),在设置页「保存并重启」
# → 新实例报 joinService.port 4002 被占 → 监督进程自动回滚 → 红色横幅「新配置未生效(原因:…被占):已回滚」
# → 诊断页「最近一次应用结果」可查时间/字段/原因/备份路径

断言只增不弱化

  • feat(console): 设置页「待重启」提示——暴露“配置已保存但未重启” #80 的 8 条 pendingRestart 断言原样保留(可写入/含刚改的键/file+running/重启元信息/全新实例为空/保存→200/含 joinService.enabled/重启后清空)
  • A 轮 E1 断言全部保留,仅 F-C4 文案断言随「单一真源迁移」改查 schema 源(语义等价:文案确实在 schema 里)
  • 新增:E1 的 G 收尾块(前台契约/自锁/三元组/暴露面/控制台接线)+ verify:config 45 条 + verify-console-ui 2 条 + jest 4 条

依赖/平台说明

  • 零新增依赖(未改 THIRD-PARTY.md / check:deps 通过)
  • 平台重启命令三平台齐备(launchd/systemd/schtasks),均可注入测试;Windows 计划任务无 restart → End+Run 退化(已单测)
  • 监督进程(apply-supervisor)分离启动(detached+unref),Linux systemd 单元内重启场景若 cgroup 级联杀死监督进程 → 回滚不触发但 .bak 仍在(诊断页给出手动回滚命令)——已记 RUNBOOK §13

守护(mebular serve)只在启动时读取一次 config.json,运行期没有热加载;
设置页保存后磁盘已变,但当前实例仍按上次启动的快照工作,用户误以为
「配置无法生效」。真实案例:config 22:12 保存 network/join=true,进程
21:30 启动,settings 仍为 false/false。

- admin.mjs:buildSettings 新增 pendingRestart,按 curated 白名单(对齐
  serve.mjs CONFIG_PATCH_SPECS)对比当前磁盘 config.json 与启动快照,元素
  形如 { path, file, running };空数组=未设置归一为 null;受环境变量优先
  的路径跳过并列入 pendingRestartEnvOverrides(不报错)。新增 restart
  元信息(serviceHeartbeat / serviceManaged)供 UI 选择重启命令。
- console.js:设置弹窗内持久「待重启」提示(中文标签 + 已配置/实际 +
  可复制重启命令 + 根因说明 + 环境变量优先说明);设置入口徽标与「运行
  状态」区标注「待重启 N 项」;保留保存 toast。
- verify-console.mjs(E1):新增 pendingRestart 断言(含刚改键、元素
  形状、重启后清空);顺带让只读降级用例改用临时端口,避免与本机真实
  实例抢 7331。
- verify-console-ui.mjs:页面内 fetch+CSRF 改 sync.antiEntropy.intervalMs,
  断言出现「待重启」及该项,改回后确认清空。

证据:npm test / npm run lint / check:cleanliness / check:docs /
verify-console / verify-console-ui 全绿。
…le-ui 冲突)

冲突根因:PR #80 基于 06211ad,而 main 随后合入 PR #79(fix(console): 设置/关于本机不再随
3s 轮询重建)——两边都在 packages/console/scripts/verify-console-ui.mjs 的同一位置**新增**
检查块,正文冲突;console.js 本身自动合并成功。

解法(并集,语义两全):
- 保留 PR #79 的「轮询重建回归」检查(DOM 节点引用跨 3s 不变 / 草稿保持 / 撤销复位)
- 保留 PR #80 的「UI 待重启」检查(写 sync.antiEntropy.intervalMs → 出现「待重启」+变更项;
  入口徽标;改回后清空)
- **顺序**:轮询回归块在前、待重启块在后 —— 前者依赖「3s 内 DOM 稳定」,后者会写配置触发
  重建,放在后面避免打断前者(并集语义即两侧原意)

语义兼容性核查(自动合并后逐项确认):
- `settingsSignature()`/about 签名均含 `state.settings` → `settings.pendingRestart` 变化会触发重建,
  PR #79 的防重建**不会**吞掉 PR #80 的「待重启」横幅/徽标
- E1(verify-console.mjs,含 PR #80 的 8 条 pendingRestart 断言)本地全绿
A 单一真源:新增 packages/mcp/src/config-schema.mjs(纯数据)——每字段
{path, kind, spec, default, requiresRestart, exposure, ui, read, effective, reason};
由它驱动 ① serve 写校验 ② pendingRestart 判定(= #80 的 24 项,语义不弱化)
③ 三元组的「实际值/未生效原因」④ 控制台编辑器元数据(settings.configSchema,控制台不再重复声明)
⑤ verify:config 矩阵。防漂移断言:schema.EDITABLE/STATUS_ONLY == IA 渲染面/只读面(多/少即红)。

B 暴露面收敛(自动的不保留在 GUI):relayServers → status-only(只读「自动 relay 池」);
relayUnlimited → internal(不渲染);高级面 16→14、可编辑 20(常用 6+高级 14);
C5–C7 新项(lan.*/relayService/broadcast/nat/endpointStore/autoConnect…)→ internal(只入 RUNBOOK §13);
敏感 internal(storagePath/deviceId/encryption/tokensFile…)显式 writable:false(H4 不回归)。

C 一键重启:packages/service 增 restartPlanFor/restartService(launchd kickstart -k /
systemctl --user restart / schtasks End+Run;未注册只给手动指引)+ `mebular service restart`;
GUI POST /admin/api/restart(memory.admin+CSRF+confirm;先回 202 再分离式触发;DRY_RUN 可注入);
待重启横幅接「一键重启」按钮。

D 三元组:已配置 X / 实际 Y / **未生效原因**(服务端计算:待重启·环境变量优先·join 端口被占·
mDNS/LAN 不可达·桥未提供·缺证书·缺可选依赖·热生效·已生效);覆盖 editable 20 + status-only 11。

E 启动失败根因化:createJoinServer 抛错 → 「joinService.port <p> 被占(bind=…):改端口(如 0)或释放占用」
+ 落 join.error.json(不是笼统「serve 起不来」)。

F verify:config(45 断言):schema 写校验/暴露面/自锁/待重启/自动重启/防抖/热路径/回滚/
restart 接口 401·403·409·202/join 根因。红→绿 7 例(4 A + 3 G)各自必红,附断言原文(见 PR)。

G 保存即生效(用户拍板:保存成功 = 已生效或已回滚+明确报错):
- 含 requiresRestart 项 → {restarting:true} 自动重启(防抖 800ms 合并);全即时(MEBULAR_CONFIG_HOT_PATHS)→ 就地生效
- 生效校验:新实例写 serve-ready + 用运行时逐字段比对 → 绿「已生效」/红「未按预期生效」
- 回滚兜底:分离监督进程(apply-supervisor)等新实例健康,不健康→还原 .bak+重启→红「已回滚(根因)」
  (pending.rollingBack 标记防回滚实例误写「已生效」);前台 nohup→手动命令+.bak 路径
- 自锁防护:auth/host/tls → 未确认返回 needsConfirmation(不写盘不重启)

断言只增不弱化:#80 的 8 条 pendingRestart 断言原样保留;A 轮 E1 断言原样保留。
零 core:git diff origin/main..HEAD -- src 为空。
根因(CI Console UI 冒烟 5/12 失败):上一轮把 CONFIG_EFFECTIVE 块换成服务端三元组时,
`[起:CONFIG_EFFECTIVE 注释, 止:旧 runtimeCompareLine]` 的整段替换**误删了夹在中间的 5 个
helper**(fieldInitial / fieldToConfig / normalizedFieldValue / collectConfigChanges /
formatRuntimeValue)——浏览器执行 renderCfgField 时抛 `fieldInitial is not defined`,
设置弹窗整个渲染不出来(E1 只做源码断言,不执行 JS,故未拦住;本地无 Chrome 也无法冒烟)。
现以 Node + DOM 桩 harness 实跑 renderSettings/renderAbout 四条路径回归(见下)。

- 恢复 5 个 helper(fieldInitial 改用服务端三元组「实际」值作初值)
- valueWithEffective 补「未生效原因」第三列(About 只读行;此前替换未命中、reason 未生效)
- schema ui 补 group 标签(反熵与快照/对端与签发者/语义召回/设备接入(高级)/MCP 接入(高级)),
  高级面恢复分组渲染(随 CONFIG_EDITOR 移除时丢失)

复验:harness renderSettings(common/advanced/diagnostics)+renderAbout 全 ✓ ·
verify:console(E1)✓ · verify:config 45/45 ✓ · npm test 849/849 · build/lint 0
…r address' 抖动)

上一次 CI 的 Console UI 失败已不是产品问题:Chrome headless 启动即报
'Could not parse server address: Unknown address type…'(CDP/dbus 抖动,历史已知 flake)。
launchChrome 包一层 launchChromeRetry(杀掉重建 + 退避,最多 3 次)。

复验:verify-console-ui 本机无 Chrome SKIP(脚本语法/导入 ✓);CI 上重跑验证。
…钟挂起)

事实链:PR #80 的原生分支(56c2587,runs 298/299,无本轮 A–G 代码)与本轮分支的
windows job 都在「Console E1」步骤挂满 30 分钟被 timeout 杀掉(ubuntu E1 全绿、
main 的 windows E1 ~30 秒)→ 挂点是 verify-console.mjs 里的**无超时 await**,
与 A–G 无关(#80 的 pendingRestart 重启用例引入)。

- stop():signalCode 也算已退出;8s 限时 + SIGKILL 兜底(此前无限等 'exit')
- execFileSync(mebular console / openssl version / openssl req)加 timeout(限时抛错)
- Windows 步骤改 Tee-Object 流式输出:若仍挂起,步骤日志直接显示卡在哪一行

复验:verify:console 本地全绿(含 #80 8 条 + G 收尾);CI windows 步骤见流式日志。
@Windsander
Windsander merged commit cf70906 into main Sep 23, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant