@mebular/mcp 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,258 @@
1
+ // 忆云切换共享引擎:停旧 → 写 current → 起新 → 健康校验 →(失败)回滚旧。
2
+ //
3
+ // 进程生命周期归 App/CLI supervisor:本模块不 spawn、不杀进程、不装服务,只编排注入的
4
+ // stop/start/verify 适配器。App 与 `mebular cloud use` 共用本引擎,避免两套状态机。
5
+ //
6
+ // 关键纪律(SPEC §4.2):
7
+ // - 停旧成功后、起新之前写 `switch.intent.json` + 注册表 current=to(崩溃时按新档案恢复);
8
+ // - 新守护健康才删除 intent、更新 lastUsedAt;
9
+ // - 起新失败:current 回滚 from、尝试重新起旧、保留已注册的新档案(永不删除)。
10
+
11
+ import { existsSync } from 'node:fs';
12
+ import { chmod, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
13
+ import { homedir } from 'node:os';
14
+ import { join } from 'node:path';
15
+ import {
16
+ cloudError,
17
+ profileHomeForEntry,
18
+ readRegistry,
19
+ setCurrentProfile,
20
+ } from './profiles.mjs';
21
+
22
+ export const SWITCH_INTENT_FILE = 'switch.intent.json';
23
+
24
+ export function switchIntentPath(root) {
25
+ return join(root, SWITCH_INTENT_FILE);
26
+ }
27
+
28
+ /** 原子写 intent(tmp → rename,0600)。 */
29
+ export async function writeSwitchIntent(root, intent) {
30
+ await mkdir(root, { recursive: true });
31
+ const path = switchIntentPath(root);
32
+ const tmp = `${path}.tmp-${process.pid}`;
33
+ try {
34
+ await writeFile(tmp, `${JSON.stringify(intent, null, 2)}\n`, { mode: 0o600 });
35
+ await chmod(tmp, 0o600).catch(() => undefined);
36
+ await rename(tmp, path);
37
+ await chmod(path, 0o600).catch(() => undefined);
38
+ } catch (error) {
39
+ await rm(tmp, { force: true }).catch(() => undefined);
40
+ throw error;
41
+ }
42
+ }
43
+
44
+ export async function readSwitchIntent(root) {
45
+ try {
46
+ const parsed = JSON.parse(await readFile(switchIntentPath(root), 'utf-8'));
47
+ return parsed && typeof parsed === 'object' ? parsed : null;
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
52
+
53
+ export async function clearSwitchIntent(root) {
54
+ await rm(switchIntentPath(root), { force: true }).catch(() => undefined);
55
+ }
56
+
57
+ function assertAdapters({ stop, start, verify }) {
58
+ for (const [name, fn] of [['stop', stop], ['start', start], ['verify', verify]]) {
59
+ if (typeof fn !== 'function') {
60
+ throw cloudError('SWITCH_ADAPTER_REQUIRED', `切换引擎缺少 ${name} 适配器`);
61
+ }
62
+ }
63
+ }
64
+
65
+ /**
66
+ * 执行一次忆云切换。
67
+ *
68
+ * 适配器契约(App / CLI 各自实现):
69
+ * stop({ root, from, to, fromHome, toHome }) → 停旧(child/service/external);失败必须 throw
70
+ * start({ root, from, to, fromHome, toHome, rollback }) → 起「to」;失败必须 throw
71
+ * verify({ root, from, to, home, rollback }) → 健康校验;返回 false / {ok:false} 视为失败
72
+ * abort({ root, from, to, fromHome, toHome }) → 可选:起新失败后停掉半启动的目标(防双活)
73
+ *
74
+ * 返回 { ok, changed, current, previous, profile, home, warning? }。
75
+ * 失败时:current 回滚 from、intent 清理;目标档案目录与注册条目保留。
76
+ */
77
+ export async function switchProfile({
78
+ root,
79
+ id,
80
+ stop,
81
+ start,
82
+ verify,
83
+ abort = null,
84
+ now = () => new Date().toISOString(),
85
+ }) {
86
+ assertAdapters({ stop, start, verify });
87
+
88
+ const state = await readRegistry(root);
89
+ const from = state.registry.current;
90
+ const target = state.registry.profiles.find((profile) => profile.id === id) ?? null;
91
+ if (!target) throw cloudError('PROFILE_NOT_FOUND', `未找到忆云:${id}`);
92
+ if (from === id) {
93
+ return { ok: true, changed: false, current: from, previous: null, profile: target, home: profileHomeForEntry(root, target) };
94
+ }
95
+ const previous = state.registry.profiles.find((profile) => profile.id === from) ?? null;
96
+ const targetHome = profileHomeForEntry(root, target);
97
+ if (!existsSync(targetHome)) {
98
+ throw cloudError('PROFILE_HOME_MISSING', `忆云「${target.name}」的目录不存在:${targetHome}`);
99
+ }
100
+ const previousHome = previous ? profileHomeForEntry(root, previous) : null;
101
+
102
+ await writeSwitchIntent(root, { from, to: id, at: now(), phase: 'stopping' });
103
+ try {
104
+ await stop({ root, from: previous, to: target, fromHome: previousHome, toHome: targetHome });
105
+ } catch (error) {
106
+ await clearSwitchIntent(root);
107
+ throw error;
108
+ }
109
+
110
+ try {
111
+ await setCurrentProfile(root, id);
112
+ } catch (error) {
113
+ // 旧已停、注册表写失败:尽量把旧守护拉回来,current 保持旧值。
114
+ let rollbackOk = true;
115
+ let rollbackError = null;
116
+ try {
117
+ await start({ root, from: target, to: previous, fromHome: targetHome, toHome: previousHome, rollback: true });
118
+ if (previous) {
119
+ const verified = await verify({ root, from: target, to: previous, home: previousHome, rollback: true });
120
+ if (verified === false || (verified && typeof verified === 'object' && verified.ok === false)) {
121
+ rollbackOk = false;
122
+ rollbackError = '旧忆云健康校验失败';
123
+ }
124
+ }
125
+ } catch (rollbackFailure) {
126
+ rollbackOk = false;
127
+ rollbackError = rollbackFailure;
128
+ }
129
+ await clearSwitchIntent(root);
130
+ throw cloudError(
131
+ 'CLOUD_SWITCH_FAILED',
132
+ `注册表写入失败,切换未开始${rollbackOk ? ';已恢复旧忆云' : `;旧忆云恢复失败:${rollbackError?.message ?? rollbackError}`}`,
133
+ { cause: error, rollback: { ok: rollbackOk, error: rollbackError?.message ?? null, current: from } },
134
+ );
135
+ }
136
+
137
+ await writeSwitchIntent(root, { from, to: id, at: now(), phase: 'starting' });
138
+ try {
139
+ await start({ root, from: previous, to: target, fromHome: previousHome, toHome: targetHome });
140
+ const result = await verify({ root, from: previous, to: target, home: targetHome });
141
+ if (result === false || (result && typeof result === 'object' && result.ok === false)) {
142
+ throw cloudError('CLOUD_SWITCH_FAILED', `新忆云「${target.name}」健康校验失败`);
143
+ }
144
+ } catch (error) {
145
+ await writeSwitchIntent(root, { from, to: id, at: now(), phase: 'rolling_back' }).catch(() => undefined);
146
+ let rollbackOk = true;
147
+ let rollbackError = null;
148
+ // 起新失败/健康校验失败:先停掉可能已半启动的新忆云,避免回滚时双活。
149
+ // abort 失败不阻断「重新起旧」的尝试(否则会留下两边都没起的最坏状态)。
150
+ if (typeof abort === 'function') {
151
+ try {
152
+ await abort({ root, from: previous, to: target, fromHome: previousHome, toHome: targetHome });
153
+ } catch (abortFailure) {
154
+ rollbackOk = false;
155
+ rollbackError = `停止新忆云失败:${abortFailure?.message ?? abortFailure}`;
156
+ }
157
+ }
158
+ try {
159
+ if (previous) await setCurrentProfile(root, from);
160
+ await start({ root, from: target, to: previous, fromHome: targetHome, toHome: previousHome, rollback: true });
161
+ if (previous) {
162
+ const verified = await verify({ root, from: target, to: previous, home: previousHome, rollback: true });
163
+ if (verified === false || (verified && typeof verified === 'object' && verified.ok === false)) {
164
+ rollbackOk = false;
165
+ rollbackError = rollbackError ?? '旧忆云健康校验失败';
166
+ }
167
+ }
168
+ } catch (rollbackFailure) {
169
+ rollbackOk = false;
170
+ rollbackError = rollbackFailure?.message ?? String(rollbackFailure);
171
+ }
172
+ await clearSwitchIntent(root);
173
+ const message = rollbackOk
174
+ ? `新忆云「${target.name}」启动失败,已切回「${previous?.name ?? '旧忆云'}」;可稍后重试`
175
+ : `新忆云「${target.name}」启动失败,且回滚也失败(${rollbackError}):请检查诊断`;
176
+ throw cloudError('CLOUD_SWITCH_FAILED', message, {
177
+ cause: error,
178
+ rollback: { ok: rollbackOk, error: rollbackError, current: rollbackOk ? from : null },
179
+ kept: id,
180
+ });
181
+ }
182
+
183
+ let warning = null;
184
+ try {
185
+ await setCurrentProfile(root, id, { lastUsedAt: now() });
186
+ } catch (error) {
187
+ warning = `切换已生效,但 lastUsedAt 更新失败:${error?.message ?? error}`;
188
+ }
189
+ await clearSwitchIntent(root);
190
+ return {
191
+ ok: true,
192
+ changed: true,
193
+ current: id,
194
+ previous: previous?.id ?? null,
195
+ profile: target,
196
+ home: targetHome,
197
+ ...(warning ? { warning } : {}),
198
+ };
199
+ }
200
+
201
+ /**
202
+ * 服务托管描述符:ServiceOptions.home 用 OS home;档案身份由 env.MEBULAR_HOME +
203
+ * env.MEBULAR_CLOUD_ROOT + heartbeatDir 携带(SPEC §4.3)。
204
+ */
205
+ export function profileServiceDescriptor({
206
+ root,
207
+ home,
208
+ cliBin,
209
+ execPath = process.execPath,
210
+ extraArgs = [],
211
+ }) {
212
+ if (typeof cliBin !== 'string' || cliBin.length === 0) {
213
+ throw cloudError('SERVICE_DESCRIPTOR_INVALID', '缺少 CLI 入口路径');
214
+ }
215
+ return {
216
+ kind: 'mebular-serve',
217
+ args: [cliBin, 'serve', ...extraArgs],
218
+ heartbeatDir: home,
219
+ workingDir: home,
220
+ execPath,
221
+ env: { MEBULAR_HOME: home, MEBULAR_CLOUD_ROOT: root },
222
+ };
223
+ }
224
+
225
+ async function serviceLib(deps) {
226
+ if (deps) return deps;
227
+ return import('@mebular/service');
228
+ }
229
+
230
+ export async function profileServiceStatus({ descriptor, serviceHome = homedir(), deps } = {}) {
231
+ const lib = await serviceLib(deps);
232
+ return lib.serviceStatus(descriptor, { home: serviceHome });
233
+ }
234
+
235
+ /** 停旧服务单元(bootout + 删旧 unit/manifest);未安装时幂等返回 removed:false。 */
236
+ export async function uninstallProfileService({ descriptor, serviceHome = homedir(), deps } = {}) {
237
+ const lib = await serviceLib(deps);
238
+ return lib.uninstallService(descriptor, { home: serviceHome });
239
+ }
240
+
241
+ /** 装新服务单元;必须 ok && started(不能只看命令退出码)。 */
242
+ export async function installProfileService({
243
+ descriptor,
244
+ serviceHome = homedir(),
245
+ autostart = true,
246
+ deps,
247
+ } = {}) {
248
+ const lib = await serviceLib(deps);
249
+ const result = lib.installService(descriptor, { home: serviceHome, autostart });
250
+ if (!result?.ok || result?.started !== true) {
251
+ throw cloudError(
252
+ 'SERVICE_START_FAILED',
253
+ `服务安装失败或未启动:${result?.message ?? result?.note ?? '未知原因'}`,
254
+ { result },
255
+ );
256
+ }
257
+ return result;
258
+ }
@@ -0,0 +1,251 @@
1
+ // G:保存即生效(自动重启 + 生效校验 + 回滚兜底)
2
+ //
3
+ // 产品语义(用户拍板):保存成功的定义 = **「已生效」或「已回滚 + 明确报错」**,
4
+ // 不再存在「已配但未启动」的中间态。
5
+ //
6
+ // 流程:
7
+ // 1) 控制台保存(POST /admin/api/config)命中 `requiresRestart:true` 项
8
+ // → 写 <home>/config.json(.bak 备份)→ 写 pending 记录 → 分离启动本模块的 supervisor → 触发重启
9
+ // 2) 新实例启动成功后(server.mjs)读取 pending → 用自身运行时计算 effective → 写 result(status='applied', verify[])
10
+ // 3) supervisor 等待新实例健康:健康 = 结束;**不健康 = 还原 .bak + 再重启 + 写 result(status='rolled-back', reason)**
11
+ //
12
+ // 全部外部交互(健康探针 / 重启通道 / 时钟)可注入,便于 hermetic 测试;零 core 依赖。
13
+
14
+ import { spawn } from 'node:child_process';
15
+ import { existsSync } from 'node:fs';
16
+ import { copyFile, readFile, rename, rm, writeFile } from 'node:fs/promises';
17
+ import { join } from 'node:path';
18
+ import { CONFIG_SCHEMA_BY_PATH } from './config-schema.mjs';
19
+
20
+ export const APPLY_RESULT_FILE = 'config-apply.result.json';
21
+ export const APPLY_PENDING_FILE = 'config-apply.pending.json';
22
+ export const JOIN_ERROR_FILE = 'join.error.json';
23
+
24
+ /** 命中即「可能把控制台自己锁在门外」的路径:保存前必须二次确认。 */
25
+ export const SELF_LOCK_PATHS = ['mcp.http.auth', 'mcp.http.host', 'mcp.http.tls'];
26
+
27
+ /** 自动重启的防抖窗口(同进程内多次保存合并为一次重启)。 */
28
+ export const RESTART_DEBOUNCE_MS = 800;
29
+ /** supervisor 等待新实例健康的上限。 */
30
+ export const APPLY_HEALTH_TIMEOUT_MS = 60_000;
31
+
32
+ export const SERVE_READY_FILE = 'serve-ready.json';
33
+
34
+ export const applyResultPath = (home) => join(home, APPLY_RESULT_FILE);
35
+ export const serveReadyPath = (home) => join(home, SERVE_READY_FILE);
36
+
37
+ /** 新实例「已监听 HTTP」标记(在 listen 成功后写;比心跳更精确——心跳在进程启动早期就写)。 */
38
+ export const writeServeReady = (home, { pid = process.pid, now = Date.now() } = {}) =>
39
+ writeJsonAtomic(serveReadyPath(home), { pid, ts: now });
40
+ export const applyPendingPath = (home) => join(home, APPLY_PENDING_FILE);
41
+ export const joinErrorPath = (home) => join(home, JOIN_ERROR_FILE);
42
+
43
+ async function readJson(path) {
44
+ try {
45
+ const value = JSON.parse(await readFile(path, 'utf-8'));
46
+ return value && typeof value === 'object' ? value : null;
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+
52
+ async function writeJsonAtomic(path, value) {
53
+ const tmp = `${path}.tmp-${process.pid}`;
54
+ await writeFile(tmp, JSON.stringify(value, null, 2), 'utf-8');
55
+ await rename(tmp, path);
56
+ }
57
+
58
+ export const readApplyResult = (home) => readJson(applyResultPath(home));
59
+ export const readApplyPending = (home) => readJson(applyPendingPath(home));
60
+ export const writeApplyResult = (home, record) => writeJsonAtomic(applyResultPath(home), record);
61
+ export const writeApplyPending = (home, record) => writeJsonAtomic(applyPendingPath(home), record);
62
+ export const clearApplyPending = (home) => rm(applyPendingPath(home), { force: true }).catch(() => undefined);
63
+
64
+ /**
65
+ * 本部署声明「可热生效」的配置路径(`MEBULAR_CONFIG_HOT_PATHS=a,b`):
66
+ * 适用于运营方自带 reload 机制的部署——保存这些路径不触发重启,也不进待重启横幅。
67
+ * 默认空(守护只在启动时读一次 config.json)。
68
+ */
69
+ export function hotPaths(env = process.env) {
70
+ return String(env.MEBULAR_CONFIG_HOT_PATHS ?? '').split(',').map((x) => x.trim()).filter((x) => x.length > 0);
71
+ }
72
+
73
+ /** 本次改动的 path 里:哪些需重启、哪些即时生效、哪些是自锁项(hot 路径视为即时)。 */
74
+ export function classifyChanges(paths, hot = []) {
75
+ const hotSet = new Set(hot);
76
+ const restart = paths.filter((p) => !hotSet.has(p) && CONFIG_SCHEMA_BY_PATH.get(p)?.requiresRestart === true);
77
+ const immediate = paths.filter((p) => hotSet.has(p) || CONFIG_SCHEMA_BY_PATH.get(p)?.requiresRestart !== true);
78
+ const selfLock = paths.filter((p) => SELF_LOCK_PATHS.includes(p) && !hotSet.has(p));
79
+ return { restart, immediate, selfLock };
80
+ }
81
+
82
+ // ---------------------------------------------------------------- 健康探针
83
+
84
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
85
+
86
+ async function readServeReady(home) {
87
+ const record = await readJson(serveReadyPath(home));
88
+ return record && typeof record.ts === 'number' && typeof record.pid === 'number' ? record : null;
89
+ }
90
+
91
+ async function tcpOpen(port, host = '127.0.0.1', timeoutMs = 500) {
92
+ const net = await import('node:net');
93
+ return new Promise((resolve) => {
94
+ const socket = net.connect({ port, host });
95
+ const done = (ok) => { socket.destroy(); resolve(ok); };
96
+ socket.setTimeout(timeoutMs);
97
+ socket.once('connect', () => done(true));
98
+ socket.once('timeout', () => done(false));
99
+ socket.once('error', () => done(false));
100
+ });
101
+ }
102
+
103
+ /**
104
+ * 新实例是否已起来:
105
+ * 1) `<home>/serve-ready.json` 由新实例在 **HTTP 监听成功后** 写入(pid != oldPid 且 ts >= 本次尝试)
106
+ * 2) 已知端口时 TCP 可连(兜底)
107
+ * 不用 service.heartbeat:它在进程启动早期就写,无法区分「起来了」与「listen 前崩了」。
108
+ */
109
+ export async function probeHealthy({ home, port, oldPid, since }) {
110
+ // serve-ready 是**必要条件**(新实例在 join 装配完成后才写;半启动/启动失败一律不算健康)
111
+ const ready = await readServeReady(home);
112
+ if (!(ready && ready.pid !== oldPid && ready.ts >= since - 5_000)) return false;
113
+ if (typeof port === 'number' && port > 0 && Number.isFinite(port)) return tcpOpen(port);
114
+ return true;
115
+ }
116
+
117
+ /** 轮询直到健康或超时;返回 {healthy, waitedMs}。 */
118
+ export async function waitHealthy({ home, port, oldPid, since, timeoutMs = APPLY_HEALTH_TIMEOUT_MS, pollMs = 600, now = Date.now, probe = probeHealthy }) {
119
+ const deadline = since + timeoutMs;
120
+ for (;;) {
121
+ if (await probe({ home, port, oldPid, since, now: now() })) return { healthy: true, waitedMs: now() - since };
122
+ if (now() >= deadline) return { healthy: false, waitedMs: now() - since };
123
+ await sleep(pollMs);
124
+ }
125
+ }
126
+
127
+ // ---------------------------------------------------------------- 重启通道
128
+
129
+ /**
130
+ * 执行重启命令。生产:@mebular/service 的平台计划(launchd/systemd/计划任务)。
131
+ * 测试钩子:`MEBULAR_RESTART_CMD`(shell 命令;verify:config 用它把子进程杀掉再拉起)。
132
+ * 返回 {executed, commands, mode}。
133
+ */
134
+ export async function runRestart({ home, kind, spawnImpl = spawn, env = process.env }) {
135
+ if (typeof env.MEBULAR_RESTART_CMD === 'string' && env.MEBULAR_RESTART_CMD.trim().length > 0) {
136
+ const child = spawnImpl(env.MEBULAR_RESTART_CMD, { shell: true, env, detached: true, stdio: 'ignore' });
137
+ child.unref?.();
138
+ return { executed: true, mode: 'cmd', commands: [env.MEBULAR_RESTART_CMD] };
139
+ }
140
+ if (!kind) return { executed: false, mode: 'foreground', commands: [] };
141
+ const mod = await import('../../service/dist/index.js');
142
+ const plan = mod.restartPlanFor({ kind, args: [], heartbeatDir: home, env: { MEBULAR_HOME: home } }, { home });
143
+ if (!plan.registered) return { executed: false, mode: 'foreground', commands: [] };
144
+ for (const [cmd, ...args] of plan.commands) {
145
+ const child = spawnImpl(cmd, args, { detached: true, stdio: 'ignore', env });
146
+ child.unref?.();
147
+ }
148
+ return { executed: true, mode: 'service', commands: plan.commands };
149
+ }
150
+
151
+ /** 分离启动 supervisor(先于重启触发;须能在守护被杀后存活)。 */
152
+ export function spawnSupervisor({ supervisorPath, args, env = process.env, spawnImpl = spawn, execPath = process.execPath }) {
153
+ const child = spawnImpl(execPath, [supervisorPath, ...args], { detached: true, stdio: 'ignore', env });
154
+ child.unref?.();
155
+ return child;
156
+ }
157
+
158
+ // ---------------------------------------------------------------- supervisor 主逻辑
159
+
160
+ /** 读启动失败根因(server.mjs 在 joinService 起不来时落盘)。 */
161
+ export const readJoinError = (home) => readJson(joinErrorPath(home));
162
+
163
+ /** 还原备份(config.json ← config.json.bak)。返回是否真的还原了。 */
164
+ export async function restoreBackup({ configFile, backupFile = `${configFile}.bak`, copyFileImpl = copyFile }) {
165
+ if (!existsSync(backupFile)) return false;
166
+ await copyFileImpl(backupFile, configFile);
167
+ return true;
168
+ }
169
+
170
+ /**
171
+ * supervisor:等新实例健康;不健康 → 还原 .bak + 再重启 + 记录。
172
+ * 可注入 health/restart/now 以便测试。
173
+ */
174
+ export async function runApplySupervisor({
175
+ home,
176
+ configFile,
177
+ backupFile = configFile ? `${configFile}.bak` : null,
178
+ oldPid,
179
+ port,
180
+ fields = [],
181
+ since = Date.now(),
182
+ timeoutMs = APPLY_HEALTH_TIMEOUT_MS,
183
+ health = waitHealthy,
184
+ restart = runRestart,
185
+ kind = process.env.MEBULAR_SERVICE_KIND,
186
+ log = () => {},
187
+ verify,
188
+ } = {}) {
189
+ const now = () => Date.now();
190
+ const pending = (await readApplyPending(home)) ?? { fields, at: new Date(since).toISOString(), attempts: 1 };
191
+ const attempts = Number(pending.attempts ?? 1);
192
+ log(`apply: 等待新实例健康(fields=${fields.join(',')} timeout=${timeoutMs}ms)`);
193
+ const outcome = await health({ home, port, oldPid, since, timeoutMs, now });
194
+ if (outcome.healthy) {
195
+ log('apply: 新实例健康');
196
+ // 新实例(server.mjs)通常已写 applied+verify;若尚未写(竞态)则在此补一条
197
+ const existing = await readApplyResult(home);
198
+ if (!existing || existing.attempt !== pending.attempt) {
199
+ const record = {
200
+ status: 'applied',
201
+ attempt: pending.attempt ?? null,
202
+ at: new Date(now()).toISOString(),
203
+ fields,
204
+ waitedMs: outcome.waitedMs,
205
+ ...(typeof verify === 'function' ? { verify: await verify() } : {}),
206
+ };
207
+ await writeApplyResult(home, record);
208
+ }
209
+ await clearApplyPending(home);
210
+ return { status: 'applied', waitedMs: outcome.waitedMs, fields };
211
+ }
212
+
213
+ // 失败:回滚兜底
214
+ const joinError = await readJoinError(home);
215
+ const reason = joinError?.message
216
+ ? String(joinError.message)
217
+ : `新实例在 ${timeoutMs}ms 内未健康(心跳/端口无响应)`;
218
+ log(`apply: 新实例未健康(${reason})→ 回滚`);
219
+ // 打标记:回滚实例启动时不得写 applied(否则会短暂谎报「已生效」)
220
+ await writeApplyPending(home, { ...pending, rollingBack: true, reason }).catch(() => undefined);
221
+ const restored = backupFile ? await restoreBackup({ configFile, backupFile }) : false;
222
+ if (restored) {
223
+ await restart({ home, kind, env: process.env });
224
+ const after = await health({ home, port, oldPid, since: now(), timeoutMs, now });
225
+ const record = {
226
+ status: after.healthy ? 'rolled-back' : 'rollback-failed',
227
+ attempt: pending.attempt ?? null,
228
+ at: new Date(now()).toISOString(),
229
+ fields,
230
+ reason,
231
+ restored,
232
+ backup: backupFile,
233
+ rolledBackHealthy: after.healthy,
234
+ retries: attempts,
235
+ };
236
+ await writeApplyResult(home, record);
237
+ await clearApplyPending(home);
238
+ return record;
239
+ }
240
+ const record = {
241
+ status: 'failed',
242
+ attempt: pending.attempt ?? null,
243
+ at: new Date(now()).toISOString(),
244
+ fields,
245
+ reason,
246
+ restored: false,
247
+ };
248
+ await writeApplyResult(home, record);
249
+ await clearApplyPending(home);
250
+ return record;
251
+ }