@noob-stupid/dsh-plugin-console 0.5.94 → 0.5.97
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/client.js +397 -0
- 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/domain/patch-yaml-check.js +10 -0
- package/lib/server/infra/fw-integrity-check.js +5 -1
- package/lib/server/routes/compat.js +14 -3
- package/lib/server/routes/framework-upgrade.js +20 -19
- package/lib/server/routes/framework.js +11 -5
- package/package.json +1 -1
|
@@ -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
|
|
|
@@ -63,6 +63,16 @@ function blockScalarIndicator(value) {
|
|
|
63
63
|
function scanValueQuotes(value, initialQuote = null) {
|
|
64
64
|
const text = String(value)
|
|
65
65
|
let quote = initialQuote
|
|
66
|
+
// 函数头注释 ⑤ 的前提「值不以引号开头 ⇒ 不跨行扫描」原先只写在注释里、代码没实现。
|
|
67
|
+
// 代价(issue #26):`- 'D:/x/y.js'` 这类带盘符的序列项里,盘符的冒号被 key:value 正则
|
|
68
|
+
// 当成分隔符吃掉,value 变成 `/x/y.js'`(首字符不是引号),于是行尾那个 `'` 被当成
|
|
69
|
+
// 「引号开口」并一直挂到文件末尾 ⇒ 误报「文件末尾仍有未闭合的引号」,
|
|
70
|
+
// 而 safeBoot 的写盘前闸门正是它 ⇒ 快照/恢复/只禁可疑行全部退化成空转。
|
|
71
|
+
// 值不以引号开头时,本行的引号不可能跨行开口。
|
|
72
|
+
if (quote === null) {
|
|
73
|
+
const first = text.trimStart()[0]
|
|
74
|
+
if (first !== "'" && first !== '"') return { quote: null }
|
|
75
|
+
}
|
|
66
76
|
for (let i = 0; i < text.length; i += 1) {
|
|
67
77
|
const ch = text[i]
|
|
68
78
|
if (quote === null) {
|
|
@@ -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
|
` }`,
|