dsh-armory 1.9.4 → 1.10.0
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.en.md +8 -34
- package/README.md +8 -34
- package/docs/chunk-split-feasibility.md +153 -0
- package/docs/host-proposal-mcp-teardown.md +184 -0
- package/docs/host-proposal-skill-toggle.md +166 -0
- package/lib/client.js +2926 -185
- package/lib/index.js +41 -4
- package/lib/mcp-catalog.js +104 -0
- package/lib/mcp-config.js +10 -1
- package/lib/mcp-import-sources.js +154 -14
- package/lib/tool-ledger.js +119 -0
- package/lib/typert.js +2 -0
- package/lib/wb-market.js +19 -0
- package/lib/whats-new.js +45 -0
- package/lib/workspace-runtime.js +133 -4
- package/package.json +9 -8
|
@@ -527,6 +527,127 @@ function parseFailure(error, text) {
|
|
|
527
527
|
return 0
|
|
528
528
|
}
|
|
529
529
|
|
|
530
|
+
// ---------------------------------------------------------------------------
|
|
531
|
+
// VS Code `${input:...}`:逐条降级,不整份拒绝
|
|
532
|
+
// ---------------------------------------------------------------------------
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* VS Code 的 `${input:名称}`:值由人在**导入时**填写,DSH 读配置的时候无从知道。
|
|
536
|
+
* 它是 VS Code 专属写法,出现在别的客户端配置里也代表同一件事。
|
|
537
|
+
*/
|
|
538
|
+
const VSCODE_INPUT = /\$\{input:([^}]*)\}/g
|
|
539
|
+
|
|
540
|
+
/** 一个字符串里出现的 input 名,按出现顺序去重。 */
|
|
541
|
+
function vscodeInputNames(value) {
|
|
542
|
+
if (typeof value !== 'string' || !value.includes('${input:')) return []
|
|
543
|
+
const names = []
|
|
544
|
+
for (const match of value.matchAll(VSCODE_INPUT)) {
|
|
545
|
+
const name = match[1].trim()
|
|
546
|
+
if (name && !names.includes(name)) names.push(name)
|
|
547
|
+
}
|
|
548
|
+
return names
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/** 报错/提示里统一用 `${input:x}` 原样回显名字(名字本身不是密钥)。 */
|
|
552
|
+
const showInputs = (names) => names.map((name) => `\${input:${name}}`).join('、')
|
|
553
|
+
|
|
554
|
+
/** 条目会走哪条传输:只用于把不支持项说清楚,不参与归一化判定。 */
|
|
555
|
+
function guessTransport(raw) {
|
|
556
|
+
if (typeof raw.type === 'string' && raw.type.trim()) return raw.type.trim()
|
|
557
|
+
if (typeof raw.transport === 'string' && raw.transport.trim()) return raw.transport.trim()
|
|
558
|
+
if (typeof raw.command === 'string' && raw.command.trim()) return 'stdio'
|
|
559
|
+
const url = raw.url ?? raw.serverUrl ?? raw.server_url
|
|
560
|
+
return typeof url === 'string' && url.trim() ? 'streamable-http' : 'unknown'
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* 把一条 VS Code 条目里的 `${input:...}` 按位置分三类处理:
|
|
565
|
+
*
|
|
566
|
+
* - **url / command / cwd**:地址或可执行文件本身就是交互值,剔掉就没得导了,
|
|
567
|
+
* 所以整条标记为不支持、不进 `entries`。
|
|
568
|
+
* - **env / headers 的某一项**:先剔掉那一项,剩下的照常导入——少一个环境变量
|
|
569
|
+
* 会变成一个看得见的启动/鉴权失败,比整条消失更容易被用户理解和补救。
|
|
570
|
+
* - **args 的某一个元素**:同上,剔掉那个参数。
|
|
571
|
+
*
|
|
572
|
+
* 返回 `blocked` 非空表示整条不可用;否则 `raw` 是剔干净后的副本。
|
|
573
|
+
*/
|
|
574
|
+
function splitVscodeInputs(name, raw) {
|
|
575
|
+
// 顺序固定成 url → command → cwd → args → env → headers:前面出现就整条判死,
|
|
576
|
+
// 后面即使也用到 input 也一并记进 names,方便用户一次看全。
|
|
577
|
+
const hard = []
|
|
578
|
+
for (const field of ['url', 'serverUrl', 'server_url', 'command', 'cwd']) {
|
|
579
|
+
for (const found of vscodeInputNames(raw[field])) if (!hard.includes(found)) hard.push(found)
|
|
580
|
+
}
|
|
581
|
+
if (hard.length) {
|
|
582
|
+
for (const field of ['args', 'env', 'headers']) {
|
|
583
|
+
for (const value of field === 'args' && Array.isArray(raw.args) ? raw.args : Object.values(isObject(raw[field]) ? raw[field] : {})) {
|
|
584
|
+
for (const found of vscodeInputNames(value)) if (!hard.includes(found)) hard.push(found)
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
return { blocked: hard, raw: null, warnings: [] }
|
|
588
|
+
}
|
|
589
|
+
const warnings = []
|
|
590
|
+
const soft = []
|
|
591
|
+
const clean = { ...raw }
|
|
592
|
+
const args = stripFromArgs(name, raw.args, soft, warnings)
|
|
593
|
+
if (args === undefined) delete clean.args
|
|
594
|
+
else clean.args = args
|
|
595
|
+
for (const field of ['env', 'headers']) {
|
|
596
|
+
// 不是对象就原样留给下游报错:这里静默删掉会把"env 写错了"变成"没有 env"。
|
|
597
|
+
if (!isObject(raw[field])) continue
|
|
598
|
+
const table = stripFromMap(name, field, raw[field], soft, warnings)
|
|
599
|
+
// 整个字段被剔空时删掉它,别让下游把 `env: {}` 当成"用户写了空表"。
|
|
600
|
+
if (table === undefined) delete clean[field]
|
|
601
|
+
else setOwn(clean, field, table)
|
|
602
|
+
}
|
|
603
|
+
return { blocked: [], raw: clean, warnings, names: soft }
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* args 里带 `${input:...}` 的元素剔除。剔除后可能剩一个空数组——那就把字段删掉,
|
|
608
|
+
* 别让下游把 `args: []` 当成"用户显式写了空参数表"。
|
|
609
|
+
*/
|
|
610
|
+
function stripFromArgs(name, args, soft, warnings) {
|
|
611
|
+
// `args` 写成单个字符串时下游会原样当成一个参数:它带 input 就同样不可用。
|
|
612
|
+
if (typeof args === 'string') {
|
|
613
|
+
const found = vscodeInputNames(args)
|
|
614
|
+
if (!found.length) return args
|
|
615
|
+
for (const inputName of found) if (!soft.includes(inputName)) soft.push(inputName)
|
|
616
|
+
warnings.push(`${name}.args 里的 ${showInputs(found)} 需要交互取值,已跳过这个参数`)
|
|
617
|
+
return undefined
|
|
618
|
+
}
|
|
619
|
+
if (!Array.isArray(args)) return args
|
|
620
|
+
const kept = []
|
|
621
|
+
for (const item of args) {
|
|
622
|
+
const found = vscodeInputNames(item)
|
|
623
|
+
if (!found.length) {
|
|
624
|
+
kept.push(item)
|
|
625
|
+
continue
|
|
626
|
+
}
|
|
627
|
+
for (const inputName of found) if (!soft.includes(inputName)) soft.push(inputName)
|
|
628
|
+
warnings.push(`${name}.args 里的 ${showInputs(found)} 需要交互取值,已跳过这个参数`)
|
|
629
|
+
}
|
|
630
|
+
return kept.length || args.length === 0 ? kept : undefined
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/** env / headers 里值带 `${input:...}` 的项剔除。整表剔空时删掉字段。 */
|
|
634
|
+
function stripFromMap(name, field, table, soft, warnings) {
|
|
635
|
+
if (!isObject(table)) return undefined
|
|
636
|
+
const kept = {}
|
|
637
|
+
let dropped = 0
|
|
638
|
+
for (const [key, value] of Object.entries(table)) {
|
|
639
|
+
const found = vscodeInputNames(value)
|
|
640
|
+
if (!found.length) {
|
|
641
|
+
setOwn(kept, key, value)
|
|
642
|
+
continue
|
|
643
|
+
}
|
|
644
|
+
dropped += 1
|
|
645
|
+
for (const inputName of found) if (!soft.includes(inputName)) soft.push(inputName)
|
|
646
|
+
warnings.push(`${name}.${field}.${key} 需要交互取值(${showInputs(found)}),已跳过`)
|
|
647
|
+
}
|
|
648
|
+
return dropped && !Object.keys(kept).length ? undefined : kept
|
|
649
|
+
}
|
|
650
|
+
|
|
530
651
|
/**
|
|
531
652
|
* 按来源解析文本并归一化成 DSH spec。逐条目容错:一条不支持不影响其余条目。
|
|
532
653
|
* 返回的 `spec` 是 Host 侧内部值(含 `!!js` 表达式与字面密钥),**不得**直接发给浏览器。
|
|
@@ -541,33 +662,50 @@ export function normalizeSourceText(source, text, format = 'json') {
|
|
|
541
662
|
parsed = parseSourceText(format, content)
|
|
542
663
|
} catch (error) {
|
|
543
664
|
const line = parseFailure(error, content)
|
|
544
|
-
return { entries: [], warnings: [], error: `不是合法 ${PARSE_LABEL[format] || format}${line ? `(第 ${line} 行附近)` : ''}` }
|
|
665
|
+
return { entries: [], warnings: [], unsupportedEntries: [], error: `不是合法 ${PARSE_LABEL[format] || format}${line ? `(第 ${line} 行附近)` : ''}` }
|
|
545
666
|
}
|
|
546
|
-
if (!isObject(parsed)) return { entries: [], warnings: [], error: '配置根节点必须是对象' }
|
|
547
|
-
// VS Code 的
|
|
548
|
-
//
|
|
667
|
+
if (!isObject(parsed)) return { entries: [], warnings: [], unsupportedEntries: [], error: '配置根节点必须是对象' }
|
|
668
|
+
// OPT-17:VS Code 的 `${input:...}` 原本在这里整份拒绝,结果是一条交互变量吞掉
|
|
669
|
+
// 文件里其余 19 条能用的服务。改成逐条降级:位置可剔的(env/headers/args)剔掉后
|
|
670
|
+
// 照常导入并在 warnings 里说清楚,位置不可剔的(url/command/cwd)只把那一条记进
|
|
671
|
+
// unsupportedEntries,`error` 只留给"整份文件读不懂"。
|
|
549
672
|
const warnings = []
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
673
|
+
const unsupportedEntries = []
|
|
674
|
+
// 声明了 `inputs` 只说明这份文件"可能"用到交互变量,不再是拒绝理由;真正的判定
|
|
675
|
+
// 落在每个条目上——没被引用的 inputs 声明不该影响任何导入,也不该冒出一条噪音提示。
|
|
553
676
|
const selected = source.select(parsed, warnings)
|
|
554
|
-
if (selected === null) return { entries: [], warnings, error: '没有找到该客户端使用的 MCP 段落' }
|
|
677
|
+
if (selected === null) return { entries: [], warnings, unsupportedEntries, error: '没有找到该客户端使用的 MCP 段落' }
|
|
555
678
|
const entries = []
|
|
556
679
|
for (const item of selected) {
|
|
557
680
|
const carried = item.warnings || []
|
|
558
681
|
if (item.error) {
|
|
559
|
-
entries.push({ name: item.name, spec: null, error: item.error, warnings: carried })
|
|
682
|
+
entries.push({ name: item.name, spec: null, error: item.error, warnings: carried, unsupported: null })
|
|
683
|
+
continue
|
|
684
|
+
}
|
|
685
|
+
const split = splitVscodeInputs(item.name, item.raw)
|
|
686
|
+
const merged = [...carried, ...split.warnings]
|
|
687
|
+
// url / command / cwd 本身是交互值:这条没法安全转换。不进 `entries`(下游只认
|
|
688
|
+
// 可导入的 spec),改在 `unsupportedEntries` 里留一份可展示信息,别让它凭空消失。
|
|
689
|
+
if (split.blocked.length) {
|
|
690
|
+
unsupportedEntries.push({
|
|
691
|
+
name: item.name,
|
|
692
|
+
transport: guessTransport(item.raw),
|
|
693
|
+
reason: `地址/命令里用到 ${showInputs(split.blocked)},需要交互取值,DSH 无法安全转换`,
|
|
694
|
+
names: split.blocked,
|
|
695
|
+
})
|
|
560
696
|
continue
|
|
561
697
|
}
|
|
562
698
|
try {
|
|
563
|
-
const normalized = normalizeMcpImport({ mcpServers: { [item.name]:
|
|
564
|
-
entries.push({ name: item.name, spec: normalized.servers[0], error: null, warnings: [...
|
|
699
|
+
const normalized = normalizeMcpImport({ mcpServers: { [item.name]: split.raw } })
|
|
700
|
+
entries.push({ name: item.name, spec: normalized.servers[0], error: null, warnings: [...merged, ...normalized.warnings], unsupported: null })
|
|
565
701
|
} catch (error) {
|
|
566
|
-
entries.push({ name: item.name, spec: null, error: String(error?.message ?? error), warnings:
|
|
702
|
+
entries.push({ name: item.name, spec: null, error: String(error?.message ?? error), warnings: merged, unsupported: null })
|
|
567
703
|
}
|
|
568
704
|
}
|
|
569
|
-
|
|
570
|
-
|
|
705
|
+
// 全部条目都不可用时 `error` 仍然是 null:上层要能说"3 条不支持 / 0 条可导入",
|
|
706
|
+
// 而不是一句"失败"。只有真的一条 MCP 都没找到才落到下面的文案。
|
|
707
|
+
if (!entries.length && !unsupportedEntries.length) return { entries, warnings, unsupportedEntries, error: '这条配置里没有 MCP' }
|
|
708
|
+
return { entries, warnings, unsupportedEntries, error: '' }
|
|
571
709
|
}
|
|
572
710
|
|
|
573
711
|
export function hashSourceText(text) {
|
|
@@ -640,6 +778,7 @@ async function inspectImportLocation(source, location, ctx, readText) {
|
|
|
640
778
|
error: '',
|
|
641
779
|
contentHash: '',
|
|
642
780
|
entries: [],
|
|
781
|
+
unsupportedEntries: [],
|
|
643
782
|
warnings: [],
|
|
644
783
|
}
|
|
645
784
|
const picked = await pickCandidate(location, readText)
|
|
@@ -655,6 +794,7 @@ async function inspectImportLocation(source, location, ctx, readText) {
|
|
|
655
794
|
row.contentHash = hashSourceText(picked.text)
|
|
656
795
|
const normalized = normalizeSourceText(source, picked.text, picked.candidate.format)
|
|
657
796
|
row.entries = normalized.entries
|
|
797
|
+
row.unsupportedEntries = normalized.unsupportedEntries
|
|
658
798
|
row.warnings = normalized.warnings
|
|
659
799
|
row.error = normalized.error
|
|
660
800
|
return row
|
package/lib/tool-ledger.js
CHANGED
|
@@ -212,6 +212,16 @@ export async function scanSessionLedger({ days, mcpServerNames, schemas, disable
|
|
|
212
212
|
let totalErrors = 0;
|
|
213
213
|
let orphanResults = 0;
|
|
214
214
|
let failedFrames = 0;
|
|
215
|
+
// §5.4 逐日趋势:扫描时顺手按天分桶,不额外遍历日志。
|
|
216
|
+
// 桶只存「窗口内的相对天序号」(0 = 最早一天,windowDays-1 = 今天),
|
|
217
|
+
// 由界面负责渲染;返回绝对日期反而让前端多一次时区换算的机会出错。
|
|
218
|
+
const dayBuckets = new Array(windowDays).fill(0);
|
|
219
|
+
const dayIndex = (time) => {
|
|
220
|
+
if (typeof time !== "number") return null;
|
|
221
|
+
const offset = now - time;
|
|
222
|
+
const idx = windowDays - 1 - Math.floor(offset / 86_400_000);
|
|
223
|
+
return idx >= 0 && idx < windowDays ? idx : null;
|
|
224
|
+
};
|
|
215
225
|
const failures = (reason) => {
|
|
216
226
|
failedFiles += 1;
|
|
217
227
|
if (failedFileSamples.length < 20) failedFileSamples.push(reason);
|
|
@@ -301,6 +311,8 @@ export async function scanSessionLedger({ days, mcpServerNames, schemas, disable
|
|
|
301
311
|
record.calls += 1;
|
|
302
312
|
totalCalls += 1;
|
|
303
313
|
dirHadCall = true;
|
|
314
|
+
const bucket = dayIndex(time);
|
|
315
|
+
if (bucket !== null) dayBuckets[bucket] += 1;
|
|
304
316
|
if (callId) record.seenCallIds.add(callId);
|
|
305
317
|
if (time !== null) {
|
|
306
318
|
if (record.firstUsedMs === null || time < record.firstUsedMs) record.firstUsedMs = time;
|
|
@@ -414,6 +426,10 @@ export async function scanSessionLedger({ days, mcpServerNames, schemas, disable
|
|
|
414
426
|
// 界面在 zstd 降级时会提示「升级到 22.15 以上」,得能说出用户当前跑的是什么版本,
|
|
415
427
|
// 否则那句提示等于没说。取宿主进程自己的版本(不是 package.json 的 engines 声明)。
|
|
416
428
|
nodeVersion: process.versions.node,
|
|
429
|
+
// OPT-19:解压文本缓存的占用快照(见 textCacheStats)。128 MB 常驻占用此前对用户
|
|
430
|
+
// 完全不可见——报告带上它,面板才有数据展示「缓存占了多少、离上界还有多远」,并
|
|
431
|
+
// 在接近上界时引导用户手动回收(清空动作由 index.js 的 toolTailor 方法承接)。
|
|
432
|
+
textCache: textCacheStats(),
|
|
417
433
|
totalCalls,
|
|
418
434
|
totalResults,
|
|
419
435
|
totalErrors,
|
|
@@ -424,6 +440,109 @@ export async function scanSessionLedger({ days, mcpServerNames, schemas, disable
|
|
|
424
440
|
deadMcpCount: deadMcpTools.length,
|
|
425
441
|
taxTotalTokens,
|
|
426
442
|
deadMcpTaxTokens,
|
|
443
|
+
// §5.4 趋势数据:逐日调用量(下标 0 = 窗口最早一天)。
|
|
444
|
+
// 界面只在「确实有调用」时画 sparkline,全 0 从这里就能看出来,不必前端猜。
|
|
445
|
+
dailyCalls: dayBuckets,
|
|
427
446
|
tools,
|
|
428
447
|
};
|
|
429
448
|
}
|
|
449
|
+
|
|
450
|
+
// OPT-19:解压文本缓存(OPT-03)的**可观测性**与**手动回收**。128 MB 上界在长时间运行的
|
|
451
|
+
// 宿主里是笔实实在在的常驻占用,但本模块此前只导出 scanSessionLedger,条数/字节数/新鲜度全
|
|
452
|
+
// 关在模块私有作用域里:用户既看不到占用,也没有任何回收手段,出问题只能重启宿主。下面两个
|
|
453
|
+
// 导出只读写模块私有状态——不读盘、不解压、不新增 RPC 契约(接线到宿主由 index.js 另行负责)。
|
|
454
|
+
|
|
455
|
+
/** utilization 的量化倍数:10^4,即保留 4 位小数(0.0001 精度),避免浮点长尾进入面板文案。 */
|
|
456
|
+
const UTILIZATION_SCALE = 10_000;
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* 从缓存条目的戳(`size:mtimeMs`)里取出**毫秒时间戳**。
|
|
460
|
+
*
|
|
461
|
+
* 戳是复合串:它同时编码落盘体积与 mtime,用来判「内容是否变化」(见 readLogTextCached)。
|
|
462
|
+
* 但对外契约要的是 number ——「这条缓存有多久没动」是个时间点,复合串既不是数字、也不能
|
|
463
|
+
* 直接按字典序比较大小(`"9:..."` > `"10:..."`),所以这里只取后段的 mtimeMs。解析不出来
|
|
464
|
+
* (历史遗留条目或将来改了格式)时返回 null,由调用方跳过该条,绝不让 NaN 污染最值聚合。
|
|
465
|
+
* @param {{ stamp?: unknown }} entry - 缓存条目。
|
|
466
|
+
* @returns {number|null} 毫秒时间戳,无法解析时为 null。
|
|
467
|
+
*/
|
|
468
|
+
function stampTimeMs(entry) {
|
|
469
|
+
if (typeof entry?.stamp !== "string") return null;
|
|
470
|
+
const sep = entry.stamp.lastIndexOf(":");
|
|
471
|
+
if (sep === -1) return null;
|
|
472
|
+
const ms = Number(entry.stamp.slice(sep + 1));
|
|
473
|
+
return Number.isFinite(ms) ? ms : null;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* 解压文本缓存的只读快照。**纯数据、无副作用**:不读盘、不解压、不改变 LRU 顺序,可以在
|
|
478
|
+
* 面板渲染时顺手取一次而不用担心干扰扫描。
|
|
479
|
+
*
|
|
480
|
+
* `utilization` 取「字节占用率」与「条数占用率」的**较大者**:rememberCacheEntry 的收缩条件
|
|
481
|
+
* 是 `size > maxEntries || bytes > maxBytes`,任一维度逼近上界都会触发淘汰;只看字节会漏掉
|
|
482
|
+
* 「32 条小文件把条数打满、字节却很低」这种同样会开始丢缓存的情况。
|
|
483
|
+
* @returns {{
|
|
484
|
+
* entries: number,
|
|
485
|
+
* bytes: number,
|
|
486
|
+
* maxEntries: number,
|
|
487
|
+
* maxBytes: number,
|
|
488
|
+
* utilization: number,
|
|
489
|
+
* oldestStamp: number|null,
|
|
490
|
+
* newestStamp: number|null
|
|
491
|
+
* }} 缓存占用快照;空缓存时 entries/bytes/utilization 全为 0,两个戳为 null。
|
|
492
|
+
*/
|
|
493
|
+
export function textCacheStats() {
|
|
494
|
+
let oldestStamp = null;
|
|
495
|
+
let newestStamp = null;
|
|
496
|
+
for (const entry of textCache.values()) {
|
|
497
|
+
const stamp = stampTimeMs(entry);
|
|
498
|
+
if (stamp === null) continue;
|
|
499
|
+
if (oldestStamp === null || stamp < oldestStamp) oldestStamp = stamp;
|
|
500
|
+
if (newestStamp === null || stamp > newestStamp) newestStamp = stamp;
|
|
501
|
+
}
|
|
502
|
+
const entries = textCache.size;
|
|
503
|
+
const bytes = Number.isFinite(textCacheBytes) ? textCacheBytes : 0;
|
|
504
|
+
// 必须 clamp:淘汰是「先写入、再按预算收缩」,单条最大可到 128 MB,写后到收缩之间瞬时
|
|
505
|
+
// 会越过上界(raw 可能 > 1);下界 0 则是防计数漂移(理论上不该为负,但负数利用率上屏
|
|
506
|
+
// 毫无意义)。四舍五入放在 clamp 之后,保证结果恒在 [0, 1] 且只保留 4 位小数。
|
|
507
|
+
const raw = Math.max(bytes / TEXT_CACHE_MAX_BYTES, entries / TEXT_CACHE_MAX_ENTRIES);
|
|
508
|
+
const utilization = Math.round(Math.min(Math.max(raw, 0), 1) * UTILIZATION_SCALE) / UTILIZATION_SCALE;
|
|
509
|
+
return {
|
|
510
|
+
entries,
|
|
511
|
+
bytes,
|
|
512
|
+
maxEntries: TEXT_CACHE_MAX_ENTRIES,
|
|
513
|
+
maxBytes: TEXT_CACHE_MAX_BYTES,
|
|
514
|
+
utilization,
|
|
515
|
+
oldestStamp,
|
|
516
|
+
newestStamp,
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* 清空解压文本缓存并复位计数,返回被清掉的内容概要(供面板说「已释放 x MB」)。
|
|
522
|
+
*
|
|
523
|
+
* 实现顺序是有意为之——**先取 size 快照 → 再 clear → 最后把计数显式归零**:
|
|
524
|
+
* 1. 不能先 `textCache.clear()` 再取 size / 遍历求和:那时 Map 已空,clearedEntries 与
|
|
525
|
+
* clearedBytes 会恒为 0,面板就永远只能说「已释放 0 MB」;
|
|
526
|
+
* 2. 也不能指望 `dropCacheEntry` 顺手收拾计数——它按 logPath 逐条 `-=`,必须先于 clear
|
|
527
|
+
* 调用才有意义;clear 之后它拿不到任何条目,等于什么也没做(这正是最容易被写反的地方);
|
|
528
|
+
* 3. 计数变量 `textCacheBytes` 是**独立**于 Map 的,`Map.clear()` 不会动它。漏掉归零就会
|
|
529
|
+
* 留下一笔幽灵占用:此后 rememberCacheEntry 的预算循环会立刻判定超限并把刚写进去的条目
|
|
530
|
+
* 淘汰掉(表现为缓存「莫名其妙永不命中」)。所以这里无条件赋 0,而不是 `-= clearedBytes`。
|
|
531
|
+
*
|
|
532
|
+
* 安全性:清空后下一次 scanSessionLedger 走的是 readLogTextCached 的未命中分支(读盘 →
|
|
533
|
+
* 解压 → 回填),完全不依赖缓存状态,只是「重新解压一遍」,不会抛错。风险点其实在反方向:
|
|
534
|
+
* 清空后若 `textCacheBytes` 没归零(留着正数),后续每一次 rememberCacheEntry 都会在预算
|
|
535
|
+
* 循环里把刚写进来的条立刻丢掉,看上去就像「缓存失效了」;归零才不会把缓存卡死。
|
|
536
|
+
* 幂等:空缓存上再调一次,返回 clearedEntries 与 clearedBytes 均为 0。
|
|
537
|
+
* @returns {{ clearedEntries: number, clearedBytes: number, entries: number, bytes: number }}
|
|
538
|
+
* 被清掉的条数/字节数;entries 与 bytes 恒为 0,表示清空后的状态。
|
|
539
|
+
*/
|
|
540
|
+
export function clearTextCache() {
|
|
541
|
+
const clearedEntries = textCache.size;
|
|
542
|
+
// 负值只可能来自计数漂移;对面板而言「-0.0 MB」没有任何意义,按 0 呈现。清空后的状态
|
|
543
|
+
// 则无条件归零,漂移不会被带进下一轮的预算判断。
|
|
544
|
+
const clearedBytes = Number.isFinite(textCacheBytes) ? Math.max(textCacheBytes, 0) : 0;
|
|
545
|
+
textCache.clear();
|
|
546
|
+
textCacheBytes = 0;
|
|
547
|
+
return { clearedEntries, clearedBytes, entries: 0, bytes: 0 };
|
|
548
|
+
}
|
package/lib/typert.js
CHANGED
|
@@ -85,6 +85,8 @@ export const TYPERT = {
|
|
|
85
85
|
// WorkBuddy 市场直连(连接器 MCP + SkillHub 技能)。payload 全部 $any:数据形状由
|
|
86
86
|
// lib/wb-market.js 校验,契约层与 builtins/importJson 同一宽容度。
|
|
87
87
|
mv('wbConnectorCatalog', [], 'WbCatalogResult'),
|
|
88
|
+
// 顶栏「市场」chip 常驻计数:只读磁盘缓存,零网络;无缓存返回 { total: null }。
|
|
89
|
+
mv('wbConnectorCachedCount', [], 'WbCachedCountResult'),
|
|
88
90
|
mv('wbConnectorPreview', [payloadParam], 'WbPreviewResult'),
|
|
89
91
|
mv('wbConnectorInstall', [payloadParam], 'NoteResult'),
|
|
90
92
|
mv('wbSkillCatalog', [payloadParam], 'WbSkillListResult'),
|
package/lib/wb-market.js
CHANGED
|
@@ -190,6 +190,25 @@ export async function wbConnectorPreview(connectorId) {
|
|
|
190
190
|
return { connectorId: id, mcpServers: servers };
|
|
191
191
|
}
|
|
192
192
|
|
|
193
|
+
/**
|
|
194
|
+
* 只读磁盘缓存的市场条目计数(供顶栏 chip 常驻显示,绝不联网)。
|
|
195
|
+
* 磁盘上有 connectors-config.zip 就直接解索引计数;没有就返回 total:null,
|
|
196
|
+
* 等「市场页真实打开过一次」(那会走完整下载并落缓存)之后再查就有数字了。
|
|
197
|
+
* 单独存在的意义:wbConnectorCatalog 即使热缓存也会做一次 304 网络探测,
|
|
198
|
+
* 冷缓存更是 24MB 下载——为顶栏一个数字触发这两者都不值得。
|
|
199
|
+
*/
|
|
200
|
+
export async function wbConnectorCachedCount() {
|
|
201
|
+
try {
|
|
202
|
+
const local = await readFile(zipPath());
|
|
203
|
+
const zip = new ZipReader(local);
|
|
204
|
+
const index = JSON.parse(zip.extract(".codebuddy-connector/connectors.json").toString("utf8"));
|
|
205
|
+
const connectors = Array.isArray(index.connectors) ? index.connectors : [];
|
|
206
|
+
return { total: connectors.filter((entry) => String(entry.id || entry.source || "").trim()).length };
|
|
207
|
+
} catch {
|
|
208
|
+
return { total: null };
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
193
212
|
// ── SkillHub 技能市场 ─────────────────────────────────────────────────
|
|
194
213
|
// 列表:/api/skills?sortBy=downloads|installs|stars|score&page=N&pageSize=60&keyword=..&category=..
|
|
195
214
|
// 详情:/api/v1/skills/:slug?namespace=<handle>(带 latestVersion、publisher、securityReports)
|
package/lib/whats-new.js
CHANGED
|
@@ -288,4 +288,49 @@ export const WHATS_NEW = [
|
|
|
288
288
|
},
|
|
289
289
|
],
|
|
290
290
|
},
|
|
291
|
+
{
|
|
292
|
+
version: '1.10.0',
|
|
293
|
+
items: [
|
|
294
|
+
{
|
|
295
|
+
text: '**英文界面真的能用了**:`t()` 框架从 1.9.x 起就写着「将来往字典合并译文即可,无需改调用点」,但字典实参一直是空的。现在填满 **720 条键**,覆盖源码里全部 685 条中文原文;语言按 `localStorage["dsh-armory.locale"]` → `navigator.language` → `zh-CN` 三级回落,宿主是英文的用户面板自动变英文。顺带修掉 `fmtInt` / `fmtTax` 把千分位写死 `en-US` 的问题',
|
|
296
|
+
where: '—(跟随宿主语言;想强制英文可设 localStorage 的 dsh-armory.locale)',
|
|
297
|
+
},
|
|
298
|
+
{
|
|
299
|
+
text: '**「明明选过仅侧栏入口,怎么又变回弹窗」根治了**:以前 `whatsNew.seen` 只存一个版本号字符串,升级后整个键被当作过期清掉,入口偏好位跟着一起归零。现在两个偏好各自结构化落盘(`whatsNew.state` / `updateHint.state`),读写都保留下旧裸键的回落(老用户升级不丢状态),升级后只有「弹窗展示」位随版本重置',
|
|
300
|
+
where: '更新提示弹窗 + 面板头部更新提示条',
|
|
301
|
+
},
|
|
302
|
+
{
|
|
303
|
+
text: '**连接诊断卡**:Server 详情页新增分区,直接列出这台服务器的连接状态(含会话槽)、会话复用数、空闲回收倒计时、上次回收耗时(超时标红)、配置是否已改动、同名项目冲突——数据来自早就注册、却从未被面板调用过的 `projectConnections`。状态不是 ready 时,卡片底部直接给「重连这台服务器」。按需拉一次不轮询,右上角可刷新',
|
|
304
|
+
where: 'MCP → 项目作用域 → 任一服务器详情',
|
|
305
|
+
},
|
|
306
|
+
{
|
|
307
|
+
text: '**teardown 残留看得见了**:先说明白一件事——**本插件不 spawn 任何 MCP 子进程**(`lib/` 里 `SIGTERM` / `SIGKILL` / `taskkill` 零命中),所以「插件自己按进程树 kill」在架构上不成立,那是宿主 mcp-client 的职责。这个版本做的是本侧可观测:记录 teardown 的耗时与结局(超过 5s 判卡住,只留最近 64 条、10 分钟过期),经既有的 `projectConnections` 行以 `teardown` 字段流出,**没有新增 RPC**;另附上游提案 `docs/host-proposal-mcp-teardown.md`',
|
|
308
|
+
where: '—(诊断数据见连接诊断卡的「上次回收」一行)',
|
|
309
|
+
},
|
|
310
|
+
{
|
|
311
|
+
text: '**导入带 `${input:xxx}` 的 VS Code 配置不再整份失败**:以前只要有一条引用交互输入,整份文件拒绝导入。现在逐条降级——`url` / `command` / `cwd` 这类核心字段含 input 的条目进「不支持」分组并附原因(去掉等于发明一个猜的值,属静默错误),而 `env.*` / `headers.*` / `args` 元素含 input 的只剔该项并给 warning,条目仍可导入。预览弹层按「可导入 / 不支持」分组呈现',
|
|
312
|
+
where: 'MCP → 导入 → 扫描来源(VS Code 配置)',
|
|
313
|
+
},
|
|
314
|
+
{
|
|
315
|
+
text: '**工具裁缝三处改善**:① 时间窗新增「今日」档,调参后看当天效果不用再切到 7 天;② Node < 22.15 时 zstd 统计全 0 会被误读成「没有调用」,现在这些数字渲染成 `—`(数据缺失),与真实 0 区分开;③ 面板新增「缓存占用:x MB / 32 条」一行,配「清空账本缓存」按钮,清完直接用返回报告刷新、不额外扫一遍',
|
|
316
|
+
where: '工具裁缝标签页',
|
|
317
|
+
},
|
|
318
|
+
{
|
|
319
|
+
text: '**专家详情不再只给看 4000 字**:persona 截断处加「展开全文(N 字符)/ 收起」,魔法数字提为 `PERSONA_PREVIEW_MAX`(已召唤 / 市场两个弹层都覆盖);quickPrompts 超过 3 条给「显示全部 N 条 / 收起」;卡片描述 48 字截断处补 `title`,悬停可看全文',
|
|
320
|
+
where: '专家 → 任一专家详情弹层',
|
|
321
|
+
},
|
|
322
|
+
{
|
|
323
|
+
text: '**认证方式有徽章、分页能跳页、只读看板说明白了**:① 内置 MCP 目录早就带 `auth` / `authNote` 元数据,现在按类型给色——需交互授权的(OAuth / OAuth-or-PAT)用警示色,只需填 key 的中性色,`none` 不占位;这两个字段只是展示元数据,绝不写进用户配置。② 三处分页统一改用同一个 `Pager` 组件,带首页/末页 + 页码跳转 + 「第 x / y 页 · 共 N 项」常驻计数。③ 技能看板顶部直接写明只读:宿主只提供 `listSkills` / `skillDetail`,要改内容请编辑 `SKILL.md`',
|
|
324
|
+
where: 'MCP → 内置目录 / 市场连接器 / 市场技能;技能标签页',
|
|
325
|
+
},
|
|
326
|
+
{
|
|
327
|
+
text: '**通用体验四件**:① 首屏数据未到时给轻骨架(`prefers-reduced-motion` 下停用 shimmer),不再「空白半秒像卡死」;② `FAB_SIZE` / `FAB_EDGE` 提到 CSS 之前并用插值,消除「改一处忘一处」;③ 9 处 `role="dialog"` 统一走 `useFocusTrap`——打开聚焦首个可交互元素、Tab 在框内循环、关闭时焦点还原到触发元素;④ 崩溃面板除 retry 外新增「复制错误详情」(`error.stack` + `componentStack` + 时间与 locale 组装入剪贴板),降低回报成本',
|
|
328
|
+
where: '—(面板任意位置;崩溃面板见出错时)',
|
|
329
|
+
},
|
|
330
|
+
{
|
|
331
|
+
text: '**测试从 13 个套件加到 18 个**,把「注释里承诺过、但没测试守得住」的契约都钉死:契约三处一致性、导入源降级结构、i18n 覆盖率双向差集(英文界面上冒中文正是这个缺口)、persona 阈值、裁缝默认窗口、崩溃详情可复制、teardown 残留诊断。每个套件都内置反向自测,证明断言不是恒真',
|
|
332
|
+
where: '—(开发者视角:`npm test`)',
|
|
333
|
+
},
|
|
334
|
+
],
|
|
335
|
+
},
|
|
291
336
|
]
|
package/lib/workspace-runtime.js
CHANGED
|
@@ -172,6 +172,10 @@ async function createSharedConnection(ctx, wsPath, server, clientConfig, configF
|
|
|
172
172
|
// 每次挂载都拆掉重建(那会每个会话创建都白起一次子进程)。再要求连接已存在一段时间,
|
|
173
173
|
// 避免把"刚建连、还在握手"误判成终态。
|
|
174
174
|
const DEAD_CONNECTION_AGE_MS = 5_000;
|
|
175
|
+
// OPT-15:teardown 上界没有硬手段(本插件不持有 MCP 子进程句柄,无法 kill 进程树),
|
|
176
|
+
// 但「卡住」必须可见。取 5s 与 DEAD_CONNECTION_AGE_MS 对齐:同一份连接在同一条时间尺度
|
|
177
|
+
// 上被判定「已死」与「回收卡住」,两个数字不同会让排障时对不上号。
|
|
178
|
+
const TEARDOWN_STUCK_MS = 5_000;
|
|
175
179
|
function isDeadConnection(ctx, entry) {
|
|
176
180
|
if (entry.fiber?.state !== FIBER_ACTIVE) return false;
|
|
177
181
|
if (entry.hadTools !== true) return false;
|
|
@@ -298,6 +302,106 @@ function releaseSharedConnection(ctx, entry) {
|
|
|
298
302
|
armIdleRetire(ctx, entry);
|
|
299
303
|
return undefined;
|
|
300
304
|
}
|
|
305
|
+
|
|
306
|
+
// ---------- OPT-15:teardown 上界可观测化 ----------
|
|
307
|
+
// 为什么只能「观测」而不能「回收」:本模块从不 spawn 任何 MCP 子进程 —— 子进程归宿主的
|
|
308
|
+
// dsh-scope / dsh-mcp-client 所有,插件侧只拿到 `scoped` 句柄,没有 pid、没有 ChildProcess,
|
|
309
|
+
// 因此没有可发信号的句柄(lib/ 下 grep 信号名与 Windows 的进程树结束命令,只命中本段注释)。
|
|
310
|
+
// 上游 @modelcontextprotocol/client 的 stdio transport 里,`close()` 等的是子进程的 `'close'`
|
|
311
|
+
// 事件;孙进程(npx/uvx 包装器、server 内部的 helper)继承并持有 stdio 管道时,`'close'`
|
|
312
|
+
// 永不触发,回收就永久挂在等待上。既无法从这侧打断,就只能把「已经等了多久、有没有超阈、
|
|
313
|
+
// 上次花了多久」变成可见事实,让人自己决定重启宿主。上游提案见 docs/host-proposal-mcp-teardown.md。
|
|
314
|
+
//
|
|
315
|
+
// OPT-15:每次 teardown 的耗时落档,按 app root 分桶,桶内按 connKey 只留最近一条。
|
|
316
|
+
// 只留一条的理由:面板要回答的是「这条连接现在还卡不卡」,不是审计历史;留全量会让
|
|
317
|
+
// 长时间运行的宿主把这本账涨成内存泄漏。上限 TEARDOWN_RECORD_MAX 兜底异常路径。
|
|
318
|
+
const TEARDOWN_RECORD_MAX = 64;
|
|
319
|
+
// OPT-15:「上次回收花了 x ms」只有在它还解释得了现在时才算信息。一条 10 分钟前的回收结果
|
|
320
|
+
// 对「现在卡不卡」没有解释力,留着反而误导(面板摆出一个 12.3s 的读数,读的人会以为那是
|
|
321
|
+
// 刚才发生的事)—— 超过这个年龄就不送了,宁可给 null。
|
|
322
|
+
const TEARDOWN_RECORD_STALE_MS = 600_000;
|
|
323
|
+
const teardownRecords = new WeakMap(); // appRoot -> Map<connKey, record>
|
|
324
|
+
// record 形状(面板契约的一部分,字段名不要改):
|
|
325
|
+
// { wsPath, serverName, startedAt, settledAt, elapsedMs, settled, stuck, error }
|
|
326
|
+
// 未落定时 elapsedMs / stuck 存的是「出生那一刻」的值(0 / false),真正的读数由读取方
|
|
327
|
+
// 现算 —— 存盘值一落地就开始过期,把过期值当事实送出去比不送更糟。
|
|
328
|
+
function beginTeardownRecord(ctx, entry, startedAt) {
|
|
329
|
+
const bucket = bucketOf(teardownRecords, ctx);
|
|
330
|
+
const record = {
|
|
331
|
+
wsPath: entry.wsPath,
|
|
332
|
+
serverName: entry.serverName,
|
|
333
|
+
startedAt,
|
|
334
|
+
settledAt: null,
|
|
335
|
+
elapsedMs: 0,
|
|
336
|
+
settled: false,
|
|
337
|
+
stuck: false,
|
|
338
|
+
error: "",
|
|
339
|
+
};
|
|
340
|
+
// 同一个 connKey 又来一轮(重建后又被拆):覆盖上一轮,仍是「只留最近一条」。
|
|
341
|
+
bucket.delete(entry.key);
|
|
342
|
+
bucket.set(entry.key, record);
|
|
343
|
+
pruneTeardownRecords(bucket);
|
|
344
|
+
return record;
|
|
345
|
+
}
|
|
346
|
+
// OPT-15:落定时改这一条。**绝不**参与返回值与错误传播 —— retireSharedConnection 的
|
|
347
|
+
// `.catch(onError)` 依旧把错误吞成正常 resolve,记录表只是旁路观察者。
|
|
348
|
+
function settleTeardownRecord(ctx, entry, record, message) {
|
|
349
|
+
const bucket = teardownRecords.get(appRoot(ctx));
|
|
350
|
+
// 已被同一 connKey 的下一轮覆盖(上一轮的 teardown 迟到才落定):不动新记录 —— 那一条
|
|
351
|
+
// 描述的是**还在跑**的那一轮,被旧结果改写就会把「正在卡住」显示成「已释放」。
|
|
352
|
+
if (!bucket || bucket.get(entry.key) !== record) return;
|
|
353
|
+
// 先落定者为准。同步抛错时 `beginDispose` 的 catch 会先记下真实错误,随后那条
|
|
354
|
+
// `Promise.resolve(undefined).then(settleOk, ...)` 还会再进来一次并带着空消息 —— 若不拦住,
|
|
355
|
+
// 「同步失败」就会被改写成「干净释放」,错误信息凭空消失。
|
|
356
|
+
if (record.settled) return;
|
|
357
|
+
const settledAt = Date.now();
|
|
358
|
+
record.settledAt = settledAt;
|
|
359
|
+
record.elapsedMs = Math.max(0, settledAt - record.startedAt);
|
|
360
|
+
record.settled = true;
|
|
361
|
+
// 落定之后 stuck 记的是「这一趟有没有卡过」,而不是「此刻还卡不卡」。
|
|
362
|
+
record.stuck = record.elapsedMs >= TEARDOWN_STUCK_MS;
|
|
363
|
+
record.error = message ? String(message) : "";
|
|
364
|
+
}
|
|
365
|
+
// OPT-15:只读取记录,不建桶 —— 读一条不存在的记录不该产生副作用(调用方是只读诊断视图)。
|
|
366
|
+
function teardownRecordFor(ctx, key) {
|
|
367
|
+
const bucket = teardownRecords.get(appRoot(ctx));
|
|
368
|
+
return (bucket && bucket.get(key)) || null;
|
|
369
|
+
}
|
|
370
|
+
function pruneTeardownRecords(bucket) {
|
|
371
|
+
while (bucket.size > TEARDOWN_RECORD_MAX) {
|
|
372
|
+
// 淘汰 startedAt 最旧的一条。桶只在这个量级,线性扫描比维护第二份有序结构更便宜。
|
|
373
|
+
let oldestKey;
|
|
374
|
+
let oldestAt = Infinity;
|
|
375
|
+
for (const [key, record] of bucket) {
|
|
376
|
+
if (record.startedAt <= oldestAt) { oldestAt = record.startedAt; oldestKey = key; }
|
|
377
|
+
}
|
|
378
|
+
if (oldestKey === undefined) return;
|
|
379
|
+
bucket.delete(oldestKey);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
// OPT-15:记录表是纯旁路观察者 —— 它自己出任何问题都不许改变 teardown 的返回值或错误传播。
|
|
383
|
+
// 一次 record 写入失败(弱表已被回收、ctx 畸形等)最坏只是面板少一行读数,绝不能升级成
|
|
384
|
+
// 「原本能正常释放的连接现在释放失败」。所以每次写都套这一层。
|
|
385
|
+
function observeTeardown(write) {
|
|
386
|
+
try { return write(); } catch { return null; }
|
|
387
|
+
}
|
|
388
|
+
// OPT-15:一条记录 → 面板行上的 `teardown` 字段。两种读法分开给,因为它们的语义不同:
|
|
389
|
+
// live —— 还在跑,必须用**现算**的 elapsedMs 重判 stuck(存盘值一落地就过期);
|
|
390
|
+
// final —— 已落定,用存盘终值;超龄则整条不送(见 TEARDOWN_RECORD_STALE_MS)。
|
|
391
|
+
// 共用这一个出处,面板才不会「释放中说 0ms、释放后说 12s」两套说法。
|
|
392
|
+
function teardownLiveView(record, now) {
|
|
393
|
+
if (!record || record.settled) return null;
|
|
394
|
+
const elapsedMs = Math.max(0, now - record.startedAt);
|
|
395
|
+
return { startedAt: record.startedAt, elapsedMs, settled: false, stuck: elapsedMs >= TEARDOWN_STUCK_MS, error: record.error };
|
|
396
|
+
}
|
|
397
|
+
// OPT-15:已落定记录才回答「上次回收花了多久」。未落定的记录在这里**不送**:ready 行描述的是
|
|
398
|
+
// 眼下这条连接,把一个不知道会不会结束的旧 teardown 挂在它身上,读的人会以为刚刚的释放出了问题。
|
|
399
|
+
function teardownFinalView(record, now) {
|
|
400
|
+
if (!record || !record.settled) return null;
|
|
401
|
+
if (now - (record.settledAt ?? record.startedAt) > TEARDOWN_RECORD_STALE_MS) return null;
|
|
402
|
+
return { startedAt: record.startedAt, elapsedMs: record.elapsedMs, settled: true, stuck: record.stuck, error: record.error };
|
|
403
|
+
}
|
|
404
|
+
|
|
301
405
|
// 立刻销毁一份连接:「不再有效」(配置删除/改动、面板重建、插件卸载),而不是「暂时没人用」。
|
|
302
406
|
// 返回 teardown promise,让调用方能把它交回 cordis:会话销毁/插件卸载不会早于 MCP 子进程关闭完成。
|
|
303
407
|
function retireSharedConnection(ctx, entry) {
|
|
@@ -308,17 +412,28 @@ function retireSharedConnection(ctx, entry) {
|
|
|
308
412
|
entry.released = true;
|
|
309
413
|
const bucket = sharedConnBucket(ctx);
|
|
310
414
|
const cell = bucket.get(entry.key);
|
|
311
|
-
|
|
415
|
+
// OPT-15:旁路观察。先落一条「未落定」的记录 —— 即使这趟 teardown 永远不返回,记录表里
|
|
416
|
+
// 也留着它的出生时间,面板才有东西可现算。记录表只写不读回,不影响下面任何分支的返回值。
|
|
417
|
+
const record = observeTeardown(() => beginTeardownRecord(ctx, entry, Date.now()));
|
|
418
|
+
const settleOk = (value) => { observeTeardown(() => settleTeardownRecord(ctx, entry, record, "")); return value; };
|
|
419
|
+
const onError = (error) => {
|
|
420
|
+
// 同步抛错(beginDispose 的 catch)与异步失败走同一个出口,于是两条返回路径都被记到。
|
|
421
|
+
observeTeardown(() => settleTeardownRecord(ctx, entry, record, error?.message ?? error));
|
|
422
|
+
ctx.logger?.error?.(`mcp-manager: 项目 MCP ${entry.serverName} 连接释放失败: ${error?.message ?? error}`);
|
|
423
|
+
};
|
|
312
424
|
// scoped.dispose() 已由 dsh-scope 的 `disposing ??=` 与 cordis 的 runner.epoch 双重幂等守护。
|
|
313
425
|
const beginDispose = () => { try { return entry.scoped.dispose(); } catch (error) { onError(error); return undefined; } };
|
|
426
|
+
// 用 `.then(settleOk, onError)` 而不是 `.finally(settleOk)`:`.finally` 会把错误原样穿过去,
|
|
427
|
+
// 而原代码的语义是 `.catch(onError)` —— 错误在 onError 处就被吞成正常 resolve了。保持原样。
|
|
428
|
+
const disposal = () => Promise.resolve(beginDispose()).then(settleOk, onError);
|
|
314
429
|
if (!cell || cell.entry !== entry) {
|
|
315
430
|
// 该 entry 已不在桶内(HMR/异常路径):直接释放,不留占位。
|
|
316
|
-
return
|
|
431
|
+
return disposal();
|
|
317
432
|
}
|
|
318
433
|
// 转为「释放中」占位:teardown 完成前不删除,期间的 acquire 会等待后重建。
|
|
319
434
|
cell.entry = undefined;
|
|
320
435
|
cell.pending = undefined;
|
|
321
|
-
cell.disposing =
|
|
436
|
+
cell.disposing = disposal().finally(() => {
|
|
322
437
|
if (bucket.get(entry.key) === cell) bucket.delete(entry.key);
|
|
323
438
|
});
|
|
324
439
|
return cell.disposing;
|
|
@@ -617,6 +732,8 @@ function workspaceScopeErrorOf(wsPath, serverName) {
|
|
|
617
732
|
async function projectConnectionsView(ctx) {
|
|
618
733
|
const bucket = sharedConnections.get(appRoot(ctx));
|
|
619
734
|
if (!bucket) return [];
|
|
735
|
+
// OPT-15:一次视图用一个时间基准,同一屏里的「已释放 12.3s」不会各行差几十毫秒。
|
|
736
|
+
const now = Date.now();
|
|
620
737
|
const rows = [];
|
|
621
738
|
for (const [key, cell] of [...bucket]) {
|
|
622
739
|
const separator = key.indexOf("\u0000");
|
|
@@ -628,10 +745,19 @@ async function projectConnectionsView(ctx) {
|
|
|
628
745
|
sessions: sessionSlotCount(ctx, key),
|
|
629
746
|
duplicateOwners: duplicateServerNameOwners(ctx, wsPath, serverName),
|
|
630
747
|
};
|
|
748
|
+
// OPT-15:teardown 读数只从记录表取(只读,不 acquire/release/await teardown —— 本视图的
|
|
749
|
+
// 只读性质不变)。记录表里没有就是 null,不为了「看起来完整」编一个零值。三条取值路径:
|
|
750
|
+
// 释放中 → 「还在跑」的现算读数;未落定记录缺失(已被下一轮覆盖)则退到已落定终值。
|
|
751
|
+
// 已就绪 → 只要已落定终值(「上次回收花了 x ms」),未落定的旧记录不该挂在它身上。
|
|
752
|
+
// 其余 → null(没有可归属于这条连接 teardown 的事实)。
|
|
753
|
+
const record = teardownRecordFor(ctx, key);
|
|
754
|
+
const teardown = cell.disposing
|
|
755
|
+
? (teardownLiveView(record, now) ?? teardownFinalView(record, now))
|
|
756
|
+
: cell.entry ? teardownFinalView(record, now) : null;
|
|
631
757
|
const entry = cell.entry;
|
|
632
758
|
if (!entry) {
|
|
633
759
|
// 还没有 entry:要么在建连,要么在释放。两者都没有连接态可读,configStale 无从判定。
|
|
634
|
-
rows.push({ ...base, state: cell.disposing ? "disposing" : "connecting", refs: 0, toolCount: 0, fiberState: undefined, configStale: null, configError: "", scopeError: workspaceScopeErrorOf(wsPath, serverName) });
|
|
760
|
+
rows.push({ ...base, state: cell.disposing ? "disposing" : "connecting", refs: 0, toolCount: 0, fiberState: undefined, configStale: null, configError: "", scopeError: workspaceScopeErrorOf(wsPath, serverName), teardown });
|
|
635
761
|
continue;
|
|
636
762
|
}
|
|
637
763
|
// configStale 与 toolCount 走面板那条同一个判定(workspaceConnectionStatus),不另写一套
|
|
@@ -652,6 +778,9 @@ async function projectConnectionsView(ctx) {
|
|
|
652
778
|
configStale: status ? status.configStale : null,
|
|
653
779
|
configError: current.error,
|
|
654
780
|
scopeError: status ? status.scopeError : workspaceScopeErrorOf(wsPath, serverName),
|
|
781
|
+
// OPT-15:已就绪的连接仍会带上一趟 teardown 的终值 —— 面板能说「上次回收花了 x ms」;
|
|
782
|
+
// 超龄的终值由 teardownViewOf 折成 null(见 TEARDOWN_RECORD_STALE_MS)。
|
|
783
|
+
teardown,
|
|
655
784
|
});
|
|
656
785
|
}
|
|
657
786
|
return rows;
|