@noob-stupid/dsh-plugin-console 0.5.94 → 0.5.95
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/lib/server/domain/framework-cleanup.js +52 -1
- package/lib/server/domain/framework-relaunch.js +462 -0
- package/lib/server/domain/framework-stale-repair.js +31 -0
- package/lib/server/domain/framework.js +23 -13
- package/lib/server/infra/fw-integrity-check.js +5 -1
- package/lib/server/routes/framework-upgrade.js +20 -19
- package/lib/server/routes/framework.js +11 -5
- package/package.json +1 -1
|
@@ -423,4 +423,55 @@ function readRollbackReferenced({ home = undefined, deps = {} } = {}) {
|
|
|
423
423
|
return [raw?.checkpointDir, raw?.cliBackupDir].filter((p) => typeof p === 'string' && p !== '')
|
|
424
424
|
} catch { return [] }
|
|
425
425
|
}
|
|
426
|
-
|
|
426
|
+
/**
|
|
427
|
+
* 升级失败后的补偿:把 `<name>.stale-<ver>-<ts>` **改回真名** —— 但只在真名缺失时。
|
|
428
|
+
*
|
|
429
|
+
* 背景(2026-10-28 真机):升级在「拉起」阶段失败,向上层报「自动回滚未生效」,而清扫步
|
|
430
|
+
* 已经把 10 个框架包改名成 `.stale-*`、真名处什么都没有。结果框架起不来:
|
|
431
|
+
* `Cannot find package '@deepseek-ai/cordis' imported from @deepseek-ai/dsh-app-boot`
|
|
432
|
+
* 那一刻用户只能手改目录名 —— 这个函数就是那条手改的自动化版本。
|
|
433
|
+
*
|
|
434
|
+
* 纪律:
|
|
435
|
+
* · **绝不删除**任何东西(只 rename);
|
|
436
|
+
* · **真名已存在就跳过**(宁可不动,也不覆盖可能更新的目录);
|
|
437
|
+
* · 幂等:重复调用第二次什么都不做;
|
|
438
|
+
* · 只看 `@deepseek-ai` 这一层(顶层 scope),不递归、不碰 `.pnpm` 内部。
|
|
439
|
+
*/
|
|
440
|
+
function restoreStaleBackups({ scopeDir, deps = {} } = {}) {
|
|
441
|
+
const exists = deps.exists ?? existsSync
|
|
442
|
+
const readdir = deps.readdir ?? ((dir) => readdirSync(dir, { withFileTypes: true }))
|
|
443
|
+
const rename = deps.rename ?? renameSync
|
|
444
|
+
const restored = []
|
|
445
|
+
const skipped = []
|
|
446
|
+
const failed = []
|
|
447
|
+
if (typeof scopeDir !== 'string' || scopeDir === '') return { ok: false, restored, skipped, failed, problem: '没有给 scopeDir' }
|
|
448
|
+
let entries = []
|
|
449
|
+
try { entries = readdir(scopeDir) } catch (error) {
|
|
450
|
+
const missing = error?.code === 'ENOENT'
|
|
451
|
+
return { ok: missing, restored, skipped, failed, problem: missing ? null : '列不出目录:' + errText(error) }
|
|
452
|
+
}
|
|
453
|
+
for (const entry of entries) {
|
|
454
|
+
const name = typeof entry === 'string' ? entry : entry?.name
|
|
455
|
+
const isDir = typeof entry === 'string' ? true : entry?.isDirectory?.() === true
|
|
456
|
+
if (typeof name !== 'string' || !isDir || !name.includes('.stale-')) continue
|
|
457
|
+
const real = name.replace(/\.stale-.*$/u, '')
|
|
458
|
+
if (real === '' || real === name) continue
|
|
459
|
+
const target = join(scopeDir, real)
|
|
460
|
+
const source = join(scopeDir, name)
|
|
461
|
+
let targetExists = false
|
|
462
|
+
try { targetExists = exists(target) } catch { targetExists = false }
|
|
463
|
+
if (targetExists) {
|
|
464
|
+
skipped.push({ name, real, reason: 'real-name-exists' })
|
|
465
|
+
continue
|
|
466
|
+
}
|
|
467
|
+
try {
|
|
468
|
+
rename(source, target)
|
|
469
|
+
restored.push({ from: name, to: real })
|
|
470
|
+
} catch (error) {
|
|
471
|
+
failed.push({ name, real, error: errText(error) })
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
return { ok: failed.length === 0, restored, skipped, failed, problem: null }
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
export { cleanFrameworkBackups, cleanFrameworkTopLevel, dirSizeBytes, isLinkEntry, readRollbackReferenced, removeLinkSafe, reportFrameworkEntities, resolveFrameworkLayout, restoreStaleBackups, verifyFrameworkTreeResolvable }
|
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
// L1 · domain —— 自动拉起服务 + 「拉起失败之后怎么办」(2026-10-28 真机事故,两轮修复)
|
|
2
|
+
//
|
|
3
|
+
// ── 第一轮现场(`<DSH_HOME>/plugin-console/fw-upgrade.log` 原文)─────────────────
|
|
4
|
+
// 16:23:25 结构校验通过 / 框架本体升级完成 / 顶层可见版本校验通过
|
|
5
|
+
// 16:30:18 服务拉起失败 → 启动失败隔离 → 隔离分析无输出 → 尝试自动回滚
|
|
6
|
+
// 16:30:33 全树回滚完成:恢复 62 个版本包 + 顶层 scope
|
|
7
|
+
// 而 `fw-relaunch.log` 里**只有一行时间戳 + bin 路径,没有任何子进程输出**。
|
|
8
|
+
// ⇒ 框架本体是好的(CLI 手动跑得出版本号,之后手动 `node …\lib\bin.js web --no-open` 也能起),
|
|
9
|
+
// 坏的是「自动拉起」这条路径;旧链条「拉起失败 → 立刻回滚」把一次**成功**的升级扔掉了。
|
|
10
|
+
//
|
|
11
|
+
// ── 第二轮现场(第一轮改过之后)暴露三个问题 ─────────────────────────────────
|
|
12
|
+
// 18:21:37 已发起拉起(升级后,PID 15832,最多等 30 秒自证)
|
|
13
|
+
// 18:22:08 拉起成功(升级后):进程已存活 30 秒(端口尚未监听) ← ① 判据太松
|
|
14
|
+
// 18:23:55 服务拉起失败 → 隔离 → 隔离分析无输出 ← ② 成功后**又查一次**
|
|
15
|
+
// 18:23:56 回滚判定器不可用 → 保守回滚 → rollback ← ③ 把已起的服务回滚了
|
|
16
|
+
// ① 「进程存活」不能算成功:**成功判据只有一个 —— 端口开始监听**(沿用调用方 webPort)。
|
|
17
|
+
// 进程存活只是"仍在启动中",到上限记为 `still-starting-timeout`(不确定,不是失败)。
|
|
18
|
+
// ② 已经判成功之后**不许再做任何就绪检查、不许再判失败**:真把 `$ok = $true` 传出去并立刻收尾。
|
|
19
|
+
// 直接来源是升级脚本"重启服务生效"那段端口轮询循环里的一行:
|
|
20
|
+
// `if (-not $ok -and -not $started) { if (Invoke-DshRelaunch '升级后') { $started = $true } }`
|
|
21
|
+
// —— 拉起的"成功"只被记成"别再拉起"($started),没有记成"服务已经好了"($ok),
|
|
22
|
+
// 于是循环又空转约 95 秒等端口,然后隔离/判定链路把已经起来的服务判死并回滚。
|
|
23
|
+
// 现在:Note-Relaunch 在"端口已监听"时置 `$script:relaunchListening`,调用点据此立刻 `$ok = $true; break`,
|
|
24
|
+
// 隔离块与判定块都在 `if (-not $ok …)` 里 ⇒ 成功之后一个字节的后续就绪检查都不会跑。
|
|
25
|
+
// ③ 自动回滚**取消**(用户原话:「他拉起来了后就不用管了啊……回滚需要人工点才行吧」):
|
|
26
|
+
// 拉起失败 / 超时 / 无输出 ⇒ 一律写 `ready|框架已就位,自动拉起失败:<原因>;请手动运行
|
|
27
|
+
// node "<真实 bin>" web --no-open,或点「回滚到上一版」`,**不自动回滚**(回滚是人工动作)。
|
|
28
|
+
// 唯一保留自动回滚的情形:失败原因里出现**明确的框架树损坏证据**(缺包 / 模块解析失败 /
|
|
29
|
+
// 缺 bin.js / 缺 package.json)—— 那种树留着也起不来,回滚才是对的。
|
|
30
|
+
// 缺陷 B(同一次真机):`cmd /c call "node" … web > out 2> err` 在 schtasks 任务环境里输出仍为零
|
|
31
|
+
// ⇒ 换掉 cmd 重定向,改由 **node 自己当拉起器**(`fw-launch.mjs`:把子进程 stdout/stderr
|
|
32
|
+
// 直接接成文件 fd,detached 启动)。判定结论也不再走 stdout(同环境下既有的
|
|
33
|
+
// `隔离分析无输出` 是同一个病),改成**写判定文件**、ps1 读文件。
|
|
34
|
+
//
|
|
35
|
+
// 判据一行都不许写进 ps1 字符串里(否则测不到、还会分叉)。分工:
|
|
36
|
+
// · judgeRelaunchFailure() 「失败原因文本 → 是否回滚」的唯一纯函数
|
|
37
|
+
// · relaunchFallbackVerdict() 判定器不可用时的兜底(与上面同规则:**默认不回滚**)
|
|
38
|
+
// · relaunchLauncherSource() 生成 `fw-launch.mjs`(node 拉起器:fd 重定向 + detached + 状态代理)
|
|
39
|
+
// · relaunchVerdictHelperSource() 生成判定器转发脚本(结论写文件)
|
|
40
|
+
// · relaunchProbeHelpers()/relaunchInvoke()/relaunchVerdictBlock() 生成 ps1 片段
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 「框架树真的坏了」的特征串(命中才自动回滚)。
|
|
44
|
+
*
|
|
45
|
+
* 2026-10-28 现场校准(用户真机证据,比猜测更硬):
|
|
46
|
+
* · 用户**手动**跑 `node "…\@deepseek-ai\dsh\lib\bin.js" web --no-open` —— **能正常起来**
|
|
47
|
+
* (当前 web 就靠它跑着,3080 在监听)。启动命令 / profile / 这套插件本身都是好的;
|
|
48
|
+
* · 脚本拉起失败时 `fw-relaunch.log` **一个字都没有** —— 若真是插件 import 失败,
|
|
49
|
+
* node 会把 `Cannot find package …` 打到 stderr。没有痕迹 ⇒ 是"子进程没活下来 / 看不到",
|
|
50
|
+
* 不是加载崩溃 ⇒ **不能因为"没输出"就回滚**。
|
|
51
|
+
* 所以判据必须**窄**:只有明确的"模块解析失败 / 关键文件缺失"才算框架树坏。
|
|
52
|
+
* 反过来,这些**不许**命中(否则好框架会被回滚掉):
|
|
53
|
+
* · `EADDRINUSE`(端口被占 —— 常见于上一个实例还没退干净)、`listen EACCES`、`npm ERR!`
|
|
54
|
+
* · `child-exited-early` / `still-starting-timeout`(秒退/超时但没有错误文本)
|
|
55
|
+
*/
|
|
56
|
+
const TREE_DAMAGE_MARKERS = [
|
|
57
|
+
'Cannot find package',
|
|
58
|
+
'ERR_MODULE_NOT_FOUND',
|
|
59
|
+
'MODULE_NOT_FOUND',
|
|
60
|
+
'Cannot find module',
|
|
61
|
+
'ERR_PACKAGE_PATH_NOT_EXPORTED',
|
|
62
|
+
'找不到 dsh 的 bin.js',
|
|
63
|
+
'package.json 不存在',
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
/** 状态/日志里给用户的手动启动命令(用户真机就是这么救回来的:`--no-open` 不弹浏览器)。 */
|
|
67
|
+
const MANUAL_URL_FLAGS = 'web --no-open'
|
|
68
|
+
|
|
69
|
+
/** 拉起器完全没留下输出、判定器也没产出结论时,判定用的原因文本(不含任何树损坏特征)。 */
|
|
70
|
+
const NO_OUTPUT_REASON = '未捕获到输出(拉起器没有留下 stdout/stderr,判定器也没有产出结论)'
|
|
71
|
+
|
|
72
|
+
/** `Invoke-DshRelaunch` 的三种结局前缀(判定器据此区分)。 */
|
|
73
|
+
const REASON_PREFIX = {
|
|
74
|
+
stillStarting: 'still-starting-timeout', // 到上限仍没监听端口,但进程还活着 → 不确定,不回滚
|
|
75
|
+
exitedEarly: 'child-exited-early', // <5 秒就退出
|
|
76
|
+
exited: 'child-exited',
|
|
77
|
+
spawnFailed: 'spawn-failed',
|
|
78
|
+
launcherMissing: 'launcher-missing',
|
|
79
|
+
noBin: '找不到 dsh 的 bin.js',
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** 原因摘录的最大长度(状态文件是单行 `status|message`,太长会把界面撑爆)。 */
|
|
83
|
+
const REASON_EXCERPT_LIMIT = 240
|
|
84
|
+
|
|
85
|
+
/** 由真实 bin 路径拼手动启动命令(拿不到路径时返回空串 —— 绝不编一个假路径)。 */
|
|
86
|
+
function manualRelaunchCommand(binPath) {
|
|
87
|
+
const bin = typeof binPath === 'string' ? binPath.trim() : ''
|
|
88
|
+
if (bin === '') return ''
|
|
89
|
+
return `node "${bin}" ${MANUAL_URL_FLAGS}`
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* 把任意输入归一成"可以写进状态文件/日志的一行文本"。
|
|
94
|
+
* 关键点:状态文件格式是 `status|message`,且会被 `/framework-upgrade-status` 按 `|` 切分,
|
|
95
|
+
* 所以原因里的换行与竖线必须先抹平(否则 message 会被切碎、stage= 判定也会错位)。
|
|
96
|
+
*/
|
|
97
|
+
function flatReason(value, limit = REASON_EXCERPT_LIMIT) {
|
|
98
|
+
const raw = typeof value === 'string' ? value : (value === null || value === undefined ? '' : String(value))
|
|
99
|
+
const flat = raw.replace(/\s+/gu, ' ').replace(/\|/gu, '/').trim()
|
|
100
|
+
if (flat === '') return ''
|
|
101
|
+
return flat.length > limit ? `${flat.slice(0, limit)}…` : flat
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* 保留框架时的状态行(三处复用:超时 / 拉起失败 / 判定器不可用)。
|
|
106
|
+
* 用户原话:拉起失败就别管了,**回滚要人工点** ⇒ 文案点明两条人工出路。
|
|
107
|
+
*/
|
|
108
|
+
function keepStatusText({ reasonPhrase, binPath }) {
|
|
109
|
+
const cmd = manualRelaunchCommand(binPath)
|
|
110
|
+
const tail = cmd === ''
|
|
111
|
+
? '请手动启动 DSH(本机 bin.js 路径未解析出来),或点「回滚到上一版」'
|
|
112
|
+
: `请手动运行 ${cmd},或点「回滚到上一版」`
|
|
113
|
+
return `框架已就位,${reasonPhrase};${tail}`
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** 统一的判定返回形状(各分支都走它,避免字段漏写)。 */
|
|
117
|
+
function verdictOf(kind, { rollback, treeDamaged = false, reasonExcerpt = '', status = null, manualCommand = null }) {
|
|
118
|
+
return { verdict: kind, rollback, treeDamaged, reasonExcerpt, status, manualCommand }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** 保留分支的统一产物(超时 / 失败 / 未验证 都走它)。 */
|
|
122
|
+
function keepVerdict({ reasonPhrase, binPath, reasonExcerpt }) {
|
|
123
|
+
const cmd = manualRelaunchCommand(binPath)
|
|
124
|
+
return verdictOf('keep', {
|
|
125
|
+
rollback: false,
|
|
126
|
+
reasonExcerpt,
|
|
127
|
+
status: keepStatusText({ reasonPhrase, binPath }),
|
|
128
|
+
manualCommand: cmd === '' ? null : cmd,
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* **唯一**的「拉起失败 → 是否回滚」判据(纯函数,无 IO、无宿主)。
|
|
134
|
+
*
|
|
135
|
+
* 规则(顺序即优先级,tests/test-framework-relaunch-verdict.mjs 逐条钉死):
|
|
136
|
+
* ① 没有失败原因(空/空白)→ ok:不需要判定,什么都不做(幂等边界);
|
|
137
|
+
* ② 原因里**有明确的框架树损坏证据** → rollback(唯一的自动回滚情形:那种树留着也起不来);
|
|
138
|
+
* ③ `still-starting-timeout` → keep(端口没通但进程还活着 = 不确定,不是坏树);
|
|
139
|
+
* ④ 其余一切(秒退 / 无输出 / 结构校验没过…)→ keep:框架保留 + 手动命令 + 提示可人工回滚。
|
|
140
|
+
* —— 2026-10-28 用户定案「拉起失败就别管了,回滚要人工点」,所以这里**不再**保守回滚。
|
|
141
|
+
*
|
|
142
|
+
* @param {object} p
|
|
143
|
+
* @param {string|null} [p.reason] 拉起失败原因(Invoke-DshRelaunch 的返回值)
|
|
144
|
+
* @param {boolean} [p.structureVerified] 结构校验是否通过(留作记录/告警,不再决定是否回滚)
|
|
145
|
+
* @param {boolean} [p.topLevelVerified] 顶层可见版本校验是否通过(同上)
|
|
146
|
+
* @param {string} [p.binPath] 真实的 dsh bin.js 路径(拼手动启动命令用;空串 = 没解析出来)
|
|
147
|
+
* @returns {{ verdict: 'ok'|'rollback'|'keep', rollback: boolean, treeDamaged: boolean,
|
|
148
|
+
* reasonExcerpt: string, status: string|null, manualCommand: string|null }}
|
|
149
|
+
*/
|
|
150
|
+
function judgeRelaunchFailure({ reason = '', structureVerified = false, topLevelVerified = false, binPath = '' } = {}) {
|
|
151
|
+
const reasonExcerpt = flatReason(reason)
|
|
152
|
+
// ① 没有原因 = 没有失败要判(调用方只在拉起失败时问,但纯函数自己也要幂等)
|
|
153
|
+
if (reasonExcerpt === '') return verdictOf('ok', { rollback: false })
|
|
154
|
+
// ② 框架树损坏证据(**唯一**的自动回滚情形)
|
|
155
|
+
if (TREE_DAMAGE_MARKERS.some((marker) => reasonExcerpt.includes(marker))) {
|
|
156
|
+
return verdictOf('rollback', { rollback: true, treeDamaged: true, reasonExcerpt })
|
|
157
|
+
}
|
|
158
|
+
// ③ 端口没通但进程还活着 = 不确定 → 保留(真机 18:24:39 那次就是再等一会儿端口才通的)
|
|
159
|
+
if (reasonExcerpt.startsWith(REASON_PREFIX.stillStarting)) {
|
|
160
|
+
return keepVerdict({ reasonPhrase: `自动拉起超时,端口在等待上限内未监听(${reasonExcerpt})`, binPath, reasonExcerpt })
|
|
161
|
+
}
|
|
162
|
+
// ④ 其余一律保留:回滚改成人工动作(点了「回滚到上一版」才回滚)
|
|
163
|
+
const unverified = structureVerified === true && topLevelVerified === true
|
|
164
|
+
? ''
|
|
165
|
+
: '(注意:结构校验/顶层版本校验没有都通过,框架是否完整未能确认)'
|
|
166
|
+
return keepVerdict({ reasonPhrase: `自动拉起失败:${reasonExcerpt}${unverified}`, binPath, reasonExcerpt })
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* 「判定器不可用」时的兜底结论 —— **必须与 judgeRelaunchFailure 同规则**:
|
|
171
|
+
* 把 `NO_OUTPUT_REASON` 喂进同一个纯函数(测试断言两者逐字段相等)。
|
|
172
|
+
* 真机 18:23:56 的"保守回滚"就是错的:拿不到输出 ≠ 框架坏了。
|
|
173
|
+
*/
|
|
174
|
+
function relaunchFallbackVerdict({ structureVerified = false, topLevelVerified = false, binPath = '' } = {}) {
|
|
175
|
+
return judgeRelaunchFailure({ reason: NO_OUTPUT_REASON, structureVerified, topLevelVerified, binPath })
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* 生成 **node 拉起器** `fw-launch.mjs`(2026-10-28 第二轮:绕开 cmd/schtasks 的重定向)。
|
|
180
|
+
*
|
|
181
|
+
* 为什么不让 ps1 直接 `Start-Process node bin.js web > out`:
|
|
182
|
+
* · `cmd /c "…"` 的行首引号剥离会让整段重定向失效(真机 18:21:进程活了 30 秒,
|
|
183
|
+
* `.out/.err` 一个字都没有);
|
|
184
|
+
* · `Start-Process -RedirectStandard*` 在本仓记录过「schtasks 下子进程启动即退出」。
|
|
185
|
+
* 拉起器做三件事(全部由 Node 自己的 fd 完成,跟 cmd 无关):
|
|
186
|
+
* ① `openSync(outFile,'a')` / `openSync(errFile,'a')` 直接当子进程的 stdio;
|
|
187
|
+
* ② `spawn(nodePath, [bin,'web'], { detached: true, stdio: ['ignore', outFd, errFd], windowsHide: true })`
|
|
188
|
+
* —— detached 保证计划任务/脚本退出后服务继续活着;
|
|
189
|
+
* ③ 自己留在前台当**状态代理**:子进程退出就 `process.exit(child.code)`,并把
|
|
190
|
+
* `{event, pid, code, signal, error}` 写进 status 文件。
|
|
191
|
+
* 这里刻意**不 unref()+立即退出**(虽然那样更省一个进程):一旦拉起器先退,ps1 的
|
|
192
|
+
* `-PassThru` 句柄立刻 `HasExited`,就再也分不清"服务在启动"还是"服务崩了",退出码也没了。
|
|
193
|
+
* detached 已经保证服务不受拉起器生死影响 —— 那正是必须保住的那件事。
|
|
194
|
+
*/
|
|
195
|
+
function relaunchLauncherSource() {
|
|
196
|
+
return [
|
|
197
|
+
'// 自动生成:DSH 服务拉起器(升级/回滚脚本用 node 启动它;逻辑见 domain/framework-relaunch.js)',
|
|
198
|
+
"import { spawn } from 'node:child_process'",
|
|
199
|
+
"import { closeSync, openSync, writeFileSync } from 'node:fs'",
|
|
200
|
+
'const [nodePath, binPath, outFile, errFile, statusFile] = process.argv.slice(2)',
|
|
201
|
+
'const say = (obj) => { try { writeFileSync(statusFile, JSON.stringify(obj), "utf8") } catch {} }',
|
|
202
|
+
'if (!nodePath || !binPath || !outFile || !errFile || !statusFile) { say({ event: "error", error: "拉起器参数不足" }); process.exit(2) }',
|
|
203
|
+
'let outFd = null',
|
|
204
|
+
'let errFd = null',
|
|
205
|
+
'try { outFd = openSync(outFile, "a"); errFd = openSync(errFile, "a") } catch (error) { say({ event: "error", error: "打不开输出文件:" + String(error && error.message ? error.message : error) }); process.exit(3) }',
|
|
206
|
+
'let child = null',
|
|
207
|
+
'try {',
|
|
208
|
+
' child = spawn(nodePath, [binPath, "web"], { detached: true, stdio: ["ignore", outFd, errFd], windowsHide: true })',
|
|
209
|
+
'} catch (error) { say({ event: "error", error: "spawn 抛错:" + String(error && error.message ? error.message : error) }); process.exit(4) }',
|
|
210
|
+
'say({ event: "spawned", pid: child.pid ?? null, at: Date.now() })',
|
|
211
|
+
'child.on("error", (error) => { say({ event: "error", pid: child.pid ?? null, error: String(error && error.message ? error.message : error) }); process.exit(5) })',
|
|
212
|
+
'child.on("exit", (code, signal) => {',
|
|
213
|
+
' say({ event: "exited", pid: child.pid ?? null, code, signal })',
|
|
214
|
+
' try { closeSync(outFd); closeSync(errFd) } catch {}',
|
|
215
|
+
' process.exit(typeof code === "number" ? code : 1)',
|
|
216
|
+
'})',
|
|
217
|
+
'',
|
|
218
|
+
].join('\n')
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* 生成「读文件尾部」+「端口监听」两个小助手(PowerShell,整段作为一个数组元素展开)。
|
|
223
|
+
*
|
|
224
|
+
* · `Get-TailText`:把某文件的尾部压成一行、只留最后 600 字符 —— 失败原因要短到能进日志与状态文件。
|
|
225
|
+
* 刻意**不用 `Test-Path` 探变量**(既有断言要求拉起助手块里的 Test-Path 只出现在
|
|
226
|
+
* Resolve-DshBin 的归一化变量上),直接 `Get-Content -ErrorAction SilentlyContinue`。
|
|
227
|
+
* · `Test-RelaunchPort`:沿用调用方(webPort)算好的 `$relaunchPort`;为 0 时永远不通过
|
|
228
|
+
* (只可能落到 still-starting-timeout,绝不假装成功)。
|
|
229
|
+
*/
|
|
230
|
+
function relaunchProbeHelpers() {
|
|
231
|
+
return [
|
|
232
|
+
`function Get-TailText($path) {`,
|
|
233
|
+
` $t = ''`,
|
|
234
|
+
// -Encoding UTF8 必写:拉起器/判定器写的是**无 BOM 的 UTF-8**,PS 5.1 的 Get-Content 默认按 ANSI(GBK) 读,
|
|
235
|
+
// 中文会变成乱码(真机 18:23 的"判定器不可用"就是这个:JSON 里的中文一乱、ConvertFrom-Json 直接抛)。
|
|
236
|
+
` try { $t = Get-Content -LiteralPath $path -Raw -Encoding UTF8 -ErrorAction SilentlyContinue } catch { $t = '' }`,
|
|
237
|
+
` if ([string]::IsNullOrEmpty($t)) { return '' }`,
|
|
238
|
+
` $t = ($t -replace '\\s+', ' ').Trim()`,
|
|
239
|
+
` if ($t.Length -gt 600) { $t = '…' + $t.Substring($t.Length - 600) }`,
|
|
240
|
+
` return $t`,
|
|
241
|
+
`}`,
|
|
242
|
+
`function Test-RelaunchPort {`,
|
|
243
|
+
` if ($relaunchPort -le 0) { return $false }`,
|
|
244
|
+
` try { $c = Get-NetTCPConnection -LocalPort $relaunchPort -State Listen -ErrorAction SilentlyContinue; if ($c) { return $true } } catch {}`,
|
|
245
|
+
` return $false`,
|
|
246
|
+
`}`,
|
|
247
|
+
]
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* 生成「拉起 DSH 服务并自证」的 PowerShell 函数(整段作为一个数组元素展开)。
|
|
252
|
+
*
|
|
253
|
+
* 2026-10-28 两轮改错:
|
|
254
|
+
* 第一轮把 fire-and-forget 改成"发出去 + 等一小段",但判据用了「进程存活」⇒ 缺陷 ①。
|
|
255
|
+
* 第二轮定稿:
|
|
256
|
+
* ① 启动走 **node 拉起器**(`fw-launch.mjs`,fd 重定向 + detached),ps1 只 `Start-Process node 拉起器`;
|
|
257
|
+
* ② **成功只认端口监听**(`webPort`);进程存活只算"仍在启动中",继续等(默认 120 秒,
|
|
258
|
+
* `$script:DSH_RELAUNCH_WAIT_SEC` 可覆盖);
|
|
259
|
+
* ③ 三种结局:`listening`(返回空串,调用方据此立刻收尾、不再做任何就绪检查)/
|
|
260
|
+
* `still-starting-timeout`(到上限端口没通但进程还活着)/ `child-exited-early|child-exited`
|
|
261
|
+
* (失败,带退出码 + 拉起器状态 + 输出尾部)。
|
|
262
|
+
* `Note-Relaunch` 是唯一的结果记账点:成功时置 `$script:relaunchListening = $true`。
|
|
263
|
+
*
|
|
264
|
+
* @param {object} p
|
|
265
|
+
* @param {(s: string) => string} p.ps PowerShell 双引号字符串字面量(与升级脚本共用同一份转义)
|
|
266
|
+
* @param {string} p.nodePath node.exe 绝对路径
|
|
267
|
+
* @param {boolean} [p.hosted] 外壳托管(桌面端承载)时脚本里只留一句拒绝
|
|
268
|
+
*/
|
|
269
|
+
function relaunchInvoke({ ps, nodePath, hosted = false }) {
|
|
270
|
+
const hostedGuard = hosted === true
|
|
271
|
+
? [` if ($script:DSH_HOSTED) { Log ('本实例由桌面端外壳托管,跳过拉起(' + $tag + '):请在桌面端内重启/更新'); return '本实例由桌面端外壳托管,不自动拉起' }`]
|
|
272
|
+
: []
|
|
273
|
+
return [
|
|
274
|
+
// 拉起结果的统一记账(三处调用点共用):
|
|
275
|
+
// 空串 = **端口已监听**(真·成功)→ $script:relaunchListening = $true,调用方立刻收尾
|
|
276
|
+
// still-starting = 已经拉起过、别再重复拉起(但不算成功)
|
|
277
|
+
// 其余 = 失败原因,允许下一轮重试
|
|
278
|
+
`function Note-Relaunch($lr) {`,
|
|
279
|
+
` if ([string]::IsNullOrEmpty($lr)) { $script:relaunchListening = $true; return $true }`,
|
|
280
|
+
` $script:relaunchReason = $lr`,
|
|
281
|
+
` if ($lr.StartsWith('${REASON_PREFIX.stillStarting}')) { return $true }`,
|
|
282
|
+
` return $false`,
|
|
283
|
+
`}`,
|
|
284
|
+
`function Invoke-DshRelaunch($tag) {`,
|
|
285
|
+
...hostedGuard,
|
|
286
|
+
` $bin = Resolve-DshBin`,
|
|
287
|
+
` if ([string]::IsNullOrWhiteSpace($bin)) { Log ('拉起失败(' + $tag + '):node resolve / .pnpm / 顶层链接 三种方式都找不到 dsh 的 bin.js,请手动启动 DSH'); return '${REASON_PREFIX.noBin}(node resolve / .pnpm / 顶层链接 三种方式都失败)' }`,
|
|
288
|
+
// 拉起器就在 fw-relaunch.log 旁边(由升级/回滚脚本落盘),不额外传参
|
|
289
|
+
` $launcher = Join-Path (Split-Path -LiteralPath $launchLog) 'fw-launch.mjs'`,
|
|
290
|
+
` if (-not (Test-Path -LiteralPath $launcher)) { Log ('拉起器缺失(' + $tag + '):' + $launcher + ' 不存在(请重跑一次升级以重新生成)'); return ('${REASON_PREFIX.launcherMissing}:' + $launcher + ' 不存在') }`,
|
|
291
|
+
` try { Add-Content -Path $launchLog -Value ((Get-Date -Format 'yyyy-MM-dd HH:mm:ss') + ' 拉起(' + $tag + '): ' + $bin) -Encoding UTF8 } catch {}`,
|
|
292
|
+
// 每轮拉起用**独立**的 .out/.err/.status.json:这轮失败才读得到这轮的证据
|
|
293
|
+
` $outFile = $launchLog + '.out'`,
|
|
294
|
+
` $errFile = $launchLog + '.err'`,
|
|
295
|
+
` $statusFile = $launchLog + '.status.json'`,
|
|
296
|
+
` try { Remove-Item -LiteralPath $outFile -Force -ErrorAction SilentlyContinue } catch {}`,
|
|
297
|
+
` try { Remove-Item -LiteralPath $errFile -Force -ErrorAction SilentlyContinue } catch {}`,
|
|
298
|
+
` try { Remove-Item -LiteralPath $statusFile -Force -ErrorAction SilentlyContinue } catch {}`,
|
|
299
|
+
` $proc = $null`,
|
|
300
|
+
` try {`,
|
|
301
|
+
` $proc = Start-Process -FilePath ${ps(nodePath)} -ArgumentList @(('"' + $launcher + '" "' + ${ps(nodePath)} + '" "' + $bin + '" "' + $outFile + '" "' + $errFile + '" "' + $statusFile + '"')) -PassThru -WindowStyle Hidden`,
|
|
302
|
+
` } catch {`,
|
|
303
|
+
` Log ('拉起器启动失败(' + $tag + '):' + $_.Exception.Message)`,
|
|
304
|
+
` return ('${REASON_PREFIX.spawnFailed}:拉起器启动失败:' + $_.Exception.Message)`,
|
|
305
|
+
` }`,
|
|
306
|
+
` $startedAt = Get-Date`,
|
|
307
|
+
` $waitSec = 120`,
|
|
308
|
+
` if ($script:DSH_RELAUNCH_WAIT_SEC -gt 0) { $waitSec = [int]$script:DSH_RELAUNCH_WAIT_SEC }`,
|
|
309
|
+
` $deadline = (Get-Date).AddSeconds($waitSec)`,
|
|
310
|
+
` Log ('已发起拉起(' + $tag + ',拉起器 PID ' + [string]$proc.Id + ',最多等 ' + [string]$waitSec + ' 秒等端口 ' + [string]$relaunchPort + ' 监听)')`,
|
|
311
|
+
// 成功**只认端口监听**:进程存活只说明"还在启动中",继续等到上限
|
|
312
|
+
` while ($true) {`,
|
|
313
|
+
` if (Test-RelaunchPort) { Log ('拉起成功(' + $tag + '):服务已监听端口 ' + [string]$relaunchPort + '(拉起器 PID ' + [string]$proc.Id + ')'); return '' }`,
|
|
314
|
+
` $alive = $false`,
|
|
315
|
+
` try { if ($proc -and -not $proc.HasExited) { $alive = $true } } catch { $alive = $false }`,
|
|
316
|
+
` if (-not $alive) { break }`,
|
|
317
|
+
` if ((Get-Date) -ge $deadline) {`,
|
|
318
|
+
` $tailT = Get-TailText $errFile`,
|
|
319
|
+
` if ([string]::IsNullOrWhiteSpace($tailT)) { $tailT = Get-TailText $outFile }`,
|
|
320
|
+
` Log ('拉起未完成(' + $tag + '):进程仍存活,但 ' + [string]$waitSec + ' 秒内端口 ' + [string]$relaunchPort + ' 未监听 → still-starting-timeout(不确定,不是坏树)')`,
|
|
321
|
+
` $why = '${REASON_PREFIX.stillStarting}(' + [string]$waitSec + 's,pid=' + [string]$proc.Id + ')'`,
|
|
322
|
+
` if (-not [string]::IsNullOrWhiteSpace($tailT)) { $why = $why + ':' + $tailT }`,
|
|
323
|
+
` return $why`,
|
|
324
|
+
` }`,
|
|
325
|
+
` Start-Sleep -Milliseconds 1000`,
|
|
326
|
+
` }`,
|
|
327
|
+
// 走到这里 = 拉起器/服务已退出(秒退或跑一会儿才崩)。这次事故就丢在这一步:必须留证据。
|
|
328
|
+
` $elapsedSec = [int](((Get-Date) - $startedAt).TotalSeconds)`,
|
|
329
|
+
` $exitTag = '${REASON_PREFIX.exited}'`,
|
|
330
|
+
` if ($elapsedSec -lt 5) { $exitTag = '${REASON_PREFIX.exitedEarly}' }`,
|
|
331
|
+
` $codeText = '未知'`,
|
|
332
|
+
` try { $codeText = [string]$proc.ExitCode } catch { $codeText = '未知' }`,
|
|
333
|
+
` $statusText = Get-TailText $statusFile`,
|
|
334
|
+
` $tailOut = Get-TailText $outFile`,
|
|
335
|
+
` $tailErr = Get-TailText $errFile`,
|
|
336
|
+
` $tail = $tailErr`,
|
|
337
|
+
` if ([string]::IsNullOrWhiteSpace($tail)) { $tail = $tailOut }`,
|
|
338
|
+
` Log ('拉起失败(' + $tag + '):' + $exitTag + '(' + [string]$elapsedSec + ' 秒后退出,exit=' + $codeText + ')')`,
|
|
339
|
+
` if (-not [string]::IsNullOrWhiteSpace($statusText)) { Log ('拉起失败(' + $tag + ')拉起器状态:' + $statusText) }`,
|
|
340
|
+
` if ([string]::IsNullOrWhiteSpace($tail)) { Log ('拉起失败(' + $tag + '):stdout/stderr 均为空 —— 没有任何输出证据(判定器据此**默认不回滚**,写 ready 交人工)') }`,
|
|
341
|
+
` if (-not [string]::IsNullOrWhiteSpace($tail)) { Log ('拉起失败(' + $tag + ')输出尾部:' + $tail) }`,
|
|
342
|
+
` $why = $exitTag + '(exit=' + $codeText + ',' + [string]$elapsedSec + 's)'`,
|
|
343
|
+
` if (-not [string]::IsNullOrWhiteSpace($statusText)) { $why = $why + '|' + $statusText }`,
|
|
344
|
+
` if (-not [string]::IsNullOrWhiteSpace($tail)) { $why = $why + ':' + $tail }`,
|
|
345
|
+
` return $why`,
|
|
346
|
+
`}`,
|
|
347
|
+
]
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* 生成「回滚判定器」转发脚本源码(写进 `<DSH_HOME>/plugin-console/fw-relaunch-verdict.mjs`)。
|
|
352
|
+
*
|
|
353
|
+
* 2026-10-28 第二轮:结论**写文件**,不再依赖 stdout —— 同一次真机里,既有的
|
|
354
|
+
* `隔离分析无输出` 与新的 `回滚判定器不可用` 是同一个病(`& node helper | Select -Last 1`
|
|
355
|
+
* 在 schtasks 环境里拿不到输出)。文件证据则一直好用(日志/状态文件都正常写)。
|
|
356
|
+
*
|
|
357
|
+
* argv: `<reasonFile> <structureFlag 0|1> <topLevelFlag 0|1> <binPath> <verdictFile>`
|
|
358
|
+
*
|
|
359
|
+
* @param {object} p
|
|
360
|
+
* @param {string} p.libUrl 本模块的 file:// URL(由调用方用 pathToFileURL 算好)
|
|
361
|
+
* @returns {string} 可直接 writeFileSync 的 .mjs 源码
|
|
362
|
+
*/
|
|
363
|
+
function relaunchVerdictHelperSource({ libUrl }) {
|
|
364
|
+
return [
|
|
365
|
+
'// 自动生成:自动拉起失败的「是否回滚」判定器(逻辑在插件内,本文件只做转发)',
|
|
366
|
+
`import { judgeRelaunchFailure } from ${JSON.stringify(libUrl)}`,
|
|
367
|
+
"import { readFileSync, writeFileSync } from 'node:fs'",
|
|
368
|
+
'const [reasonPath, structureFlag, topFlag, binPath, verdictFile] = process.argv.slice(2)',
|
|
369
|
+
"let reason = ''",
|
|
370
|
+
'try { reason = readFileSync(reasonPath, "utf8") } catch {}',
|
|
371
|
+
'let out = null',
|
|
372
|
+
'try {',
|
|
373
|
+
' out = judgeRelaunchFailure({',
|
|
374
|
+
" reason, structureVerified: structureFlag === '1', topLevelVerified: topFlag === '1', binPath,",
|
|
375
|
+
' })',
|
|
376
|
+
'} catch (error) { out = { error: String(error && error.message ? error.message : error) } }',
|
|
377
|
+
'try { writeFileSync(verdictFile, JSON.stringify(out), "utf8") } catch (error) { process.exit(6) }',
|
|
378
|
+
'',
|
|
379
|
+
].join('\n')
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* 生成「拉起失败 → 判定 → 保留(或唯一情形的回滚)」的 PowerShell 执行块。
|
|
384
|
+
*
|
|
385
|
+
* 要求调用点已具备:`$log` / `SetState` / `Log` / `Beat` / `Resolve-DshBin` / `Invoke-Rollback` /
|
|
386
|
+
* `Invoke-DshRelaunch` / `Note-Relaunch`,以及三个事实位:
|
|
387
|
+
* `$script:relaunchReason`(Note-Relaunch 记下的原因)、`$script:structureOk`、`$script:topLevelOk`。
|
|
388
|
+
*
|
|
389
|
+
* 判定器不可用时**不再保守回滚**(真机 18:23:56 那样会把好框架扔掉):走
|
|
390
|
+
* `relaunchFallbackVerdict` 的同一规则 —— 没有树损坏证据就保留 + 给手动命令。
|
|
391
|
+
*
|
|
392
|
+
* @param {object} p
|
|
393
|
+
* @param {(s: string) => string} p.ps PowerShell 双引号字符串字面量(与升级脚本共用同一份转义)
|
|
394
|
+
* @param {string} p.nodePath node.exe 绝对路径
|
|
395
|
+
* @param {string} p.helperPath 判定器 .mjs 的绝对路径
|
|
396
|
+
* @param {number} p.port 目标端口(沿用调用方的 webPort,不在这里另算)
|
|
397
|
+
*/
|
|
398
|
+
function relaunchVerdictBlock({ ps, nodePath, helperPath, port }) {
|
|
399
|
+
return [
|
|
400
|
+
` $relaunchWhy = [string]$script:relaunchReason`,
|
|
401
|
+
` if ([string]::IsNullOrWhiteSpace($relaunchWhy)) { $relaunchWhy = '${NO_OUTPUT_REASON}' }`,
|
|
402
|
+
` Log ('自动拉起失败 → 判定是否回滚:' + $relaunchWhy)`,
|
|
403
|
+
// 原因与结论都走文件(该环境下子进程 stdout 抓不到,文件读写一直好用)
|
|
404
|
+
` $reasonFile = $log + '.relaunch-reason.txt'`,
|
|
405
|
+
` $verdictFile = $log + '.relaunch-verdict.json'`,
|
|
406
|
+
` try { Set-Content -Path $reasonFile -Value $relaunchWhy -Encoding UTF8 } catch {}`,
|
|
407
|
+
` try { Remove-Item -LiteralPath $verdictFile -Force -ErrorAction SilentlyContinue } catch {}`,
|
|
408
|
+
` $binForCmd = Resolve-DshBin`,
|
|
409
|
+
` $structureFlag = '0'; if ($script:structureOk) { $structureFlag = '1' }`,
|
|
410
|
+
` $topFlag = '0'; if ($script:topLevelOk) { $topFlag = '1' }`,
|
|
411
|
+
` try { & ${ps(nodePath)} ${ps(helperPath)} $reasonFile $structureFlag $topFlag $binForCmd $verdictFile 2>&1 | Out-Null } catch { Log ('回滚判定器调用异常:' + $_.Exception.Message) }`,
|
|
412
|
+
` $verdictJson = ''`,
|
|
413
|
+
// -Encoding UTF8 必写:判定器写的是无 BOM UTF-8,而 PS 5.1 默认按 ANSI(GBK) 读
|
|
414
|
+
// ⇒ 中文乱码 ⇒ ConvertFrom-Json 抛"Invalid object passed in" ⇒ 判定被误判成"不可用"(真机 18:23)。
|
|
415
|
+
` try { $verdictJson = Get-Content -LiteralPath $verdictFile -Raw -Encoding UTF8 -ErrorAction SilentlyContinue } catch { $verdictJson = '' }`,
|
|
416
|
+
` $verdict = $null`,
|
|
417
|
+
` if (-not [string]::IsNullOrWhiteSpace($verdictJson)) { try { $verdict = $verdictJson | ConvertFrom-Json } catch { $verdict = $null } }`,
|
|
418
|
+
` if ($null -ne $verdict -and -not [string]::IsNullOrWhiteSpace([string]$verdict.error)) { Log ('回滚判定器报错:' + [string]$verdict.error); $verdict = $null }`,
|
|
419
|
+
` if ($null -eq $verdict) {`,
|
|
420
|
+
// 兜底 = 与 relaunchFallbackVerdict 同规则:没有树损坏证据 ⇒ 保留 + 手动命令(回归人工回滚)
|
|
421
|
+
` $verdict = [pscustomobject]@{ verdict = 'keep'; rollback = $false; status = ('框架已就位,自动拉起失败:' + $relaunchWhy + ';请手动运行 node "' + $binForCmd + '" web --no-open,或点「回滚到上一版」'); manualCommand = ('node "' + $binForCmd + '" web --no-open') }`,
|
|
422
|
+
` Log '判定器没有产出结论 → 兜底为「保留框架 + 人工决定是否回滚」(自动回滚已取消)'`,
|
|
423
|
+
` }`,
|
|
424
|
+
` Log ('回滚判定:' + [string]$verdict.verdict)`,
|
|
425
|
+
` if ($verdict.rollback) {`,
|
|
426
|
+
` Log '判定结果指向框架树损坏 → 自动回滚到升级前版本(唯一保留的自动回滚情形)'`,
|
|
427
|
+
` Invoke-Rollback`,
|
|
428
|
+
` $ok = $false`,
|
|
429
|
+
` $started = $false`,
|
|
430
|
+
` for ($i = 0; $i -lt 20; $i++) {`,
|
|
431
|
+
` try { $c = Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue; if ($c.Count -gt 0) { $ok = $true; break } } catch {}`,
|
|
432
|
+
` if (-not $ok -and -not $started) { $started = Note-Relaunch (Invoke-DshRelaunch '回滚后') }; Beat`,
|
|
433
|
+
` if ($script:relaunchListening) { $ok = $true; break }`,
|
|
434
|
+
` Start-Sleep -Seconds 5`,
|
|
435
|
+
` }`,
|
|
436
|
+
` } else {`,
|
|
437
|
+
// 保留分支(默认分支):框架本体已在目标版本,**不回滚**;状态写成明确的终态 ready,
|
|
438
|
+
// 并把真实的手动启动命令留给用户(服务此时是停的,界面看不到 —— 命令得能直接复制粘贴)。
|
|
439
|
+
` $script:keptReady = $true`,
|
|
440
|
+
` SetState 'ready' ([string]$verdict.status)`,
|
|
441
|
+
` Log ('框架本体已就位、不回滚:' + [string]$verdict.status)`,
|
|
442
|
+
` Log ('手动启动命令:' + [string]$verdict.manualCommand)`,
|
|
443
|
+
` }`,
|
|
444
|
+
].join('\r\n')
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
export {
|
|
448
|
+
judgeRelaunchFailure,
|
|
449
|
+
relaunchFallbackVerdict,
|
|
450
|
+
relaunchLauncherSource,
|
|
451
|
+
relaunchVerdictHelperSource,
|
|
452
|
+
relaunchVerdictBlock,
|
|
453
|
+
relaunchProbeHelpers,
|
|
454
|
+
relaunchInvoke,
|
|
455
|
+
manualRelaunchCommand,
|
|
456
|
+
keepStatusText,
|
|
457
|
+
TREE_DAMAGE_MARKERS,
|
|
458
|
+
REASON_EXCERPT_LIMIT,
|
|
459
|
+
REASON_PREFIX,
|
|
460
|
+
NO_OUTPUT_REASON,
|
|
461
|
+
MANUAL_URL_FLAGS,
|
|
462
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// 升级失败后的「改名残留」修复:把 `<name>.stale-<ver>-<ts>` 改回真名。
|
|
2
|
+
//
|
|
3
|
+
// 2026-10-28 真机:升级在「拉起」阶段失败,清扫步已把 10 个框架包改名成 `.stale-*`,
|
|
4
|
+
// 真名处什么都没有 ⇒ 框架起不来:`Cannot find package '@deepseek-ai/cordis'`。
|
|
5
|
+
// 当时只能手工改目录名;这里把那件事自动化,并**只返回人类可读的步骤**,
|
|
6
|
+
// 让调用方(routes/framework-upgrade.js)保持精简(那个文件有 700 行硬顶)。
|
|
7
|
+
import { join } from 'node:path'
|
|
8
|
+
import { restoreStaleBackups } from './framework-cleanup.js'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @returns {{ steps: string[], restored: Array<{from: string, to: string}>, failed: Array<object> }}
|
|
12
|
+
*/
|
|
13
|
+
function repairStaleRenameResidue({ fwRoot } = {}) {
|
|
14
|
+
const steps = []
|
|
15
|
+
if (typeof fwRoot !== 'string' || fwRoot === '') return { steps, restored: [], failed: [] }
|
|
16
|
+
try {
|
|
17
|
+
const repair = restoreStaleBackups({ scopeDir: join(fwRoot, '@deepseek-ai') })
|
|
18
|
+
if (repair.restored.length > 0) {
|
|
19
|
+
steps.push(`修复上次失败留下的改名残留:${repair.restored.length} 个包已改回真名(${repair.restored.map((r) => r.to).join('、')})`)
|
|
20
|
+
}
|
|
21
|
+
if (repair.failed.length > 0) {
|
|
22
|
+
steps.push(`改名残留修复未全部成功:${repair.failed.map((f) => f.real).join('、')}(不阻塞升级)`)
|
|
23
|
+
}
|
|
24
|
+
return { steps, restored: repair.restored, failed: repair.failed }
|
|
25
|
+
} catch (error) {
|
|
26
|
+
steps.push(`改名残留修复失败(不阻塞升级):${error instanceof Error ? error.message : String(error)}`)
|
|
27
|
+
return { steps, restored: [], failed: [{ error: String(error) }] }
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export { repairStaleRenameResidue }
|
|
@@ -12,6 +12,7 @@ import { listEntries } from './runtime.js'
|
|
|
12
12
|
import { copyTree } from '../infra/fsx.js'
|
|
13
13
|
import { dshHome, entryPkgMeta, findPatchPath, pluginRoot, profileDirOf, resolvePackageJson } from '../infra/paths.js'
|
|
14
14
|
import { frameworkUpgradeCandidates, isFrameworkVersionNewer, parseFrameworkVersion } from '../infra/semver.js'
|
|
15
|
+
import { relaunchInvoke, relaunchProbeHelpers } from './framework-relaunch.js'
|
|
15
16
|
|
|
16
17
|
/** 解析 dsh CLI 的 bin.js 绝对路径(守护拉起用)。 */
|
|
17
18
|
function resolveDshBin() {
|
|
@@ -295,8 +296,19 @@ function platformUpgradeRefusal() {
|
|
|
295
296
|
* 2) 解析走**多级回退**(不再假设某一处路径一定可用):node resolve → 目标版本的 .pnpm 实体
|
|
296
297
|
* 目录 → 顶层可见链接 → .pnpm 里最新的一个;
|
|
297
298
|
* 3) 全路径参数一律 `Test-Path -LiteralPath`,失败只记录、不抛错。
|
|
299
|
+
*
|
|
300
|
+
* 2026-10-28 改错(真机:升级到 0.2.1-rc.4 后「自动拉起」无任何证据 → 好升级被当成失败回滚):
|
|
301
|
+
* 旧实现在这里 `Start-Process cmd /c "node bin.js web >> fw-relaunch.log 2>&1"` 之后**立刻**
|
|
302
|
+
* `Log '已发起拉起服务'` + `return $true` —— fire-and-forget:不等、不验、不抓退出码。
|
|
303
|
+
* 所以 `fw-relaunch.log` 里只剩一行时间戳 + bin 路径,`隔离分析无输出`、直接回滚。
|
|
304
|
+
* 现在拉起分三段(实现与判据都在 domain/framework-relaunch.js,本函数只负责拼装):
|
|
305
|
+
* ① `relaunchProbeHelpers()`:读文件尾部 / 端口监听两个小助手(端口沿用调用方 webPort,不另算);
|
|
306
|
+
* ② `relaunchInvoke()`:`-PassThru` 拿句柄 + 每轮独立的 .out/.err + 轮询**正面证据**
|
|
307
|
+
* (进程仍存活 或 端口开始监听),失败时留下退出码 / `child-exited-early` / stderr 尾部;
|
|
308
|
+
* ③ 返回值是**失败原因字符串**(空串 = 成功)—— 调用方拿它去判"该不该回滚"
|
|
309
|
+
* (judgeRelaunchFailure),不再是布尔。
|
|
298
310
|
*/
|
|
299
|
-
function relaunchPrelude({ nodePath, pluginDir, fwRoot, target, ps, hosted }) {
|
|
311
|
+
function relaunchPrelude({ nodePath, pluginDir, fwRoot, target, ps, hosted, port }) {
|
|
300
312
|
// 未显式传 hosted 时自己判定一次(自接线,避免调用方漏传导致守卫失效)。
|
|
301
313
|
// 独立 `dsh web` 实例下 detectHostShape().hosted === false ⇒ 生成结果与旧实现逐字节相同。
|
|
302
314
|
const shellHosted = typeof hosted === 'boolean' ? hosted : detectHostShape().hosted === true
|
|
@@ -305,11 +317,13 @@ function relaunchPrelude({ nodePath, pluginDir, fwRoot, target, ps, hosted }) {
|
|
|
305
317
|
// 外壳托管(桌面端 Electron 承载):脚本里**只留一句拒绝**,绝不 Start-Process 拉起第二个实例。
|
|
306
318
|
// `hosted === false` 时这里是空数组 —— 生成的脚本与旧实现**逐字节相同**(加法分支)。
|
|
307
319
|
const hostedFlag = shellHosted === true ? [`$script:DSH_HOSTED = $true`] : []
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
: []
|
|
320
|
+
// 端口:沿用调用方(webPort)算好的值;没传就退化成 0 = 端口判据永不通过(不编一个端口)
|
|
321
|
+
const relaunchPort = Number.isInteger(port) && port > 0 ? port : 0
|
|
311
322
|
return [
|
|
312
323
|
`$launchLog = ''`,
|
|
324
|
+
// 拉起结果的三个事实位(2026-10-28):原因文本 / 「端口已监听」(唯一成功判据)/ 是否已拉起过
|
|
325
|
+
`$script:relaunchReason = ''; $script:relaunchListening = $false`,
|
|
326
|
+
`$relaunchPort = ${relaunchPort}`,
|
|
313
327
|
...hostedFlag,
|
|
314
328
|
// 心跳(v0.3.39):脚本每推进一小步就更新一次心跳文件的时间戳。服务端据此区分
|
|
315
329
|
// 「脚本还在干活」与「脚本进程被系统/启动器杀掉」。2026-09-11 真机事故:回滚脚本
|
|
@@ -331,15 +345,11 @@ function relaunchPrelude({ nodePath, pluginDir, fwRoot, target, ps, hosted }) {
|
|
|
331
345
|
` try { foreach ($d in @(Get-ChildItem -Path (Join-Path ${ps(fwRoot)} '.pnpm') -Directory -Filter '@deepseek-ai+dsh@*' -ErrorAction SilentlyContinue | Sort-Object Name -Descending)) { $c = ${pnpmBin('$d.FullName')}; if (Test-Path -LiteralPath $c) { return $c } } } catch {}`,
|
|
332
346
|
` return ''`,
|
|
333
347
|
`}`,
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
` try { Start-Process -FilePath 'cmd.exe' -ArgumentList @('/c', ('"' + ${ps(nodePath)} + '" "' + $bin + '" web >> "' + $launchLog + '" 2>&1')) -WindowStyle Hidden } catch { Log ('拉起进程启动失败(' + $tag + '):' + $_.Exception.Message); return $false }`,
|
|
340
|
-
` Log ('已发起拉起服务(' + $tag + ',输出见 fw-relaunch.log)')`,
|
|
341
|
-
` return $true`,
|
|
342
|
-
`}`,
|
|
348
|
+
// 读文件尾部 / 端口监听两个小助手 + 「拉起并自证」的 Invoke-DshRelaunch 都在
|
|
349
|
+
// domain/framework-relaunch.js 里生成(本文件贴着 600 行上限,且拉起与"失败后判该不该回滚"
|
|
350
|
+
// 是同一件事,放一处更好审)。
|
|
351
|
+
...relaunchProbeHelpers(),
|
|
352
|
+
...relaunchInvoke({ ps, nodePath, hosted: shellHosted }),
|
|
343
353
|
].join('\r\n')
|
|
344
354
|
}
|
|
345
355
|
|
|
@@ -5,6 +5,10 @@
|
|
|
5
5
|
// 也让这段逻辑能被单独读、单独审 —— 它是"升级完却没装全"这类事故的最后一道闸。
|
|
6
6
|
//
|
|
7
7
|
// 自包含:只用 target / nodePath / fwRoot 三个值(路径由调用方用 ps() 算好传进来),不依赖任何宿主上下文。
|
|
8
|
+
//
|
|
9
|
+
// 2026-10-28 加法:通过时置 `$script:structureOk = $true` —— 这个事实位是「拉起失败后该不该回滚」
|
|
10
|
+
// 判据(domain/framework-relaunch.js#judgeRelaunchFailure)的输入之一。只有"真通过"才置位,
|
|
11
|
+
// 失败分支保持原样(该值留在 $false,判据据此走保守回滚)。
|
|
8
12
|
|
|
9
13
|
/** 生成"安装后结构校验"的 PowerShell 片段(返回单个字符串,供升级脚本数组作为一个元素展开)。 */
|
|
10
14
|
function fwIntegrityCheck(params) {
|
|
@@ -27,7 +31,7 @@ function fwIntegrityCheck(params) {
|
|
|
27
31
|
` else {`,
|
|
28
32
|
` $probe = (& '${nodePath}' $bin --version 2>&1 | Out-String).Trim()`,
|
|
29
33
|
` if ($probe -ne '${target}') { Log ('结构校验失败:CLI 自报版本 ' + $probe + ',目标 ${target}(安装不完整或被其它副本顶替),按失败处理'); $code = 1 }`,
|
|
30
|
-
` else { Log ('结构校验通过:package.json + bin.js 就位,CLI 自报 ' + $probe) }`,
|
|
34
|
+
` else { $script:structureOk = $true; Log ('结构校验通过:package.json + bin.js 就位,CLI 自报 ' + $probe) }`,
|
|
31
35
|
` }`,
|
|
32
36
|
` } catch { Log ('结构校验异常,按失败处理:' + $_.Exception.Message); $code = 1 }`,
|
|
33
37
|
` }`,
|
|
@@ -11,7 +11,9 @@ import { pathToFileURL } from 'node:url'
|
|
|
11
11
|
import { createRequire } from 'node:module'
|
|
12
12
|
import { readCompatMode, writeCompatMode } from '../domain/compat-state.js'
|
|
13
13
|
import { FRAMEWORK_BACKUP_ROOT, backupProfileSnapshot, checkpointFrameworkTree, platformUpgradeRefusal, preflightDisableIncompatible, relaunchPrelude, resolveDshBin, resolveFrameworkRootNodeModules, resolveFrameworkUpgradePlan, shellHostedRefusal } from '../domain/framework.js'
|
|
14
|
+
import { relaunchLauncherSource, relaunchVerdictBlock, relaunchVerdictHelperSource } from '../domain/framework-relaunch.js'
|
|
14
15
|
import { fwIntegrityCheck } from '../infra/fw-integrity-check.js'
|
|
16
|
+
import { repairStaleRenameResidue } from '../domain/framework-stale-repair.js'
|
|
15
17
|
import { CORE_PATCH_ROW_IDS } from '../domain/patch.js'
|
|
16
18
|
import { migrateAgentConfigsForUpgrade } from '../domain/presets.js'
|
|
17
19
|
import { planQuarantine } from '../domain/quarantine.js'
|
|
@@ -191,6 +193,7 @@ async function routeFrameworkUpgrade(req, res, rc) {
|
|
|
191
193
|
sendError(res, 500, `无法定位框架安装根(期望含 .pnpm 与 @deepseek-ai/dsh 的顶层 node_modules,实际:${fwRoot ?? '未找到'})——已取消升级,请修复框架安装后重试`)
|
|
192
194
|
return
|
|
193
195
|
}
|
|
196
|
+
steps.push(...repairStaleRenameResidue({ fwRoot }).steps) // 修上次失败的 .stale-* 改名残留(只 rename / 跳过已存在 / 幂等)
|
|
194
197
|
// 框架全树 checkpoint(可靠回滚点):升级前镜像全部 @deepseek-ai 版本包(自包内容)+ 顶层 scope + lock
|
|
195
198
|
let fwCheckpoint = null
|
|
196
199
|
try {
|
|
@@ -213,6 +216,7 @@ async function routeFrameworkUpgrade(req, res, rc) {
|
|
|
213
216
|
const qHelperPath = join(dshHome(), 'plugin-console', 'fw-analyze-boot.mjs')
|
|
214
217
|
const qCandidatesPath = join(dshHome(), 'plugin-console', 'fw-quarantine-candidates.json')
|
|
215
218
|
const qRecordPath = join(dshHome(), 'plugin-console', 'fw-quarantine.json')
|
|
219
|
+
const rvHelperPath = join(dshHome(), 'plugin-console', 'fw-relaunch-verdict.mjs'), rlPath = join(dshHome(), 'plugin-console', 'fw-launch.mjs') // 判定器转发脚本 + node 服务拉起器(逻辑都在 domain/framework-relaunch.js)
|
|
216
220
|
let thirdPartyRows = []
|
|
217
221
|
try {
|
|
218
222
|
thirdPartyRows = listEntries(ctx)
|
|
@@ -236,6 +240,7 @@ async function routeFrameworkUpgrade(req, res, rc) {
|
|
|
236
240
|
'',
|
|
237
241
|
].join('\n')
|
|
238
242
|
writeFileSync(qHelperPath, helperSource, 'utf8')
|
|
243
|
+
const rUrl = pathToFileURL(join(pluginRoot(), 'lib', 'server', 'domain', 'framework-relaunch.js')).href; writeFileSync(rvHelperPath, relaunchVerdictHelperSource({ libUrl: rUrl }), 'utf8'); writeFileSync(rlPath, relaunchLauncherSource(), 'utf8') // 判定器转发脚本 + node 拉起器(逻辑仍在插件内)
|
|
239
244
|
const candidateSnapshot = listEntries(ctx)
|
|
240
245
|
.filter((e) => typeof e.rowId === 'string' && e.rowId !== '')
|
|
241
246
|
.map((e) => ({ rowId: e.rowId, moduleName: e.moduleName, toggleable: e.toggleable === true, enabled: e.enabled === true }))
|
|
@@ -252,8 +257,10 @@ async function routeFrameworkUpgrade(req, res, rc) {
|
|
|
252
257
|
return j.replace(/`/gu, '``').replace(/\$/gu, '`$')
|
|
253
258
|
}
|
|
254
259
|
const patchFilePath = join(profileDir, 'cordis.patch.yml')
|
|
255
|
-
//
|
|
256
|
-
|
|
260
|
+
// 拉起片段(隔离重试用;与升级/回滚共用 relaunchPrelude 里的同一份实现)。
|
|
261
|
+
// 2026-10-28:成功只认端口监听 → Note-Relaunch 置位后**立刻**收尾(以前只记"别再拉起",
|
|
262
|
+
// 循环又空转约 95 秒,最后把已起来的服务判死并回滚 —— 缺陷 ②)。
|
|
263
|
+
const launchSnippet = (tag) => `if (-not $ok -and -not $started) { $started = Note-Relaunch (Invoke-DshRelaunch '${tag}'); if ($script:relaunchListening) { $ok = $true; break } }; Beat`
|
|
257
264
|
const lines = [
|
|
258
265
|
`$state = ${ps(stateFile)}`,
|
|
259
266
|
`$log = ${ps(logFile)}`,
|
|
@@ -265,7 +272,7 @@ async function routeFrameworkUpgrade(req, res, rc) {
|
|
|
265
272
|
" try { if ($s -eq 'failed') { Set-Content -Path $state -Value ($s + '|' + $m + '|stage=' + [string]$script:stage) -Encoding UTF8; return } } catch {}",
|
|
266
273
|
" try { if ($s -ne 'done' -and $s -ne 'idle') { $script:stage = $s }; Set-Content -Path $state -Value ($s + '|' + $m) -Encoding UTF8; Beat } catch {}",
|
|
267
274
|
"}",
|
|
268
|
-
relaunchPrelude({ nodePath, pluginDir: pluginRoot(), fwRoot, target, ps }),
|
|
275
|
+
relaunchPrelude({ nodePath, pluginDir: pluginRoot(), fwRoot, target, ps, port }),
|
|
269
276
|
// 全局异常兜底(事故教训):脚本任何未捕获异常都会无声死亡、状态永远卡住、服务没人拉起。
|
|
270
277
|
// trap 捕获后:写 failed 状态 + 日志 + 尝试拉起服务 + 自删任务 + 退出
|
|
271
278
|
"trap {",
|
|
@@ -392,7 +399,7 @@ async function routeFrameworkUpgrade(req, res, rc) {
|
|
|
392
399
|
` SetState 'failed' ('回滚过程异常,框架可能处于混合状态:' + $_.Exception.Message)`,
|
|
393
400
|
` }`,
|
|
394
401
|
`}`,
|
|
395
|
-
`$script:rolledBack = $false`,
|
|
402
|
+
`$script:rolledBack = $false; $script:structureOk = $false; $script:topLevelOk = $false; $script:keptReady = $false; $script:relaunchListening = $false`,
|
|
396
403
|
`$code = Install-Framework 'https://registry.npmmirror.com'`,
|
|
397
404
|
`if ($code -ne 0) { Log ('npmmirror 安装失败(exit=' + $code + '),切换 registry.npmjs.org 重试一次'); $code = Install-Framework 'https://registry.npmjs.org' }`,
|
|
398
405
|
`if ($code -ne 0) {`,
|
|
@@ -421,9 +428,9 @@ async function routeFrameworkUpgrade(req, res, rc) {
|
|
|
421
428
|
` if ($code -ne 0) { $code = Install-Framework 'https://registry.npmjs.org' }`,
|
|
422
429
|
` $vv = ''`,
|
|
423
430
|
` try { $vv = (Get-Content (Join-Path $visible 'package.json') -Raw | ConvertFrom-Json).version } catch {}`,
|
|
424
|
-
` if ($vv -eq '${target}') { Log '顶层可见版本已修正为 ${target}' } else { Log ('顶层可见版本仍为 ' + $vv + '(拉起不受影响:Node 按链接真实路径解析依赖)') }`,
|
|
431
|
+
` if ($vv -eq '${target}') { $script:topLevelOk = $true; Log '顶层可见版本已修正为 ${target}' } else { Log ('顶层可见版本仍为 ' + $vv + '(拉起不受影响:Node 按链接真实路径解析依赖)') }`,
|
|
425
432
|
` } else { Log ('顶层可见版本异常(' + $vv + ')且非实体目录,跳过改名(LinkType=' + $item.LinkType + ')') }`,
|
|
426
|
-
` } else { Log '顶层可见版本校验通过' }`,
|
|
433
|
+
` } else { $script:topLevelOk = $true; Log '顶层可见版本校验通过' }`,
|
|
427
434
|
`}`,
|
|
428
435
|
// 回滚后跳过重链/依赖修复/版本对齐(它们按新树预期运行,会对已恢复的旧树造成二次破坏)
|
|
429
436
|
`if (-not $script:rolledBack) {`,
|
|
@@ -564,7 +571,7 @@ async function routeFrameworkUpgrade(req, res, rc) {
|
|
|
564
571
|
'$started = $false',
|
|
565
572
|
`for ($i = 0; $i -lt 20; $i++) {`,
|
|
566
573
|
` try { $c = Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue; if ($c.Count -gt 0) { $ok = $true; break } } catch {}`,
|
|
567
|
-
` if (-not $ok -and -not $started) {
|
|
574
|
+
` if (-not $ok -and -not $started) { $started = Note-Relaunch (Invoke-DshRelaunch '升级后'); if ($script:relaunchListening) { $ok = $true; break } }; Beat`,
|
|
568
575
|
' Start-Sleep -Seconds 5',
|
|
569
576
|
'}',
|
|
570
577
|
// ── 启动失败隔离(用户硬要求):新框架起不来时先隔离肇事者再重试,不直接回滚 ──
|
|
@@ -620,19 +627,13 @@ async function routeFrameworkUpgrade(req, res, rc) {
|
|
|
620
627
|
` Log ('第 ' + $q + ' 轮隔离后仍失败')`,
|
|
621
628
|
` }`,
|
|
622
629
|
`}`,
|
|
623
|
-
//
|
|
630
|
+
// 隔离也救不回:**先判定该怎么办**(2026-10-28 用户定案:自动回滚只留给"框架树确认损坏",
|
|
631
|
+
// 其余一律 ready + 手动命令,回滚交人工)。判定 = domain/framework-relaunch.js#judgeRelaunchFailure。
|
|
624
632
|
`if (-not $ok -and -not $script:rolledBack) {`,
|
|
625
|
-
|
|
626
|
-
` Invoke-Rollback`,
|
|
627
|
-
` $ok = $false`,
|
|
628
|
-
` $started = $false`,
|
|
629
|
-
` for ($i = 0; $i -lt 20; $i++) {`,
|
|
630
|
-
` try { $c = Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue; if ($c.Count -gt 0) { $ok = $true; break } } catch {}`,
|
|
631
|
-
` if (-not $ok -and -not $started) { if (Invoke-DshRelaunch '回滚后') { $started = $true } }; Beat`,
|
|
632
|
-
` Start-Sleep -Seconds 5`,
|
|
633
|
-
` }`,
|
|
633
|
+
relaunchVerdictBlock({ ps, nodePath, helperPath: rvHelperPath, port }),
|
|
634
634
|
`}`,
|
|
635
|
-
|
|
635
|
+
// keptReady 时上面已写过 ready 终态,这里不再覆盖成 failed(否则又变成"整列红叉")
|
|
636
|
+
`if ($ok) { if ($script:rolledBack) { SetState 'failed' '升级失败,已自动回滚到升级前版本(服务已恢复)' ; Log '升级失败已自动回滚,服务已恢复' } else { SetState 'done' '升级完成,服务已监听 ${port}' ; Log '服务已监听 ${port},升级完成' } } elseif (-not $script:keptReady) { SetState 'failed' '拉起失败且自动回滚未生效:请手动运行 node ${binPath} web' ; Log '拉起失败且自动回滚未生效:请手动运行 node ${binPath} web(参考本日志排查)' }`,
|
|
636
637
|
`schtasks /delete /f /tn ${taskName} 2>$null`,
|
|
637
638
|
].filter((l) => l !== '').join('\r\n')
|
|
638
639
|
// 初始状态(备份完成、脚本即将执行)
|
|
@@ -695,4 +696,4 @@ async function routeFrameworkUpgrade(req, res, rc) {
|
|
|
695
696
|
return
|
|
696
697
|
}
|
|
697
698
|
|
|
698
|
-
export { routeFrameworkUpgrade }
|
|
699
|
+
export { routeFrameworkUpgrade }
|
|
@@ -9,6 +9,7 @@ import { tmpdir } from 'node:os'
|
|
|
9
9
|
import { createRequire } from 'node:module'
|
|
10
10
|
import { readCompatGate, writeCompatGate } from '../domain/compat.js'
|
|
11
11
|
import { cleanupStaleFwTasks, currentFrameworkVersion, detectHostShape, pickFrameworkTarget, platformUpgradeRefusal, relaunchPrelude, resolveDshBin, shellHostedRefusal } from '../domain/framework.js'
|
|
12
|
+
import { relaunchLauncherSource } from '../domain/framework-relaunch.js'
|
|
12
13
|
import { restartPathDecision } from '../domain/restart.js'
|
|
13
14
|
// 0.5.84 加法(用户第 5 项):独立实例的 detached 重拉 —— 计划在 domain/restart-launcher.js,
|
|
14
15
|
// 进程原语仍走 infra/egress.js 的可注入出口(单测能证明"一次真进程都没起")。
|
|
@@ -113,7 +114,9 @@ async function routeFrameworkUpgradeStatusGet(req, res, rc) {
|
|
|
113
114
|
// = 0xC000013A),终态没写成 → 界面永远卡在「回滚中…」并每 3 秒轮询。心跳 + 现实核对
|
|
114
115
|
// 能把它纠正回来:脚本不再心跳(>90 秒没动静)时,用「已装版本 vs 回滚记录的 from/to」
|
|
115
116
|
// 判断真实结果;同时清掉残留的 DSH-FW-* 计划任务。
|
|
116
|
-
|
|
117
|
+
// 2026-10-28 加法:`ready` = 框架本体已就位、只有自动拉起失败(**不回滚**,回滚交人工)。
|
|
118
|
+
// 它是**终态**(下面两处 terminal 判定都得认它,否则自愈会改写成 done、15 分钟残留清理会抹成 idle)。
|
|
119
|
+
const terminal = status.status === 'idle' || status.status === 'done' || status.status === 'failed' || status.status === 'ready'
|
|
117
120
|
if (!terminal) {
|
|
118
121
|
let hbAge = null
|
|
119
122
|
try { hbAge = Date.now() - statSync(`${f}.hb`).mtimeMs } catch { hbAge = null }
|
|
@@ -152,11 +155,13 @@ async function routeFrameworkUpgradeStatusGet(req, res, rc) {
|
|
|
152
155
|
// 视为上次升级的残留——升级脚本要么成功(done)要么失败(failed),服务重启后不可能还在
|
|
153
156
|
// 中途;任务调度失败/脚本空跑时状态会永远停在 starting,重启后不应再自动恢复进度条。
|
|
154
157
|
// (升级进行中页面断连后用户手动重启服务属边缘情况:<15 分钟不受影响,进度仍可恢复)
|
|
155
|
-
if (status.status !== 'idle' && status.status !== 'done' && status.status !== 'failed'
|
|
158
|
+
if (status.status !== 'idle' && status.status !== 'done' && status.status !== 'failed' && status.status !== 'ready'
|
|
156
159
|
&& Date.now() - at > 15 * 60 * 1000) {
|
|
157
160
|
try { writeFileSync(f, 'idle|', 'utf8') } catch {}
|
|
158
161
|
status = { status: 'idle', message: null, at: Date.now() }
|
|
159
162
|
}
|
|
163
|
+
// ready → 对客户端表现成 done + keptFrame/relaunchFailed 标记(不改 UI:done 是终态、message 原样显示)
|
|
164
|
+
if (status.status === 'ready') status = { ...status, status: 'done', keptFrame: true, relaunchFailed: true }
|
|
160
165
|
}
|
|
161
166
|
} catch {}
|
|
162
167
|
sendJson(res, 200, { ok: true, ...status })
|
|
@@ -414,6 +419,7 @@ async function routeFrameworkRollback(req, res, rc) {
|
|
|
414
419
|
const logFile = join(dshHome(), 'plugin-console', 'fw-upgrade.log')
|
|
415
420
|
const stateFile = join(dshHome(), 'plugin-console', 'fw-upgrade-state.txt')
|
|
416
421
|
const ps = (s) => JSON.stringify(s).replace(/\\\\/gu, '\\')
|
|
422
|
+
try { writeFileSync(join(dshHome(), 'plugin-console', 'fw-launch.mjs'), relaunchLauncherSource(), 'utf8') } catch {} // 2026-10-28:回滚后的拉起也走 node 拉起器(与升级脚本共用同一份生成物)
|
|
417
423
|
const lines = [
|
|
418
424
|
`$state = ${ps(stateFile)}`,
|
|
419
425
|
`$log = ${ps(logFile)}`,
|
|
@@ -422,7 +428,7 @@ async function routeFrameworkRollback(req, res, rc) {
|
|
|
422
428
|
" try { if ($s -eq 'failed') { Set-Content -Path $state -Value ($s + '|' + $m + '|stage=' + [string]$script:stage) -Encoding UTF8; return } } catch {}",
|
|
423
429
|
" try { if ($s -ne 'done' -and $s -ne 'idle') { $script:stage = $s }; Set-Content -Path $state -Value ($s + '|' + $m) -Encoding UTF8; Beat } catch {}",
|
|
424
430
|
"}",
|
|
425
|
-
relaunchPrelude({ nodePath, pluginDir: pluginRoot(), fwRoot: rec.fwRoot, target: rec.from ?? '', ps }),
|
|
431
|
+
relaunchPrelude({ nodePath, pluginDir: pluginRoot(), fwRoot: rec.fwRoot, target: rec.from ?? '', ps, port }),
|
|
426
432
|
"trap {",
|
|
427
433
|
" try { SetState 'failed' ('回滚脚本异常终止:' + $_.Exception.Message) } catch {}",
|
|
428
434
|
` schtasks /delete /f /tn ${taskName} 2>$null`,
|
|
@@ -449,10 +455,10 @@ async function routeFrameworkRollback(req, res, rc) {
|
|
|
449
455
|
`$started = $false`,
|
|
450
456
|
`for ($i = 0; $i -lt 20; $i++) {`,
|
|
451
457
|
` try { $c = Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue; if ($c.Count -gt 0) { $ok = $true; break } } catch {}`,
|
|
452
|
-
` if (-not $ok -and -not $started) {
|
|
458
|
+
` if (-not $ok -and -not $started) { $started = Note-Relaunch (Invoke-DshRelaunch '回滚后'); if ($script:relaunchListening) { $ok = $true; break } }; Beat`,
|
|
453
459
|
` Start-Sleep -Seconds 5`,
|
|
454
460
|
`}`,
|
|
455
|
-
`if ($ok) { SetState 'done' ('已回滚到升级前版本 ${rec.from ?? '?'},服务正常') ; Log '回滚完成,服务已恢复' } else { SetState 'failed' '
|
|
461
|
+
`if ($ok) { SetState 'done' ('已回滚到升级前版本 ${rec.from ?? '?'},服务正常') ; Log '回滚完成,服务已恢复' } else { SetState 'failed' ('回滚后服务拉起失败:' + [string]$script:relaunchReason + ';请手动运行 node ${resolveDshBin() ?? '<bin>'} web --no-open') ; Log ('回滚后拉起失败:' + [string]$script:relaunchReason) }`,
|
|
456
462
|
`schtasks /delete /f /tn ${taskName} 2>$null`,
|
|
457
463
|
].filter((l) => l !== '').join('\r\n')
|
|
458
464
|
try { writeFileSync(stateFile, 'rollback|回滚脚本已启动…', 'utf8') } catch {}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@noob-stupid/dsh-plugin-console",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.95",
|
|
4
4
|
"description": "DSH 框架升级安全与插件升级门控:一键升级、失败自动回滚、升级后回滚上版、旧插件不适配自动禁用;内置多源插件市场为发现层,插件源全部可自定义,可指向公司内网私有源 / 私有索引 / 本地 Git 仓库,纯内网离线可用 | Framework upgrade safety & plugin version gating for DSH, with a customizable multi-source plugin market: point every source at internal mirrors or a local file:// repo for intranet-only, offline installs.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|