@twinsearth/dsh-master 3.3.3 → 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 +17 -0
- package/client.js +126 -1
- package/cordis.patch.yml +51 -0
- package/dist-host/agents/pjm-agent.js +62 -0
- package/dist-host/agents/pm-agent.js +57 -0
- package/dist-host/agents/prompts.js +71 -0
- package/dist-host/agents/toolkit.js +64 -0
- 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 +143 -0
- package/dist-host/dual/orchestrator.js +214 -0
- package/dist-host/dual/schema.js +367 -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 +154 -3
- package/dist-host/kernel.js +27 -1
- package/dist-host/plugins/cost-optimizer.js +3 -3
- package/dist-host/plugins/dual-manager.js +374 -77
- package/docs/AGENTS-PM-PJM.md +207 -0
- package/docs/ARCHITECTURE.md +2 -0
- package/docs/CHANGELOG.md +74 -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 +179 -0
- package/docs/examples/command-center/README.md +167 -0
- package/package.json +5 -1
- package/src/agents/pjm-agent.ts +62 -0
- package/src/agents/pm-agent.ts +58 -0
- package/src/agents/prompts.ts +74 -0
- package/src/agents/toolkit.ts +64 -0
- 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 +142 -0
- package/src/dual/orchestrator.ts +223 -0
- package/src/dual/schema.ts +355 -0
- 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 +143 -4
- package/src/kernel.ts +28 -1
- package/src/plugins/cost-optimizer.ts +3 -3
- package/src/plugins/dual-manager.ts +337 -76
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (c) 2026 dsh-Master contributors — see LICENSE-CORE.
|
|
3
|
+
// @ts-nocheck
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 可中断的 LLM 流(3.3.5)。
|
|
7
|
+
*
|
|
8
|
+
* 真实宿主 API 是 `ctx.llm.stream(options)`(不是草案里的 `llm.client.chat.completions.create`):
|
|
9
|
+
* * `options.signal` 原生支持取消;
|
|
10
|
+
* * 取消后流会以 `{type:'finish', reason:{kind:'aborted'}}` 收尾;
|
|
11
|
+
* * `StreamChunk` 里文本增量是 `{type:'text-delta', text}`。
|
|
12
|
+
*
|
|
13
|
+
* 本模块负责「谁在跑、怎么停、停完怎么记账」:
|
|
14
|
+
* * 每个 key(会话/消息)最多一条在跑的流,新的一轮会先中止旧的;
|
|
15
|
+
* * 中止后**保留已产出的半截文本**(`partial`),供 UI 展示与上下文续写;
|
|
16
|
+
* * `dispose()` 中止全部(宿主卸载用);
|
|
17
|
+
* * **不声称**能验证服务端计费已停止——我们只能保证自己停止读取并传了 signal。
|
|
18
|
+
*
|
|
19
|
+
* 依赖注入:`streamFn(options) => AsyncIterable<StreamChunk>`,便于测试与替换。
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { createAbortRegistry } from '../channel/abort-registry.ts'
|
|
23
|
+
|
|
24
|
+
const ABORTED = Symbol('aborted')
|
|
25
|
+
|
|
26
|
+
function watchAbort(signal) {
|
|
27
|
+
if (!signal) return { promise: new Promise(() => {}), cancel: () => {} }
|
|
28
|
+
let listener = null
|
|
29
|
+
const promise = new Promise((resolve) => {
|
|
30
|
+
if (signal.aborted) { resolve(ABORTED); return }
|
|
31
|
+
listener = () => resolve(ABORTED)
|
|
32
|
+
signal.addEventListener('abort', listener, { once: true })
|
|
33
|
+
})
|
|
34
|
+
return {
|
|
35
|
+
promise,
|
|
36
|
+
cancel: () => { try { if (listener) signal.removeEventListener('abort', listener) } catch { /* ignore */ } },
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function chunkText(chunk) {
|
|
41
|
+
if (!chunk || typeof chunk !== 'object') return ''
|
|
42
|
+
if (chunk.type === 'text-delta') return String(chunk.text ?? '')
|
|
43
|
+
if (chunk.type === 'reasoning-delta') return ''
|
|
44
|
+
return ''
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @param {{
|
|
49
|
+
* streamFn: (options: object) => AsyncIterable<object>,
|
|
50
|
+
* registry?: object,
|
|
51
|
+
* onDelta?: (delta: string, meta: object) => void,
|
|
52
|
+
* logger?: { info?: Function, warn?: Function, error?: Function },
|
|
53
|
+
* }} deps
|
|
54
|
+
*/
|
|
55
|
+
export function createLlmStreamer(deps = {}) {
|
|
56
|
+
if (typeof deps.streamFn !== 'function') {
|
|
57
|
+
return {
|
|
58
|
+
available: false,
|
|
59
|
+
reason: 'streamFn-required',
|
|
60
|
+
run: async () => ({ ok: false, code: 'streamFn-required', error: 'a streamFn(options) => AsyncIterable is required' }),
|
|
61
|
+
abort: () => false,
|
|
62
|
+
abortAll: () => [],
|
|
63
|
+
activeKeys: () => [],
|
|
64
|
+
dispose: () => ({ aborted: [] }),
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const registry = deps.registry ?? createAbortRegistry()
|
|
69
|
+
const log = (level, message) => { try { deps.logger?.[level]?.(message) } catch { /* ignore */ } }
|
|
70
|
+
const stallTimeoutMs = Number(deps.stallTimeoutMs) > 0 ? Number(deps.stallTimeoutMs) : 0
|
|
71
|
+
let disposed = false
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 跑一条流并返回结构化结果(永不抛:错误变成 code)。
|
|
75
|
+
* @param {{key: string, options?: object, signal?: AbortSignal}} input
|
|
76
|
+
*/
|
|
77
|
+
async function run(input = {}) {
|
|
78
|
+
const key = String(input.key ?? '').trim()
|
|
79
|
+
if (!key) return { ok: false, code: 'key-required', error: 'key is required' }
|
|
80
|
+
if (disposed) return { ok: false, code: 'streamer-disposed', error: 'streamer was disposed' }
|
|
81
|
+
|
|
82
|
+
const entry = registry.begin(key)
|
|
83
|
+
const signal = entry.signal
|
|
84
|
+
const onDelta = typeof input.onDelta === 'function' ? input.onDelta : deps.onDelta
|
|
85
|
+
const external = input.signal
|
|
86
|
+
const onExternalAbort = () => entry.abort('external-abort')
|
|
87
|
+
if (external) {
|
|
88
|
+
if (external.aborted) entry.abort('external-abort')
|
|
89
|
+
else external.addEventListener('abort', onExternalAbort, { once: true })
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let text = ''
|
|
93
|
+
let finish = null
|
|
94
|
+
let aborted = false
|
|
95
|
+
let usage = null
|
|
96
|
+
const watch = watchAbort(signal)
|
|
97
|
+
const iteratorRef = { current: null }
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
const iterator = deps.streamFn({ ...(input.options ?? {}), signal })
|
|
101
|
+
iteratorRef.current = iterator?.[Symbol.asyncIterator]?.() ?? null
|
|
102
|
+
if (!iteratorRef.current) return { ok: false, code: 'stream-not-async-iterable', error: 'streamFn must return an AsyncIterable', text, aborted: false }
|
|
103
|
+
|
|
104
|
+
while (true) {
|
|
105
|
+
// 卡住兜底:上游长时间不吐字(连接半死/网关静默)时主动中止,避免句柄挂着烧钱
|
|
106
|
+
const stall = stallTimeoutMs > 0
|
|
107
|
+
? new Promise((resolve) => { const timer = setTimeout(() => resolve('stalled'), stallTimeoutMs); if (typeof timer.unref === 'function') timer.unref() })
|
|
108
|
+
: new Promise(() => {})
|
|
109
|
+
const step = await Promise.race([iteratorRef.current.next(), watch.promise, stall])
|
|
110
|
+
if (step === 'stalled') {
|
|
111
|
+
entry.abort('stalled')
|
|
112
|
+
log('warn', `[llm-stream] upstream stalled >${stallTimeoutMs}ms key=${key}`)
|
|
113
|
+
return { ok: false, code: 'stalled', error: `no chunk within ${stallTimeoutMs}ms`, text, aborted: true, finish, usage, savedContent: text }
|
|
114
|
+
}
|
|
115
|
+
if (step === ABORTED) { aborted = true; break }
|
|
116
|
+
if (!step || step.done) break
|
|
117
|
+
const chunk = step.value
|
|
118
|
+
if (signal.aborted) { aborted = true; break }
|
|
119
|
+
if (chunk?.type === 'usage' && chunk.usage) usage = chunk.usage
|
|
120
|
+
if (chunk?.type === 'finish') {
|
|
121
|
+
finish = chunk.reason ?? null
|
|
122
|
+
if (finish?.kind === 'aborted') aborted = true
|
|
123
|
+
if (finish?.kind === 'error') {
|
|
124
|
+
return { ok: false, code: 'llm-error', error: finish.failure?.message ?? 'model call failed', failure: finish.failure, text, aborted: false, usage }
|
|
125
|
+
}
|
|
126
|
+
continue
|
|
127
|
+
}
|
|
128
|
+
const delta = chunkText(chunk)
|
|
129
|
+
if (delta) {
|
|
130
|
+
text += delta
|
|
131
|
+
try { onDelta?.(delta, { key, text }) } catch { /* 消费回调异常不影响流 */ }
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (aborted) {
|
|
136
|
+
log('info', `[llm-stream] aborted key=${key} after ${text.length} chars`)
|
|
137
|
+
return { ok: false, code: 'aborted', error: 'generation aborted by caller', text, aborted: true, finish, usage, savedContent: text }
|
|
138
|
+
}
|
|
139
|
+
log('info', `[llm-stream] done key=${key} chars=${text.length}`)
|
|
140
|
+
return { ok: true, code: 'completed', text, aborted: false, finish, usage }
|
|
141
|
+
} catch (error) {
|
|
142
|
+
const message = String(error?.message ?? error)
|
|
143
|
+
const isAbort = signal.aborted || error?.name === 'AbortError' || /abort/iu.test(message)
|
|
144
|
+
if (isAbort) {
|
|
145
|
+
log('info', `[llm-stream] abort surfaced as error key=${key}: ${message}`)
|
|
146
|
+
return { ok: false, code: 'aborted', error: message, text, aborted: true, finish, usage, savedContent: text }
|
|
147
|
+
}
|
|
148
|
+
log('error', `[llm-stream] failed key=${key}: ${message}`)
|
|
149
|
+
return { ok: false, code: 'stream-failed', error: message, text, aborted: false, finish, usage }
|
|
150
|
+
} finally {
|
|
151
|
+
watch.cancel()
|
|
152
|
+
try { external?.removeEventListener?.('abort', onExternalAbort) } catch { /* ignore */ }
|
|
153
|
+
// 清理上游时**绝不能无限等**:卡死的异步生成器会把 return() 排在未完成的 next() 后面,
|
|
154
|
+
// await 它会永久挂住(这正是我们要防的僵尸句柄)。这里给一个有界让步。
|
|
155
|
+
try {
|
|
156
|
+
const closing = iteratorRef.current?.return?.()
|
|
157
|
+
if (closing && typeof closing.then === 'function') {
|
|
158
|
+
await Promise.race([
|
|
159
|
+
closing,
|
|
160
|
+
new Promise((resolve) => { const timer = setTimeout(resolve, 50); if (typeof timer.unref === 'function') timer.unref() }),
|
|
161
|
+
])
|
|
162
|
+
}
|
|
163
|
+
} catch { /* 尽力关闭上游 */ }
|
|
164
|
+
entry.end()
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
available: true,
|
|
170
|
+
run,
|
|
171
|
+
abort: (key, reason = 'user-interrupt') => registry.abort(String(key ?? ''), reason),
|
|
172
|
+
abortAll: (reason = 'shutdown') => registry.abortAll(reason),
|
|
173
|
+
activeKeys: () => registry.keys(),
|
|
174
|
+
dispose: () => {
|
|
175
|
+
if (disposed) return { aborted: [] }
|
|
176
|
+
disposed = true
|
|
177
|
+
return { aborted: registry.abortAll('disposed') }
|
|
178
|
+
},
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* 把真实 `ctx.llm.stream` 适配成 streamFn。
|
|
184
|
+
* 之所以包一层:只取需要的字段,且把 method 绑定到服务对象上(避免 this 丢失)。
|
|
185
|
+
*/
|
|
186
|
+
export function llmStreamFn(llm) {
|
|
187
|
+
if (!llm || typeof llm.stream !== 'function') return null
|
|
188
|
+
return (options) => llm.stream(options)
|
|
189
|
+
}
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (c) 2026 dsh-Master contributors — see LICENSE-CORE.
|
|
3
|
+
// @ts-nocheck
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 插件安全更新流水线(房主版 PluginUpdater · 3.3.5 增强)。
|
|
7
|
+
*
|
|
8
|
+
* 语义:快照 → 后台更新(可轮询)→ 冒烟 → 成功清理 / 失败物理回滚。
|
|
9
|
+
* 不假设存在 VM:执行能力由调用方注入 `executor`。
|
|
10
|
+
*
|
|
11
|
+
* 本版新增(相对上一版):
|
|
12
|
+
* 1. **生命周期中断**:`dispose()`(宿主侧挂在 ctx.effect 里)会中止所有进行中的更新;
|
|
13
|
+
* 2. **信号合并**:外部 `signal` + 内部整体超时 + job 超时,三者任一触发都能打断轮询;
|
|
14
|
+
* 3. **进度节流**:`minProgressIntervalMs` 限制 onProgress 频率,终态与阶段切换必发;
|
|
15
|
+
* 4. **致命告警钩子**:回滚失败时 `onFatal()` 必被调用(宿主可据此弹全局告警);
|
|
16
|
+
* 5. **命令构造辅助**:`shellArgv()` 以 argv 形式传脚本,避免手写引号导致的注入/截断。
|
|
17
|
+
*
|
|
18
|
+
* executor 契约:
|
|
19
|
+
* snapshot(name) → {ok}
|
|
20
|
+
* restore(name) → {ok}
|
|
21
|
+
* run(argv, {phase, signal?}) → {ok, jobId?}
|
|
22
|
+
* jobStatus(jobId) → {status:'running'|'completed'|'failed', exitCode?, stdout?, stderr?}
|
|
23
|
+
* deleteSnapshot?(name)
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
export const UPDATE_PHASES = Object.freeze([
|
|
27
|
+
'idle', 'snapshotting', 'updating', 'smoke-testing', 'rolling-back', 'success', 'failed',
|
|
28
|
+
])
|
|
29
|
+
|
|
30
|
+
const DEFAULTS = Object.freeze({
|
|
31
|
+
timeoutMs: 10 * 60 * 1000,
|
|
32
|
+
smokeTimeoutMs: 60 * 1000,
|
|
33
|
+
pollIntervalMs: 2_000,
|
|
34
|
+
minProgressIntervalMs: 0,
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
const sleepDefault = (ms) => new Promise((resolve) => setTimeout(resolve, Math.max(1, ms)))
|
|
38
|
+
const ABORTED = Symbol('aborted')
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 把 AbortSignal 变成可 race 的 promise:即使适配器的某个调用永不 resolve,
|
|
42
|
+
* 中止也能立刻打断等待(这是“不留僵尸轮询”的关键)。
|
|
43
|
+
*/
|
|
44
|
+
function watchAbort(signal) {
|
|
45
|
+
if (!signal) return { promise: new Promise(() => {}), cancel: () => {} }
|
|
46
|
+
let listener = null
|
|
47
|
+
const promise = new Promise((resolve) => {
|
|
48
|
+
if (signal.aborted) { resolve(ABORTED); return }
|
|
49
|
+
listener = () => resolve(ABORTED)
|
|
50
|
+
signal.addEventListener('abort', listener, { once: true })
|
|
51
|
+
})
|
|
52
|
+
return {
|
|
53
|
+
promise,
|
|
54
|
+
cancel: () => { try { if (listener) signal.removeEventListener('abort', listener) } catch { /* ignore */ } },
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function requiredExecutor(executor) {
|
|
59
|
+
if (!executor || typeof executor.snapshot !== 'function' || typeof executor.restore !== 'function' || typeof executor.run !== 'function') {
|
|
60
|
+
return null
|
|
61
|
+
}
|
|
62
|
+
return executor
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function positiveInt(value, fallback) {
|
|
66
|
+
const n = Number(value)
|
|
67
|
+
return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback
|
|
68
|
+
}
|
|
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()) return []
|
|
77
|
+
return [shell, login ? '-lc' : '-c', text]
|
|
78
|
+
}
|
|
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
|
+
/**
|
|
99
|
+
* @param {{
|
|
100
|
+
* executor?: object,
|
|
101
|
+
* logger?: { info?: Function, warn?: Function, error?: Function },
|
|
102
|
+
* onProgress?: (progress: {phase:string, message:string, percent:number, at:number}) => void,
|
|
103
|
+
* onFatal?: (failure: {code:string, message:string, snapshotId:string, steps:object[]}) => void,
|
|
104
|
+
* minProgressIntervalMs?: number,
|
|
105
|
+
* now?: () => number,
|
|
106
|
+
* sleepImpl?: (ms:number) => Promise<void>,
|
|
107
|
+
* }} deps
|
|
108
|
+
*/
|
|
109
|
+
export function createPluginUpdater(deps = {}) {
|
|
110
|
+
const logger = deps.logger ?? {}
|
|
111
|
+
const now = typeof deps.now === 'function' ? deps.now : () => Date.now()
|
|
112
|
+
const sleep = typeof deps.sleepImpl === 'function' ? deps.sleepImpl : sleepDefault
|
|
113
|
+
const progressInterval = Number(deps.minProgressIntervalMs) > 0 ? Number(deps.minProgressIntervalMs) : DEFAULTS.minProgressIntervalMs
|
|
114
|
+
const running = new Map()
|
|
115
|
+
const lifecycle = new AbortController()
|
|
116
|
+
let disposed = false
|
|
117
|
+
|
|
118
|
+
const log = (level, message) => { try { logger[level]?.(message) } catch { /* ignore */ } }
|
|
119
|
+
|
|
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) return
|
|
125
|
+
lastEmitAt = now()
|
|
126
|
+
lastPhase = progress.phase
|
|
127
|
+
try { deps.onProgress?.(progress) } catch { /* 回调异常不影响事务 */ }
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
report: (phase, message, percent) => {
|
|
131
|
+
const terminal = percent >= 100 || phase === 'success' || phase === 'failed'
|
|
132
|
+
const phaseChanged = phase !== lastPhase
|
|
133
|
+
emit({ phase, message, percent, at: now() }, terminal || phaseChanged)
|
|
134
|
+
},
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* 轮询异步作业直到终态;支持外部信号与超时,超时给具名 code。
|
|
140
|
+
* `jobStatus` 与 `sleep` 都与中止信号 race,因此卡死的适配器也能被打断。
|
|
141
|
+
*/
|
|
142
|
+
async function waitForJob(executor, jobId, timeoutMs, signal, onPoll) {
|
|
143
|
+
const startedAt = now()
|
|
144
|
+
const deadline = startedAt + timeoutMs
|
|
145
|
+
const watch = watchAbort(signal)
|
|
146
|
+
let polls = 0
|
|
147
|
+
try {
|
|
148
|
+
while (true) {
|
|
149
|
+
if (signal?.aborted) return { ok: false, code: 'aborted', polls, elapsedMs: Math.max(0, now() - startedAt) }
|
|
150
|
+
polls += 1
|
|
151
|
+
const status = await Promise.race([executor.jobStatus(jobId), watch.promise])
|
|
152
|
+
if (status === ABORTED) return { ok: false, code: 'aborted', polls, elapsedMs: Math.max(0, now() - startedAt) }
|
|
153
|
+
const state = String(status?.status ?? '')
|
|
154
|
+
if (state === 'completed' || state === 'failed') {
|
|
155
|
+
return { ok: state === 'completed' && Number(status?.exitCode ?? 0) === 0, status, polls, elapsedMs: Math.max(0, now() - startedAt) }
|
|
156
|
+
}
|
|
157
|
+
try { onPoll?.(polls) } catch { /* ignore */ }
|
|
158
|
+
if (now() >= deadline) return { ok: false, code: 'job-timeout', status, polls, elapsedMs: Math.max(0, now() - startedAt) }
|
|
159
|
+
const remaining = deadline - now()
|
|
160
|
+
const slept = await Promise.race([sleep(Math.min(DEFAULTS.pollIntervalMs, Math.max(1, remaining))), watch.promise])
|
|
161
|
+
if (slept === ABORTED) return { ok: false, code: 'aborted', polls, elapsedMs: Math.max(0, now() - startedAt) }
|
|
162
|
+
}
|
|
163
|
+
} finally {
|
|
164
|
+
watch.cancel()
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function safeUpdate(input = {}) {
|
|
169
|
+
if (disposed) {
|
|
170
|
+
return { success: false, code: 'updater-disposed', message: 'updater was disposed', rolledBack: false, fatal: false, steps: [] }
|
|
171
|
+
}
|
|
172
|
+
const executor = requiredExecutor(deps.executor)
|
|
173
|
+
if (!executor) return { success: false, code: 'executor-required', message: 'an executor with snapshot/restore/run is required; no VM is assumed', rolledBack: false, fatal: false, steps: [] }
|
|
174
|
+
|
|
175
|
+
const update = Array.isArray(input.update) && input.update.length > 0 ? input.update : null
|
|
176
|
+
if (!update) return { success: false, code: 'update-command-required', message: 'update argv is required', rolledBack: false, fatal: false, steps: [] }
|
|
177
|
+
|
|
178
|
+
const key = String(input.key ?? input.target ?? 'default')
|
|
179
|
+
if (running.has(key)) return { success: false, code: 'update-in-progress', message: `another update is already running for ${key}`, rolledBack: false, fatal: false, steps: [] }
|
|
180
|
+
|
|
181
|
+
const timeoutMs = positiveInt(input.timeoutMs, DEFAULTS.timeoutMs)
|
|
182
|
+
const smokeTimeoutMs = positiveInt(input.smokeTimeoutMs, DEFAULTS.smokeTimeoutMs)
|
|
183
|
+
const snapshotName = String(input.snapshotName ?? '').trim() || `pre-update-${now()}`
|
|
184
|
+
const steps = []
|
|
185
|
+
const record = (step, detail = {}) => { steps.push({ step, at: now(), ...detail }) }
|
|
186
|
+
const { report } = makeReporter()
|
|
187
|
+
|
|
188
|
+
// 三个中断源合并成一个内部控制器:外部 signal、整体超时、dispose()
|
|
189
|
+
const controller = new AbortController()
|
|
190
|
+
const signal = controller.signal
|
|
191
|
+
const onExternalAbort = () => controller.abort(String(input.signal?.reason ?? 'external-abort'))
|
|
192
|
+
if (input.signal) {
|
|
193
|
+
if (input.signal.aborted) controller.abort('external-abort')
|
|
194
|
+
else input.signal.addEventListener('abort', onExternalAbort, { once: true })
|
|
195
|
+
}
|
|
196
|
+
const onLifecycleAbort = () => controller.abort('disposed')
|
|
197
|
+
lifecycle.signal.addEventListener('abort', onLifecycleAbort, { once: true })
|
|
198
|
+
const deadline = now() + timeoutMs
|
|
199
|
+
const remaining = () => Math.max(1, deadline - now())
|
|
200
|
+
const timeoutTimer = setTimeout(() => controller.abort('timeout'), timeoutMs)
|
|
201
|
+
if (typeof timeoutTimer.unref === 'function') timeoutTimer.unref()
|
|
202
|
+
|
|
203
|
+
const cleanupSignals = () => {
|
|
204
|
+
clearTimeout(timeoutTimer)
|
|
205
|
+
try { input.signal?.removeEventListener?.('abort', onExternalAbort) } catch { /* ignore */ }
|
|
206
|
+
try { lifecycle.signal.removeEventListener('abort', onLifecycleAbort) } catch { /* ignore */ }
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const run = async () => {
|
|
210
|
+
const abortResult = (code, extra = {}) => ({
|
|
211
|
+
success: false, code, message: `update ${code}`, rolledBack: false, fatal: false, snapshotId: snapshotName, steps, ...extra,
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
if (signal.aborted) return abortResult('aborted', { abortedBeforeSnapshot: true })
|
|
215
|
+
|
|
216
|
+
// ── Phase 1: snapshot ────────────────────────────────────────────────
|
|
217
|
+
report('snapshotting', '正在创建快照…', 5)
|
|
218
|
+
try {
|
|
219
|
+
const snapshot = await executor.snapshot(snapshotName)
|
|
220
|
+
if (snapshot && snapshot.ok === false) {
|
|
221
|
+
record('snapshot', { ok: false, detail: snapshot })
|
|
222
|
+
report('failed', '快照失败,已中止更新(环境未改动)', 100)
|
|
223
|
+
return { success: false, code: 'snapshot-failed', message: 'snapshot failed; update aborted before any change', rolledBack: false, fatal: false, snapshotId: snapshotName, steps }
|
|
224
|
+
}
|
|
225
|
+
record('snapshot', { ok: true })
|
|
226
|
+
log('info', `[plugin-updater] snapshot ready: ${snapshotName}`)
|
|
227
|
+
} catch (error) {
|
|
228
|
+
record('snapshot', { ok: false, error: String(error?.message ?? error) })
|
|
229
|
+
report('failed', '快照异常,已中止更新(环境未改动)', 100)
|
|
230
|
+
return { success: false, code: 'snapshot-threw', message: String(error?.message ?? error), rolledBack: false, fatal: false, snapshotId: snapshotName, steps }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const rollback = async (reason, message) => {
|
|
234
|
+
report('rolling-back', '更新失败,正在回滚…', 85)
|
|
235
|
+
let restored = null
|
|
236
|
+
try { restored = await executor.restore(snapshotName) } catch (error) { restored = { ok: false, error: String(error?.message ?? error) } }
|
|
237
|
+
const restoredOk = restored?.ok !== false
|
|
238
|
+
record('restore', { ok: restoredOk, detail: restored })
|
|
239
|
+
if (!restoredOk) {
|
|
240
|
+
const failure = { code: 'rollback-failed', message: `${reason}: rollback failed, manual intervention required`, snapshotId: snapshotName, steps }
|
|
241
|
+
log('error', `[plugin-updater] FATAL rollback failed for ${snapshotName}: ${JSON.stringify(restored)}`)
|
|
242
|
+
report('failed', '回滚失败,需要人工介入!', 100)
|
|
243
|
+
try { deps.onFatal?.(failure) } catch { /* 告警钩子异常不改变结论 */ }
|
|
244
|
+
return { success: false, ...failure, rolledBack: false, fatal: true }
|
|
245
|
+
}
|
|
246
|
+
if (Array.isArray(input.restart) && input.restart.length > 0) {
|
|
247
|
+
try {
|
|
248
|
+
const restarted = await executor.run(input.restart, { phase: 'restart' })
|
|
249
|
+
record('restart', { ok: restarted?.ok !== false, detail: restarted })
|
|
250
|
+
} catch (error) {
|
|
251
|
+
record('restart', { ok: false, error: String(error?.message ?? error) })
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
report('failed', '已回滚到更新前状态', 100)
|
|
255
|
+
log('warn', `[plugin-updater] rolled back after ${reason}`)
|
|
256
|
+
return { success: false, code: reason, message, rolledBack: true, fatal: false, snapshotId: snapshotName, steps }
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (signal.aborted) return await rollback('aborted', 'update aborted before applying changes; snapshot discarded')
|
|
260
|
+
|
|
261
|
+
// ── Phase 2: update ─────────────────────────────────────────────────
|
|
262
|
+
report('updating', '正在执行更新…', 20)
|
|
263
|
+
try {
|
|
264
|
+
const updated = await executor.run(update, { phase: 'update', signal })
|
|
265
|
+
if (updated && updated.ok === false) {
|
|
266
|
+
record('update', { ok: false, detail: updated })
|
|
267
|
+
return await rollback('update-failed', 'update command failed; environment rolled back')
|
|
268
|
+
}
|
|
269
|
+
if (signal.aborted) return await rollback('aborted', 'update aborted by caller; environment rolled back')
|
|
270
|
+
if (updated && typeof updated.jobId === 'string' && updated.jobId) {
|
|
271
|
+
const job = await waitForJob(executor, updated.jobId, Math.min(remaining(), timeoutMs), signal, (polls) => {
|
|
272
|
+
report('updating', `等待更新作业…(第 ${polls} 次轮询)`, 20 + Math.min(45, polls * 2))
|
|
273
|
+
})
|
|
274
|
+
if (!job.ok) {
|
|
275
|
+
record('update-job', { ok: false, code: job.code ?? 'job-failed', polls: job.polls, detail: job.status })
|
|
276
|
+
if (job.code === 'aborted') return await rollback('aborted', 'update job aborted; environment rolled back')
|
|
277
|
+
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')
|
|
278
|
+
}
|
|
279
|
+
record('update-job', { ok: true, polls: job.polls, elapsedMs: job.elapsedMs })
|
|
280
|
+
}
|
|
281
|
+
record('update', { ok: true, async: Boolean(updated?.jobId) })
|
|
282
|
+
} catch (error) {
|
|
283
|
+
record('update', { ok: false, error: String(error?.message ?? error) })
|
|
284
|
+
return await rollback('update-threw', String(error?.message ?? error))
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ── Phase 3: smoke ──────────────────────────────────────────────────
|
|
288
|
+
if (Array.isArray(input.smoke) && input.smoke.length > 0) {
|
|
289
|
+
report('smoke-testing', '正在做冒烟测试…', 70)
|
|
290
|
+
try {
|
|
291
|
+
const smoke = await executor.run(input.smoke, { phase: 'smoke', signal })
|
|
292
|
+
if (smoke && smoke.ok === false) {
|
|
293
|
+
record('smoke', { ok: false, detail: smoke })
|
|
294
|
+
return await rollback('smoke-failed', 'smoke test failed after update; environment rolled back')
|
|
295
|
+
}
|
|
296
|
+
if (smoke && typeof smoke.jobId === 'string' && smoke.jobId) {
|
|
297
|
+
const job = await waitForJob(executor, smoke.jobId, Math.min(remaining(), smokeTimeoutMs), signal, (polls) => {
|
|
298
|
+
report('smoke-testing', `等待冒烟作业…(第 ${polls} 次轮询)`, 70 + Math.min(20, polls))
|
|
299
|
+
})
|
|
300
|
+
if (!job.ok) {
|
|
301
|
+
record('smoke-job', { ok: false, code: job.code ?? 'job-failed', polls: job.polls })
|
|
302
|
+
if (job.code === 'aborted') return await rollback('aborted', 'smoke test aborted; environment rolled back')
|
|
303
|
+
return await rollback('smoke-failed', 'smoke test job failed; environment rolled back')
|
|
304
|
+
}
|
|
305
|
+
record('smoke-job', { ok: true, polls: job.polls })
|
|
306
|
+
}
|
|
307
|
+
record('smoke', { ok: true })
|
|
308
|
+
} catch (error) {
|
|
309
|
+
record('smoke', { ok: false, error: String(error?.message ?? error) })
|
|
310
|
+
return await rollback('smoke-threw', String(error?.message ?? error))
|
|
311
|
+
}
|
|
312
|
+
// 冒烟期间被中止:不能把“刚好跑完”当成成功
|
|
313
|
+
if (signal.aborted) return await rollback('aborted', 'update aborted during smoke test; environment rolled back')
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// ── Phase 4: success ────────────────────────────────────────────────
|
|
317
|
+
report('success', '更新完成,系统运行正常', 100)
|
|
318
|
+
if (input.keepSnapshotOnSuccess !== true && typeof executor.deleteSnapshot === 'function') {
|
|
319
|
+
try {
|
|
320
|
+
await executor.deleteSnapshot(snapshotName)
|
|
321
|
+
record('cleanup', { ok: true })
|
|
322
|
+
} catch (error) {
|
|
323
|
+
record('cleanup', { ok: false, error: String(error?.message ?? error) })
|
|
324
|
+
log('warn', `[plugin-updater] snapshot cleanup failed: ${String(error?.message ?? error)}`)
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
log('info', '[plugin-updater] update succeeded')
|
|
328
|
+
return { success: true, code: 'updated', message: 'all plugins updated and verified', rolledBack: false, fatal: false, snapshotId: snapshotName, steps }
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
running.set(key, true)
|
|
332
|
+
try {
|
|
333
|
+
const result = await run()
|
|
334
|
+
return { ...result, phase: result.success ? 'success' : 'failed' }
|
|
335
|
+
} finally {
|
|
336
|
+
running.delete(key)
|
|
337
|
+
cleanupSignals()
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** 宿主卸载时调用(通常包在 ctx.effect 里):中止所有在跑的更新。 */
|
|
342
|
+
function dispose() {
|
|
343
|
+
if (disposed) return { aborted: 0 }
|
|
344
|
+
disposed = true
|
|
345
|
+
let aborted = 0
|
|
346
|
+
try { lifecycle.abort('disposed') } catch { /* ignore */ }
|
|
347
|
+
for (const key of running.keys()) { running.delete(key); aborted += 1 }
|
|
348
|
+
log('info', `[plugin-updater] disposed; ${aborted} update(s) signalled to stop`)
|
|
349
|
+
return { aborted }
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return {
|
|
353
|
+
safeUpdate,
|
|
354
|
+
isRunning: (key = 'default') => running.has(String(key)),
|
|
355
|
+
activeCount: () => running.size,
|
|
356
|
+
phases: UPDATE_PHASES,
|
|
357
|
+
dispose,
|
|
358
|
+
}
|
|
359
|
+
}
|