@noob-stupid/dsh-plugin-console 0.5.71 → 0.5.85
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 +269 -25
- package/lib/server/domain/disk-audit.js +330 -0
- package/lib/server/domain/enrich-throttle.js +242 -0
- package/lib/server/domain/install-finalize.js +3 -1
- package/lib/server/domain/install-job.js +5 -5
- package/lib/server/domain/install-jobs-store.js +69 -0
- package/lib/server/domain/install.js +1 -1
- package/lib/server/domain/lock-autofix.js +310 -0
- package/lib/server/domain/manifest.js +15 -1
- package/lib/server/domain/market.js +13 -7
- package/lib/server/domain/persist.js +123 -3
- package/lib/server/domain/restart-launcher.js +110 -0
- package/lib/server/domain/restart.js +84 -10
- package/lib/server/domain/skills.js +9 -5
- package/lib/server/domain/update-candidates.js +25 -5
- package/lib/server/infra/egress.js +34 -1
- package/lib/server/routes/disk.js +59 -0
- package/lib/server/routes/framework.js +63 -2
- package/lib/server/routes/index.js +8 -0
- package/lib/server/routes/install.js +6 -0
- package/lib/server/routes/revoke-force.js +35 -0
- package/lib/server/routes/state.js +32 -2
- package/package.json +1 -1
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
// L1 · domain —— disk-audit.js(`<DSH_HOME>/plugin-console` 的**只读体检** + **用户确认后**的清理;0.5.84 加法)
|
|
2
|
+
// 分层分组:L1 · domain(不认识 cordis ctx;读盘/删除全部可注入)
|
|
3
|
+
//
|
|
4
|
+
// ── 为什么(用户 2026-10 第 7 项)────────────────────────────────────────────────
|
|
5
|
+
// 这个目录涨到 2.1 GB:`framework-backups/` 里躺着历次框架升级前的**整棵树**(含全平台 office kit)。
|
|
6
|
+
// 用户要的是「先看清楚谁占的,再决定删什么」——**绝不自动删**。
|
|
7
|
+
//
|
|
8
|
+
// ── 本模块的死规矩(每一条都有测试盯着;违背任何一条都比不清理更糟)───────────────
|
|
9
|
+
// ① **只读体检**:`auditConsoleDir()` 只统计,绝不删、不写、不改任何东西。
|
|
10
|
+
// ② **候选分级**:每一项标 `level`:
|
|
11
|
+
// · 'safe' —— 删掉不影响任何功能(日志、旧自删守护留下的计数残渣、`*.bak-*` 状态备份、
|
|
12
|
+
// 本机测试残留 `persist-e2e-*`);
|
|
13
|
+
// · 'tradeoff' —— 删掉**会失去某种回退能力**(旧版本的框架备份、profile 快照备份)——
|
|
14
|
+
// 必须让人知道代价,仍然允许,但要说清楚;
|
|
15
|
+
// · 'protected' —— **绝不**列为可删:当前回滚点指向的检查点、当前框架版本的备份、
|
|
16
|
+
// 控制台自己的状态文件(compat-* / framework-state.json / install-jobs.json / sources…)。
|
|
17
|
+
// ⚠️ 真机实测(2026-10):最大的那 2.1 GB 正是 `framework-backups/0.2.0-rc.1` ——
|
|
18
|
+
// 而 `framework-rollback.json` 的 `checkpointDir` **就指向它**。所以"最大的那个"恰恰是**不能删**的:
|
|
19
|
+
// 删了等于把当前唯一的框架回滚点销毁。判据必须从回滚记录里读,不能按体积猜。
|
|
20
|
+
// ③ **边界**:只允许删 `<root>` 之内、且经 `planCleanup()` 白名单放行的项;任何绝对路径/`..`一律拒。
|
|
21
|
+
// ④ **必须显式确认**:`runCleanup({ confirm: true })` 才动;否则返回 plan("将删什么、能省多少")。
|
|
22
|
+
// ⑤ **删完读回核实**:删不干净就如实回报(本仓 fsx.js 记过:Windows 上 rmSync 会静默落空)。
|
|
23
|
+
import { existsSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs'
|
|
24
|
+
import { join, resolve, sep } from 'node:path'
|
|
25
|
+
|
|
26
|
+
/** 单次体检最多统计多少个文件(防止在异常目录上跑到天荒地老;超了如实标记 truncated)。 */
|
|
27
|
+
const MAX_FILES = 60000
|
|
28
|
+
/** 认为是"日志"的文件名。 */
|
|
29
|
+
const LOG_RE = /\.log$/iu
|
|
30
|
+
/** 状态备份(旧实现留下的 `.bak-<时间>`):删了只失去"那时候的状态"。 */
|
|
31
|
+
const STATE_BAK_RE = /\.bak-\d/iu
|
|
32
|
+
/** 旧实现留下的自删守护计数(0.5.84 的代码已经不再创建它们)。 */
|
|
33
|
+
const GUARD_RESIDUE_RE = /^restart-guard-\d+\.count$/iu
|
|
34
|
+
/** 本机测试残留(含旧实现留下的 console-restart.log)。 */
|
|
35
|
+
const TEST_RESIDUE_RE = /^(?:persist-e2e-|console-restart\.log$)/iu
|
|
36
|
+
/** 需要**按子项**展开的目录:判据要细到"哪一份备份",而不是整个目录一刀切。
|
|
37
|
+
* 为什么必须展开(真机 2026-10 实测):`framework-backups/` 里 2.1 GB 全在 0.2.0-rc.1 这一份,
|
|
38
|
+
* 而回滚点正好指向它;其余 9 份加起来不到 2 MB。若按顶层整目录判,整块只能标"受保护"⇒
|
|
39
|
+
* 用户永远清不掉那 9 份旧备份;展开成子项后,只有被回滚点引用的那份受保护,其余标 tradeoff。 */
|
|
40
|
+
const EXPAND_DIRS = new Set(['framework-backups', 'profile-sync-backups'])
|
|
41
|
+
/** 绝不能删的状态文件(名字精确匹配或前缀匹配)。 */
|
|
42
|
+
const PROTECTED_FILES = new Set([
|
|
43
|
+
'framework-rollback.json', 'framework-state.json', 'install-jobs.json', 'compat-pending.json',
|
|
44
|
+
'compat-gate.json', 'compat-mode.json', 'compat-ack.json', 'components.json', 'repo-land.json',
|
|
45
|
+
'preset-sources.json', 'auto-preflight.json', 'env-fingerprint.json', 'fw-quarantine.json',
|
|
46
|
+
'fw-quarantine-candidates.json', 'sources.json', 'ai-jobs.json', 'lock-autofix.json',
|
|
47
|
+
])
|
|
48
|
+
const PROTECTED_PREFIX = ['fw-upgrade-state.txt', 'link-self-sufficiency', 'source-specs', 'safe-boot', 'subdir-src']
|
|
49
|
+
|
|
50
|
+
/** 递归量体积(字节 + 文件数);只读,且带 MAX_FILES 上限。 */
|
|
51
|
+
function measure(dir, { readdir = readdirSync, stat = statSync, budget = { files: MAX_FILES } } = {}) {
|
|
52
|
+
let bytes = 0
|
|
53
|
+
let files = 0
|
|
54
|
+
let truncated = false
|
|
55
|
+
const walk = (current) => {
|
|
56
|
+
let entries = []
|
|
57
|
+
try {
|
|
58
|
+
entries = readdir(current, { withFileTypes: true })
|
|
59
|
+
} catch {
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
for (const entry of entries) {
|
|
63
|
+
if (budget.files <= 0) { truncated = true; return }
|
|
64
|
+
const full = join(current, entry.name)
|
|
65
|
+
if (entry.isDirectory()) {
|
|
66
|
+
walk(full)
|
|
67
|
+
continue
|
|
68
|
+
}
|
|
69
|
+
budget.files -= 1
|
|
70
|
+
files += 1
|
|
71
|
+
try {
|
|
72
|
+
bytes += stat(full).size
|
|
73
|
+
} catch {}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
walk(dir)
|
|
77
|
+
return { bytes, files, truncated }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** 当前框架回滚记录(读不到返回 null)——**最大的那份备份往往就是它指向的**。 */
|
|
81
|
+
function readRollbackPointer({ root, readFile = (p) => readFileSync(p, 'utf8') } = {}) {
|
|
82
|
+
try {
|
|
83
|
+
const rec = JSON.parse(readFile(join(root, 'framework-rollback.json')))
|
|
84
|
+
return {
|
|
85
|
+
from: typeof rec?.from === 'string' ? rec.from : null,
|
|
86
|
+
to: typeof rec?.to === 'string' ? rec.to : null,
|
|
87
|
+
checkpointDir: typeof rec?.checkpointDir === 'string' ? rec.checkpointDir : null,
|
|
88
|
+
cliBackupDir: typeof rec?.cliBackupDir === 'string' ? rec.cliBackupDir : null,
|
|
89
|
+
at: typeof rec?.at === 'number' ? rec.at : null,
|
|
90
|
+
}
|
|
91
|
+
} catch {
|
|
92
|
+
return null
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const mb = (bytes) => Math.round((bytes / 1024 / 1024) * 100) / 100
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* **只读体检**:返回
|
|
100
|
+
* `{ ok, root, totalBytes, totalFiles, truncated, rollback, entries:[{ id, name, kind, bytes, files, level, reason }], reclaimableBytes, safeBytes }`。
|
|
101
|
+
* `entries` 按体积降序;`level` 见文件头 ②。任何一项都不删、不写。
|
|
102
|
+
*/
|
|
103
|
+
function auditConsoleDir({ root, deps = {} } = {}) {
|
|
104
|
+
const base = typeof root === 'string' && root !== '' ? resolve(root) : null
|
|
105
|
+
if (base === null) return { ok: false, error: '拿不到 plugin-console 目录', entries: [], totalBytes: 0, totalFiles: 0, truncated: false, rollback: null, reclaimableBytes: 0, safeBytes: 0 }
|
|
106
|
+
if (!existsSync(base)) return { ok: true, root: base, totalBytes: 0, totalFiles: 0, truncated: false, rollback: null, entries: [], reclaimableBytes: 0, safeBytes: 0, note: '目录还不存在(没产生过备份/日志)' }
|
|
107
|
+
const readdir = deps.readdir ?? readdirSync
|
|
108
|
+
const stat = deps.stat ?? statSync
|
|
109
|
+
const rollback = readRollbackPointer({ root: base, ...(deps.readFile === undefined ? {} : { readFile: deps.readFile }) })
|
|
110
|
+
const budget = { files: MAX_FILES }
|
|
111
|
+
let names = []
|
|
112
|
+
try {
|
|
113
|
+
names = readdir(base)
|
|
114
|
+
} catch (error) {
|
|
115
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error), entries: [], totalBytes: 0, totalFiles: 0, truncated: false, rollback, reclaimableBytes: 0, safeBytes: 0 }
|
|
116
|
+
}
|
|
117
|
+
const entries = []
|
|
118
|
+
let totalBytes = 0
|
|
119
|
+
let totalFiles = 0
|
|
120
|
+
let truncated = false
|
|
121
|
+
for (const name of names) {
|
|
122
|
+
const full = join(base, name)
|
|
123
|
+
let isDir = false
|
|
124
|
+
let fileBytes = 0
|
|
125
|
+
try {
|
|
126
|
+
const st = stat(full)
|
|
127
|
+
isDir = st.isDirectory()
|
|
128
|
+
if (!isDir) fileBytes = st.size
|
|
129
|
+
} catch {
|
|
130
|
+
continue
|
|
131
|
+
}
|
|
132
|
+
if (isDir && EXPAND_DIRS.has(name)) {
|
|
133
|
+
// 展开成子项(见 EXPAND_DIRS 的注释:判据要细到"哪一份备份")
|
|
134
|
+
let children = []
|
|
135
|
+
try {
|
|
136
|
+
children = readdir(full)
|
|
137
|
+
} catch {
|
|
138
|
+
children = []
|
|
139
|
+
}
|
|
140
|
+
for (const child of children) {
|
|
141
|
+
const childFull = join(full, child)
|
|
142
|
+
let childIsDir = false
|
|
143
|
+
try {
|
|
144
|
+
childIsDir = stat(childFull).isDirectory()
|
|
145
|
+
} catch {
|
|
146
|
+
continue
|
|
147
|
+
}
|
|
148
|
+
const m = childIsDir ? measure(childFull, { readdir, stat, budget }) : { bytes: stat(childFull).size, files: 1, truncated: false }
|
|
149
|
+
if (m.truncated) truncated = true
|
|
150
|
+
totalBytes += m.bytes
|
|
151
|
+
totalFiles += m.files
|
|
152
|
+
entries.push({ id: `${name}/${child}`, name: child, parent: name, full: childFull, isDir: childIsDir, bytes: m.bytes, files: m.files })
|
|
153
|
+
}
|
|
154
|
+
continue
|
|
155
|
+
}
|
|
156
|
+
let bytes = fileBytes
|
|
157
|
+
let files = isDir ? 0 : 1
|
|
158
|
+
if (isDir) {
|
|
159
|
+
const m = measure(full, { readdir, stat, budget })
|
|
160
|
+
bytes = m.bytes
|
|
161
|
+
files = m.files
|
|
162
|
+
if (m.truncated) truncated = true
|
|
163
|
+
}
|
|
164
|
+
totalBytes += bytes
|
|
165
|
+
totalFiles += files
|
|
166
|
+
entries.push({ id: name, name, parent: null, full, isDir, bytes, files })
|
|
167
|
+
}
|
|
168
|
+
// ── 分级(唯一判据;面板与清理都不许另判一套)──────────────────────────────────
|
|
169
|
+
const inRollback = []
|
|
170
|
+
for (const p of [rollback?.checkpointDir, rollback?.cliBackupDir]) {
|
|
171
|
+
if (typeof p === 'string' && p !== '') inRollback.push(resolve(p))
|
|
172
|
+
}
|
|
173
|
+
const classify = (entry) => {
|
|
174
|
+
const { name, full, isDir, parent } = entry
|
|
175
|
+
// ① 回滚点指向的东西:绝不列为可删(真机实测:最大的 2.1 GB 就是这个)
|
|
176
|
+
if (inRollback.some((p) => resolve(full) === p || resolve(full) === resolve(p, '..') || p.startsWith(resolve(full) + sep))) {
|
|
177
|
+
return { level: 'protected', reason: '当前框架回滚点(framework-rollback.json)指向这里 —— 删掉就等于销毁唯一的回滚点' }
|
|
178
|
+
}
|
|
179
|
+
// ② 纯垃圾类(**先于**状态文件判据:`fw-upgrade-state.txt.bak-<时间>` 是那份状态的旧备份,
|
|
180
|
+
// 删它不丢当前状态;而 `fw-upgrade-state.txt` 本身仍然受下面的前缀保护)
|
|
181
|
+
if (!isDir && (LOG_RE.test(name) || GUARD_RESIDUE_RE.test(name) || STATE_BAK_RE.test(name) || TEST_RESIDUE_RE.test(name))) {
|
|
182
|
+
return { level: 'safe', reason: LOG_RE.test(name) ? '日志:删了只是丢历史输出'
|
|
183
|
+
: GUARD_RESIDUE_RE.test(name) ? '旧实现自删守护留下的计数残渣(现在的代码已不创建)'
|
|
184
|
+
: STATE_BAK_RE.test(name) ? '旧状态文件的 .bak 备份(当前状态文件不在清理范围)'
|
|
185
|
+
: '本机测试残留' }
|
|
186
|
+
}
|
|
187
|
+
if (isDir && TEST_RESIDUE_RE.test(name)) return { level: 'safe', reason: '本机测试残留' }
|
|
188
|
+
// ③ 控制台状态:一律不许动
|
|
189
|
+
if (PROTECTED_FILES.has(name) || PROTECTED_PREFIX.some((p) => name === p || name.startsWith(p))) {
|
|
190
|
+
return { level: 'protected', reason: '控制台状态文件/受保护目录(删了会丢配置、记录或已安装子目录插件的源码)' }
|
|
191
|
+
}
|
|
192
|
+
// ④ 展开出来的子项(framework-backups/<版本> / profile-sync-backups/<快照>)
|
|
193
|
+
if (parent === 'framework-backups') {
|
|
194
|
+
return { level: 'tradeoff', reason: '某一版本框架升级前的整棵树备份:删掉会失去回滚到该版本的能力(当前回滚点引用的那一份不在此列)' }
|
|
195
|
+
}
|
|
196
|
+
if (parent === 'profile-sync-backups') {
|
|
197
|
+
return { level: 'tradeoff', reason: '某一次 profile 快照备份:删掉会失去那一次的快照,不影响当前运行' }
|
|
198
|
+
}
|
|
199
|
+
if (isDir && (name === 'backups' || /^pre-upgrade-snapshot-/u.test(name))) {
|
|
200
|
+
return { level: 'tradeoff', reason: '历史快照备份:删掉会失去那几次的快照,不影响当前运行' }
|
|
201
|
+
}
|
|
202
|
+
if (isDir && name === 'release-0.5.38') {
|
|
203
|
+
return { level: 'tradeoff', reason: '旧版本的发布物副本:不影响当前运行' }
|
|
204
|
+
}
|
|
205
|
+
return { level: 'protected', reason: '未知/状态类条目:不在清理白名单里(宁可不删)' }
|
|
206
|
+
}
|
|
207
|
+
const graded = entries.map((entry) => {
|
|
208
|
+
const g = classify(entry)
|
|
209
|
+
return {
|
|
210
|
+
id: entry.id,
|
|
211
|
+
name: entry.name,
|
|
212
|
+
parent: entry.parent ?? null,
|
|
213
|
+
kind: entry.isDir ? 'dir' : 'file',
|
|
214
|
+
mb: mb(entry.bytes),
|
|
215
|
+
bytes: entry.bytes,
|
|
216
|
+
files: entry.files,
|
|
217
|
+
level: g.level,
|
|
218
|
+
reason: g.reason,
|
|
219
|
+
}
|
|
220
|
+
}).sort((a, b) => b.bytes - a.bytes)
|
|
221
|
+
const reclaimable = graded.filter((e) => e.level === 'safe' || e.level === 'tradeoff')
|
|
222
|
+
const safe = graded.filter((e) => e.level === 'safe')
|
|
223
|
+
return {
|
|
224
|
+
ok: true,
|
|
225
|
+
root: base,
|
|
226
|
+
totalBytes,
|
|
227
|
+
totalFiles,
|
|
228
|
+
totalMb: mb(totalBytes),
|
|
229
|
+
truncated,
|
|
230
|
+
rollback,
|
|
231
|
+
entries: graded,
|
|
232
|
+
reclaimableBytes: reclaimable.reduce((sum, e) => sum + e.bytes, 0),
|
|
233
|
+
reclaimableMb: mb(reclaimable.reduce((sum, e) => sum + e.bytes, 0)),
|
|
234
|
+
safeBytes: safe.reduce((sum, e) => sum + e.bytes, 0),
|
|
235
|
+
safeMb: mb(safe.reduce((sum, e) => sum + e.bytes, 0)),
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* 清理**计划**(纯函数,不删任何东西):把用户点名的 id 过一遍白名单与边界。
|
|
241
|
+
* @returns {{ ok, plan:[{id,bytes,level}], refused:[{id,reason}], freedBytes, note }}
|
|
242
|
+
* · 只接受 `audit.entries` 里出现过的 id;`protected` 一律拒绝并说明为什么;
|
|
243
|
+
* · 任何包含 `/` `\` `..` 的 id 直接拒绝(路径穿越)。
|
|
244
|
+
*/
|
|
245
|
+
function planCleanup({ audit, ids } = {}) {
|
|
246
|
+
const list = Array.isArray(ids) ? ids : (typeof ids === 'string' && ids !== '' ? [ids] : [])
|
|
247
|
+
const byName = new Map((Array.isArray(audit?.entries) ? audit.entries : []).map((e) => [e.id, e]))
|
|
248
|
+
const plan = []
|
|
249
|
+
const refused = []
|
|
250
|
+
for (const raw of list) {
|
|
251
|
+
const id = typeof raw === 'string' ? raw.trim() : ''
|
|
252
|
+
if (id === '') continue
|
|
253
|
+
// 允许 `name` 或 `父目录/子项`(只展开一层:framework-backups/<版本>、profile-sync-backups/<快照>);
|
|
254
|
+
// 任何 `..`、绝对路径、多级路径一律拒 —— 删东西的口子越小越好。
|
|
255
|
+
if (id === '..' || id === '.' || id.startsWith('/') || /^[A-Za-z]:/u.test(id) || (id.match(/\//gu) ?? []).length > 1 || /\\/u.test(id) || id.includes('..')) {
|
|
256
|
+
refused.push({ id, reason: '不是本目录下的条目名(拒绝路径穿越)' })
|
|
257
|
+
continue
|
|
258
|
+
}
|
|
259
|
+
const entry = byName.get(id)
|
|
260
|
+
if (entry === undefined) { refused.push({ id, reason: '体检结果里没有这一项(请先做一次体检)' }); continue }
|
|
261
|
+
if (entry.level === 'protected') { refused.push({ id, reason: entry.reason }); continue }
|
|
262
|
+
plan.push({ id, bytes: entry.bytes, mb: entry.mb, level: entry.level, reason: entry.reason })
|
|
263
|
+
}
|
|
264
|
+
return {
|
|
265
|
+
ok: refused.length === 0 && plan.length > 0,
|
|
266
|
+
plan,
|
|
267
|
+
refused,
|
|
268
|
+
freedBytes: plan.reduce((sum, p) => sum + p.bytes, 0),
|
|
269
|
+
freedMb: mb(plan.reduce((sum, p) => sum + p.bytes, 0)),
|
|
270
|
+
note: plan.length === 0 ? (refused.length === 0 ? '没有指定要清理的条目' : '所有指定条目都被拒绝了') : null,
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* **真的删**(只有在 `confirm === true` 时才会动):删前再过一次 planCleanup,删后读回核实。
|
|
276
|
+
* @returns {{ ok, applied:boolean, removed:[], failed:[{id,reason}], needsConfirm:boolean, freedBytes, freedMb, plan }}
|
|
277
|
+
*/
|
|
278
|
+
function runCleanup({ root, audit, ids, confirm = false, deps = {} } = {}) {
|
|
279
|
+
const exists = deps.exists ?? existsSync
|
|
280
|
+
const remove = deps.remove ?? ((p) => rmSync(p, { recursive: true, force: false }))
|
|
281
|
+
const planResult = planCleanup({ audit, ids })
|
|
282
|
+
const base = typeof root === 'string' && root !== '' ? resolve(root) : (typeof audit?.root === 'string' ? resolve(audit.root) : null)
|
|
283
|
+
if (base === null) return { ok: false, applied: false, needsConfirm: false, removed: [], failed: [], freedBytes: 0, freedMb: 0, plan: planResult, error: '拿不到 plugin-console 目录' }
|
|
284
|
+
if (confirm !== true) {
|
|
285
|
+
// 没确认 ⇒ 只回计划(面板据此渲染"确认清理"按钮),**一个文件都不动**
|
|
286
|
+
return { ok: false, applied: false, needsConfirm: true, removed: [], failed: [], freedBytes: 0, freedMb: 0, plan: planResult, note: '需要显式确认(confirm:true)才会删除;未确认前后一个字都没动' }
|
|
287
|
+
}
|
|
288
|
+
const removed = []
|
|
289
|
+
const failed = []
|
|
290
|
+
let freed = 0
|
|
291
|
+
for (const item of planResult.plan) {
|
|
292
|
+
const target = resolve(base, item.id)
|
|
293
|
+
// 边界:目标必须在 root 之内(resolve 过,`..` 已经被 planCleanup 挡掉,这里是第二道)
|
|
294
|
+
if (!target.startsWith(resolve(base) + sep)) {
|
|
295
|
+
failed.push({ id: item.id, reason: '目标不在 plugin-console 目录内(拒绝删除)' })
|
|
296
|
+
continue
|
|
297
|
+
}
|
|
298
|
+
try {
|
|
299
|
+
remove(target)
|
|
300
|
+
} catch (error) {
|
|
301
|
+
failed.push({ id: item.id, reason: error instanceof Error ? error.message : String(error) })
|
|
302
|
+
continue
|
|
303
|
+
}
|
|
304
|
+
// 读回核实:Windows 上 rmSync 可能静默落空(infra/fsx.js 记过这类事)
|
|
305
|
+
if (exists(target)) {
|
|
306
|
+
failed.push({ id: item.id, reason: '删除后目录仍在(静默落空)' })
|
|
307
|
+
continue
|
|
308
|
+
}
|
|
309
|
+
removed.push({ id: item.id, bytes: item.bytes, mb: item.mb })
|
|
310
|
+
freed += item.bytes
|
|
311
|
+
}
|
|
312
|
+
return {
|
|
313
|
+
ok: failed.length === 0 && removed.length > 0,
|
|
314
|
+
applied: true,
|
|
315
|
+
needsConfirm: false,
|
|
316
|
+
removed,
|
|
317
|
+
failed,
|
|
318
|
+
freedBytes: freed,
|
|
319
|
+
freedMb: mb(freed),
|
|
320
|
+
plan: planResult,
|
|
321
|
+
note: failed.length === 0
|
|
322
|
+
? `已清理 ${removed.length} 项,释放约 ${mb(freed)} MB`
|
|
323
|
+
: `清理了 ${removed.length} 项(约 ${mb(freed)} MB),但有 ${failed.length} 项没清干净(原因见 failed)`,
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export {
|
|
328
|
+
GUARD_RESIDUE_RE, LOG_RE, MAX_FILES, PROTECTED_FILES, PROTECTED_PREFIX, STATE_BAK_RE, TEST_RESIDUE_RE,
|
|
329
|
+
auditConsoleDir, measure, planCleanup, readRollbackPointer, runCleanup,
|
|
330
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
// L1 · domain —— enrich-throttle.js(市场卡片「富化请求」的**唯一出口闸**;0.5.84 加法)
|
|
2
|
+
// 分层分组:L1 · domain(边界由 tests/test-architecture-guard.mjs 断言;不认识 cordis ctx)
|
|
3
|
+
//
|
|
4
|
+
// ── 为什么要有这一层(真机症状)──────────────────────────────────────────────────
|
|
5
|
+
// 一次打开市场面板,服务端会对**每一条**卡片发出下面这些请求(domain/market.js#enrichItemOne):
|
|
6
|
+
// · package.json:curl 直连 + ghproxy 镜像(Promise.any 两条同时发)
|
|
7
|
+
// · SKILL.md :curl 直连 + ghproxy + jsDelivr(三条同时发,见 domain/skills.js#detectSkillRepo)
|
|
8
|
+
// · .gitmodules :rawTextFetch 的四条通道竞速(https / gh / curl / jsDelivr)
|
|
9
|
+
// ⇒ 单条卡片最坏 ≈ 9 个并发请求,而 enrichItems 的 worker 数是 12 ⇒ 面板一打开就是 ≈100 个连接
|
|
10
|
+
// 同时抢出去;raw.githubusercontent 在这个量级下几乎全部 ERR_TIMED_OUT / 404,
|
|
11
|
+
// 网络被占满,安装作业(pnpm / clone)跟着卡 —— 用户看到的是「市场一直在转圈」。
|
|
12
|
+
//
|
|
13
|
+
// ── 这一层干什么(四条,缺一条都还会打满网络)────────────────────────────────────
|
|
14
|
+
// ① **并发上限**:所有富化请求共用**一个** limiter(默认 4)。不是每个调用点各写一份
|
|
15
|
+
// Promise.all —— 那样上限会随调用点数量相乘而失效。
|
|
16
|
+
// ② **单飞(single-flight)**:同一个 URL 同时只发一次。市场面板 + 详情卡片 + 搜索并发打开时,
|
|
17
|
+
// 它们问的是同一批 URL,重复发没有意义。
|
|
18
|
+
// ③ **负缓存(短期记忆)**:失败的 URL 10 分钟内**不再发请求**,直接短路失败。
|
|
19
|
+
// 这就是"不要反复重试同一个失败 URL"——黑洞期里重试只会继续吃掉并发额度。
|
|
20
|
+
// ④ **短缓存**:成功结果缓存 5 分钟(富化判定本身另有 24h/1h 缓存,见 market.js)。
|
|
21
|
+
// 短缓存只解决"同一批卡片在几分钟内被反复打开"。
|
|
22
|
+
// 另:**单次超时不许长**。富化是"锦上添花"的判定,不该占用 10s 的抓取预算(那 10s 是给
|
|
23
|
+
// 安装路径上"必须读到清单"的场景的);本模块把富化探针的预算统一压到 4s。
|
|
24
|
+
//
|
|
25
|
+
// ── 语义边界(重要)──────────────────────────────────────────────────────────────
|
|
26
|
+
// · 本模块**只做闸门与记忆**,不做判定:成功/失败/404 的解释权仍在各通道自己手里。
|
|
27
|
+
// · `throttledCurlText` / `throttledCurlJson` **失败仍然抛错**(与裸实现一致)—— 上游用
|
|
28
|
+
// `Promise.any` / `allSettled` 写的分支逻辑一个字都不用改;负缓存命中时也抛(只是不发请求)。
|
|
29
|
+
// · `throttledRawText` **失败仍然返回 null**(与 rawTextWithFallback 一致)。
|
|
30
|
+
// · 生产不注入任何东西就是真实现(默认值直接来自 infra/http.js,与全仓出网单点同一套)。
|
|
31
|
+
import { FETCH_OK, curlJson, curlText, rawTextFetch } from '../infra/http.js'
|
|
32
|
+
|
|
33
|
+
/** 并发上限(真机结论:4 条就够快,更多只会把网络打满)。 */
|
|
34
|
+
const ENRICH_CONCURRENCY = 4
|
|
35
|
+
/** 失败记忆窗口:这段时间内同一个 key 不再发请求。 */
|
|
36
|
+
const FAILURE_COOLDOWN_MS = 10 * 60 * 1000
|
|
37
|
+
/** 成功短缓存:同一批卡片在几分钟内重复打开不再打网络。 */
|
|
38
|
+
const SHORT_CACHE_TTL_MS = 5 * 60 * 1000
|
|
39
|
+
/** 富化探针的单次预算上限("不要把单次超时设得太长")。 */
|
|
40
|
+
const ENRICH_TIMEOUT_MS = 4000
|
|
41
|
+
/** 记忆表上限(防止长时间运行后无限增长;满了淘汰最旧的一批)。 */
|
|
42
|
+
const MEMORY_LIMIT = 4000
|
|
43
|
+
|
|
44
|
+
/** 计数限流器:`run(fn)` 在有空位时才真正执行 fn(队列 FIFO,绝不丢弃)。 */
|
|
45
|
+
function makeLimiter(max) {
|
|
46
|
+
const size = Number.isFinite(max) && max > 0 ? Math.floor(max) : 1
|
|
47
|
+
let active = 0
|
|
48
|
+
let peak = 0
|
|
49
|
+
const queue = []
|
|
50
|
+
const pump = () => {
|
|
51
|
+
while (active < size && queue.length > 0) {
|
|
52
|
+
const job = queue.shift()
|
|
53
|
+
active += 1
|
|
54
|
+
if (active > peak) peak = active
|
|
55
|
+
Promise.resolve()
|
|
56
|
+
.then(job.fn)
|
|
57
|
+
.then(job.resolve, job.reject)
|
|
58
|
+
.then(() => { active -= 1; pump() }, () => { active -= 1; pump() })
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
run(fn) {
|
|
63
|
+
return new Promise((resolve, reject) => { queue.push({ fn, resolve, reject }); pump() })
|
|
64
|
+
},
|
|
65
|
+
stats() { return { max: size, active, queued: queue.length, peak } },
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 记忆表:`Map` + 上限淘汰(只清最旧的一批,不整体清空 —— 否则上限一到就等于没有记忆)。 */
|
|
70
|
+
function boundedSet(map, key, value) {
|
|
71
|
+
if (map.size >= MEMORY_LIMIT) {
|
|
72
|
+
const drop = Math.max(1, Math.floor(MEMORY_LIMIT / 4))
|
|
73
|
+
let n = 0
|
|
74
|
+
for (const k of map.keys()) { map.delete(k); if (++n >= drop) break }
|
|
75
|
+
}
|
|
76
|
+
map.set(key, value)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── 模块级状态(一个进程一份闸门;`__resetEnrichThrottle()` 供单测还原)─────────────
|
|
80
|
+
const state = {
|
|
81
|
+
limiter: makeLimiter(ENRICH_CONCURRENCY),
|
|
82
|
+
inflight: new Map(), // key → Promise(单飞)
|
|
83
|
+
okCache: new Map(), // key → { at, value }
|
|
84
|
+
failCache: new Map(), // key → at(负缓存)
|
|
85
|
+
skipped: 0, // 被负缓存短路掉的请求数(面板诊断用)
|
|
86
|
+
ran: 0, // 真的发出去的请求数
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** 出网实现的注入缝(默认 = infra/http.js 的真实现;单测换成 tripwire 即可证明"一个请求都没发")。 */
|
|
90
|
+
let impl = null
|
|
91
|
+
/** 单测注入出口实现:传对象 = 打桩(缺项回落真实现);传 null = 还原。返回是否已注入。 */
|
|
92
|
+
function __setEnrichThrottleImpl(next) {
|
|
93
|
+
impl = next !== null && typeof next === 'object' ? next : null
|
|
94
|
+
return impl !== null
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** 清空全部记忆与在途记录(单测 & 诊断用;已在途的请求不会被取消)。 */
|
|
98
|
+
function __resetEnrichThrottle() {
|
|
99
|
+
state.limiter = makeLimiter(ENRICH_CONCURRENCY)
|
|
100
|
+
state.inflight.clear()
|
|
101
|
+
state.okCache.clear()
|
|
102
|
+
state.failCache.clear()
|
|
103
|
+
state.skipped = 0
|
|
104
|
+
state.ran = 0
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** 闸门现状(面板诊断 / 单测断言"并发确实被压住"用)。 */
|
|
108
|
+
function enrichThrottleStats() {
|
|
109
|
+
return {
|
|
110
|
+
...state.limiter.stats(),
|
|
111
|
+
inflight: state.inflight.size,
|
|
112
|
+
okCacheSize: state.okCache.size,
|
|
113
|
+
failCacheSize: state.failCache.size,
|
|
114
|
+
skipped: state.skipped,
|
|
115
|
+
ran: state.ran,
|
|
116
|
+
concurrencyLimit: ENRICH_CONCURRENCY,
|
|
117
|
+
failureCooldownMs: FAILURE_COOLDOWN_MS,
|
|
118
|
+
shortCacheTtlMs: SHORT_CACHE_TTL_MS,
|
|
119
|
+
timeoutMs: ENRICH_TIMEOUT_MS,
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** 占位错误:负缓存命中时用它短路(带上原因,别让上游以为是"文件不存在")。 */
|
|
124
|
+
function coolingDownError(key, waitMs) {
|
|
125
|
+
const error = new Error(`富化请求在冷却期(${Math.max(1, Math.round(waitMs / 1000))}s 内不再重试):${key}`)
|
|
126
|
+
error.code = 'ENRICH_COOLING_DOWN'
|
|
127
|
+
return error
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* 过闸执行一次富化请求(**唯一入口**)。
|
|
132
|
+
* @param {string} key 记忆键(同一份内容必须给出同一个 key)
|
|
133
|
+
* @param {() => Promise<any>} run 真请求(抛错 = 失败;返回值由 accept 判定算不算"读到了")
|
|
134
|
+
* @param {object} [opts]
|
|
135
|
+
* @param {(v:any) => boolean} [opts.accept] 什么算成功(默认"非空字符串")
|
|
136
|
+
* @param {number} [opts.okTtlMs] 成功短缓存时长
|
|
137
|
+
* @param {number} [opts.failTtlMs] 失败记忆时长
|
|
138
|
+
* @returns {Promise<any>} 成功值;冷却命中/失败由调用方包装成各自的语义
|
|
139
|
+
*/
|
|
140
|
+
async function throughGate(key, run, opts = {}) {
|
|
141
|
+
const accept = typeof opts.accept === 'function' ? opts.accept : (v) => typeof v === 'string' && v.trim() !== ''
|
|
142
|
+
const okTtlMs = Number.isFinite(opts.okTtlMs) ? opts.okTtlMs : SHORT_CACHE_TTL_MS
|
|
143
|
+
const failTtlMs = Number.isFinite(opts.failTtlMs) ? opts.failTtlMs : FAILURE_COOLDOWN_MS
|
|
144
|
+
const now = Date.now()
|
|
145
|
+
// ④ 短缓存命中
|
|
146
|
+
const cached = state.okCache.get(key)
|
|
147
|
+
if (cached !== undefined) {
|
|
148
|
+
if (now - cached.at < okTtlMs) return cached.value
|
|
149
|
+
state.okCache.delete(key)
|
|
150
|
+
}
|
|
151
|
+
// ③ 负缓存命中 → **不发请求**,直接按"这次也不会有"短路
|
|
152
|
+
const failedAt = state.failCache.get(key)
|
|
153
|
+
if (failedAt !== undefined) {
|
|
154
|
+
if (now - failedAt < failTtlMs) {
|
|
155
|
+
state.skipped += 1
|
|
156
|
+
throw coolingDownError(key, failTtlMs - (now - failedAt))
|
|
157
|
+
}
|
|
158
|
+
state.failCache.delete(key)
|
|
159
|
+
}
|
|
160
|
+
// ② 单飞:同一 key 的并发调用共用同一个 Promise
|
|
161
|
+
const running = state.inflight.get(key)
|
|
162
|
+
if (running !== undefined) return running
|
|
163
|
+
const task = state.limiter
|
|
164
|
+
.run(() => { state.ran += 1; return run() })
|
|
165
|
+
.then((value) => {
|
|
166
|
+
if (accept(value)) {
|
|
167
|
+
boundedSet(state.okCache, key, { at: Date.now(), value })
|
|
168
|
+
state.failCache.delete(key)
|
|
169
|
+
return value
|
|
170
|
+
}
|
|
171
|
+
boundedSet(state.failCache, key, Date.now())
|
|
172
|
+
throw new Error('富化请求未读到内容')
|
|
173
|
+
}, (error) => {
|
|
174
|
+
boundedSet(state.failCache, key, Date.now())
|
|
175
|
+
throw error
|
|
176
|
+
})
|
|
177
|
+
.finally(() => { state.inflight.delete(key) })
|
|
178
|
+
state.inflight.set(key, task)
|
|
179
|
+
return task
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** 预算归一:富化探针一律压到 ENRICH_TIMEOUT_MS 以内。 */
|
|
183
|
+
function budgetOf(timeoutMs) {
|
|
184
|
+
const wanted = Number.isFinite(timeoutMs) ? timeoutMs : ENRICH_TIMEOUT_MS
|
|
185
|
+
return Math.min(Math.max(wanted, 800), ENRICH_TIMEOUT_MS)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* 过闸的 `curlText`(**失败仍然抛错**,与裸实现同语义)。
|
|
190
|
+
* @param {string} url
|
|
191
|
+
* @param {number} [timeoutMs] 超过 ENRICH_TIMEOUT_MS 会被压到上限
|
|
192
|
+
*/
|
|
193
|
+
async function throttledCurlText(url, timeoutMs = ENRICH_TIMEOUT_MS, opts = {}) {
|
|
194
|
+
const real = typeof impl?.curlText === 'function' ? impl.curlText : curlText
|
|
195
|
+
return await throughGate(`text:${url}`, () => real(url, budgetOf(timeoutMs), opts.curlOpts ?? {}), opts)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** 过闸的 `curlJson`(**失败仍然抛错**)。 */
|
|
199
|
+
async function throttledCurlJson(url, timeoutMs = ENRICH_TIMEOUT_MS, opts = {}) {
|
|
200
|
+
const real = typeof impl?.curlJson === 'function' ? impl.curlJson : curlJson
|
|
201
|
+
return await throughGate(
|
|
202
|
+
`json:${url}`,
|
|
203
|
+
() => real(url, budgetOf(timeoutMs), opts.headers ?? {}),
|
|
204
|
+
{ ...opts, accept: opts.accept ?? ((v) => v !== null && typeof v === 'object') },
|
|
205
|
+
)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* 过闸的 raw 抓取(**失败仍然返回 null**,与 rawTextWithFallback 同语义)。
|
|
210
|
+
* 通道竞速仍在 infra/http.js(四条通道一条不删),只是**预算由这里压到 ENRICH_TIMEOUT_MS 以内**
|
|
211
|
+
* 并共用同一个闸门 —— 富化探针绝不允许占用安装路径那 10s 的抓取预算。
|
|
212
|
+
*/
|
|
213
|
+
async function throttledRawText(repo, branch, path, opts = {}) {
|
|
214
|
+
const real = typeof impl?.rawTextFetch === 'function' ? impl.rawTextFetch : rawTextFetch
|
|
215
|
+
const budget = budgetOf(opts.budgetMs)
|
|
216
|
+
try {
|
|
217
|
+
const text = await throughGate(`raw:${repo}@${branch}/${path}`, async () => {
|
|
218
|
+
const { state: st, body } = await real(repo, branch, path, budget)
|
|
219
|
+
return st === FETCH_OK ? body : null
|
|
220
|
+
}, opts)
|
|
221
|
+
return typeof text === 'string' ? text : null
|
|
222
|
+
} catch {
|
|
223
|
+
// 冷却期 / 抓取失败 / 404 → 都是"这次没读到",与 rawTextWithFallback 的 null 同义
|
|
224
|
+
return null
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export {
|
|
229
|
+
ENRICH_CONCURRENCY,
|
|
230
|
+
ENRICH_TIMEOUT_MS,
|
|
231
|
+
FAILURE_COOLDOWN_MS,
|
|
232
|
+
SHORT_CACHE_TTL_MS,
|
|
233
|
+
MEMORY_LIMIT,
|
|
234
|
+
makeLimiter,
|
|
235
|
+
throughGate,
|
|
236
|
+
throttledCurlText,
|
|
237
|
+
throttledCurlJson,
|
|
238
|
+
throttledRawText,
|
|
239
|
+
enrichThrottleStats,
|
|
240
|
+
__resetEnrichThrottle,
|
|
241
|
+
__setEnrichThrottleImpl,
|
|
242
|
+
}
|
|
@@ -32,7 +32,9 @@ async function finalizeInstallPersistence({
|
|
|
32
32
|
// 与运行时已有行都不算,旧代码在这两条路上也不设 entryId)——撤销安装的归属判定依赖这一点。
|
|
33
33
|
job.entryId = persist.after.parts.mount.via === 'patch-row' ? (persist.rowId ?? null) : null
|
|
34
34
|
job.persisted = persist.persisted
|
|
35
|
-
|
|
35
|
+
// 0.5.84 判据降噪:把「这次不一致会不会丢插件」的结论一并下发(面板据此决定报红还是降级提示)。
|
|
36
|
+
// 纯加法:missing / actual / note 三个老字段一字未变,老客户端忽略 severity 即可。
|
|
37
|
+
job.persist = { missing: persist.missing, actual: persist.after.actual, note: persist.note, severity: persist.after?.severity ?? persist.severity ?? null }
|
|
36
38
|
if (persist.persisted !== true) job.persistNote = persist.note
|
|
37
39
|
const persistSuggested = persist.suggested ?? null
|
|
38
40
|
if (persistSuggested !== null
|
|
@@ -1,7 +1,4 @@
|
|
|
1
1
|
// L1 · domain —— install-job.js(安装任务编排:多通道安装 + 套装补丁 + 适配门 + 收尾)。
|
|
2
|
-
// 分层 Step 8c-2 从 lib/index.js 搬出;形参由 ctx 收窄为 ports(调用方注入的窄接口),只搬移未改逻辑。
|
|
3
|
-
// 分层分组:L1 · domain(边界由 tests/test-architecture-guard.mjs 断言)
|
|
4
|
-
|
|
5
2
|
import { dirname, join, resolve } from 'node:path'
|
|
6
3
|
import { aiRepair } from './ai-run.js'
|
|
7
4
|
import { maybeAutoAdaptCompat } from './compat.js'
|
|
@@ -71,6 +68,9 @@ function channelImpls(ports) {
|
|
|
71
68
|
* 属无意义尝试)**并保留 !expanded**(同一作业里对同一个 job.repo 反复 clone 纯属浪费时间,
|
|
72
69
|
* 级联顺序也不该被破坏)。 */
|
|
73
70
|
async function tryCandidateChannels({ job, ch, name, profileDir, registries, repoChannelAllowed, budget, baseUrl = null, expanded = false, deadline = null, expand = null, sourceDeps = {} }) {
|
|
71
|
+
const installSpec = (typeof job?.version === 'string' && job.version !== '' && name === job.packageName)
|
|
72
|
+
? `${name}@${job.version}`
|
|
73
|
+
: name // ★ 0.5.81:规格与身份分离 —— name 仍是身份;点名包+指定版本时规格才带 @version
|
|
74
74
|
// 声明行的目标 profile(0.5.28):框架 0.1.7-rc.x 起预设不再靠目录发现,装配后必须往这个 profile 的补丁写 `preset-<id>` 行,否则用户看不见(见 preset-declare.js 顶部)。
|
|
75
75
|
const patchPath = join(profileDir, 'cordis.patch.yml')
|
|
76
76
|
let installedName = null
|
|
@@ -88,7 +88,7 @@ async function tryCandidateChannels({ job, ch, name, profileDir, registries, rep
|
|
|
88
88
|
// 通道 0:并行竞速(pnpm 与 curl 同时启动,先成功者生效;失败方 abort,不影响后续串行通道)——守卫①
|
|
89
89
|
{
|
|
90
90
|
try {
|
|
91
|
-
const raced = await ch.raceInstallChannels(profileDir,
|
|
91
|
+
const raced = await ch.raceInstallChannels(profileDir, installSpec, registries)
|
|
92
92
|
if (raced) {
|
|
93
93
|
installedName = name
|
|
94
94
|
if (raced.channel === 'curl') {
|
|
@@ -117,7 +117,7 @@ async function tryCandidateChannels({ job, ch, name, profileDir, registries, rep
|
|
|
117
117
|
let pnpmError = null
|
|
118
118
|
for (let ri = 0; ri < registries.length && installedName === null; ri += 1) {
|
|
119
119
|
try {
|
|
120
|
-
await ch.pnpmInstall(profileDir,
|
|
120
|
+
await ch.pnpmInstall(profileDir, installSpec, registries[ri])
|
|
121
121
|
installedName = name
|
|
122
122
|
break
|
|
123
123
|
} catch (error) { lastError = error; pnpmError = error }
|