@noob-stupid/dsh-plugin-console 0.5.77 → 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 +230 -20
- 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/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/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 +6 -0
- package/lib/server/routes/install.js +6 -0
- package/lib/server/routes/state.js +23 -1
- package/package.json +1 -1
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// L1 · domain —— 安装/更新作业的**持久化**(0.5.83)
|
|
2
|
+
//
|
|
3
|
+
// 今天排障最大的障碍:installJobs 是**内存表**,宿主一重启就清空 ——
|
|
4
|
+
// 用户点了更新、作业卡住或失败,重启后我**查不到它到底干了什么**(本次连"版本号有没有传下去"都只能靠猜)。
|
|
5
|
+
// 与「待重启徽章」同一个病根(那个已用 pending-restart.json 修好)。
|
|
6
|
+
//
|
|
7
|
+
// 本模块把**已结束**的作业(done / failed)追加到 <DSH_HOME>/plugin-console/install-jobs.json:
|
|
8
|
+
// · 只保留最近 N 条(默认 50),文件不会无限增长;
|
|
9
|
+
// · 只记**诊断需要的字段**,不复制整个 job(避免把大对象/敏感信息落盘);
|
|
10
|
+
// · 任何异常都吞掉并降级为"没有持久记录"(绝不因为写日志失败影响安装本身)。
|
|
11
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
|
|
12
|
+
import { dirname, join } from 'node:path'
|
|
13
|
+
import { dshHome } from '../infra/paths.js'
|
|
14
|
+
|
|
15
|
+
const INSTALL_JOBS_FILE = 'install-jobs.json'
|
|
16
|
+
const KEEP = 50
|
|
17
|
+
|
|
18
|
+
function installJobsPath(home = undefined) {
|
|
19
|
+
return join(home ?? dshHome(), 'plugin-console', INSTALL_JOBS_FILE)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** 只挑诊断需要的字段(多余的一律不落盘)。 */
|
|
23
|
+
function pickJobFields(job) {
|
|
24
|
+
if (job === null || typeof job !== 'object') return null
|
|
25
|
+
const id = typeof job.id === 'string' ? job.id : (typeof job.jobId === 'string' ? job.jobId : null)
|
|
26
|
+
if (id === null) return null
|
|
27
|
+
return {
|
|
28
|
+
jobId: id,
|
|
29
|
+
packageName: typeof job.packageName === 'string' ? job.packageName : null,
|
|
30
|
+
repo: typeof job.repo === 'string' ? job.repo : null,
|
|
31
|
+
status: typeof job.status === 'string' ? job.status : null,
|
|
32
|
+
stage: typeof job.stage === 'string' ? job.stage : null,
|
|
33
|
+
version: typeof job.version === 'string' ? job.version : null,
|
|
34
|
+
error: typeof job.error === 'string' ? job.error.slice(0, 500) : null,
|
|
35
|
+
source: typeof job.source === 'string' ? job.source : null,
|
|
36
|
+
startedAt: Number.isFinite(Number(job.startedAt)) ? Number(job.startedAt) : null,
|
|
37
|
+
finishedAt: Number.isFinite(Number(job.finishedAt)) ? Number(job.finishedAt) : Date.now(),
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function readInstallJobs({ home = undefined } = {}) {
|
|
42
|
+
try {
|
|
43
|
+
const raw = JSON.parse(readFileSync(installJobsPath(home), 'utf8'))
|
|
44
|
+
const list = raw !== null && typeof raw === 'object' && Array.isArray(raw.items) ? raw.items : []
|
|
45
|
+
return list.filter((x) => x !== null && typeof x === 'object' && typeof x.jobId === 'string')
|
|
46
|
+
} catch { return [] }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 记录一条**已结束**的作业;进行中的不写(否则每次轮询都写盘)。
|
|
51
|
+
* @returns {{ saved: boolean, reason?: string }}
|
|
52
|
+
*/
|
|
53
|
+
function recordInstallJob(job, { home = undefined } = {}) {
|
|
54
|
+
const picked = pickJobFields(job)
|
|
55
|
+
if (picked === null) return { saved: false, reason: 'job 形状不可识别' }
|
|
56
|
+
if (picked.status !== 'done' && picked.status !== 'failed') return { saved: false, reason: '作业尚未结束' }
|
|
57
|
+
try {
|
|
58
|
+
const prev = readInstallJobs({ home }).filter((x) => x.jobId !== picked.jobId)
|
|
59
|
+
const items = [...prev, picked].slice(-KEEP)
|
|
60
|
+
const file = installJobsPath(home)
|
|
61
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
62
|
+
writeFileSync(file, `${JSON.stringify({ items }, null, 2)}\n`, 'utf8')
|
|
63
|
+
return { saved: true }
|
|
64
|
+
} catch (error) {
|
|
65
|
+
return { saved: false, reason: error instanceof Error ? error.message : String(error) }
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export { INSTALL_JOBS_FILE, installJobsPath, readInstallJobs, recordInstallJob }
|
|
@@ -513,7 +513,7 @@ function installJobView(job) {
|
|
|
513
513
|
entryId: job.entryId ?? null,
|
|
514
514
|
bundle: job.bundle ?? false,
|
|
515
515
|
ai: job.ai ?? false,
|
|
516
|
-
aiNote: job.aiNote ?? null,
|
|
516
|
+
aiNote: job.aiNote ?? null, version: job.version ?? null, // ★ 0.5.82:目标版本一并投影
|
|
517
517
|
subpackages: job.subpackages ?? null,
|
|
518
518
|
source: job.source ?? 'github',
|
|
519
519
|
curlNote: job.curlNote ?? null,
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
// L1 · domain —— lock-autofix.js(清单 ↔ lock 口径不一致的**后台自动对账**;0.5.84 加法)
|
|
2
|
+
// 分层分组:L1 · domain(不认识 cordis ctx;pnpm 执行与读盘都可注入)
|
|
3
|
+
//
|
|
4
|
+
// ── 为什么(用户 2026-10 反馈)──────────────────────────────────────────────────
|
|
5
|
+
// profile 的 `package.json` 与 `pnpm-lock.yaml` 经常对不上(改过依赖没重装 / 装完没写 lock / 手改过清单),
|
|
6
|
+
// 而唯一会把它收敛的动作是用户自己去点「重建 lock」。不点时每一次 pnpm 操作都要重新解析,
|
|
7
|
+
// 还可能在 frozen-lockfile 下直接 `ERR_PNPM_OUTDATED_LOCKFILE`(安装失败的头号原因之一)。
|
|
8
|
+
// 所以:**检测到就自己修**,不必等用户。
|
|
9
|
+
//
|
|
10
|
+
// ── 三条硬约束(用户明确要求,任何一条都不能松)──────────────────────────────────
|
|
11
|
+
// ① **后台**:调用方 `void scheduleLockAutofix(...)` 即返回,**绝不 await**、绝不阻塞 /state。
|
|
12
|
+
// 这里所有慢动作(registry 探测 + pnpm)都在自己的 promise 里跑,异常一律被吞进状态字段。
|
|
13
|
+
// ② **冷却窗**:两次尝试之间至少 `COOLDOWN_MS`(10 分钟);期间任何触发都直接跳过(不排队、不重试)。
|
|
14
|
+
// ③ **失败就停**:一旦失败,**不再自动重试** —— 除非"不一致的样子"变了(signature 变化,
|
|
15
|
+
// 即用户又改了清单/lock)。失败原因与 pnpm 输出尾部如实留在状态里,面板据此提示,不静默。
|
|
16
|
+
//
|
|
17
|
+
// ── 为什么可以整份 `--lockfile-only`(以及为什么**只**在这个场景可以)──────────────
|
|
18
|
+
// 仓库既有事实(见 domain/lock-importer.js 顶部那段实测):本机 pnpm 的 `--lockfile-only`
|
|
19
|
+
// 会把**无关条目**的 peer 后缀剥掉 ⇒ 它**不是**"定点补一条"的无损工具。
|
|
20
|
+
// 但本模块是**整包对账**:argv 由 lockfile-health.js#repairArgsFor 唯一产出
|
|
21
|
+
// (`install --lockfile-only --no-frozen-lockfile --registry …`),让 pnpm 按清单重算整份 lock ——
|
|
22
|
+
// 这正是它擅长的场景;而"定点补条目"走 lock-importer 的纯文本外科手术,两条路**不混用**。
|
|
23
|
+
// 另外还守住一条:**只要不一致里出现来源型依赖(link:/file:/git/URL)就不自动修** ——
|
|
24
|
+
// 那类条目除了 importer 两行还有 packages/snapshots 的解析记录,整份重算可能把它们改写成别的形态,
|
|
25
|
+
// 交给「一键钉住」(persist.js 的收口)才对。
|
|
26
|
+
//
|
|
27
|
+
// ── 复用的是既有唯一实现,不新造第二条修 lock 的路 ────────────────────────────────
|
|
28
|
+
// 检测:lockfile-health.js#diffLockfile(纯函数,清单 vs lock vs 磁盘三方对账)
|
|
29
|
+
// 修: lockfile-health.js#runLockfileRepair(用户显式点「重建」时走的就是它:先只读体检、
|
|
30
|
+
// 有 404/探测不到就 blocked 一个文件都不写、跑完复检、按 install-diagnose 分类如实回报)
|
|
31
|
+
// 本模块只加"什么时候该跑 / 多久跑一次 / 失败了怎么办"这三件调度上的事。
|
|
32
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
33
|
+
import { dirname, join } from 'node:path'
|
|
34
|
+
import { diffLockfile, isSourceSpec, runLockfileRepair } from './lockfile-health.js'
|
|
35
|
+
import { isTestProcess } from '../infra/egress.js'
|
|
36
|
+
import { dshHome } from '../infra/paths.js'
|
|
37
|
+
|
|
38
|
+
/** 两次自动尝试之间的最小间隔(冷却窗)。 */
|
|
39
|
+
const COOLDOWN_MS = 10 * 60 * 1000
|
|
40
|
+
/** 状态落盘文件(跨重启也有记忆,避免一重启就再跑一次)。 */
|
|
41
|
+
const STATE_FILE_NAME = 'lock-autofix.json'
|
|
42
|
+
|
|
43
|
+
/** 进程内状态(`__resetLockAutofix()` 供单测还原)。 */
|
|
44
|
+
let state = null
|
|
45
|
+
const blank = () => ({
|
|
46
|
+
inFlight: false,
|
|
47
|
+
lastAt: null, // 上次尝试的时间戳
|
|
48
|
+
lastResult: null, // 上次结论(如实下发,含原因/包名/pnpm 输出尾部)
|
|
49
|
+
failures: 0, // 连续失败次数(>0 ⇒ 已停)
|
|
50
|
+
stopped: false, // 失败后停止自动重试
|
|
51
|
+
stoppedReason: null,
|
|
52
|
+
stoppedSignature: null // 停下时那份"不一致的样子";样子变了才允许再试
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
const stateFile = () => join(dshHome(), 'plugin-console', STATE_FILE_NAME)
|
|
56
|
+
|
|
57
|
+
function readStateFile() {
|
|
58
|
+
try {
|
|
59
|
+
const raw = JSON.parse(readFileSync(stateFile(), 'utf8'))
|
|
60
|
+
if (raw === null || typeof raw !== 'object') return null
|
|
61
|
+
return raw
|
|
62
|
+
} catch {
|
|
63
|
+
return null
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function writeStateFile(snapshot) {
|
|
68
|
+
try {
|
|
69
|
+
mkdirSync(dirname(stateFile()), { recursive: true })
|
|
70
|
+
writeFileSync(stateFile(), JSON.stringify(snapshot, null, 2), 'utf8')
|
|
71
|
+
} catch {}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** 取(必要时从磁盘恢复)状态。 */
|
|
75
|
+
function stateNow() {
|
|
76
|
+
if (state !== null) return state
|
|
77
|
+
state = blank()
|
|
78
|
+
const saved = readStateFile()
|
|
79
|
+
if (saved !== null) {
|
|
80
|
+
state.lastAt = typeof saved.lastAt === 'number' ? saved.lastAt : null
|
|
81
|
+
state.lastResult = saved.lastResult ?? null
|
|
82
|
+
state.stopped = saved.stopped === true
|
|
83
|
+
state.stoppedReason = typeof saved.stoppedReason === 'string' ? saved.stoppedReason : null
|
|
84
|
+
state.stoppedSignature = typeof saved.stoppedSignature === 'string' ? saved.stoppedSignature : null
|
|
85
|
+
state.failures = Number.isFinite(saved.failures) ? saved.failures : 0
|
|
86
|
+
}
|
|
87
|
+
return state
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** 单测还原:清空内存状态(落盘文件由调用方自己决定要不要清)。 */
|
|
91
|
+
function __resetLockAutofix() {
|
|
92
|
+
state = null
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* 「这次不一致」的样子(用于"失败后不重试,除非样子变了")。
|
|
97
|
+
* 只由**不一致本身**构成:缺哪些、specifier 怎么漂、版本怎么不符 —— 修好或再变都会改变它。
|
|
98
|
+
*/
|
|
99
|
+
function mismatchSignature(outdated) {
|
|
100
|
+
const miss = Array.isArray(outdated?.missing) ? [...outdated.missing].sort() : []
|
|
101
|
+
const spec = Array.isArray(outdated?.specifierMismatch)
|
|
102
|
+
? outdated.specifierMismatch.map((x) => `${x.name}@${x.spec}/${x.lockSpecifier ?? ''}`).sort() : []
|
|
103
|
+
const ver = Array.isArray(outdated?.versionMismatch)
|
|
104
|
+
? outdated.versionMismatch.map((x) => `${x.name}@${x.spec}/${x.lockVersion ?? ''}`).sort() : []
|
|
105
|
+
return JSON.stringify({ miss, spec, ver })
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* 该不该自动修(**纯判据**,不写盘、不发网络、不 spawn)。
|
|
110
|
+
* @returns {{ applicable: boolean, reason: string|null, signature: string|null, outdated: object|null, sourceSpecNames: string[] }}
|
|
111
|
+
* · applicable=false 时 reason 说明为什么不动手(面板可原样展示,**不许**含糊成"跳过了")
|
|
112
|
+
*/
|
|
113
|
+
function planLockAutofix({ profileDir, readFile = (p) => readFileSync(p, 'utf8'), exists = existsSync, now = Date.now() } = {}) {
|
|
114
|
+
const dir = typeof profileDir === 'string' && profileDir !== '' ? profileDir : null
|
|
115
|
+
if (dir === null) return { applicable: false, reason: 'profile 目录未知,未自动对账', signature: null, outdated: null, sourceSpecNames: [] }
|
|
116
|
+
const read = (name) => {
|
|
117
|
+
try {
|
|
118
|
+
const file = join(dir, name)
|
|
119
|
+
return exists(file) ? readFile(file) : null
|
|
120
|
+
} catch {
|
|
121
|
+
return null
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const manifestText = read('package.json')
|
|
125
|
+
if (manifestText === null) return { applicable: false, reason: 'profile 没有 package.json(清单都读不到,无从对账)', signature: null, outdated: null, sourceSpecNames: [] }
|
|
126
|
+
let manifest = null
|
|
127
|
+
try {
|
|
128
|
+
manifest = JSON.parse(manifestText)
|
|
129
|
+
} catch {
|
|
130
|
+
return { applicable: false, reason: 'package.json 不是合法 JSON —— 不碰它(那是用户的文件),请先修好清单', signature: null, outdated: null, sourceSpecNames: [] }
|
|
131
|
+
}
|
|
132
|
+
if (manifest === null || typeof manifest !== 'object') {
|
|
133
|
+
return { applicable: false, reason: 'package.json 不是对象 —— 不碰它', signature: null, outdated: null, sourceSpecNames: [] }
|
|
134
|
+
}
|
|
135
|
+
const lockText = read('pnpm-lock.yaml')
|
|
136
|
+
const diff = diffLockfile({ manifestText, lockText })
|
|
137
|
+
const signature = mismatchSignature(diff)
|
|
138
|
+
const outdated = {
|
|
139
|
+
lockfileMissing: !diff.lockfilePresent,
|
|
140
|
+
missing: diff.missing,
|
|
141
|
+
specifierMismatch: diff.specifierMismatch,
|
|
142
|
+
versionMismatch: diff.versionMismatch,
|
|
143
|
+
drift: diff.drift,
|
|
144
|
+
}
|
|
145
|
+
const mismatchNames = [...new Set([
|
|
146
|
+
...diff.missing,
|
|
147
|
+
...diff.specifierMismatch.map((x) => x.name),
|
|
148
|
+
...diff.versionMismatch.map((x) => x.name),
|
|
149
|
+
])]
|
|
150
|
+
if (mismatchNames.length === 0) {
|
|
151
|
+
return { applicable: false, reason: null, signature, outdated, sourceSpecNames: [] }
|
|
152
|
+
}
|
|
153
|
+
// 来源型依赖一旦出现在不一致清单里 ⇒ 整份重算不是无损工具(见文件头那段),**不自动修**
|
|
154
|
+
const specsByName = new Map()
|
|
155
|
+
for (const section of ['dependencies', 'devDependencies', 'optionalDependencies']) {
|
|
156
|
+
const block = manifest[section]
|
|
157
|
+
if (block === null || typeof block !== 'object') continue
|
|
158
|
+
for (const [name, spec] of Object.entries(block)) if (typeof spec === 'string') specsByName.set(name, spec)
|
|
159
|
+
}
|
|
160
|
+
const sourceSpecNames = mismatchNames.filter((name) => isSourceSpec(specsByName.get(name) ?? ''))
|
|
161
|
+
if (sourceSpecNames.length > 0) {
|
|
162
|
+
return {
|
|
163
|
+
applicable: false,
|
|
164
|
+
reason: `不一致里有来源型依赖(${sourceSpecNames.slice(0, 4).join('、')})—— 整份 pnpm install --lockfile-only 可能改写它们的解析记录,已改为交给「一键钉住」处理,本次不自动对账`,
|
|
165
|
+
signature, outdated, sourceSpecNames,
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return { applicable: true, reason: null, signature, outdated, sourceSpecNames: [] }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* 后台调度一次自动对账(**fire-and-forget**:调用方不要 await)。
|
|
173
|
+
* 所有前置条件(冷却窗 / 已停 / 在跑 / 无不一致)都只**读**内存状态 —— 快速返回,绝不阻塞 HTTP 响应。
|
|
174
|
+
* @returns {{ started: boolean, reason: string|null }}
|
|
175
|
+
*/
|
|
176
|
+
function scheduleLockAutofix({ profileDir, registries = [], now = Date.now(), deps = {} } = {}) {
|
|
177
|
+
// ★ 测试进程里一律不自动跑 pnpm:它会重写 fixture 的 pnpm-lock.yaml,而多套测试对
|
|
178
|
+
// "lock 是否被写过"有断言(例如 test-route-inventory 的只读体检那条)。判据来自
|
|
179
|
+
// infra/egress.js#isTestProcess(看 process.argv[1],测试自己解不开这条保护)。
|
|
180
|
+
// 注入了 repair 替身 = 调用方自己承担(离线单测就是这样覆盖全流程的),此时不拦。
|
|
181
|
+
const repairImpl = typeof deps.repair === 'function' ? deps.repair : null
|
|
182
|
+
if (repairImpl === null && isTestProcess()) {
|
|
183
|
+
return { started: false, reason: '测试进程:不自动跑 pnpm(见 infra/egress.js#isTestProcess)' }
|
|
184
|
+
}
|
|
185
|
+
const st = stateNow()
|
|
186
|
+
const plan = planLockAutofix({ profileDir, now, ...(deps.plan ?? {}) })
|
|
187
|
+
if (plan.applicable !== true) {
|
|
188
|
+
// 没有不一致时 quiet(reason=null);有但不动手时如实说明
|
|
189
|
+
return { started: false, reason: plan.reason }
|
|
190
|
+
}
|
|
191
|
+
if (st.inFlight) return { started: false, reason: '上一次自动对账还在跑,本次跳过' }
|
|
192
|
+
if (st.stopped && st.stoppedSignature === plan.signature) {
|
|
193
|
+
// 说清"停了"与"什么情况下才会再试",再带上上次的真实原因(不让人以为它还会自己动)
|
|
194
|
+
const why = st.stoppedReason ?? '上次自动对账未成功'
|
|
195
|
+
return { started: false, reason: `已停止自动重试(清单/lock 变化后会再试)—— ${why}` }
|
|
196
|
+
}
|
|
197
|
+
if (st.lastAt !== null && now - st.lastAt < COOLDOWN_MS) {
|
|
198
|
+
return { started: false, reason: `冷却中(${Math.max(1, Math.round((COOLDOWN_MS - (now - st.lastAt)) / 1000))}s 后才会再试)` }
|
|
199
|
+
}
|
|
200
|
+
st.inFlight = true
|
|
201
|
+
st.lastAt = now
|
|
202
|
+
if (st.stopped && st.stoppedSignature !== plan.signature) {
|
|
203
|
+
// 样子变了 ⇒ 解除停止(但失败计数留着,让人看得出历史上失败过)
|
|
204
|
+
st.stopped = false
|
|
205
|
+
st.stoppedReason = null
|
|
206
|
+
st.stoppedSignature = null
|
|
207
|
+
}
|
|
208
|
+
const repair = repairImpl ?? runLockfileRepair
|
|
209
|
+
// ★ 绝不在调用栈里 await:让 HTTP 立刻返回,pnpm 在自己的 promise 里跑
|
|
210
|
+
void (async () => {
|
|
211
|
+
let result = null
|
|
212
|
+
try {
|
|
213
|
+
const out = await repair({ profileDir, registries, now: Date.now(), ...(deps.repairDeps ?? {}) })
|
|
214
|
+
const action = typeof out?.action === 'string' ? out.action : 'unknown'
|
|
215
|
+
const ok = out?.ok === true
|
|
216
|
+
result = {
|
|
217
|
+
at: Date.now(),
|
|
218
|
+
action,
|
|
219
|
+
ok,
|
|
220
|
+
kind: out?.kind ?? null,
|
|
221
|
+
reason: out?.reason ?? null,
|
|
222
|
+
hint: out?.hint ?? null,
|
|
223
|
+
packages: Array.isArray(out?.packages) ? out.packages.slice(0, 8) : [],
|
|
224
|
+
stderrTail: typeof out?.stderrTail === 'string' ? out.stderrTail.slice(-600) : null,
|
|
225
|
+
command: out?.command ?? null,
|
|
226
|
+
signature: plan.signature,
|
|
227
|
+
}
|
|
228
|
+
// blocked / noop 不是"失败":blocked 是真做不了(如实提示,但**不改**停止位以免反复试),
|
|
229
|
+
// noop = 已经没有需要修的(不动停止位);只有 failed / partial 才算失败并停。
|
|
230
|
+
if (action === 'failed' || action === 'partial' || (ok !== true && action === 'unknown')) {
|
|
231
|
+
st.failures += 1
|
|
232
|
+
st.stopped = true
|
|
233
|
+
st.stoppedSignature = plan.signature
|
|
234
|
+
st.stoppedReason = action === 'failed'
|
|
235
|
+
? `自动对账失败:${result.kind ?? '未知原因'} —— ${(result.stderrTail ?? result.reason ?? '').slice(-200)}`
|
|
236
|
+
: `自动对账只修好一部分:${result.reason ?? result.hint ?? '仍有差距'}`
|
|
237
|
+
} else if (action === 'blocked') {
|
|
238
|
+
st.failures += 1
|
|
239
|
+
st.stopped = true
|
|
240
|
+
st.stoppedSignature = plan.signature
|
|
241
|
+
st.stoppedReason = `自动对账被拦下(未改任何文件):${result.reason ?? result.kind ?? '未知原因'}`
|
|
242
|
+
} else {
|
|
243
|
+
st.stopped = false
|
|
244
|
+
st.stoppedReason = null
|
|
245
|
+
st.stoppedSignature = null
|
|
246
|
+
}
|
|
247
|
+
} catch (error) {
|
|
248
|
+
result = {
|
|
249
|
+
at: Date.now(),
|
|
250
|
+
action: 'failed',
|
|
251
|
+
ok: false,
|
|
252
|
+
kind: 'exception',
|
|
253
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
254
|
+
hint: null,
|
|
255
|
+
packages: [],
|
|
256
|
+
stderrTail: null,
|
|
257
|
+
command: null,
|
|
258
|
+
signature: plan.signature,
|
|
259
|
+
}
|
|
260
|
+
st.failures += 1
|
|
261
|
+
st.stopped = true
|
|
262
|
+
st.stoppedSignature = plan.signature
|
|
263
|
+
st.stoppedReason = `自动对账抛错:${result.reason}`
|
|
264
|
+
} finally {
|
|
265
|
+
st.inFlight = false
|
|
266
|
+
st.lastResult = result
|
|
267
|
+
writeStateFile({ lastAt: st.lastAt, lastResult: st.lastResult, failures: st.failures, stopped: st.stopped, stoppedReason: st.stoppedReason, stoppedSignature: st.stoppedSignature })
|
|
268
|
+
}
|
|
269
|
+
})()
|
|
270
|
+
return { started: true, reason: null }
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** 面板/诊断用的状态快照(纯读,不触发任何动作)。 */
|
|
274
|
+
function lockAutofixStatus({ profileDir = null, now = Date.now() } = {}) {
|
|
275
|
+
const st = stateNow()
|
|
276
|
+
let mismatch = null
|
|
277
|
+
if (typeof profileDir === 'string' && profileDir !== '') {
|
|
278
|
+
try {
|
|
279
|
+
const plan = planLockAutofix({ profileDir, now })
|
|
280
|
+
mismatch = plan.outdated
|
|
281
|
+
} catch {
|
|
282
|
+
mismatch = null
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return {
|
|
286
|
+
cooldownMs: COOLDOWN_MS,
|
|
287
|
+
inFlight: st.inFlight,
|
|
288
|
+
lastAt: st.lastAt,
|
|
289
|
+
lastResult: st.lastResult,
|
|
290
|
+
failures: st.failures,
|
|
291
|
+
stopped: st.stopped,
|
|
292
|
+
stoppedReason: st.stoppedReason,
|
|
293
|
+
mismatch,
|
|
294
|
+
note: st.lastResult === null
|
|
295
|
+
? null
|
|
296
|
+
: (st.lastResult.ok === true
|
|
297
|
+
? `已自动对账:${st.lastResult.action === 'repaired' ? 'lock 已按清单重写' : st.lastResult.action}`
|
|
298
|
+
: `自动对账未成功(${st.lastResult.action}):${st.lastResult.reason ?? st.lastResult.stoppedReason ?? '原因未知'}`),
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export {
|
|
303
|
+
COOLDOWN_MS,
|
|
304
|
+
STATE_FILE_NAME,
|
|
305
|
+
lockAutofixStatus,
|
|
306
|
+
mismatchSignature,
|
|
307
|
+
planLockAutofix,
|
|
308
|
+
scheduleLockAutofix,
|
|
309
|
+
__resetLockAutofix,
|
|
310
|
+
}
|
|
@@ -13,6 +13,12 @@ import { repoMatchedRegistryChannels } from './repo-package-match.js'
|
|
|
13
13
|
import { probeChannelLine, probeFailureLines, composeProbeFailureText } from './probe-outcome.js'
|
|
14
14
|
import { FETCH_OK, FETCH_UNREACHABLE, GITHUB_API, channelLabel, channelFailureSummary, curlJson, curlText, fetchJsonUrl, githubJson, looksLikeGitmodules, rawTextFetch, rawTextWithFallback } from '../infra/http.js'
|
|
15
15
|
import { ENRICH_CACHE_FILE, baseDirOf, marketIndexCacheFile, resolvePackageJson } from '../infra/paths.js'
|
|
16
|
+
// 0.5.84 加法:富化请求全部过**同一个闸门**(并发 4 / 失败 10 分钟不再试 / 成功短缓存 5 分钟)。
|
|
17
|
+
// 见 domain/enrich-throttle.js 顶部那段真机症状;这里只做接线,判定一个字没改。
|
|
18
|
+
import { ENRICH_CONCURRENCY, throttledCurlJson, throttledCurlText, throttledRawText } from './enrich-throttle.js'
|
|
19
|
+
|
|
20
|
+
/** 富化里"还在用裸出网"的第三路(技能探测)也过同一道闸:把两个出口作为依赖传下去。 */
|
|
21
|
+
const ENRICH_EGRESS = { curlText: throttledCurlText, curlJson: throttledCurlJson }
|
|
16
22
|
|
|
17
23
|
function readEnrichCache() {
|
|
18
24
|
try {
|
|
@@ -41,11 +47,11 @@ async function enrichItemOne(item) {
|
|
|
41
47
|
const base = `https://raw.githubusercontent.com/${item.fullName}/${encodeURIComponent(branch)}/package.json`
|
|
42
48
|
const [pkgResult, skillResult, suiteResult] = await Promise.allSettled([
|
|
43
49
|
Promise.any([
|
|
44
|
-
|
|
45
|
-
|
|
50
|
+
throttledCurlText(base),
|
|
51
|
+
throttledCurlText(`https://ghproxy.net/${base}`),
|
|
46
52
|
]),
|
|
47
|
-
detectSkillRepo(item.fullName, branch),
|
|
48
|
-
|
|
53
|
+
detectSkillRepo(item.fullName, branch, ENRICH_EGRESS),
|
|
54
|
+
throttledRawText(item.fullName, branch, '.gitmodules'),
|
|
49
55
|
])
|
|
50
56
|
// 套装判定必须过内容校验:代理/CDN 对不存在的 .gitmodules 也可能回 2xx 空 body,
|
|
51
57
|
// 只判"探测非 null"会把普通插件标成套装置仓库(2026-09-19 事故)。
|
|
@@ -64,13 +70,13 @@ async function enrichItemOne(item) {
|
|
|
64
70
|
if (pkg.private === true && (Array.isArray(pkg.workspaces) || /(^|-)dsh[-/]/u.test(String(pkg.name ?? '')))) {
|
|
65
71
|
aggregate = true
|
|
66
72
|
try {
|
|
67
|
-
const tree = await
|
|
73
|
+
const tree = await throttledCurlJson(`https://api.github.com/repos/${item.fullName}/git/trees/${encodeURIComponent(branch)}?recursive=1`)
|
|
68
74
|
const pkgPaths = (tree.tree ?? [])
|
|
69
75
|
.filter((n) => n.type === 'blob' && /^packages\/[^/]+\/package\.json$/u.test(n.path))
|
|
70
76
|
.map((n) => n.path)
|
|
71
77
|
.slice(0, 12)
|
|
72
78
|
if (pkgPaths.length > 0) {
|
|
73
|
-
const subs = await Promise.all(pkgPaths.map((p) =>
|
|
79
|
+
const subs = await Promise.all(pkgPaths.map((p) => throttledCurlText(`https://raw.githubusercontent.com/${item.fullName}/${encodeURIComponent(branch)}/${p}`)
|
|
74
80
|
.then((t) => { try { return JSON.parse(t) } catch { return null } })
|
|
75
81
|
.catch(() => null)))
|
|
76
82
|
if (subs.some((sp) => sp && typeof sp.dsh?.bundle?.patch === 'string')) {
|
|
@@ -138,7 +144,7 @@ async function enrichItems(items) {
|
|
|
138
144
|
}
|
|
139
145
|
}
|
|
140
146
|
}
|
|
141
|
-
await Promise.all(Array.from({ length: Math.min(
|
|
147
|
+
await Promise.all(Array.from({ length: Math.min(ENRICH_CONCURRENCY, items.length) }, worker))
|
|
142
148
|
if (cacheDirty) writeEnrichCache(cache)
|
|
143
149
|
return out
|
|
144
150
|
}
|