@twinsearth/dsh-master 3.3.4 → 3.3.5
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.
- package/README.md +1 -0
- package/client.js +83 -4
- package/cordis.patch.yml +2 -0
- package/dist-host/agents/pjm-agent.js +18 -2
- package/dist-host/agents/pm-agent.js +23 -1
- package/dist-host/agents/prompts.js +16 -6
- package/dist-host/channel/abort-registry.js +93 -0
- package/dist-host/channel/protocol.js +114 -0
- package/dist-host/channel/server.js +349 -0
- package/dist-host/channel/stream-state.js +343 -0
- package/dist-host/channel/ws.js +199 -0
- package/dist-host/dual/documents.js +36 -0
- package/dist-host/dual/orchestrator.js +214 -0
- package/dist-host/dual/schema.js +153 -0
- package/dist-host/engine/atomic-switch.js +159 -0
- package/dist-host/engine/health-probe.js +86 -0
- package/dist-host/engine/index.js +18 -0
- package/dist-host/engine/llm-stream.js +215 -0
- package/dist-host/engine/plugin-updater.js +392 -0
- package/dist-host/engine/safe-update.js +150 -0
- package/dist-host/index.js +114 -2
- package/dist-host/kernel.js +7 -7
- package/dist-host/plugins/cost-optimizer.js +3 -3
- package/dist-host/plugins/dual-manager.js +230 -6
- package/docs/AGENTS-PM-PJM.md +90 -6
- package/docs/CHANGELOG.md +58 -0
- package/docs/CHANNEL-WEBSOCKET.md +169 -0
- package/docs/DESIGN-command-center.md +147 -0
- package/docs/ENGINE-PRIMITIVES.md +215 -0
- package/docs/GAP-ANALYSIS-fang-session.md +75 -0
- package/docs/REVIEW-PM-PJM-3.3.4.md +32 -6
- package/docs/examples/command-center/README.md +167 -0
- package/package.json +3 -1
- package/src/agents/pjm-agent.ts +18 -2
- package/src/agents/pm-agent.ts +21 -1
- package/src/agents/prompts.ts +16 -6
- package/src/channel/abort-registry.ts +85 -0
- package/src/channel/protocol.ts +110 -0
- package/src/channel/server.ts +289 -0
- package/src/channel/stream-state.ts +349 -0
- package/src/channel/ws.ts +182 -0
- package/src/dual/documents.ts +35 -0
- package/src/dual/orchestrator.ts +223 -0
- package/src/dual/schema.ts +148 -1
- package/src/engine/atomic-switch.ts +146 -0
- package/src/engine/health-probe.ts +84 -0
- package/src/engine/index.ts +20 -0
- package/src/engine/llm-stream.ts +189 -0
- package/src/engine/plugin-updater.ts +359 -0
- package/src/engine/safe-update.ts +148 -0
- package/src/index.ts +105 -2
- package/src/kernel.ts +7 -7
- package/src/plugins/cost-optimizer.ts +3 -3
- package/src/plugins/dual-manager.ts +211 -6
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (c) 2026 dsh-Master contributors — see LICENSE-CORE.
|
|
3
|
+
// @ts-nocheck
|
|
4
|
+
/**
|
|
5
|
+
* 插件安全更新流水线(房主版 PluginUpdater · 3.3.5 增强)。
|
|
6
|
+
*
|
|
7
|
+
* 语义:快照 → 后台更新(可轮询)→ 冒烟 → 成功清理 / 失败物理回滚。
|
|
8
|
+
* 不假设存在 VM:执行能力由调用方注入 `executor`。
|
|
9
|
+
*
|
|
10
|
+
* 本版新增(相对上一版):
|
|
11
|
+
* 1. **生命周期中断**:`dispose()`(宿主侧挂在 ctx.effect 里)会中止所有进行中的更新;
|
|
12
|
+
* 2. **信号合并**:外部 `signal` + 内部整体超时 + job 超时,三者任一触发都能打断轮询;
|
|
13
|
+
* 3. **进度节流**:`minProgressIntervalMs` 限制 onProgress 频率,终态与阶段切换必发;
|
|
14
|
+
* 4. **致命告警钩子**:回滚失败时 `onFatal()` 必被调用(宿主可据此弹全局告警);
|
|
15
|
+
* 5. **命令构造辅助**:`shellArgv()` 以 argv 形式传脚本,避免手写引号导致的注入/截断。
|
|
16
|
+
*
|
|
17
|
+
* executor 契约:
|
|
18
|
+
* snapshot(name) → {ok}
|
|
19
|
+
* restore(name) → {ok}
|
|
20
|
+
* run(argv, {phase, signal?}) → {ok, jobId?}
|
|
21
|
+
* jobStatus(jobId) → {status:'running'|'completed'|'failed', exitCode?, stdout?, stderr?}
|
|
22
|
+
* deleteSnapshot?(name)
|
|
23
|
+
*/
|
|
24
|
+
export const UPDATE_PHASES = Object.freeze([
|
|
25
|
+
'idle', 'snapshotting', 'updating', 'smoke-testing', 'rolling-back', 'success', 'failed',
|
|
26
|
+
]);
|
|
27
|
+
const DEFAULTS = Object.freeze({
|
|
28
|
+
timeoutMs: 10 * 60 * 1000,
|
|
29
|
+
smokeTimeoutMs: 60 * 1000,
|
|
30
|
+
pollIntervalMs: 2_000,
|
|
31
|
+
minProgressIntervalMs: 0,
|
|
32
|
+
});
|
|
33
|
+
const sleepDefault = (ms) => new Promise((resolve) => setTimeout(resolve, Math.max(1, ms)));
|
|
34
|
+
const ABORTED = Symbol('aborted');
|
|
35
|
+
/**
|
|
36
|
+
* 把 AbortSignal 变成可 race 的 promise:即使适配器的某个调用永不 resolve,
|
|
37
|
+
* 中止也能立刻打断等待(这是“不留僵尸轮询”的关键)。
|
|
38
|
+
*/
|
|
39
|
+
function watchAbort(signal) {
|
|
40
|
+
if (!signal)
|
|
41
|
+
return { promise: new Promise(() => { }), cancel: () => { } };
|
|
42
|
+
let listener = null;
|
|
43
|
+
const promise = new Promise((resolve) => {
|
|
44
|
+
if (signal.aborted) {
|
|
45
|
+
resolve(ABORTED);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
listener = () => resolve(ABORTED);
|
|
49
|
+
signal.addEventListener('abort', listener, { once: true });
|
|
50
|
+
});
|
|
51
|
+
return {
|
|
52
|
+
promise,
|
|
53
|
+
cancel: () => { try {
|
|
54
|
+
if (listener)
|
|
55
|
+
signal.removeEventListener('abort', listener);
|
|
56
|
+
}
|
|
57
|
+
catch { /* ignore */ } },
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
function requiredExecutor(executor) {
|
|
61
|
+
if (!executor || typeof executor.snapshot !== 'function' || typeof executor.restore !== 'function' || typeof executor.run !== 'function') {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
return executor;
|
|
65
|
+
}
|
|
66
|
+
function positiveInt(value, fallback) {
|
|
67
|
+
const n = Number(value);
|
|
68
|
+
return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* 把一段 shell 脚本包成 argv,**不拼接引号**:脚本始终是单个参数。
|
|
72
|
+
* 这是避免“引号地狱 / 注入”的正解——不要手写 `'${script.replace(/'/g, ...)}'`。
|
|
73
|
+
*/
|
|
74
|
+
export function shellArgv(script, { shell = 'bash', login = true } = {}) {
|
|
75
|
+
const text = String(script ?? '');
|
|
76
|
+
if (!text.trim())
|
|
77
|
+
return [];
|
|
78
|
+
return [shell, login ? '-lc' : '-c', text];
|
|
79
|
+
}
|
|
80
|
+
/** 默认冒烟脚本:set -e + 有限次健康探测(端口由调用方传入,别硬编码 3000)。 */
|
|
81
|
+
export function buildSmokeScript({ port = 3000, path = '/api/health', attempts = 10, intervalSeconds = 2 } = {}) {
|
|
82
|
+
const safePath = String(path).startsWith('/') ? String(path) : `/${path}`;
|
|
83
|
+
const tries = Math.max(1, Math.min(60, Number(attempts) || 10));
|
|
84
|
+
const wait = Math.max(1, Math.min(30, Number(intervalSeconds) || 2));
|
|
85
|
+
return [
|
|
86
|
+
'set -eu',
|
|
87
|
+
'dsh stop >/dev/null 2>&1 || true',
|
|
88
|
+
'dsh start --daemon',
|
|
89
|
+
`for i in $(seq 1 ${tries}); do`,
|
|
90
|
+
` if curl -fsS "http://127.0.0.1:${Number(port) || 3000}${safePath}" >/dev/null; then exit 0; fi`,
|
|
91
|
+
` sleep ${wait}`,
|
|
92
|
+
'done',
|
|
93
|
+
'echo "health check timeout" >&2',
|
|
94
|
+
'exit 1',
|
|
95
|
+
].join('\n');
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* @param {{
|
|
99
|
+
* executor?: object,
|
|
100
|
+
* logger?: { info?: Function, warn?: Function, error?: Function },
|
|
101
|
+
* onProgress?: (progress: {phase:string, message:string, percent:number, at:number}) => void,
|
|
102
|
+
* onFatal?: (failure: {code:string, message:string, snapshotId:string, steps:object[]}) => void,
|
|
103
|
+
* minProgressIntervalMs?: number,
|
|
104
|
+
* now?: () => number,
|
|
105
|
+
* sleepImpl?: (ms:number) => Promise<void>,
|
|
106
|
+
* }} deps
|
|
107
|
+
*/
|
|
108
|
+
export function createPluginUpdater(deps = {}) {
|
|
109
|
+
const logger = deps.logger ?? {};
|
|
110
|
+
const now = typeof deps.now === 'function' ? deps.now : () => Date.now();
|
|
111
|
+
const sleep = typeof deps.sleepImpl === 'function' ? deps.sleepImpl : sleepDefault;
|
|
112
|
+
const progressInterval = Number(deps.minProgressIntervalMs) > 0 ? Number(deps.minProgressIntervalMs) : DEFAULTS.minProgressIntervalMs;
|
|
113
|
+
const running = new Map();
|
|
114
|
+
const lifecycle = new AbortController();
|
|
115
|
+
let disposed = false;
|
|
116
|
+
const log = (level, message) => { try {
|
|
117
|
+
logger[level]?.(message);
|
|
118
|
+
}
|
|
119
|
+
catch { /* ignore */ } };
|
|
120
|
+
function makeReporter() {
|
|
121
|
+
let lastEmitAt = -Infinity;
|
|
122
|
+
let lastPhase = null;
|
|
123
|
+
const emit = (progress, force = false) => {
|
|
124
|
+
if (!force && progress.percent < 100 && progressInterval > 0 && now() - lastEmitAt < progressInterval)
|
|
125
|
+
return;
|
|
126
|
+
lastEmitAt = now();
|
|
127
|
+
lastPhase = progress.phase;
|
|
128
|
+
try {
|
|
129
|
+
deps.onProgress?.(progress);
|
|
130
|
+
}
|
|
131
|
+
catch { /* 回调异常不影响事务 */ }
|
|
132
|
+
};
|
|
133
|
+
return {
|
|
134
|
+
report: (phase, message, percent) => {
|
|
135
|
+
const terminal = percent >= 100 || phase === 'success' || phase === 'failed';
|
|
136
|
+
const phaseChanged = phase !== lastPhase;
|
|
137
|
+
emit({ phase, message, percent, at: now() }, terminal || phaseChanged);
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* 轮询异步作业直到终态;支持外部信号与超时,超时给具名 code。
|
|
143
|
+
* `jobStatus` 与 `sleep` 都与中止信号 race,因此卡死的适配器也能被打断。
|
|
144
|
+
*/
|
|
145
|
+
async function waitForJob(executor, jobId, timeoutMs, signal, onPoll) {
|
|
146
|
+
const startedAt = now();
|
|
147
|
+
const deadline = startedAt + timeoutMs;
|
|
148
|
+
const watch = watchAbort(signal);
|
|
149
|
+
let polls = 0;
|
|
150
|
+
try {
|
|
151
|
+
while (true) {
|
|
152
|
+
if (signal?.aborted)
|
|
153
|
+
return { ok: false, code: 'aborted', polls, elapsedMs: Math.max(0, now() - startedAt) };
|
|
154
|
+
polls += 1;
|
|
155
|
+
const status = await Promise.race([executor.jobStatus(jobId), watch.promise]);
|
|
156
|
+
if (status === ABORTED)
|
|
157
|
+
return { ok: false, code: 'aborted', polls, elapsedMs: Math.max(0, now() - startedAt) };
|
|
158
|
+
const state = String(status?.status ?? '');
|
|
159
|
+
if (state === 'completed' || state === 'failed') {
|
|
160
|
+
return { ok: state === 'completed' && Number(status?.exitCode ?? 0) === 0, status, polls, elapsedMs: Math.max(0, now() - startedAt) };
|
|
161
|
+
}
|
|
162
|
+
try {
|
|
163
|
+
onPoll?.(polls);
|
|
164
|
+
}
|
|
165
|
+
catch { /* ignore */ }
|
|
166
|
+
if (now() >= deadline)
|
|
167
|
+
return { ok: false, code: 'job-timeout', status, polls, elapsedMs: Math.max(0, now() - startedAt) };
|
|
168
|
+
const remaining = deadline - now();
|
|
169
|
+
const slept = await Promise.race([sleep(Math.min(DEFAULTS.pollIntervalMs, Math.max(1, remaining))), watch.promise]);
|
|
170
|
+
if (slept === ABORTED)
|
|
171
|
+
return { ok: false, code: 'aborted', polls, elapsedMs: Math.max(0, now() - startedAt) };
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
finally {
|
|
175
|
+
watch.cancel();
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
async function safeUpdate(input = {}) {
|
|
179
|
+
if (disposed) {
|
|
180
|
+
return { success: false, code: 'updater-disposed', message: 'updater was disposed', rolledBack: false, fatal: false, steps: [] };
|
|
181
|
+
}
|
|
182
|
+
const executor = requiredExecutor(deps.executor);
|
|
183
|
+
if (!executor)
|
|
184
|
+
return { success: false, code: 'executor-required', message: 'an executor with snapshot/restore/run is required; no VM is assumed', rolledBack: false, fatal: false, steps: [] };
|
|
185
|
+
const update = Array.isArray(input.update) && input.update.length > 0 ? input.update : null;
|
|
186
|
+
if (!update)
|
|
187
|
+
return { success: false, code: 'update-command-required', message: 'update argv is required', rolledBack: false, fatal: false, steps: [] };
|
|
188
|
+
const key = String(input.key ?? input.target ?? 'default');
|
|
189
|
+
if (running.has(key))
|
|
190
|
+
return { success: false, code: 'update-in-progress', message: `another update is already running for ${key}`, rolledBack: false, fatal: false, steps: [] };
|
|
191
|
+
const timeoutMs = positiveInt(input.timeoutMs, DEFAULTS.timeoutMs);
|
|
192
|
+
const smokeTimeoutMs = positiveInt(input.smokeTimeoutMs, DEFAULTS.smokeTimeoutMs);
|
|
193
|
+
const snapshotName = String(input.snapshotName ?? '').trim() || `pre-update-${now()}`;
|
|
194
|
+
const steps = [];
|
|
195
|
+
const record = (step, detail = {}) => { steps.push({ step, at: now(), ...detail }); };
|
|
196
|
+
const { report } = makeReporter();
|
|
197
|
+
// 三个中断源合并成一个内部控制器:外部 signal、整体超时、dispose()
|
|
198
|
+
const controller = new AbortController();
|
|
199
|
+
const signal = controller.signal;
|
|
200
|
+
const onExternalAbort = () => controller.abort(String(input.signal?.reason ?? 'external-abort'));
|
|
201
|
+
if (input.signal) {
|
|
202
|
+
if (input.signal.aborted)
|
|
203
|
+
controller.abort('external-abort');
|
|
204
|
+
else
|
|
205
|
+
input.signal.addEventListener('abort', onExternalAbort, { once: true });
|
|
206
|
+
}
|
|
207
|
+
const onLifecycleAbort = () => controller.abort('disposed');
|
|
208
|
+
lifecycle.signal.addEventListener('abort', onLifecycleAbort, { once: true });
|
|
209
|
+
const deadline = now() + timeoutMs;
|
|
210
|
+
const remaining = () => Math.max(1, deadline - now());
|
|
211
|
+
const timeoutTimer = setTimeout(() => controller.abort('timeout'), timeoutMs);
|
|
212
|
+
if (typeof timeoutTimer.unref === 'function')
|
|
213
|
+
timeoutTimer.unref();
|
|
214
|
+
const cleanupSignals = () => {
|
|
215
|
+
clearTimeout(timeoutTimer);
|
|
216
|
+
try {
|
|
217
|
+
input.signal?.removeEventListener?.('abort', onExternalAbort);
|
|
218
|
+
}
|
|
219
|
+
catch { /* ignore */ }
|
|
220
|
+
try {
|
|
221
|
+
lifecycle.signal.removeEventListener('abort', onLifecycleAbort);
|
|
222
|
+
}
|
|
223
|
+
catch { /* ignore */ }
|
|
224
|
+
};
|
|
225
|
+
const run = async () => {
|
|
226
|
+
const abortResult = (code, extra = {}) => ({
|
|
227
|
+
success: false, code, message: `update ${code}`, rolledBack: false, fatal: false, snapshotId: snapshotName, steps, ...extra,
|
|
228
|
+
});
|
|
229
|
+
if (signal.aborted)
|
|
230
|
+
return abortResult('aborted', { abortedBeforeSnapshot: true });
|
|
231
|
+
// ── Phase 1: snapshot ────────────────────────────────────────────────
|
|
232
|
+
report('snapshotting', '正在创建快照…', 5);
|
|
233
|
+
try {
|
|
234
|
+
const snapshot = await executor.snapshot(snapshotName);
|
|
235
|
+
if (snapshot && snapshot.ok === false) {
|
|
236
|
+
record('snapshot', { ok: false, detail: snapshot });
|
|
237
|
+
report('failed', '快照失败,已中止更新(环境未改动)', 100);
|
|
238
|
+
return { success: false, code: 'snapshot-failed', message: 'snapshot failed; update aborted before any change', rolledBack: false, fatal: false, snapshotId: snapshotName, steps };
|
|
239
|
+
}
|
|
240
|
+
record('snapshot', { ok: true });
|
|
241
|
+
log('info', `[plugin-updater] snapshot ready: ${snapshotName}`);
|
|
242
|
+
}
|
|
243
|
+
catch (error) {
|
|
244
|
+
record('snapshot', { ok: false, error: String(error?.message ?? error) });
|
|
245
|
+
report('failed', '快照异常,已中止更新(环境未改动)', 100);
|
|
246
|
+
return { success: false, code: 'snapshot-threw', message: String(error?.message ?? error), rolledBack: false, fatal: false, snapshotId: snapshotName, steps };
|
|
247
|
+
}
|
|
248
|
+
const rollback = async (reason, message) => {
|
|
249
|
+
report('rolling-back', '更新失败,正在回滚…', 85);
|
|
250
|
+
let restored = null;
|
|
251
|
+
try {
|
|
252
|
+
restored = await executor.restore(snapshotName);
|
|
253
|
+
}
|
|
254
|
+
catch (error) {
|
|
255
|
+
restored = { ok: false, error: String(error?.message ?? error) };
|
|
256
|
+
}
|
|
257
|
+
const restoredOk = restored?.ok !== false;
|
|
258
|
+
record('restore', { ok: restoredOk, detail: restored });
|
|
259
|
+
if (!restoredOk) {
|
|
260
|
+
const failure = { code: 'rollback-failed', message: `${reason}: rollback failed, manual intervention required`, snapshotId: snapshotName, steps };
|
|
261
|
+
log('error', `[plugin-updater] FATAL rollback failed for ${snapshotName}: ${JSON.stringify(restored)}`);
|
|
262
|
+
report('failed', '回滚失败,需要人工介入!', 100);
|
|
263
|
+
try {
|
|
264
|
+
deps.onFatal?.(failure);
|
|
265
|
+
}
|
|
266
|
+
catch { /* 告警钩子异常不改变结论 */ }
|
|
267
|
+
return { success: false, ...failure, rolledBack: false, fatal: true };
|
|
268
|
+
}
|
|
269
|
+
if (Array.isArray(input.restart) && input.restart.length > 0) {
|
|
270
|
+
try {
|
|
271
|
+
const restarted = await executor.run(input.restart, { phase: 'restart' });
|
|
272
|
+
record('restart', { ok: restarted?.ok !== false, detail: restarted });
|
|
273
|
+
}
|
|
274
|
+
catch (error) {
|
|
275
|
+
record('restart', { ok: false, error: String(error?.message ?? error) });
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
report('failed', '已回滚到更新前状态', 100);
|
|
279
|
+
log('warn', `[plugin-updater] rolled back after ${reason}`);
|
|
280
|
+
return { success: false, code: reason, message, rolledBack: true, fatal: false, snapshotId: snapshotName, steps };
|
|
281
|
+
};
|
|
282
|
+
if (signal.aborted)
|
|
283
|
+
return await rollback('aborted', 'update aborted before applying changes; snapshot discarded');
|
|
284
|
+
// ── Phase 2: update ─────────────────────────────────────────────────
|
|
285
|
+
report('updating', '正在执行更新…', 20);
|
|
286
|
+
try {
|
|
287
|
+
const updated = await executor.run(update, { phase: 'update', signal });
|
|
288
|
+
if (updated && updated.ok === false) {
|
|
289
|
+
record('update', { ok: false, detail: updated });
|
|
290
|
+
return await rollback('update-failed', 'update command failed; environment rolled back');
|
|
291
|
+
}
|
|
292
|
+
if (signal.aborted)
|
|
293
|
+
return await rollback('aborted', 'update aborted by caller; environment rolled back');
|
|
294
|
+
if (updated && typeof updated.jobId === 'string' && updated.jobId) {
|
|
295
|
+
const job = await waitForJob(executor, updated.jobId, Math.min(remaining(), timeoutMs), signal, (polls) => {
|
|
296
|
+
report('updating', `等待更新作业…(第 ${polls} 次轮询)`, 20 + Math.min(45, polls * 2));
|
|
297
|
+
});
|
|
298
|
+
if (!job.ok) {
|
|
299
|
+
record('update-job', { ok: false, code: job.code ?? 'job-failed', polls: job.polls, detail: job.status });
|
|
300
|
+
if (job.code === 'aborted')
|
|
301
|
+
return await rollback('aborted', 'update job aborted; environment rolled back');
|
|
302
|
+
return await rollback(job.code === 'job-timeout' ? 'job-timeout' : 'update-job-failed', job.code === 'job-timeout' ? `update job timed out after ${timeoutMs}ms` : 'update job failed; environment rolled back');
|
|
303
|
+
}
|
|
304
|
+
record('update-job', { ok: true, polls: job.polls, elapsedMs: job.elapsedMs });
|
|
305
|
+
}
|
|
306
|
+
record('update', { ok: true, async: Boolean(updated?.jobId) });
|
|
307
|
+
}
|
|
308
|
+
catch (error) {
|
|
309
|
+
record('update', { ok: false, error: String(error?.message ?? error) });
|
|
310
|
+
return await rollback('update-threw', String(error?.message ?? error));
|
|
311
|
+
}
|
|
312
|
+
// ── Phase 3: smoke ──────────────────────────────────────────────────
|
|
313
|
+
if (Array.isArray(input.smoke) && input.smoke.length > 0) {
|
|
314
|
+
report('smoke-testing', '正在做冒烟测试…', 70);
|
|
315
|
+
try {
|
|
316
|
+
const smoke = await executor.run(input.smoke, { phase: 'smoke', signal });
|
|
317
|
+
if (smoke && smoke.ok === false) {
|
|
318
|
+
record('smoke', { ok: false, detail: smoke });
|
|
319
|
+
return await rollback('smoke-failed', 'smoke test failed after update; environment rolled back');
|
|
320
|
+
}
|
|
321
|
+
if (smoke && typeof smoke.jobId === 'string' && smoke.jobId) {
|
|
322
|
+
const job = await waitForJob(executor, smoke.jobId, Math.min(remaining(), smokeTimeoutMs), signal, (polls) => {
|
|
323
|
+
report('smoke-testing', `等待冒烟作业…(第 ${polls} 次轮询)`, 70 + Math.min(20, polls));
|
|
324
|
+
});
|
|
325
|
+
if (!job.ok) {
|
|
326
|
+
record('smoke-job', { ok: false, code: job.code ?? 'job-failed', polls: job.polls });
|
|
327
|
+
if (job.code === 'aborted')
|
|
328
|
+
return await rollback('aborted', 'smoke test aborted; environment rolled back');
|
|
329
|
+
return await rollback('smoke-failed', 'smoke test job failed; environment rolled back');
|
|
330
|
+
}
|
|
331
|
+
record('smoke-job', { ok: true, polls: job.polls });
|
|
332
|
+
}
|
|
333
|
+
record('smoke', { ok: true });
|
|
334
|
+
}
|
|
335
|
+
catch (error) {
|
|
336
|
+
record('smoke', { ok: false, error: String(error?.message ?? error) });
|
|
337
|
+
return await rollback('smoke-threw', String(error?.message ?? error));
|
|
338
|
+
}
|
|
339
|
+
// 冒烟期间被中止:不能把“刚好跑完”当成成功
|
|
340
|
+
if (signal.aborted)
|
|
341
|
+
return await rollback('aborted', 'update aborted during smoke test; environment rolled back');
|
|
342
|
+
}
|
|
343
|
+
// ── Phase 4: success ────────────────────────────────────────────────
|
|
344
|
+
report('success', '更新完成,系统运行正常', 100);
|
|
345
|
+
if (input.keepSnapshotOnSuccess !== true && typeof executor.deleteSnapshot === 'function') {
|
|
346
|
+
try {
|
|
347
|
+
await executor.deleteSnapshot(snapshotName);
|
|
348
|
+
record('cleanup', { ok: true });
|
|
349
|
+
}
|
|
350
|
+
catch (error) {
|
|
351
|
+
record('cleanup', { ok: false, error: String(error?.message ?? error) });
|
|
352
|
+
log('warn', `[plugin-updater] snapshot cleanup failed: ${String(error?.message ?? error)}`);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
log('info', '[plugin-updater] update succeeded');
|
|
356
|
+
return { success: true, code: 'updated', message: 'all plugins updated and verified', rolledBack: false, fatal: false, snapshotId: snapshotName, steps };
|
|
357
|
+
};
|
|
358
|
+
running.set(key, true);
|
|
359
|
+
try {
|
|
360
|
+
const result = await run();
|
|
361
|
+
return { ...result, phase: result.success ? 'success' : 'failed' };
|
|
362
|
+
}
|
|
363
|
+
finally {
|
|
364
|
+
running.delete(key);
|
|
365
|
+
cleanupSignals();
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
/** 宿主卸载时调用(通常包在 ctx.effect 里):中止所有在跑的更新。 */
|
|
369
|
+
function dispose() {
|
|
370
|
+
if (disposed)
|
|
371
|
+
return { aborted: 0 };
|
|
372
|
+
disposed = true;
|
|
373
|
+
let aborted = 0;
|
|
374
|
+
try {
|
|
375
|
+
lifecycle.abort('disposed');
|
|
376
|
+
}
|
|
377
|
+
catch { /* ignore */ }
|
|
378
|
+
for (const key of running.keys()) {
|
|
379
|
+
running.delete(key);
|
|
380
|
+
aborted += 1;
|
|
381
|
+
}
|
|
382
|
+
log('info', `[plugin-updater] disposed; ${aborted} update(s) signalled to stop`);
|
|
383
|
+
return { aborted };
|
|
384
|
+
}
|
|
385
|
+
return {
|
|
386
|
+
safeUpdate,
|
|
387
|
+
isRunning: (key = 'default') => running.has(String(key)),
|
|
388
|
+
activeCount: () => running.size,
|
|
389
|
+
phases: UPDATE_PHASES,
|
|
390
|
+
dispose,
|
|
391
|
+
};
|
|
392
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (c) 2026 dsh-Master contributors — see LICENSE-CORE.
|
|
3
|
+
// @ts-nocheck
|
|
4
|
+
/**
|
|
5
|
+
* 快照 → 更新 → 冒烟 → 失败回滚(PluginSafeUpdater 的可编译实现)。
|
|
6
|
+
*
|
|
7
|
+
* 关键设计:**不假装有 VM**。执行能力由调用方注入一个 `executor`:
|
|
8
|
+
*
|
|
9
|
+
* executor.snapshot(name) → 建快照
|
|
10
|
+
* executor.restore(name) → 回滚到快照
|
|
11
|
+
* executor.run(argv, options) → 跑一条命令,返回 { ok, stdout, stderr, code }
|
|
12
|
+
*
|
|
13
|
+
* 房主本身没有 vm-sandbox;真实接入要么由瓮城的 VM 层提供 executor,
|
|
14
|
+
* 要么由宿主进程管理器提供。这里只保证**顺序、门禁与回滚语义**正确。
|
|
15
|
+
*/
|
|
16
|
+
function asString(value) { return String(value ?? '').trim(); }
|
|
17
|
+
function executorOf(input) {
|
|
18
|
+
const executor = input && typeof input === 'object' ? input.executor : null;
|
|
19
|
+
if (!executor || typeof executor.snapshot !== 'function' || typeof executor.restore !== 'function' || typeof executor.run !== 'function') {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
return executor;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* @param {{
|
|
26
|
+
* executor?: {snapshot:Function, restore:Function, run:Function},
|
|
27
|
+
* snapshotName?: string,
|
|
28
|
+
* update?: string[], // 更新命令 argv
|
|
29
|
+
* smoke?: string[], // 冒烟命令 argv(可选)
|
|
30
|
+
* restart?: string[], // 回滚后重启命令 argv(可选)
|
|
31
|
+
* now?: () => number,
|
|
32
|
+
* }} input
|
|
33
|
+
*/
|
|
34
|
+
export async function runSafeUpdate(input = {}) {
|
|
35
|
+
const executor = executorOf(input);
|
|
36
|
+
if (!executor) {
|
|
37
|
+
return { ok: false, code: 'executor-required', error: 'an executor with snapshot/restore/run is required; no VM is assumed' };
|
|
38
|
+
}
|
|
39
|
+
const now = typeof input.now === 'function' ? input.now : () => Date.now();
|
|
40
|
+
const snapshotName = asString(input.snapshotName) || `pre-update-${now()}`;
|
|
41
|
+
const steps = [];
|
|
42
|
+
const record = (step, detail) => { steps.push({ step, at: now(), ...detail }); };
|
|
43
|
+
// 1. 快照是硬前置:拿不到快照就不允许更新,否则失败时无法回滚。
|
|
44
|
+
try {
|
|
45
|
+
const snapshot = await executor.snapshot(snapshotName);
|
|
46
|
+
if (snapshot && snapshot.ok === false) {
|
|
47
|
+
record('snapshot', { ok: false, detail: snapshot });
|
|
48
|
+
return { ok: false, code: 'snapshot-failed', error: 'snapshot failed; refusing to update', snapshotName, steps };
|
|
49
|
+
}
|
|
50
|
+
record('snapshot', { ok: true });
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
record('snapshot', { ok: false, error: String(error?.message ?? error) });
|
|
54
|
+
return { ok: false, code: 'snapshot-threw', error: String(error?.message ?? error), snapshotName, steps };
|
|
55
|
+
}
|
|
56
|
+
const rollback = async (reason, detail) => {
|
|
57
|
+
record('rollback-start', { reason });
|
|
58
|
+
let restored = null;
|
|
59
|
+
try {
|
|
60
|
+
restored = await executor.restore(snapshotName);
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
restored = { ok: false, error: String(error?.message ?? error) };
|
|
64
|
+
}
|
|
65
|
+
record('restore', { ok: restored?.ok !== false, detail: restored });
|
|
66
|
+
if (Array.isArray(input.restart) && input.restart.length > 0) {
|
|
67
|
+
try {
|
|
68
|
+
const restarted = await executor.run(input.restart, { phase: 'restart' });
|
|
69
|
+
record('restart', { ok: restarted?.ok !== false, detail: restarted });
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
record('restart', { ok: false, error: String(error?.message ?? error) });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return { ok: false, code: reason, error: detail, snapshotName, steps, rolledBack: restored?.ok !== false };
|
|
76
|
+
};
|
|
77
|
+
// 2. 更新
|
|
78
|
+
const update = Array.isArray(input.update) && input.update.length > 0 ? input.update : null;
|
|
79
|
+
if (!update)
|
|
80
|
+
return await rollback('update-command-required', 'update argv is required');
|
|
81
|
+
try {
|
|
82
|
+
const updated = await executor.run(update, { phase: 'update' });
|
|
83
|
+
if (updated && updated.ok === false) {
|
|
84
|
+
record('update', { ok: false, detail: updated });
|
|
85
|
+
return await rollback('update-failed', 'update command failed');
|
|
86
|
+
}
|
|
87
|
+
record('update', { ok: true });
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
record('update', { ok: false, error: String(error?.message ?? error) });
|
|
91
|
+
return await rollback('update-threw', String(error?.message ?? error));
|
|
92
|
+
}
|
|
93
|
+
// 3. 冒烟(可选但推荐):不通过同样回滚,绝不“更新成功但起不来”。
|
|
94
|
+
if (Array.isArray(input.smoke) && input.smoke.length > 0) {
|
|
95
|
+
try {
|
|
96
|
+
const smoke = await executor.run(input.smoke, { phase: 'smoke' });
|
|
97
|
+
if (smoke && smoke.ok === false) {
|
|
98
|
+
record('smoke', { ok: false, detail: smoke });
|
|
99
|
+
return await rollback('smoke-failed', 'smoke test failed after update');
|
|
100
|
+
}
|
|
101
|
+
record('smoke', { ok: true });
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
record('smoke', { ok: false, error: String(error?.message ?? error) });
|
|
105
|
+
return await rollback('smoke-threw', String(error?.message ?? error));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return { ok: true, code: 'updated', snapshotName, steps, rolledBack: false };
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* 组合使用:切指针 + 健康检查 + 失败回滚。适合蓝绿切换后的验证。
|
|
112
|
+
* 指针切换成功但健康检查失败时,把指针切回旧目标。
|
|
113
|
+
*/
|
|
114
|
+
export async function switchAndVerify(input = {}) {
|
|
115
|
+
const { readPointer, switchWithRollback } = await import("./atomic-switch.js");
|
|
116
|
+
const { probeHealth } = await import("./health-probe.js");
|
|
117
|
+
// 先记住旧目标:健康检查失败时要切回它,而不是切到“刚切过去的新目标”。
|
|
118
|
+
const previous = await readPointer({ linkPath: input.linkPath, fs: input.fs });
|
|
119
|
+
const switched = await switchWithRollback({
|
|
120
|
+
linkPath: input.linkPath,
|
|
121
|
+
targetDir: input.targetDir,
|
|
122
|
+
fs: input.fs,
|
|
123
|
+
platform: input.platform,
|
|
124
|
+
now: input.now,
|
|
125
|
+
});
|
|
126
|
+
if (!switched.ok)
|
|
127
|
+
return { ok: false, code: switched.code, error: switched.error, switch: switched, health: null, previous };
|
|
128
|
+
const health = await probeHealth({
|
|
129
|
+
url: input.healthUrl,
|
|
130
|
+
fetchImpl: input.fetchImpl,
|
|
131
|
+
timeoutMs: input.timeoutMs,
|
|
132
|
+
intervalMs: input.intervalMs,
|
|
133
|
+
maxAttempts: input.maxAttempts,
|
|
134
|
+
now: input.now,
|
|
135
|
+
sleepImpl: input.sleepImpl,
|
|
136
|
+
});
|
|
137
|
+
if (health.ok)
|
|
138
|
+
return { ok: true, code: 'switched-and-healthy', switch: switched, health, previous };
|
|
139
|
+
if (!previous.ok || !previous.resolved) {
|
|
140
|
+
return { ok: false, code: 'health-failed-after-switch', switch: switched, health, previous, rollback: null };
|
|
141
|
+
}
|
|
142
|
+
const back = await switchWithRollback({
|
|
143
|
+
linkPath: input.linkPath,
|
|
144
|
+
targetDir: previous.resolved,
|
|
145
|
+
fs: input.fs,
|
|
146
|
+
platform: input.platform,
|
|
147
|
+
now: input.now,
|
|
148
|
+
}).catch((error) => ({ ok: false, error: String(error?.message ?? error) }));
|
|
149
|
+
return { ok: false, code: 'health-failed-after-switch', switch: switched, health, previous, rollback: back };
|
|
150
|
+
}
|