@noob-stupid/dsh-plugin-console 0.5.31 → 0.5.33
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 +72 -10
- package/README.zh.md +63 -5
- package/lib/client.js +85 -53
- package/lib/server/domain/auto-disable.js +226 -0
- package/lib/server/domain/auto-preflight-run.js +184 -0
- package/lib/server/domain/auto-preflight.js +345 -0
- package/lib/server/domain/compat-state.js +57 -8
- package/lib/server/domain/compat.js +10 -60
- package/lib/server/domain/patch-yaml-check.js +151 -0
- package/lib/server/domain/peer-veto.js +103 -0
- package/lib/server/domain/safe-boot.js +520 -0
- package/lib/server/domain/settings-api-scan.js +251 -0
- package/lib/server/routes/compat.js +240 -11
- package/lib/server/routes/framework-preflight.js +14 -1
- package/lib/server/routes/index.js +4 -0
- package/lib/server/routes/plugins.js +3 -1
- package/lib/server/routes/safe-boot.js +139 -0
- package/lib/server/routes/state.js +17 -3
- package/package.json +1 -1
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
// L1 · domain —— auto-preflight.js(环境指纹变化 → 自动预检的**判定与记录层**;0.5.32 加法 D1)
|
|
2
|
+
//
|
|
3
|
+
// 为什么要有它(用户 2026-09-29 诉求,原话):
|
|
4
|
+
// 「走**别的**更新通道(官方桌面端更新器 / 手动 pnpm / npx 缓存变化)改了框架后,我们**仍要自动守门**」。
|
|
5
|
+
// 现状(0.5.31):`domain/compat-state.js` 的 `compareFingerprint` 已经能**发现**任何来源的框架变更
|
|
6
|
+
// (`routes/compat.js` 每次状态查询现场算指纹并与基线比对),但发现之后**只上报一行文案**,
|
|
7
|
+
// 不会自动跑预检;而「启动失败隔离」(`domain/quarantine.js`)的输入是我们自己的升级脚本留下的
|
|
8
|
+
// 记录 —— 别人升级时**没有这份记录**,兜底因此不触发。本模块补上中间那一段:
|
|
9
|
+
//
|
|
10
|
+
// 指纹变化(changed=true)→ **自动跑一次只读预检** → 落成三样东西:
|
|
11
|
+
// ① 兼容清单(受影响行 + 判据) ② 隔离计划(建议禁用哪些行、为什么) ③ 本次变更摘要(版本 from→to / 新增消失的包 / 指纹 reasons)
|
|
12
|
+
//
|
|
13
|
+
// 四条硬约束(全部可离线断言,见 tests/test-auto-preflight.mjs):
|
|
14
|
+
// · **只读**:不下载、不安装、不改补丁、不碰 loader —— 本模块连 ctx/ports 都不认识(只吃注入的窄接口);
|
|
15
|
+
// · **同一指纹只自动跑一次**:以指纹为 key 持久化「已扫过」标记;失败**如实记录原因**且**允许重试**;
|
|
16
|
+
// · **绝不阻塞状态查询**:超时/异常一律降级成「未完成,可手动重跑」,状态查询该返回什么还返回什么;
|
|
17
|
+
// · 纯函数为主:判定(shouldAutoRun)、超时包裹(withTimeout)、增量(summarizeDelta)、
|
|
18
|
+
// 记录合并(upsertRecord)都不做 IO,IO 只出现在 store 读写与 runAutoPreflight 的调用缝上。
|
|
19
|
+
//
|
|
20
|
+
// 分层:L1 domain —— 无 ctx、无 HTTP、不抛(异常在 runAutoPreflight 内被收成 state='failed')。
|
|
21
|
+
|
|
22
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
23
|
+
import { dirname, join } from 'node:path'
|
|
24
|
+
import { dshHome } from '../infra/paths.js'
|
|
25
|
+
|
|
26
|
+
/** 记录文件(与我们其余的常驻状态同住 `dshHome()/plugin-console/`,不碰用户 profile)。 */
|
|
27
|
+
const autoPreflightFile = () => join(dshHome(), 'plugin-console', 'auto-preflight.json')
|
|
28
|
+
|
|
29
|
+
/** 保留最近的扫描记录条数(防止指纹频繁抖动把状态文件撑大)。 */
|
|
30
|
+
const MAX_RECORDS = 12
|
|
31
|
+
|
|
32
|
+
/** 自动预检的默认超时(毫秒)。宁可降级成「未完成,可手动重跑」,也不让状态查询等它。 */
|
|
33
|
+
const DEFAULT_TIMEOUT_MS = 4000
|
|
34
|
+
|
|
35
|
+
const STATES = ['pending', 'done', 'failed', 'timeout']
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 指纹的**稳定 key**:只取参与变更判定的三个字段(`at` 每次都变,绝不能进 key)。
|
|
39
|
+
* 归一化:字符串去空白 + 小写(win32 路径大小写不敏感;两个指纹「实际相同」时不许各跑一次)。
|
|
40
|
+
*/
|
|
41
|
+
function fingerprintKey(fp) {
|
|
42
|
+
const norm = (value, lower = false) => {
|
|
43
|
+
if (value === null || value === undefined) return ''
|
|
44
|
+
const text = String(value).trim()
|
|
45
|
+
return lower ? text.toLowerCase() : text
|
|
46
|
+
}
|
|
47
|
+
// 空包数按 '?' 归一:null 与 undefined 是同一件事(数不出来),别当成"变了"
|
|
48
|
+
const entities = fp?.pnpmEntities === null || fp?.pnpmEntities === undefined ? '?' : String(fp.pnpmEntities)
|
|
49
|
+
// 路径:反斜杠归一成正斜杠(Windows 的 `C:\x` 与 `C:/x` 是同一棵树)+ 小写(win32 不区分大小写)
|
|
50
|
+
const root = norm(fp?.pnpmRoot, true).replace(/\\/gu, '/')
|
|
51
|
+
return [norm(fp?.frameworkVersion), root, entities].join('|')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 两份指纹是否「实际相同」(同一 key)。 */
|
|
55
|
+
function sameFingerprint(a, b) {
|
|
56
|
+
return fingerprintKey(a) === fingerprintKey(b)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function readStore(file = autoPreflightFile()) {
|
|
60
|
+
try {
|
|
61
|
+
const rec = JSON.parse(readFileSync(file, 'utf8'))
|
|
62
|
+
if (rec === null || typeof rec !== 'object') return { schema: 1, records: [] }
|
|
63
|
+
return { schema: 1, records: Array.isArray(rec.records) ? rec.records : [] }
|
|
64
|
+
} catch {
|
|
65
|
+
return { schema: 1, records: [] }
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 只增不删的合并:同 key 记录用新值覆盖,别的 key 原样保留;按 at 倒序裁剪到 MAX_RECORDS。 */
|
|
70
|
+
function upsertRecord(records, record, limit = MAX_RECORDS) {
|
|
71
|
+
const list = Array.isArray(records) ? records.filter((r) => r !== null && typeof r === 'object') : []
|
|
72
|
+
const key = typeof record?.key === 'string' ? record.key : fingerprintKey(record?.fingerprint)
|
|
73
|
+
const next = list.filter((r) => (typeof r.key === 'string' ? r.key : fingerprintKey(r.fingerprint)) !== key)
|
|
74
|
+
next.push({ ...record, key })
|
|
75
|
+
next.sort((a, b) => (Number(b?.at ?? 0) - Number(a?.at ?? 0)))
|
|
76
|
+
return next.slice(0, limit)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function writeStore(store, file = autoPreflightFile()) {
|
|
80
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
81
|
+
writeFileSync(file, `${JSON.stringify({ schema: 1, updatedAt: new Date().toISOString(), records: store.records }, null, 2)}\n`, 'utf8')
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** 从记录集里取某个指纹的那条记录(没有则 null)。 */
|
|
85
|
+
function findRecord(records, fp) {
|
|
86
|
+
const key = fingerprintKey(fp)
|
|
87
|
+
return (Array.isArray(records) ? records : []).find((r) => (typeof r?.key === 'string' ? r.key : fingerprintKey(r?.fingerprint)) === key) ?? null
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 自动预检的**执行判定**(纯函数)。三种「不跑」:
|
|
92
|
+
* · state='done' → 同一指纹已经扫过(这就是"同指纹只自动跑一次"的闸门);
|
|
93
|
+
* · state='pending' → 上一次还挂在超时边缘(没有结论),交给手动重跑,不叠加并发;
|
|
94
|
+
* · state='failed' / 'timeout' → **允许重试一次**(失败要能重试),但同一指纹最多自动重试 limit 次。
|
|
95
|
+
* @returns {{ run: boolean, reason: string, attempt: number }}
|
|
96
|
+
*/
|
|
97
|
+
function shouldAutoRun(record, { retryLimit = 1 } = {}) {
|
|
98
|
+
if (record === null || record === undefined) return { run: true, reason: 'first-seen', attempt: 1 }
|
|
99
|
+
const state = typeof record.state === 'string' ? record.state : 'failed'
|
|
100
|
+
const attempts = Number.isFinite(Number(record.attempts)) ? Number(record.attempts) : 0
|
|
101
|
+
if (state === 'done') return { run: false, reason: 'already-scanned', attempt: attempts }
|
|
102
|
+
if (state === 'pending') return { run: false, reason: 'in-flight', attempt: attempts }
|
|
103
|
+
if (attempts >= retryLimit + 1) return { run: false, reason: 'retry-exhausted', attempt: attempts }
|
|
104
|
+
return { run: true, reason: state === 'timeout' ? 'retry-after-timeout' : 'retry-after-failure', attempt: attempts + 1 }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* 超时包裹(纯函数,**不抛**):到点就返回 `{ timedOut: true }`,绝不把异常甩给调用方。
|
|
109
|
+
* 注意它**不会取消**底层 promise(JS 没有取消语义)—— 底层跑完后的写盘由 runAutoPreflight 收口,
|
|
110
|
+
* 但那不影响状态查询:调用方拿到 timedOut 就立刻回响应。
|
|
111
|
+
*/
|
|
112
|
+
function withTimeout(promise, ms, onTimeout = null) {
|
|
113
|
+
const limit = Number.isFinite(Number(ms)) && Number(ms) > 0 ? Number(ms) : DEFAULT_TIMEOUT_MS
|
|
114
|
+
let timer = null
|
|
115
|
+
const timeout = new Promise((resolve) => {
|
|
116
|
+
timer = setTimeout(() => {
|
|
117
|
+
if (typeof onTimeout === 'function') { try { onTimeout() } catch {} }
|
|
118
|
+
resolve({ timedOut: true, value: null })
|
|
119
|
+
}, limit)
|
|
120
|
+
if (typeof timer?.unref === 'function') timer.unref()
|
|
121
|
+
})
|
|
122
|
+
const wrapped = Promise.resolve(promise).then(
|
|
123
|
+
(value) => ({ timedOut: false, value }),
|
|
124
|
+
(error) => ({ timedOut: false, value: null, error: error instanceof Error ? error.message : String(error) }),
|
|
125
|
+
)
|
|
126
|
+
return Promise.race([wrapped, timeout]).finally(() => { if (timer !== null) clearTimeout(timer) })
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* 兼容清单:把补丁体检与源码扫描的结论收敛成**受影响行**(面板按行显示,一行一句)。
|
|
131
|
+
* 只做汇总与排序,不新增判据(判据留在 patch-composition-audit / format-scan 两个模块里)。
|
|
132
|
+
*/
|
|
133
|
+
function affectedRows({ patchReport = null, scan = null, rows = [], limit = 40 } = {}) {
|
|
134
|
+
const out = []
|
|
135
|
+
const nameById = new Map((Array.isArray(rows) ? rows : []).map((r) => [r?.id, r?.name ?? null]))
|
|
136
|
+
for (const item of (patchReport?.blockers ?? [])) {
|
|
137
|
+
out.push({
|
|
138
|
+
rowId: item.row ?? null, moduleName: item.name ?? nameById.get(item.row) ?? null, severity: 'blocker',
|
|
139
|
+
code: item.code ?? 'patch-row', line: item.line ?? null, detail: item.detail ?? null,
|
|
140
|
+
hint: item.hint ?? null, suggestions: item.suggestions ?? [],
|
|
141
|
+
})
|
|
142
|
+
}
|
|
143
|
+
for (const item of (patchReport?.warnings ?? [])) {
|
|
144
|
+
out.push({
|
|
145
|
+
rowId: item.row ?? null, moduleName: item.name ?? nameById.get(item.row) ?? null, severity: 'warn',
|
|
146
|
+
code: item.code ?? 'patch-row', line: item.line ?? null, detail: item.detail ?? null,
|
|
147
|
+
hint: item.hint ?? null, suggestions: item.suggestions ?? [],
|
|
148
|
+
})
|
|
149
|
+
}
|
|
150
|
+
for (const f of (scan?.findings ?? [])) {
|
|
151
|
+
out.push({
|
|
152
|
+
rowId: null, moduleName: f.moduleName ?? null, severity: f.severity ?? 'warn',
|
|
153
|
+
code: f.rule ?? 'source-scan', line: f.line ?? null,
|
|
154
|
+
detail: `${f.file ?? '?'}${f.line === null || f.line === undefined ? '' : `:${f.line}`}${f.before === undefined ? '' : ` → ${f.after ?? ''}`}`,
|
|
155
|
+
hint: f.reason ?? f.note ?? null, suggestions: [],
|
|
156
|
+
})
|
|
157
|
+
}
|
|
158
|
+
const rank = { blocker: 0, warn: 1 }
|
|
159
|
+
out.sort((a, b) => (rank[a.severity] ?? 9) - (rank[b.severity] ?? 9) || String(a.rowId ?? '').localeCompare(String(b.rowId ?? '')))
|
|
160
|
+
return out.slice(0, limit)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* 隔离计划:**建议**禁用哪些行 + 为什么(绝不自动写盘;真正落盘只走用户显式触发的
|
|
165
|
+
* `POST /plugin-console/safe-boot {action:'disable-suspects'}` 或离线脚本)。
|
|
166
|
+
* 只收「点名 + 可开关 + 非核心」的行:核心行禁掉只会让服务更起不来(正确动作是回滚框架)。
|
|
167
|
+
*/
|
|
168
|
+
function planSuspectRows(affected, { coreRowIds = new Set(), toggleableById = new Map(), limit = 20 } = {}) {
|
|
169
|
+
const suspects = []
|
|
170
|
+
const skipped = []
|
|
171
|
+
for (const item of (Array.isArray(affected) ? affected : [])) {
|
|
172
|
+
if (item.severity !== 'blocker' || typeof item.rowId !== 'string' || item.rowId === '') continue
|
|
173
|
+
if (coreRowIds.has(item.rowId)) { skipped.push({ rowId: item.rowId, reason: 'core-row' }); continue }
|
|
174
|
+
if (item.rowId === 'plugin-console') { skipped.push({ rowId: item.rowId, reason: 'self' }); continue }
|
|
175
|
+
if (toggleableById.has(item.rowId) && toggleableById.get(item.rowId) === false) { skipped.push({ rowId: item.rowId, reason: 'not-toggleable' }); continue }
|
|
176
|
+
if (suspects.some((s) => s.rowId === item.rowId)) continue
|
|
177
|
+
if (suspects.length >= limit) break
|
|
178
|
+
suspects.push({ rowId: item.rowId, moduleName: item.moduleName ?? null, reason: item.code, detail: item.detail ?? null, line: item.line ?? null })
|
|
179
|
+
}
|
|
180
|
+
return { suspects, skipped }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** 本次变更摘要:框架版本 from→to + 新增/消失的包 + 指纹 reasons(面板一句话,详情放长文)。 */
|
|
184
|
+
function summarizeDelta({ current = null, previous = null, currentPackages = [], previousPackages = [] } = {}) {
|
|
185
|
+
const from = previous?.frameworkVersion ?? null
|
|
186
|
+
const to = current?.frameworkVersion ?? null
|
|
187
|
+
const before = new Set((Array.isArray(previousPackages) ? previousPackages : []).filter((n) => typeof n === 'string' && n !== ''))
|
|
188
|
+
const after = new Set((Array.isArray(currentPackages) ? currentPackages : []).filter((n) => typeof n === 'string' && n !== ''))
|
|
189
|
+
const added = [...after].filter((n) => !before.has(n)).sort()
|
|
190
|
+
const removed = [...before].filter((n) => !after.has(n)).sort()
|
|
191
|
+
return {
|
|
192
|
+
frameworkVersionFrom: from,
|
|
193
|
+
frameworkVersionTo: to,
|
|
194
|
+
versionChanged: from !== to,
|
|
195
|
+
packagesAdded: added.slice(0, 50),
|
|
196
|
+
packagesRemoved: removed.slice(0, 50),
|
|
197
|
+
packagesAddedCount: added.length,
|
|
198
|
+
packagesRemovedCount: removed.length,
|
|
199
|
+
rootChanged: (previous?.pnpmRoot ?? null) !== (current?.pnpmRoot ?? null),
|
|
200
|
+
entitiesChanged: (previous?.pnpmEntities ?? null) !== (current?.pnpmEntities ?? null),
|
|
201
|
+
rationaleAvailable: before.size > 0 || after.size > 0,
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** 面板/离线脚本共用的一句话短句(中英双语;面板只放这一句,长文进 detail)。 */
|
|
206
|
+
function autoPreflightNote(state, { blockers = 0, suspects = 0 } = {}) {
|
|
207
|
+
const zh = {
|
|
208
|
+
pending: '环境变了,正在自动预检…',
|
|
209
|
+
done: blockers > 0 ? `环境变了:预检发现 ${blockers} 行受影响${suspects > 0 ? `,建议禁用 ${suspects} 行` : ''}` : '环境变了:自动预检未发现受影响行',
|
|
210
|
+
failed: '环境变了,但自动预检失败(可手动重跑)',
|
|
211
|
+
timeout: '环境变了,自动预检超时(可手动重跑)',
|
|
212
|
+
idle: '环境未变化,无需自动预检',
|
|
213
|
+
}
|
|
214
|
+
const en = {
|
|
215
|
+
pending: 'Environment changed — auto-preflight running…',
|
|
216
|
+
done: blockers > 0 ? `Environment changed: ${blockers} affected row(s)${suspects > 0 ? `, ${suspects} suggested for disable` : ''}` : 'Environment changed: auto-preflight found no affected rows',
|
|
217
|
+
failed: 'Environment changed, but auto-preflight failed (you can re-run it)',
|
|
218
|
+
timeout: 'Environment changed; auto-preflight timed out (you can re-run it)',
|
|
219
|
+
idle: 'No environment change — auto-preflight not needed',
|
|
220
|
+
}
|
|
221
|
+
const key = Object.prototype.hasOwnProperty.call(zh, state) ? state : 'idle'
|
|
222
|
+
return { state: key, zh: zh[key], en: en[key] }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* 自动预检收口(**这条路径不阻塞任何调用方**:调用方不 await 它)。
|
|
227
|
+
* 流程:判定是否该跑 → 写 state='pending' 占位(同指纹只跑一次的闸门立刻生效)→ 跑注入的扫描
|
|
228
|
+
* → 落 `{state:'done', preflight, affected, suspects, delta}`;失败/超时如实落 `failed`/`timeout` + reason。
|
|
229
|
+
* 写盘失败也不抛(返回 `{ok:false, error}`),因为它的调用方是只读状态查询。
|
|
230
|
+
*
|
|
231
|
+
* ★ 并发纪律(2026-09-29 实测踩到):**终态写入必须只落自己的那一条**。
|
|
232
|
+
* 早先的写法是"读整份 store → push → 整份写回",两个指纹的扫描同时收尾时会互相覆盖
|
|
233
|
+
* (谁后写谁赢,另一条的终态**永远停在 pending** —— 面板就永远显示"正在自动预检…")。
|
|
234
|
+
* 现在终态写盘只 upsert **当前 key 的那一条**,其余记录原样保留。
|
|
235
|
+
*/
|
|
236
|
+
async function runAutoPreflight({ current, previous, scan, timeoutMs = DEFAULT_TIMEOUT_MS, now = () => Date.now(), file = autoPreflightFile() } = {}) {
|
|
237
|
+
const key = fingerprintKey(current)
|
|
238
|
+
const startedAt = now()
|
|
239
|
+
try {
|
|
240
|
+
const store = readStore(file)
|
|
241
|
+
const existing = findRecord(store.records, current)
|
|
242
|
+
const verdict = shouldAutoRun(existing)
|
|
243
|
+
if (!verdict.run) return { ok: true, ran: false, reason: verdict.reason, key, state: existing?.state ?? 'idle' }
|
|
244
|
+
const pendingRecord = {
|
|
245
|
+
...(existing ?? {}), key, at: startedAt, startedAt, finishedAt: null, state: 'pending',
|
|
246
|
+
attempts: verdict.attempt, reason: verdict.reason,
|
|
247
|
+
fingerprint: current ?? null, previousFingerprint: previous ?? null,
|
|
248
|
+
}
|
|
249
|
+
writeStore({ records: upsertRecord(store.records, pendingRecord) }, file)
|
|
250
|
+
let result = null
|
|
251
|
+
const raced = await withTimeout(
|
|
252
|
+
Promise.resolve().then(() => scan({ current, previous, key })),
|
|
253
|
+
timeoutMs,
|
|
254
|
+
)
|
|
255
|
+
if (raced.timedOut) {
|
|
256
|
+
result = { ok: false, state: 'timeout', reason: `自动预检超过 ${timeoutMs}ms 未完成(已降级为「未完成,可手动重跑」,未阻塞状态查询)` }
|
|
257
|
+
} else if (raced.error !== undefined && raced.error !== null) {
|
|
258
|
+
result = { ok: false, state: 'failed', reason: `自动预检抛错:${raced.error}` }
|
|
259
|
+
} else {
|
|
260
|
+
const value = raced.value ?? {}
|
|
261
|
+
result = value.ok === false
|
|
262
|
+
? { ok: false, state: 'failed', reason: value.reason ?? '自动预检未产出结论' }
|
|
263
|
+
: { ok: true, state: 'done', preflight: value }
|
|
264
|
+
}
|
|
265
|
+
const finishedAt = now()
|
|
266
|
+
const record = {
|
|
267
|
+
...pendingRecord,
|
|
268
|
+
finishedAt,
|
|
269
|
+
state: result.state,
|
|
270
|
+
error: result.ok ? null : result.reason,
|
|
271
|
+
summary: result.ok ? (result.preflight?.summary ?? null) : null,
|
|
272
|
+
affected: result.ok ? (result.preflight?.affected ?? []) : [],
|
|
273
|
+
suspects: result.ok ? (result.preflight?.suspects ?? []) : [],
|
|
274
|
+
evidence: result.ok ? (result.preflight?.evidence ?? null) : null,
|
|
275
|
+
delta: result.ok ? (result.preflight?.delta ?? null) : null,
|
|
276
|
+
skipped: result.ok ? (result.preflight?.skipped ?? []) : [],
|
|
277
|
+
}
|
|
278
|
+
// 终态写盘:只 upsert 自己这条(读最新一份 + 只替换同 key),不覆盖别的指纹已经写好的结论
|
|
279
|
+
const after = readStore(file)
|
|
280
|
+
writeStore({ records: upsertRecord(after.records, record) }, file)
|
|
281
|
+
return { ok: result.ok, ran: true, key, state: result.state, record, error: result.ok ? null : result.reason, reason: verdict.reason }
|
|
282
|
+
} catch (error) {
|
|
283
|
+
return { ok: false, ran: false, key, state: 'failed', error: `自动预检记录写入失败:${error instanceof Error ? error.message : String(error)}` }
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* 状态查询下发的视图(**加法字段**,不改 `/compat-status` 既有字段):
|
|
289
|
+
* 面板只需要一句短句 + 是否有结论 + 有几行受影响;长文(reasons/affected/delta)放在详情里。
|
|
290
|
+
*/
|
|
291
|
+
function autoPreflightView({ changed, current, records = null, file = autoPreflightFile() } = {}) {
|
|
292
|
+
const list = Array.isArray(records) ? records : readStore(file).records
|
|
293
|
+
// 指纹没变 → 不下发任何「上次的扫描结论」:那是上一个环境的结论,混在一起会让面板说假话。
|
|
294
|
+
const record = changed === true ? findRecord(list, current) : null
|
|
295
|
+
const state = record === null ? 'idle' : (STATES.includes(record.state) ? record.state : 'idle')
|
|
296
|
+
const preflight = record === null ? null : {
|
|
297
|
+
state: record.state ?? null,
|
|
298
|
+
startedAt: record.startedAt ?? null,
|
|
299
|
+
finishedAt: record.finishedAt ?? null,
|
|
300
|
+
attempts: record.attempts ?? 0,
|
|
301
|
+
error: record.error ?? null,
|
|
302
|
+
summary: record.summary ?? null,
|
|
303
|
+
affected: record.affected ?? [],
|
|
304
|
+
suspects: record.suspects ?? [],
|
|
305
|
+
skipped: record.skipped ?? [],
|
|
306
|
+
evidence: record.evidence ?? null,
|
|
307
|
+
delta: record.delta ?? null,
|
|
308
|
+
}
|
|
309
|
+
const blockers = (preflight?.affected ?? []).filter((a) => a.severity === 'blocker').length
|
|
310
|
+
return {
|
|
311
|
+
changed: changed === true,
|
|
312
|
+
state,
|
|
313
|
+
key: fingerprintKey(current),
|
|
314
|
+
ranAt: record?.startedAt ?? null,
|
|
315
|
+
scannedAt: record?.finishedAt ?? null,
|
|
316
|
+
blockers,
|
|
317
|
+
affectedCount: (preflight?.affected ?? []).length,
|
|
318
|
+
suspects: (preflight?.suspects ?? []).map((s) => s.rowId),
|
|
319
|
+
note: autoPreflightNote(state, { blockers, suspects: (preflight?.suspects ?? []).length }),
|
|
320
|
+
error: record?.error ?? null,
|
|
321
|
+
retryable: state === 'failed' || state === 'timeout',
|
|
322
|
+
preflight,
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export {
|
|
327
|
+
DEFAULT_TIMEOUT_MS,
|
|
328
|
+
MAX_RECORDS,
|
|
329
|
+
STATES,
|
|
330
|
+
affectedRows,
|
|
331
|
+
autoPreflightFile,
|
|
332
|
+
autoPreflightNote,
|
|
333
|
+
autoPreflightView,
|
|
334
|
+
findRecord,
|
|
335
|
+
fingerprintKey,
|
|
336
|
+
planSuspectRows,
|
|
337
|
+
readStore,
|
|
338
|
+
runAutoPreflight,
|
|
339
|
+
sameFingerprint,
|
|
340
|
+
shouldAutoRun,
|
|
341
|
+
summarizeDelta,
|
|
342
|
+
upsertRecord,
|
|
343
|
+
withTimeout,
|
|
344
|
+
writeStore,
|
|
345
|
+
}
|
|
@@ -17,6 +17,44 @@ const COMPAT_MODES = ['managed', 'observer']
|
|
|
17
17
|
// 默认**观察者**(用户 2026-09-24 定):只守门、不接管框架升级;一旦用户走了本控制台的框架升级,服务端会自动切回托管
|
|
18
18
|
const DEFAULT_MODE = 'observer'
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* 模式写入的**来源**(0.5.32 加法 D4;缺字段一律按 `manual` 兼容 —— 老记录等于"用户手动设定过",永不自动改):
|
|
22
|
+
* · `manual` 用户在本控制台手动拨过(`POST /compat-mode`)→ 之后**永不自动改**
|
|
23
|
+
* · `auto:console-upgrade` 走了本控制台的框架升级 → 自动切托管(可被桌面端启动拨回)
|
|
24
|
+
* · `auto:desktop-host` 检测到桌面端宿主形态 → 自动切观察者
|
|
25
|
+
*/
|
|
26
|
+
const MODE_SOURCES = ['manual', 'auto:console-upgrade', 'auto:desktop-host']
|
|
27
|
+
const DEFAULT_MODE_SOURCE = 'manual'
|
|
28
|
+
|
|
29
|
+
/** 自动切换的判定(纯函数):**只在"上次不是用户手动设定"时才自动改**。 */
|
|
30
|
+
const AUTO_MODE_SOURCES = ['auto:console-upgrade', 'auto:desktop-host']
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 「桌面端宿主启动 → 自动拨回观察者」的判定(纯函数,加法 D4)。
|
|
34
|
+
* 规则(三条,全部可离线断言):
|
|
35
|
+
* ① 不是桌面端宿主 → 不动(`standalone` 实例的既有行为一字不改);
|
|
36
|
+
* ② 当前已经是 observer → 不动(**零写盘**:连 source 都不刷新);
|
|
37
|
+
* ③ 当前模式的来源是 `manual`(含缺字段的老记录)→ **不动**(用户手动优先,绝不抢用户意图)。
|
|
38
|
+
* @returns {{ action: 'none'|'switch-to-observer', mode: string, from: string, reason: string|null, note: {zh,en}|null }}
|
|
39
|
+
*/
|
|
40
|
+
function planDesktopHostSwitch(modeRecord, { hosted = false } = {}) {
|
|
41
|
+
const mode = typeof modeRecord?.mode === 'string' && COMPAT_MODES.includes(modeRecord.mode) ? modeRecord.mode : DEFAULT_MODE
|
|
42
|
+
const source = typeof modeRecord?.source === 'string' && MODE_SOURCES.includes(modeRecord.source) ? modeRecord.source : DEFAULT_MODE_SOURCE
|
|
43
|
+
if (hosted !== true) return { action: 'none', mode, from: source, reason: 'not-desktop-host', note: null }
|
|
44
|
+
if (mode === 'observer') return { action: 'none', mode, from: source, reason: 'already-observer', note: null }
|
|
45
|
+
if (!AUTO_MODE_SOURCES.includes(source)) return { action: 'none', mode, from: source, reason: 'user-set-manual', note: null }
|
|
46
|
+
return {
|
|
47
|
+
action: 'switch-to-observer',
|
|
48
|
+
mode: 'observer',
|
|
49
|
+
from: source,
|
|
50
|
+
reason: 'desktop-host-detected',
|
|
51
|
+
note: {
|
|
52
|
+
zh: '检测到桌面端托管,已切观察者:不接管框架升级',
|
|
53
|
+
en: 'Desktop host detected — switched to Observer: framework upgrades are not taken over',
|
|
54
|
+
},
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
20
58
|
const stateDir = () => join(dshHome(), 'plugin-console')
|
|
21
59
|
const modeFile = () => join(stateDir(), 'compat-mode.json')
|
|
22
60
|
const fingerprintFile = () => join(stateDir(), 'env-fingerprint.json')
|
|
@@ -36,25 +74,36 @@ function writeJson(path, value) {
|
|
|
36
74
|
}
|
|
37
75
|
|
|
38
76
|
/**
|
|
39
|
-
*
|
|
40
|
-
*
|
|
77
|
+
* 读运行模式。**默认观察者** —— 纯加法:没有设置过的机器行为与之前完全一致。
|
|
78
|
+
* `source` 缺字段(0.5.32 之前写下的记录)按 `manual` 兼容:视为用户手动设定过,绝不自动改。
|
|
79
|
+
* @returns {{ mode: string, at: number|null, source: 'manual'|'auto:console-upgrade'|'auto:desktop-host', reason: string|null, fileSource: 'file'|'default' }}
|
|
41
80
|
*/
|
|
42
81
|
function readCompatMode() {
|
|
43
82
|
const rec = readJson(modeFile())
|
|
44
83
|
const mode = typeof rec?.mode === 'string' && COMPAT_MODES.includes(rec.mode) ? rec.mode : DEFAULT_MODE
|
|
45
|
-
|
|
84
|
+
const source = typeof rec?.source === 'string' && MODE_SOURCES.includes(rec.source) ? rec.source : DEFAULT_MODE_SOURCE
|
|
85
|
+
return { mode, at: typeof rec?.at === 'number' ? rec.at : null, source, reason: typeof rec?.reason === 'string' ? rec.reason : null, fileSource: rec === null ? 'default' : 'file' }
|
|
46
86
|
}
|
|
47
87
|
|
|
48
88
|
/**
|
|
49
89
|
* 写运行模式(只接受白名单值,避免拼错导致「模式静默失效」)。
|
|
50
|
-
*
|
|
90
|
+
* **幂等零写盘**(0.5.32 加法 D4):模式与来源都没变时一个字节都不写 —— 否则"桌面端每次启动都重写一份
|
|
91
|
+
* 状态文件",而用户看不出任何区别(也破坏"已是 observer 时零写盘"的断言)。
|
|
92
|
+
* @returns {{ ok: boolean, mode: string, source: string, changed: boolean, error?: string }}
|
|
51
93
|
*/
|
|
52
94
|
function writeCompatMode(mode, meta = {}) {
|
|
53
95
|
if (typeof mode !== 'string' || !COMPAT_MODES.includes(mode)) {
|
|
54
|
-
|
|
96
|
+
const current = readCompatMode()
|
|
97
|
+
return { ok: false, mode: current.mode, source: current.source, changed: false, error: `未知模式 ${String(mode)}(可选:${COMPAT_MODES.join(' / ')})` }
|
|
98
|
+
}
|
|
99
|
+
const source = typeof meta.source === 'string' && MODE_SOURCES.includes(meta.source) ? meta.source : DEFAULT_MODE_SOURCE
|
|
100
|
+
const reason = typeof meta.reason === 'string' ? meta.reason : null
|
|
101
|
+
const current = readCompatMode()
|
|
102
|
+
if (current.mode === mode && current.source === source && current.reason === reason && current.at !== null) {
|
|
103
|
+
return { ok: true, mode, source, changed: false }
|
|
55
104
|
}
|
|
56
|
-
writeJson(modeFile(), { mode, at: Date.now(),
|
|
57
|
-
return { ok: true, mode }
|
|
105
|
+
writeJson(modeFile(), { mode, at: Date.now(), source, reason })
|
|
106
|
+
return { ok: true, mode, source, changed: true }
|
|
58
107
|
}
|
|
59
108
|
|
|
60
109
|
/**
|
|
@@ -124,4 +173,4 @@ function readRollbackRecord() {
|
|
|
124
173
|
}
|
|
125
174
|
}
|
|
126
175
|
|
|
127
|
-
export { COMPAT_MODES, DEFAULT_MODE, compareFingerprint, computeFingerprint, readCompatMode, readFingerprint, readRollbackRecord, stampFingerprint, writeCompatMode }
|
|
176
|
+
export { AUTO_MODE_SOURCES, COMPAT_MODES, DEFAULT_MODE, DEFAULT_MODE_SOURCE, MODE_SOURCES, compareFingerprint, computeFingerprint, planDesktopHostSwitch, readCompatMode, readFingerprint, readRollbackRecord, stampFingerprint, writeCompatMode }
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// L1 · domain —— compat.js(框架兼容判定与适配门:设置 API 扫描 / 兼容门读写 / 待适配记录 / 隔离记录合并的前置部分 / 启动失败分析;分层 Step 6 从 lib/index.js 搬出,只搬移未改逻辑。注:detectCompat 等吃运行上下文的留到 Step 8)
|
|
2
2
|
// 分层分组:L1 · domain(边界由 tests/test-architecture-guard.mjs 断言)
|
|
3
3
|
|
|
4
|
-
import { readFileSync, writeFileSync,
|
|
4
|
+
import { readFileSync, writeFileSync, mkdirSync, realpathSync } from 'node:fs'
|
|
5
5
|
import { readFile } from 'node:fs/promises'
|
|
6
6
|
import { spawn } from 'node:child_process'
|
|
7
7
|
import { dirname, join, sep, resolve } from 'node:path'
|
|
@@ -13,6 +13,9 @@ import { listEntries } from './runtime.js'
|
|
|
13
13
|
import { fetchJsonUrl } from '../infra/http.js'
|
|
14
14
|
import { dshHome, findPatchPath, pluginRoot, profileDirOf, resolvePackageJson } from '../infra/paths.js'
|
|
15
15
|
import { semverRangeMatch, semverRangeMatchLoose } from '../infra/semver.js'
|
|
16
|
+
// 0.5.33 改错:已删除 API 的源码扫描搬进独立 domain 模块(判据收紧为"真的从 dsh-settings 绑定进来")。
|
|
17
|
+
// 这三个名字继续由本模块导出,对外契约不变。
|
|
18
|
+
import { REMOVED_SETTINGS_SYMBOLS, referencesRemovedSymbol, scanSettingsApiUsage } from './settings-api-scan.js'
|
|
16
19
|
|
|
17
20
|
/** Cordis Fiber 状态映射(与 dsh-host-plugin-inventory 一致)。 */
|
|
18
21
|
const FIBER_STATE = { PENDING: 0, LOADING: 1, ACTIVE: 2, FAILED: 3, DISPOSED: 4, UNLOADING: 5 }
|
|
@@ -26,63 +29,6 @@ const FIBER_PHASE = {
|
|
|
26
29
|
[FIBER_STATE.UNLOADING]: 'unloading',
|
|
27
30
|
}
|
|
28
31
|
|
|
29
|
-
/**
|
|
30
|
-
* 扫描已安装包源码,检测对已删除的 dsh-settings API 的引用
|
|
31
|
-
* (2026-09-04 教训:0.1.2-rc.1 起 settingsNamespace / installSettingsSection 已删除,
|
|
32
|
-
* 静态声明检查判 pass 是假通过;真实兼容性只有模块 import 时见分晓,而 loader 单行失败
|
|
33
|
-
* = 整个服务启动崩溃)。返回命中的符号列表,空数组 = 干净。
|
|
34
|
-
*/
|
|
35
|
-
const REMOVED_SETTINGS_SYMBOLS = ['settingsNamespace', 'installSettingsSection']
|
|
36
|
-
|
|
37
|
-
/** 源码是否**引用**了已删除符号——而不是「恰好包含同名前缀的其它标识符」或「自己定义的同名局部函数」。
|
|
38
|
-
* v0.3.35 修复(真机演练抓到的误伤):原先用 `text.includes('settingsNamespace')` 子串匹配,
|
|
39
|
-
* 而框架自带的 @deepseek-ai/dsh-api-settings-controller 里有个 `settingsNamespaceRequestSchema`,
|
|
40
|
-
* 于是框架自己的 settings 控制器被判「引用已删除 API」→ 升级预扫会把它自动禁用(砍掉框架功能)。
|
|
41
|
-
* 现在要求标识符边界,且排除「本地 const/let/var/function/class 定义且同行没有 dsh-settings 引用」。 */
|
|
42
|
-
function referencesRemovedSymbol(text, sym) {
|
|
43
|
-
const re = new RegExp(`(?<![A-Za-z0-9_$])${sym}(?![A-Za-z0-9_$])`, 'u')
|
|
44
|
-
for (const line of text.split(/\r?\n/u)) {
|
|
45
|
-
const m = re.exec(line)
|
|
46
|
-
if (m === null) continue
|
|
47
|
-
const before = line.slice(0, m.index)
|
|
48
|
-
const localDefinition = /(?:^|[^\w$])(?:const|let|var|function|class|async\s+function)\s*$/u.test(before)
|
|
49
|
-
if (localDefinition && !/dsh-settings/u.test(line)) continue
|
|
50
|
-
return true
|
|
51
|
-
}
|
|
52
|
-
return false
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function scanSettingsApiUsage(pkgDir) {
|
|
56
|
-
const found = []
|
|
57
|
-
const seen = new Set()
|
|
58
|
-
const walk = (dir, depth) => {
|
|
59
|
-
if (depth > 3) return
|
|
60
|
-
let entries = []
|
|
61
|
-
try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return }
|
|
62
|
-
for (const entry of entries) {
|
|
63
|
-
const full = join(dir, entry.name)
|
|
64
|
-
if (entry.isDirectory()) {
|
|
65
|
-
if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue
|
|
66
|
-
walk(full, depth + 1)
|
|
67
|
-
continue
|
|
68
|
-
}
|
|
69
|
-
if (!entry.isFile() || !/\.(?:js|mjs|cjs)$/u.test(entry.name)) continue
|
|
70
|
-
if (seen.has(full)) continue
|
|
71
|
-
seen.add(full)
|
|
72
|
-
if (seen.size > 120) return
|
|
73
|
-
try {
|
|
74
|
-
const text = readFileSync(full, 'utf8').slice(0, 400000)
|
|
75
|
-
for (const sym of REMOVED_SETTINGS_SYMBOLS) {
|
|
76
|
-
if (found.includes(sym)) continue
|
|
77
|
-
if (referencesRemovedSymbol(text, sym)) found.push(sym)
|
|
78
|
-
}
|
|
79
|
-
} catch {}
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
walk(pkgDir, 0)
|
|
83
|
-
return found
|
|
84
|
-
}
|
|
85
|
-
|
|
86
32
|
/** 包是否由框架自带(解析到 profile 目录之外,如 npx/pnpm 缓存里的 @deepseek-ai/*)。
|
|
87
33
|
* v0.3.35:这类行随框架一起发布,禁用**不是**正确处置(正确处置是回滚框架),
|
|
88
34
|
* 而且一旦判定有误就会把框架功能砍掉——所以升级预扫永不自动禁用它们。 */
|
|
@@ -194,10 +140,13 @@ function writeCompatPending(payload) {
|
|
|
194
140
|
|
|
195
141
|
/** 兼容门总开关(用户定案 2026-09-11):用户可关掉自动行为,回到纯手动。
|
|
196
142
|
* autoDisable —— 升级前是否自动禁用判定不适配的行(关掉 = 只提示不动开关)
|
|
197
|
-
* autoDetect —— 打开控制台时是否自动检测「已适配」(关掉 = 不显示可解锁提示)
|
|
143
|
+
* autoDetect —— 打开控制台时是否自动检测「已适配」(关掉 = 不显示可解锁提示)
|
|
144
|
+
* autoDisableOnEvidence —— 0.5.32 加法 D5:**指纹变化自动预检 / 启动失败记录**给出**确证证据**时
|
|
145
|
+
* 是否自动写 `disabled: true`(默认启用 = 按用户意图;关掉 = 只报告 + 等用户点「只禁可疑行」)。
|
|
146
|
+
* 它与 autoDisable 是两个独立开关:后者管"我们升级时",前者管"别人升级 / 启动失败时"。 */
|
|
198
147
|
const COMPAT_GATE_FILE = () => join(dshHome(), 'plugin-console', 'compat-gate.json')
|
|
199
148
|
|
|
200
|
-
const COMPAT_GATE_DEFAULTS = { autoDisable: true, autoDetect: true, noticeMode: 'once-per-version' }
|
|
149
|
+
const COMPAT_GATE_DEFAULTS = { autoDisable: true, autoDetect: true, autoDisableOnEvidence: true, noticeMode: 'once-per-version' }
|
|
201
150
|
|
|
202
151
|
function readCompatGate() {
|
|
203
152
|
try {
|
|
@@ -205,6 +154,7 @@ function readCompatGate() {
|
|
|
205
154
|
return {
|
|
206
155
|
autoDisable: raw?.autoDisable !== false,
|
|
207
156
|
autoDetect: raw?.autoDetect !== false,
|
|
157
|
+
autoDisableOnEvidence: raw?.autoDisableOnEvidence !== false,
|
|
208
158
|
// 兼容提示的展示模式(2026-09-28 加法):非法/缺失一律回落默认,**不影响 supported 判定**
|
|
209
159
|
noticeMode: NOTICE_MODES.includes(raw?.noticeMode) ? raw.noticeMode : DEFAULT_NOTICE_MODE,
|
|
210
160
|
}
|