@ljwei-stak/dsh-model-router 0.16.0 → 0.16.2
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/.dsh-plugin/client.js +314 -80
- package/.dsh-plugin/index.mjs +23 -17
- package/.dsh-plugin/shared/adaptive-feedback.mjs +120 -40
- package/.dsh-plugin/shared/dynamic-data-view.mjs +59 -4
- package/.dsh-plugin/shared/dynamic-data.mjs +161 -83
- package/.dsh-plugin/shared/latest-versions.mjs +3 -21
- package/.dsh-plugin/shared/official-tools-runtime.mjs +188 -41
- package/.dsh-plugin/shared/router-state.mjs +139 -38
- package/.dsh-plugin/shared/run-ledger.mjs +32 -11
- package/.dsh-plugin/shared/version-order.mjs +30 -0
- package/CHANGELOG.md +20 -0
- package/INSTALLATION_GUIDE.zh.md +12 -12
- package/README.md +9 -5
- package/README.zh.md +9 -5
- package/docs/ROUTING_ADAPTIVE_LEARNING.zh.md +2 -0
- package/docs/ROUTING_MATURITY.zh.md +76 -0
- package/package.json +3 -1
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
* a package name, executable or argument. Installs are serialized (npm global
|
|
8
8
|
* state must not interleave), output is kept as a bounded tail, and success is
|
|
9
9
|
* always confirmed by a fresh probe rather than the installer's exit code.
|
|
10
|
+
* When a newer release is known, a probe that is still older fails the job
|
|
11
|
+
* instead of reporting success.
|
|
10
12
|
*/
|
|
11
13
|
import { spawn } from 'node:child_process'
|
|
12
14
|
import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'
|
|
@@ -15,6 +17,7 @@ import { getOfficialTool, installCommandLine, OFFICIAL_TOOLS } from './official-
|
|
|
15
17
|
import { discoverZCodeBundle, unverifiedZCodeInstalls } from './zcode-bundle.mjs'
|
|
16
18
|
import { openZCodeInstaller } from './zcode-installer.mjs'
|
|
17
19
|
import { compareReleaseVersions, latestVersions } from './latest-versions.mjs'
|
|
20
|
+
import { versionStillOlder } from './version-order.mjs'
|
|
18
21
|
|
|
19
22
|
const PROBE_TIMEOUT_MS = 8_000
|
|
20
23
|
const INSTALL_TIMEOUT_MS = 900_000
|
|
@@ -30,6 +33,44 @@ const probeCache = new Map()
|
|
|
30
33
|
const PROBE_CACHE_MS = 60_000
|
|
31
34
|
const installJobs = new Map()
|
|
32
35
|
let installChain = Promise.resolve()
|
|
36
|
+
const CLAUDE_UPDATE_TIMEOUT_MS = 180_000
|
|
37
|
+
|
|
38
|
+
/** Non-interactive updater for a native Claude Code install. npm does not move that copy. */
|
|
39
|
+
export const CLAUDE_UPDATE_ARGS = Object.freeze(['update'])
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Quote a fixed registry command for `shell: true`.
|
|
43
|
+
* Node concatenates shell arguments without escaping; Windows cmd would otherwise
|
|
44
|
+
* split on characters inside package specs such as `@scope/pkg@latest`.
|
|
45
|
+
*/
|
|
46
|
+
export function shellCommandLine(executable, args, platform = process.platform) {
|
|
47
|
+
const quote = value => {
|
|
48
|
+
const text = String(value)
|
|
49
|
+
if (text.includes('\0') || /[\r\n]/.test(text)) throw new Error('命令参数含有非法字符。')
|
|
50
|
+
if (platform === 'win32') return `"${text.replace(/"/g, '""')}"`
|
|
51
|
+
return `'${text.replace(/'/g, `'"'"'`)}'`
|
|
52
|
+
}
|
|
53
|
+
return [executable, ...(args ?? [])].map(quote).join(' ')
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Visible failure when the installer exited cleanly but the probed copy is still older. */
|
|
57
|
+
export function staleInstallMessage(label, installedVersion, latestVersion) {
|
|
58
|
+
return `${label} 的更新没有生效:检测到的版本仍是 ${installedVersion},低于最新 ${latestVersion}。PATH 上可能有另一份更早的安装。请查看本行安装日志。`
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Which updater to run. `order` is compareReleaseVersions(installed, latest):
|
|
63
|
+
* -1 older, 0 same, 1 newer, null when either version is unknown.
|
|
64
|
+
*/
|
|
65
|
+
export function officialUpdatePlan({ toolId, installed, order, platform }) {
|
|
66
|
+
if (installed && order === 1) return 'refuse-downgrade'
|
|
67
|
+
if (installed && order === 0) return 'already-current'
|
|
68
|
+
if (installed && order === -1 && toolId === 'claude-code') return 'claude-update-then-registry'
|
|
69
|
+
if (installed && (order === -1 || order === null) && toolId === 'minimax-code' && platform === 'win32') {
|
|
70
|
+
return 'minimax-official-installer'
|
|
71
|
+
}
|
|
72
|
+
return 'registry-install'
|
|
73
|
+
}
|
|
33
74
|
|
|
34
75
|
async function trustedExecutionReadiness(toolId) {
|
|
35
76
|
// Loaded only during an install to avoid coupling fast version probes to
|
|
@@ -50,7 +91,10 @@ function runCapture(executable, args, { timeoutMs, onOutput, useShell = false, s
|
|
|
50
91
|
let stopReason = null
|
|
51
92
|
let child
|
|
52
93
|
try {
|
|
53
|
-
|
|
94
|
+
// One pre-quoted command string so Node's shell wrapper cannot split
|
|
95
|
+
// `@scope/pkg@latest`. PowerShell keeps useShell false and an args array.
|
|
96
|
+
const command = useShell ? shellCommandLine(executable, args) : executable
|
|
97
|
+
child = spawn(command, useShell ? [] : args, {
|
|
54
98
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
55
99
|
windowsHide: true,
|
|
56
100
|
// Windows npm CLIs install as .cmd shims; only cmd.exe can execute
|
|
@@ -145,14 +189,14 @@ async function fetchOfficialMiniMaxInstaller(signal) {
|
|
|
145
189
|
return officialMiniMaxInstaller(Buffer.concat(chunks))
|
|
146
190
|
}
|
|
147
191
|
|
|
148
|
-
async function runOfficialMiniMaxInstaller(job) {
|
|
192
|
+
async function runOfficialMiniMaxInstaller(job, { reason } = {}) {
|
|
149
193
|
let staging = null
|
|
150
194
|
let prefixRoot = null
|
|
151
195
|
try {
|
|
152
196
|
job.finishedAt = null
|
|
153
197
|
job.error = null
|
|
154
198
|
job.phase = 'downloading-and-verifying'
|
|
155
|
-
pushLine(job, '— npm 原生依赖未就绪;改用 MiniMax 官方 Windows 安装脚本(安装最新版)—')
|
|
199
|
+
pushLine(job, reason || '— npm 原生依赖未就绪;改用 MiniMax 官方 Windows 安装脚本(安装最新版)—')
|
|
156
200
|
const prefixResult = await runCapture('npm', ['config', 'get', 'prefix'], {
|
|
157
201
|
timeoutMs: 5_000, useShell: true, signal: job.controller.signal,
|
|
158
202
|
})
|
|
@@ -208,6 +252,10 @@ async function runOfficialMiniMaxInstaller(job) {
|
|
|
208
252
|
if (!probe.installed || !readiness.ready) {
|
|
209
253
|
throw new Error(`官方安装器完成,但执行入口未通过 npm registry 摘要核验:${readiness.reason ?? probe.detail}`)
|
|
210
254
|
}
|
|
255
|
+
const latest = await latestVersions.lookup(job.tool).catch(() => null)
|
|
256
|
+
if (latest?.version && versionStillOlder(probe.version, latest.version)) {
|
|
257
|
+
throw new Error(staleInstallMessage(job.tool.label, probe.version, latest.version))
|
|
258
|
+
}
|
|
211
259
|
job.status = 'succeeded'
|
|
212
260
|
job.finishedAt = new Date().toISOString()
|
|
213
261
|
pushLine(job, `— 官方安装器已完成,MiniMax Code ${probe.version ?? ''} 的执行入口已核验 —`)
|
|
@@ -444,6 +492,86 @@ export function startInstall(toolId) {
|
|
|
444
492
|
return jobView(job)
|
|
445
493
|
}
|
|
446
494
|
|
|
495
|
+
function captureLines(job) {
|
|
496
|
+
return chunk => {
|
|
497
|
+
for (const line of String(chunk).split(/\r?\n/)) {
|
|
498
|
+
if (line.trim()) pushLine(job, line.slice(0, 500))
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/** Mark the job succeeded only when the fresh probe is installed, ready, and not still older. */
|
|
504
|
+
async function finishVerifiedInstall(job, probe) {
|
|
505
|
+
job.postInstallProbe = probe
|
|
506
|
+
if (job.cancelRequested) { markCancelled(job); return true }
|
|
507
|
+
if (!probe.installed) {
|
|
508
|
+
job.status = 'failed'
|
|
509
|
+
job.finishedAt = new Date().toISOString()
|
|
510
|
+
job.error = `安装命令已完成,但重探测失败:${probe.detail}`
|
|
511
|
+
return true
|
|
512
|
+
}
|
|
513
|
+
const latest = await latestVersions.lookup(job.tool).catch(() => null)
|
|
514
|
+
if (latest?.version && versionStillOlder(probe.version, latest.version)) {
|
|
515
|
+
failBecauseStillOlder(job, probe, latest.version)
|
|
516
|
+
return true
|
|
517
|
+
}
|
|
518
|
+
const readiness = await trustedExecutionReadiness(job.tool.id)
|
|
519
|
+
if (job.cancelRequested) { markCancelled(job); return true }
|
|
520
|
+
if (!readiness.ready) {
|
|
521
|
+
if (IS_WINDOWS && job.tool.id === 'minimax-code') {
|
|
522
|
+
await runOfficialMiniMaxInstaller(job)
|
|
523
|
+
return true
|
|
524
|
+
}
|
|
525
|
+
job.status = 'failed'
|
|
526
|
+
job.finishedAt = new Date().toISOString()
|
|
527
|
+
job.error = `安装命令和版本探测已成功,但官方执行入口未就绪:${readiness.reason}`
|
|
528
|
+
return true
|
|
529
|
+
}
|
|
530
|
+
job.status = 'succeeded'
|
|
531
|
+
job.finishedAt = new Date().toISOString()
|
|
532
|
+
pushLine(job, `— 已安装 ${job.tool.label} ${probe.version ?? ''},官方执行入口已核验(新版本未经插件测试)—`)
|
|
533
|
+
return true
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function failBecauseStillOlder(job, probe, latestVersion) {
|
|
537
|
+
job.postInstallProbe = probe
|
|
538
|
+
job.status = 'failed'
|
|
539
|
+
job.finishedAt = new Date().toISOString()
|
|
540
|
+
job.error = staleInstallMessage(job.tool.label, probe?.version, latestVersion)
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Native Claude Code lives outside the npm prefix. `claude update` is the
|
|
545
|
+
* vendor's non-interactive updater; return true when the job is finished.
|
|
546
|
+
*/
|
|
547
|
+
async function tryClaudeUpdate(job, latestVersion) {
|
|
548
|
+
if (job.cancelRequested) { markCancelled(job); return true }
|
|
549
|
+
pushLine(job, '$ claude update')
|
|
550
|
+
job.phase = 'installing'
|
|
551
|
+
const outcome = await runCapture('claude', CLAUDE_UPDATE_ARGS, {
|
|
552
|
+
timeoutMs: CLAUDE_UPDATE_TIMEOUT_MS,
|
|
553
|
+
useShell: IS_WINDOWS,
|
|
554
|
+
signal: job.controller.signal,
|
|
555
|
+
onOutput: captureLines(job),
|
|
556
|
+
})
|
|
557
|
+
if (job.status !== 'running') return true
|
|
558
|
+
if (outcome.cancelled || job.cancelRequested) { markCancelled(job); return true }
|
|
559
|
+
if (!outcome.ok) {
|
|
560
|
+
pushLine(job, `— claude update 未完成(退出码 ${outcome.code ?? '信号终止'}),将继续尝试注册表安装 —`)
|
|
561
|
+
return false
|
|
562
|
+
}
|
|
563
|
+
await ensureNpmPrefixOnPath()
|
|
564
|
+
probeCache.delete(job.tool.id)
|
|
565
|
+
const probe = await probeToolWith(job.tool, defaultRunner)
|
|
566
|
+
if (job.cancelRequested) { markCancelled(job); return true }
|
|
567
|
+
if (latestVersion && versionStillOlder(probe.version, latestVersion)) {
|
|
568
|
+
job.postInstallProbe = probe
|
|
569
|
+
pushLine(job, `— claude update 已结束,但探测到的版本仍是 ${probe.version},低于 ${latestVersion} —`)
|
|
570
|
+
return false
|
|
571
|
+
}
|
|
572
|
+
return finishVerifiedInstall(job, probe)
|
|
573
|
+
}
|
|
574
|
+
|
|
447
575
|
async function runInstallJob(manager, args, job) {
|
|
448
576
|
if (job.status !== 'running') return
|
|
449
577
|
job.phase = 'preflight'
|
|
@@ -452,10 +580,12 @@ async function runInstallJob(manager, args, job) {
|
|
|
452
580
|
probeCache.delete(job.tool.id)
|
|
453
581
|
const before = await probeToolWith(job.tool, defaultRunner)
|
|
454
582
|
if (job.cancelRequested) { markCancelled(job); return }
|
|
583
|
+
let latest = null
|
|
584
|
+
let order = null
|
|
455
585
|
if (before.installed) {
|
|
456
|
-
|
|
586
|
+
latest = await latestVersions.lookup(job.tool, { fresh: true }).catch(() => null)
|
|
457
587
|
if (job.cancelRequested) { markCancelled(job); return }
|
|
458
|
-
|
|
588
|
+
order = latest?.version ? compareReleaseVersions(before.version, latest.version) : null
|
|
459
589
|
if (order === 1) {
|
|
460
590
|
job.status = 'failed'
|
|
461
591
|
job.finishedAt = new Date().toISOString()
|
|
@@ -476,59 +606,76 @@ async function runInstallJob(manager, args, job) {
|
|
|
476
606
|
pushLine(job, `— 已是最新版本 ${before.version},但执行入口未就绪:${readiness.reason};重新安装修复 —`)
|
|
477
607
|
}
|
|
478
608
|
}
|
|
609
|
+
const plan = officialUpdatePlan({
|
|
610
|
+
toolId: job.tool.id,
|
|
611
|
+
installed: before.installed === true,
|
|
612
|
+
order,
|
|
613
|
+
platform: process.platform,
|
|
614
|
+
})
|
|
615
|
+
let triedClaudeUpdate = false
|
|
616
|
+
if (plan === 'claude-update-then-registry') {
|
|
617
|
+
triedClaudeUpdate = true
|
|
618
|
+
const finished = await tryClaudeUpdate(job, latest?.version)
|
|
619
|
+
if (finished || job.status !== 'running') return
|
|
620
|
+
} else if (plan === 'minimax-official-installer') {
|
|
621
|
+
await runOfficialMiniMaxInstaller(job, {
|
|
622
|
+
reason: '— 已安装的 MiniMax Code 由官方安装目录接管;直接运行官方 Windows 安装脚本以更新到最新版 —',
|
|
623
|
+
})
|
|
624
|
+
return
|
|
625
|
+
}
|
|
479
626
|
job.phase = 'installing'
|
|
480
627
|
const outcome = await runCapture(manager, args, {
|
|
481
628
|
timeoutMs: INSTALL_TIMEOUT_MS,
|
|
482
629
|
useShell: IS_WINDOWS,
|
|
483
630
|
signal: job.controller.signal,
|
|
484
|
-
onOutput:
|
|
485
|
-
for (const line of chunk.split(/\r?\n/)) {
|
|
486
|
-
if (line.trim()) pushLine(job, line.slice(0, 500))
|
|
487
|
-
}
|
|
488
|
-
},
|
|
631
|
+
onOutput: captureLines(job),
|
|
489
632
|
})
|
|
490
633
|
if (job.status !== 'running') return
|
|
491
634
|
if (outcome.cancelled || job.cancelRequested) { markCancelled(job); return }
|
|
492
|
-
job.finishedAt = new Date().toISOString()
|
|
493
635
|
job.exitCode = outcome.code
|
|
494
636
|
job.timedOut = outcome.timedOut
|
|
495
637
|
if (outcome.timedOut) {
|
|
496
638
|
job.status = 'failed'
|
|
639
|
+
job.finishedAt = new Date().toISOString()
|
|
497
640
|
job.error = `安装超时(${INSTALL_TIMEOUT_MS / 1000}s),已终止。`
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
job.postInstallProbe = probe
|
|
505
|
-
if (!probe.installed) {
|
|
506
|
-
job.status = 'failed'
|
|
507
|
-
job.error = `安装命令已完成,但重探测失败:${probe.detail}`
|
|
508
|
-
} else {
|
|
509
|
-
const latest = await latestVersions.lookup(job.tool).catch(() => null)
|
|
510
|
-
if (latest?.version && compareReleaseVersions(probe.version, latest.version) === -1) {
|
|
511
|
-
pushLine(job, `— 注意:PATH 上的 ${job.tool.label} 仍是 ${probe.version},低于最新 ${latest.version};可能有另一份旧安装排在 PATH 前面 —`)
|
|
512
|
-
}
|
|
513
|
-
const readiness = await trustedExecutionReadiness(job.tool.id)
|
|
514
|
-
if (job.cancelRequested) { markCancelled(job); return }
|
|
515
|
-
if (!readiness.ready) {
|
|
516
|
-
if (IS_WINDOWS && job.tool.id === 'minimax-code') await runOfficialMiniMaxInstaller(job)
|
|
517
|
-
else {
|
|
518
|
-
job.status = 'failed'
|
|
519
|
-
job.error = `安装命令和版本探测已成功,但官方执行入口未就绪:${readiness.reason}`
|
|
520
|
-
}
|
|
521
|
-
} else {
|
|
522
|
-
job.status = 'succeeded'
|
|
523
|
-
pushLine(job, `— 已安装 ${job.tool.label} ${probe.version ?? ''},官方执行入口已核验(新版本未经插件测试)—`)
|
|
524
|
-
}
|
|
641
|
+
return
|
|
642
|
+
}
|
|
643
|
+
if (!outcome.ok) {
|
|
644
|
+
if (IS_WINDOWS && job.tool.id === 'minimax-code') {
|
|
645
|
+
await runOfficialMiniMaxInstaller(job)
|
|
646
|
+
return
|
|
525
647
|
}
|
|
526
|
-
} else if (IS_WINDOWS && job.tool.id === 'minimax-code') {
|
|
527
|
-
await runOfficialMiniMaxInstaller(job)
|
|
528
|
-
} else {
|
|
529
648
|
job.status = 'failed'
|
|
649
|
+
job.finishedAt = new Date().toISOString()
|
|
530
650
|
job.error = `安装命令失败(退出码 ${outcome.code ?? '信号终止'})。${outcome.stderr.split('\n').find(Boolean)?.slice(0, 200) ?? ''}`
|
|
651
|
+
return
|
|
652
|
+
}
|
|
653
|
+
job.phase = 'verifying'
|
|
654
|
+
await ensureNpmPrefixOnPath()
|
|
655
|
+
probeCache.delete(job.tool.id)
|
|
656
|
+
const probe = await probeToolWith(job.tool, defaultRunner)
|
|
657
|
+
if (job.cancelRequested) { markCancelled(job); return }
|
|
658
|
+
job.postInstallProbe = probe
|
|
659
|
+
const latestAfter = (await latestVersions.lookup(job.tool).catch(() => null)) ?? latest
|
|
660
|
+
const stillOlder = Boolean(latestAfter?.version && versionStillOlder(probe.version, latestAfter.version))
|
|
661
|
+
if (stillOlder && job.tool.id === 'claude-code' && !triedClaudeUpdate) {
|
|
662
|
+
triedClaudeUpdate = true
|
|
663
|
+
const finished = await tryClaudeUpdate(job, latestAfter.version)
|
|
664
|
+
if (finished || job.status !== 'running') return
|
|
665
|
+
failBecauseStillOlder(job, job.postInstallProbe ?? probe, latestAfter.version)
|
|
666
|
+
return
|
|
667
|
+
}
|
|
668
|
+
if (stillOlder && IS_WINDOWS && job.tool.id === 'minimax-code') {
|
|
669
|
+
pushLine(job, `— npm 完成后 ${job.tool.label} 仍是 ${probe.version},低于最新 ${latestAfter.version};改用官方安装脚本 —`)
|
|
670
|
+
await runOfficialMiniMaxInstaller(job)
|
|
671
|
+
return
|
|
672
|
+
}
|
|
673
|
+
if (stillOlder) {
|
|
674
|
+
pushLine(job, `— 注意:PATH 上的 ${job.tool.label} 仍是 ${probe.version},低于最新 ${latestAfter.version} —`)
|
|
675
|
+
failBecauseStillOlder(job, probe, latestAfter.version)
|
|
676
|
+
return
|
|
531
677
|
}
|
|
678
|
+
await finishVerifiedInstall(job, probe)
|
|
532
679
|
}
|
|
533
680
|
|
|
534
681
|
async function runZCodeInstallJob(job) {
|
|
@@ -4,13 +4,15 @@
|
|
|
4
4
|
* errors, cost, review and user ratings). One JSON file under the DSH home. Never stores keys.
|
|
5
5
|
*
|
|
6
6
|
* Several Host processes may share one DSH home, so every update takes a
|
|
7
|
-
* lock file (`state.json.lock`, exclusive create; stale locks
|
|
7
|
+
* lock file (`state.json.lock`, exclusive create; only stale dead-owner locks
|
|
8
|
+
* are recovered). A busy live owner causes a bounded, explicit timeout,
|
|
8
9
|
* re-reads the file, applies the change to the fresh copy and writes it back
|
|
9
10
|
* atomically (temp file + rename). Reads re-read the file when its mtime or
|
|
10
11
|
* size changed. A file that cannot be parsed is kept as
|
|
11
12
|
* `state.json.corrupt-<timestamp>` and a notice is recorded instead of
|
|
12
13
|
* silently discarding the history.
|
|
13
14
|
*/
|
|
15
|
+
import { randomUUID } from 'node:crypto'
|
|
14
16
|
import { mkdir, open, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises'
|
|
15
17
|
import { homedir } from 'node:os'
|
|
16
18
|
import { dirname, isAbsolute, join, resolve } from 'node:path'
|
|
@@ -39,7 +41,7 @@ const LOCK_STALE_MS = 15_000
|
|
|
39
41
|
const LOCK_TIMEOUT_MS = 10_000
|
|
40
42
|
|
|
41
43
|
function emptyState() {
|
|
42
|
-
return { version: STATE_VERSION, onboarding: { completedAt: null }, health: null, quota: {}, authFailures: {}, runs: [], dynamicData: { liveBench: null, pricing: null, status: {}, revision: '' }, archivedSpending: sanitizeArchivedSpending(), notices: [], terminalSessions: [] }
|
|
44
|
+
return { version: STATE_VERSION, historyIncomplete: false, onboarding: { completedAt: null }, health: null, quota: {}, authFailures: {}, runs: [], dynamicData: { liveBench: null, pricing: null, status: {}, revision: '' }, archivedSpending: sanitizeArchivedSpending(), notices: [], terminalSessions: [] }
|
|
43
45
|
}
|
|
44
46
|
|
|
45
47
|
function sanitizeNotices(value) {
|
|
@@ -55,16 +57,62 @@ function retainRuns(state, maxRuns) {
|
|
|
55
57
|
return state
|
|
56
58
|
}
|
|
57
59
|
|
|
60
|
+
const record = value => value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
61
|
+
const nonnegative = value => typeof value === 'number' && Number.isFinite(value) && value >= 0
|
|
62
|
+
function invalidArchivedSpending(value) {
|
|
63
|
+
if (value === undefined || value === null) return false // old v1 files omit it
|
|
64
|
+
if (!record(value)) return true
|
|
65
|
+
for (const [field, pattern] of [['days', /^\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])$/u],
|
|
66
|
+
['months', /^\d{4}-(?:0[1-9]|1[0-2])$/u]]) {
|
|
67
|
+
if (value[field] === undefined) continue
|
|
68
|
+
if (!record(value[field])) return true
|
|
69
|
+
for (const [key, totals] of Object.entries(value[field])) {
|
|
70
|
+
if (!pattern.test(key) || !record(totals)) return true
|
|
71
|
+
if (['costUsd', 'unknown', 'subscriptionUsd', 'subscriptionRuns'].some(name => totals[name] !== undefined
|
|
72
|
+
&& (!nonnegative(totals[name]) || (['unknown', 'subscriptionRuns'].includes(name) && !Number.isInteger(totals[name]))))) return true
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return false
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function invalidRunSpending(run) {
|
|
79
|
+
if (!record(run) || invalidArchivedSpending(run.priorAttemptSpending)) return true
|
|
80
|
+
for (const field of ['packages', 'reviews']) {
|
|
81
|
+
if (run[field] === undefined) continue // missing legacy fields are not fabricated
|
|
82
|
+
if (!Array.isArray(run[field])) return true
|
|
83
|
+
for (const item of run[field]) {
|
|
84
|
+
if (!record(item) || (item.ran !== undefined && typeof item.ran !== 'boolean')) return true
|
|
85
|
+
if (['costUsd', 'referenceCostUsd'].some(name => item[name] !== undefined && item[name] !== null && !nonnegative(item[name]))) return true
|
|
86
|
+
if (item.ran === true || nonnegative(item.costUsd)) {
|
|
87
|
+
const at = item.finishedAt ?? run.createdAt
|
|
88
|
+
if (!nonnegative(at) || !Number.isFinite(new Date(at).getTime())) return true
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return false
|
|
93
|
+
}
|
|
94
|
+
|
|
58
95
|
function sanitize(value, maxRuns) {
|
|
59
|
-
if (!value || typeof value !== 'object' || value.version !== STATE_VERSION
|
|
96
|
+
if (!value || typeof value !== 'object' || Array.isArray(value) || value.version !== STATE_VERSION
|
|
97
|
+
|| !Array.isArray(value.runs)) {
|
|
98
|
+
const error = new Error('模型路由状态版本或结构不受支持;原文件未修改,请先恢复或升级兼容版本。')
|
|
99
|
+
error.code = 'ROUTER_STATE_UNSUPPORTED'
|
|
100
|
+
throw error
|
|
101
|
+
}
|
|
102
|
+
const runs = value.runs.filter(run => run && typeof run.id === 'string')
|
|
60
103
|
return retainRuns({
|
|
61
104
|
version: STATE_VERSION,
|
|
105
|
+
// Unlike bounded notices, an incomplete-history marker cannot silently
|
|
106
|
+
// expire. Restoring a verified state file is required to recover budgets.
|
|
107
|
+
historyIncomplete: value.historyIncomplete === true || runs.length !== value.runs.length
|
|
108
|
+
|| invalidArchivedSpending(value.archivedSpending) || runs.some(invalidRunSpending)
|
|
109
|
+
|| sanitizeNotices(value.notices).some(notice => notice.kind === 'state-corrupt'),
|
|
62
110
|
onboarding: { completedAt: Number.isFinite(value.onboarding?.completedAt) ? value.onboarding.completedAt : null },
|
|
63
111
|
health: value.health && Array.isArray(value.health.tools) ? value.health : null,
|
|
64
112
|
quota: value.quota && typeof value.quota === 'object' && !Array.isArray(value.quota) ? value.quota : {},
|
|
65
113
|
authFailures: Object.fromEntries(Object.entries(value.authFailures && typeof value.authFailures === 'object' && !Array.isArray(value.authFailures) ? value.authFailures : {})
|
|
66
114
|
.filter(([, entry]) => Number.isFinite(entry?.at))),
|
|
67
|
-
runs
|
|
115
|
+
runs,
|
|
68
116
|
dynamicData: value.dynamicData && typeof value.dynamicData === 'object' && !Array.isArray(value.dynamicData) ? value.dynamicData : { liveBench: null, pricing: null, status: {}, revision: '' },
|
|
69
117
|
archivedSpending: sanitizeArchivedSpending(value.archivedSpending),
|
|
70
118
|
notices: sanitizeNotices(value.notices),
|
|
@@ -93,36 +141,66 @@ export function terminalSessionRecord(record) {
|
|
|
93
141
|
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
|
|
94
142
|
const stamp = at => new Date(at).toISOString().replace(/[:.]/g, '-')
|
|
95
143
|
|
|
96
|
-
|
|
144
|
+
function ownerPid(raw) {
|
|
145
|
+
try {
|
|
146
|
+
const pid = JSON.parse(raw).pid
|
|
147
|
+
if (Number.isSafeInteger(pid) && pid > 0) return pid
|
|
148
|
+
} catch { /* support the previous pid/timestamp format */ }
|
|
149
|
+
const pid = Number(/^(\d+)\s+\d+\s*$/u.exec(raw)?.[1])
|
|
150
|
+
return Number.isSafeInteger(pid) && pid > 0 ? pid : null
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function ownerIsDead(pid) {
|
|
154
|
+
if (pid === null) return false
|
|
155
|
+
try { process.kill(pid, 0); return false }
|
|
156
|
+
catch (error) { return error?.code === 'ESRCH' }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Never steal a live or unknown owner's lock, even after the wait timeout. */
|
|
97
160
|
async function acquireLock(lockFile, { staleMs = LOCK_STALE_MS, timeoutMs = LOCK_TIMEOUT_MS, now = Date.now } = {}) {
|
|
98
|
-
|
|
161
|
+
// Elapsed waits must not freeze or jump when wall-clock time changes.
|
|
162
|
+
const started = performance.now()
|
|
99
163
|
for (let attempt = 0; ; attempt += 1) {
|
|
100
164
|
try {
|
|
101
165
|
const handle = await open(lockFile, 'wx')
|
|
102
|
-
|
|
166
|
+
const owner = `${JSON.stringify({ pid: process.pid, token: randomUUID(), at: now() })}\n`
|
|
167
|
+
try { await handle.writeFile(owner, 'utf8') }
|
|
168
|
+
catch (error) { await handle.close(); await unlink(lockFile).catch(() => {}); throw error }
|
|
103
169
|
await handle.close()
|
|
104
|
-
return async () => {
|
|
170
|
+
return async () => {
|
|
171
|
+
if (await readFile(lockFile, 'utf8').catch(() => null) === owner) await unlink(lockFile).catch(() => {})
|
|
172
|
+
}
|
|
105
173
|
} catch (error) {
|
|
106
174
|
if (error?.code !== 'EEXIST') throw error
|
|
107
175
|
const info = await stat(lockFile).catch(() => null)
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
176
|
+
const owner = await readFile(lockFile, 'utf8').catch(() => null)
|
|
177
|
+
if (info && owner !== null && now() - info.mtimeMs > staleMs && ownerIsDead(ownerPid(owner))) {
|
|
178
|
+
const currentInfo = await stat(lockFile).catch(() => null)
|
|
179
|
+
if (currentInfo?.ino === info.ino && currentInfo?.mtimeMs === info.mtimeMs
|
|
180
|
+
&& await readFile(lockFile, 'utf8').catch(() => null) === owner) {
|
|
181
|
+
await unlink(lockFile).catch(() => {})
|
|
182
|
+
continue
|
|
183
|
+
}
|
|
111
184
|
}
|
|
112
|
-
if (now() - started
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
185
|
+
if (performance.now() - started >= timeoutMs) {
|
|
186
|
+
const failure = new Error('模型路由状态正在被其他操作使用,保存未完成;请稍后重试。')
|
|
187
|
+
failure.code = 'ROUTER_STATE_LOCK_TIMEOUT'
|
|
188
|
+
throw failure
|
|
116
189
|
}
|
|
117
190
|
await sleep(Math.min(5 + attempt * 5, 50) + Math.floor(Math.random() * 10))
|
|
118
191
|
}
|
|
119
192
|
}
|
|
120
193
|
}
|
|
121
194
|
|
|
122
|
-
export function createRouterState({ file = defaultStatePath(), maxRuns = MAX_RUNS, now = Date.now
|
|
195
|
+
export function createRouterState({ file = defaultStatePath(), maxRuns = MAX_RUNS, now = Date.now,
|
|
196
|
+
lockTimeoutMs = LOCK_TIMEOUT_MS, lockStaleMs = LOCK_STALE_MS } = {}) {
|
|
197
|
+
if (![lockTimeoutMs, lockStaleMs].every(value => Number.isFinite(value) && value >= 1 && value <= 3_600_000)) {
|
|
198
|
+
throw new RangeError('Lock timing must be between 1 and 3600000 milliseconds')
|
|
199
|
+
}
|
|
123
200
|
const historyLimit = Number.isInteger(maxRuns) && maxRuns > 0 ? Math.min(maxRuns, MAX_RUNS) : MAX_RUNS
|
|
124
201
|
let cache = null
|
|
125
202
|
let cacheKey = ''
|
|
203
|
+
let cacheCorrupt = false
|
|
126
204
|
let chain = Promise.resolve()
|
|
127
205
|
let pendingNotices = []
|
|
128
206
|
const lockFile = `${file}.lock`
|
|
@@ -131,43 +209,53 @@ export function createRouterState({ file = defaultStatePath(), maxRuns = MAX_RUN
|
|
|
131
209
|
return info ? `${info.mtimeMs}:${info.size}:${info.ino}` : 'missing'
|
|
132
210
|
}
|
|
133
211
|
/** Parse the file; keep an unparseable file as a backup and record a notice. */
|
|
134
|
-
const readFresh = async () => {
|
|
212
|
+
const readFresh = async ({ recoverCorrupt = false } = {}) => {
|
|
135
213
|
let raw
|
|
136
214
|
try { raw = await readFile(file, 'utf8') }
|
|
137
215
|
catch (error) {
|
|
138
216
|
if (error?.code === 'ENOENT') return { state: emptyState(), corrupt: false }
|
|
139
217
|
throw error
|
|
140
218
|
}
|
|
141
|
-
|
|
219
|
+
let parsed
|
|
220
|
+
try { parsed = JSON.parse(raw) }
|
|
142
221
|
catch {
|
|
222
|
+
// Readers do not rename files outside the writer lock. Recheck under the
|
|
223
|
+
// lock so a concurrent valid replacement cannot be discarded as corrupt.
|
|
224
|
+
if (!recoverCorrupt) return { state: emptyState(), corrupt: true }
|
|
143
225
|
const at = now()
|
|
144
|
-
const backup = `${file}.corrupt-${stamp(at)}`
|
|
145
|
-
|
|
146
|
-
try { await rename(file, backup) } catch { saved = false }
|
|
226
|
+
const backup = `${file}.corrupt-${stamp(at)}-${randomUUID()}`
|
|
227
|
+
await rename(file, backup)
|
|
147
228
|
pendingNotices.push({
|
|
148
|
-
kind: 'state-corrupt', at, backup
|
|
149
|
-
message:
|
|
150
|
-
? `模型路由状态文件已损坏,无法读取,已备份为 ${backup} 并重新开始记录。执行历史、评价和订阅冷却信息需要从备份中人工恢复。`
|
|
151
|
-
: '模型路由状态文件已损坏,无法读取,备份失败;已重新开始记录。',
|
|
229
|
+
kind: 'state-corrupt', at, backup,
|
|
230
|
+
message: `模型路由状态文件已损坏,无法读取,已备份为 ${backup} 并重新开始记录。执行历史、评价和订阅冷却信息需要从备份中人工恢复。`,
|
|
152
231
|
})
|
|
153
|
-
return { state: emptyState(), corrupt: true }
|
|
232
|
+
return { state: { ...emptyState(), historyIncomplete: true }, corrupt: true }
|
|
154
233
|
}
|
|
234
|
+
// Valid JSON with an unknown schema is not corrupt: preserve it exactly,
|
|
235
|
+
// and refuse both reads-as-empty and writes that would downgrade it.
|
|
236
|
+
return { state: sanitize(parsed, historyLimit), corrupt: false }
|
|
155
237
|
}
|
|
156
238
|
const load = async () => {
|
|
157
239
|
const key = await fileKey()
|
|
158
240
|
if (cache && key === cacheKey) return cache
|
|
159
241
|
const fresh = await readFresh()
|
|
160
242
|
cache = fresh.state
|
|
161
|
-
cacheKey =
|
|
243
|
+
cacheKey = key
|
|
244
|
+
cacheCorrupt = fresh.corrupt
|
|
162
245
|
return cache
|
|
163
246
|
}
|
|
164
247
|
const persist = async state => {
|
|
165
248
|
await mkdir(dirname(file), { recursive: true })
|
|
249
|
+
const committed = structuredClone(state)
|
|
250
|
+
const serialized = `${JSON.stringify(committed)}\n`
|
|
166
251
|
const temp = `${file}.${process.pid}.${now()}.${Math.random().toString(36).slice(2, 8)}.tmp`
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
252
|
+
try {
|
|
253
|
+
await writeFile(temp, serialized, 'utf8')
|
|
254
|
+
await rename(temp, file)
|
|
255
|
+
} finally { await unlink(temp).catch(() => {}) }
|
|
256
|
+
cache = committed
|
|
170
257
|
cacheKey = await fileKey()
|
|
258
|
+
cacheCorrupt = false
|
|
171
259
|
}
|
|
172
260
|
/**
|
|
173
261
|
* Serialize read-modify-write updates within this process and, through the
|
|
@@ -176,17 +264,26 @@ export function createRouterState({ file = defaultStatePath(), maxRuns = MAX_RUN
|
|
|
176
264
|
const update = change => {
|
|
177
265
|
const next = chain.then(async () => {
|
|
178
266
|
await mkdir(dirname(file), { recursive: true })
|
|
179
|
-
const release = await acquireLock(lockFile, { now })
|
|
267
|
+
const release = await acquireLock(lockFile, { now, timeoutMs: lockTimeoutMs, staleMs: lockStaleMs })
|
|
180
268
|
try {
|
|
181
269
|
cache = null
|
|
182
|
-
const state = await
|
|
183
|
-
if (pendingNotices.length)
|
|
270
|
+
const { state } = await readFresh({ recoverCorrupt: true })
|
|
271
|
+
if (pendingNotices.length) state.notices.push(...pendingNotices)
|
|
184
272
|
const result = await change(state)
|
|
273
|
+
const returned = result === undefined ? undefined : structuredClone(result)
|
|
185
274
|
retainRuns(state, historyLimit)
|
|
186
275
|
state.notices = sanitizeNotices(state.notices)
|
|
187
276
|
state.terminalSessions = sanitizeTerminalSessions(state.terminalSessions)
|
|
188
277
|
await persist(state)
|
|
189
|
-
|
|
278
|
+
pendingNotices = []
|
|
279
|
+
return returned
|
|
280
|
+
} catch (error) {
|
|
281
|
+
// A rejected callback or disk write must not leak uncommitted state
|
|
282
|
+
// into the reader cache, including partial rating/budget changes.
|
|
283
|
+
cache = null
|
|
284
|
+
cacheKey = ''
|
|
285
|
+
cacheCorrupt = false
|
|
286
|
+
throw error
|
|
190
287
|
} finally {
|
|
191
288
|
await release()
|
|
192
289
|
}
|
|
@@ -198,9 +295,13 @@ export function createRouterState({ file = defaultStatePath(), maxRuns = MAX_RUN
|
|
|
198
295
|
const read = async () => {
|
|
199
296
|
await chain
|
|
200
297
|
const state = await load()
|
|
201
|
-
if (pendingNotices.length) {
|
|
298
|
+
if (cacheCorrupt || pendingNotices.length) {
|
|
202
299
|
const notices = [...pendingNotices]
|
|
203
|
-
try { await update(() => {}) }
|
|
300
|
+
try { await update(() => {}) }
|
|
301
|
+
catch {
|
|
302
|
+
return structuredClone({ ...state, notices: [...state.notices, ...notices,
|
|
303
|
+
{ kind: 'state-recovery-pending', at: now(), message: '模型路由状态恢复未完成;原文件保留,请稍后重试。' }] })
|
|
304
|
+
}
|
|
204
305
|
return structuredClone(cache)
|
|
205
306
|
}
|
|
206
307
|
return structuredClone(state)
|
|
@@ -233,10 +334,10 @@ export function createRouterState({ file = defaultStatePath(), maxRuns = MAX_RUN
|
|
|
233
334
|
return entry
|
|
234
335
|
}),
|
|
235
336
|
appendRun: run => update(current => { current.runs.push(run); return run }),
|
|
236
|
-
updateRun: (id, change) => update(current => {
|
|
337
|
+
updateRun: (id, change) => update(async current => {
|
|
237
338
|
const run = current.runs.find(item => item.id === id)
|
|
238
339
|
if (!run) throw new Error(`未找到运行记录 ${id}`)
|
|
239
|
-
change(run)
|
|
340
|
+
await change(run)
|
|
240
341
|
return structuredClone(run)
|
|
241
342
|
}),
|
|
242
343
|
/** Remove one notice (for example after the user read the corrupt-file warning). */
|