dsh-session-rollover 1.1.1 → 1.2.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/CHANGELOG.md +10 -0
- package/README.md +5 -3
- package/client.js +39 -12
- package/compatibility/policy.json +21 -0
- package/compatibility/runtime-0.2.2-rc.1.json +32 -0
- package/docs/COMPATIBILITY.md +70 -0
- package/docs/DETAIL.md +1 -1
- package/docs/DETAIL.zh-CN.md +1 -1
- package/docs/DEVELOPMENT.md +3 -0
- package/docs/PUBLISHING.md +22 -5
- package/marketplace/SUBMISSION.md +12 -1
- package/package.json +7 -3
- package/scripts/check-dsh-compatibility.mjs +44 -0
- package/scripts/check-package.mjs +51 -0
- package/scripts/check-release.mjs +13 -1
- package/scripts/dsh-contracts.mjs +69 -0
- package/src/client/rollover-ui.js +38 -11
- package/src/server/compatibility.mjs +84 -0
- package/src/server/host-adapter.mjs +8 -4
- package/src/server/notice-route.mjs +6 -0
- package/src/server/plugin.mjs +33 -9
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# 更新记录
|
|
2
2
|
|
|
3
|
+
## 1.2.0 — 2026-11-11
|
|
4
|
+
|
|
5
|
+
- 增加对 DSH `0.2.2-rc.1` 的确切版本支持,完成独立 home / profile 的真实宿主验收;保留旧版范围与原数据路径。
|
|
6
|
+
- 修复新版宿主重建 `uiConversation` 时交接卡插槽未随子作用域释放、重复占用 keyed slot 的问题。
|
|
7
|
+
- 集中宿主能力探测与读取结构预检,不兼容时在建卷和改名前暂停分卷,保留历史快照与只读拦截。
|
|
8
|
+
- 新版优先读取工作目录、活动 Agent 模型的领域服务,保留旧投影接口回退;可选服务热替换时避免旧清理回调误删新服务。
|
|
9
|
+
- 会话状态及面板补充兼容诊断,HTTP 建卷明确返回 503,缺失界面服务不直接中断渲染。
|
|
10
|
+
- 增加所有后续 npm DSH 版本的公开声明检查、Windows/Linux 与 Node 22/24 回归工作流和升级验收指南;不自动扩大兼容范围或发布。
|
|
11
|
+
- 增加实际 tgz 的隔离安装、稳定导出和面板资源校验;兼容策略及新模块随包分发。
|
|
12
|
+
|
|
3
13
|
## 1.1.1 — 2026-11-07
|
|
4
14
|
|
|
5
15
|
- 公开包名改为 `dsh-session-rollover`,作者 Oak-liuhj;不改变运行数据路径、`sessionRollover` 服务和兼容导出。
|
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
适合持续编码、问题排查和多步骤任务。支持 Web 与桌面客户端,无需为插件单独配置 API Key。
|
|
10
10
|
|
|
11
|
-
**版本 1.
|
|
11
|
+
**版本 1.2.0** · **支持 DSH 0.2.1-rc.5 / 0.2.2-rc.1** · **维护者 Oak-liuhj**
|
|
12
12
|
|
|
13
13
|
[使用指南](./docs/DETAIL.zh-CN.md) · [English guide](./docs/DETAIL.md) · [更新记录](./CHANGELOG.md) · [问题反馈](https://github.com/Oak-liuhj/dsh-session-rollover/issues)
|
|
14
14
|
|
|
@@ -44,6 +44,8 @@
|
|
|
44
44
|
|
|
45
45
|
需要已配置模型、可正常对话的 DSH 宿主。浏览器入口随包预构建,安装插件时无需自行构建。
|
|
46
46
|
|
|
47
|
+
`1.2.0` 支持 DSH `0.2.1-rc.5` 和已完成实机验收的 `0.2.2-rc.1`。旧版 `1.1.1` 只声明支持前者,在新版宿主中会被阻止启用;请更新兼容版本,保留原数据目录。后续版本通过逐版检查和真实宿主验收适配;关键接口变化时暂停分卷并提示。维护机制与证据边界见 [DSH 升级适配](docs/COMPATIBILITY.md)。
|
|
48
|
+
|
|
47
49
|
可在 DSH 插件市场搜索 `dsh-session-rollover` 安装并启用,也可通过插件包名、GitHub 源或本地安装包安装。
|
|
48
50
|
|
|
49
51
|
### 通过 npm 包名安装
|
|
@@ -75,7 +77,7 @@ dsh plugin --profile desktop add github:Oak-liuhj/dsh-session-rollover
|
|
|
75
77
|
将路径替换为实际 tgz 文件位置:
|
|
76
78
|
|
|
77
79
|
```powershell
|
|
78
|
-
dsh plugin --profile desktop add "D:/release/dsh-session-rollover-1.
|
|
80
|
+
dsh plugin --profile desktop add "D:/release/dsh-session-rollover-1.2.0.tgz"
|
|
79
81
|
```
|
|
80
82
|
|
|
81
83
|
桌面用户安装后,请通过桌面客户端重启宿主。已安装旧包 `@deepseek-ai/dsh-session-rollover` 时,应先替换旧依赖,避免同时加载新旧两份插件;升级前备份配置与插件数据。详细步骤见 [升级指南](./docs/PUBLISHING.md#从-scoped-旧包迁移)。
|
|
@@ -143,7 +145,7 @@ dsh plugin --profile desktop remove dsh-session-rollover
|
|
|
143
145
|
|
|
144
146
|
## 兼容性与开发
|
|
145
147
|
|
|
146
|
-
|
|
148
|
+
历史基线 DSH `0.2.1-rc.5` 的验证覆盖自动分卷、待办续接、来源导航、双向只读开关及浅深主题;`1.2.0` 对 DSH `0.2.2-rc.1` 的实机验收记录见[升级适配指南](docs/COMPATIBILITY.md)。旧宿主可能不显示插件长说明,可从会话 `⋯ → 使用说明` 查看内置指南。
|
|
147
149
|
|
|
148
150
|
现有验证使用隔离宿主与本地模型夹具,完整 Bot 群端、生产模型和后续宿主版本仍需单独复验。详见 [功能验证](./docs/r3-validation.md) 与 [市场展示验证](./docs/marketplace-validation.md)。
|
|
149
151
|
|
package/client.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Generated by npm run build; edit src/client/. Version 1.
|
|
1
|
+
// Generated by npm run build; edit src/client/. Version 1.2.0.
|
|
2
2
|
// dsh-session-rollover · 浏览器半边(B6 一次性提示)。
|
|
3
3
|
// 通道:server 半把「该提示什么」落盘,这里同源 GET /api-dsh-session-rollover/pending 取走渲染。
|
|
4
4
|
// 不碰宿主 Toast 实现、不建 React hooks 面(D-36:bindInjectSources 会剥掉 hooks 字段),纯 DOM。
|
|
@@ -311,6 +311,18 @@ function createRolloverUI(React) {
|
|
|
311
311
|
const entries = new Map();
|
|
312
312
|
let navigate = null;
|
|
313
313
|
let renderInline = false;
|
|
314
|
+
const clientWarnings = new Set();
|
|
315
|
+
function unavailable(feature, error) {
|
|
316
|
+
clientWarnings.add(feature);
|
|
317
|
+
console.warn('[dsh-session-rollover] client capability unavailable:', feature, error?.message ?? '');
|
|
318
|
+
}
|
|
319
|
+
function inject(ctx, names, setup) {
|
|
320
|
+
try {
|
|
321
|
+
ctx.inject(names, scope => {
|
|
322
|
+
try { return setup(scope); } catch (error) { unavailable(names.join(','), error); }
|
|
323
|
+
});
|
|
324
|
+
} catch (error) { unavailable(names.join(','), error); }
|
|
325
|
+
}
|
|
314
326
|
|
|
315
327
|
async function request(sessionId, action) {
|
|
316
328
|
const response = await fetch(action ? API : `${API}?session=${encodeURIComponent(sessionId)}`, action ? {
|
|
@@ -428,8 +440,8 @@ function createRolloverUI(React) {
|
|
|
428
440
|
h('p', {className: 'dsr-help-credit'}, 'dsh-session-rollover · Oak-liuhj')) : null,
|
|
429
441
|
h('div', {className: 'dsr-actions-body'},
|
|
430
442
|
h('button', {type: 'button', 'aria-expanded': showHelp, onClick: () => setShowHelp(!showHelp)}, showHelp ? '收起使用说明' : '使用说明'),
|
|
431
|
-
h('button', {type: 'button', disabled: busy || info.sealed || !navigate, onClick: () => action('rollover')}, '现在换会话'),
|
|
432
|
-
h('button', {type: 'button', disabled: busy || !navigate, onClick: () => action('new')}, '新建独立会话'),
|
|
443
|
+
h('button', {type: 'button', disabled: busy || info.sealed || !navigate || info.compatibility?.compatible === false, onClick: () => action('rollover')}, '现在换会话'),
|
|
444
|
+
h('button', {type: 'button', disabled: busy || !navigate || info.compatibility?.compatible === false, onClick: () => action('new')}, '新建独立会话'),
|
|
433
445
|
h('button', {type: 'button', onClick: () => dialog.current?.close()}, '关闭'))),
|
|
434
446
|
error ? h('span', {role: 'alert'}, error) : null);
|
|
435
447
|
}
|
|
@@ -489,6 +501,8 @@ function createRolloverUI(React) {
|
|
|
489
501
|
if (!info) return null;
|
|
490
502
|
return h('div', {className: 'dsr-dock'},
|
|
491
503
|
info.error ? h('div', {role: 'alert'}, info.error) : null,
|
|
504
|
+
info.compatibility?.compatible === false ? h('div', {role: 'alert', className: 'dsr-warning'}, info.compatibility.message) : null,
|
|
505
|
+
clientWarnings.size ? h('div', {role: 'alert', className: 'dsr-warning'}, '部分会话界面功能与当前 DSH 不兼容,请更新插件。') : null,
|
|
492
506
|
h(ReadonlyControl, {key: props.sessionId, info}),
|
|
493
507
|
info.capped ? h('div', {role: 'status', className: 'dsr-warning'}, '同簇已达 9 卷上限(9/9),请通过会话操作新建独立会话。') : null,
|
|
494
508
|
info.retryLimitReached ? h('div', {role: 'status', className: 'dsr-warning'}, '摘要生成失败,已停止自动重试。可在修正内容后手动换会话。') : null,
|
|
@@ -506,7 +520,9 @@ function createRolloverUI(React) {
|
|
|
506
520
|
|
|
507
521
|
/** 释放时仅撤销自己的禁用;保留模型选择等其他插件后来设置的阻塞原因。 */
|
|
508
522
|
function blockComposer(blocks, id, reason) {
|
|
523
|
+
if (typeof blocks?.storeFor !== 'function' || typeof blocks?.set !== 'function') throw new Error('composer blocks unavailable');
|
|
509
524
|
const store = blocks.storeFor(id);
|
|
525
|
+
if (typeof store?.getSnapshot !== 'function' || typeof store?.subscribe !== 'function') throw new Error('composer store unavailable');
|
|
510
526
|
let previous = store.getSnapshot();
|
|
511
527
|
const own = {reason};
|
|
512
528
|
let writing = false;
|
|
@@ -528,13 +544,15 @@ function createRolloverUI(React) {
|
|
|
528
544
|
style.setAttribute('data-dsh-plugin', 'dsh-session-rollover-ui');
|
|
529
545
|
document.head.appendChild(style);
|
|
530
546
|
ctx.effect?.(() => () => { style.remove(); for (const item of entries.values()) item.stop?.(); entries.clear(); });
|
|
531
|
-
|
|
547
|
+
inject(ctx, ['uiWorkspace'], scope => {
|
|
548
|
+
if (typeof scope.uiWorkspace?.openSession !== 'function') throw new Error('session navigation unavailable');
|
|
532
549
|
navigate = id => scope.uiWorkspace.openSession(id);
|
|
533
550
|
scope.effect?.(() => () => { navigate = null; });
|
|
534
551
|
});
|
|
535
|
-
|
|
552
|
+
inject(ctx, ['slots'], scope => {
|
|
536
553
|
const slots = scope.slots;
|
|
537
|
-
|
|
554
|
+
if (typeof slots?.inject !== 'function' || typeof slots?.register !== 'function') throw new Error('slots unavailable');
|
|
555
|
+
const mount = (name, Component, options = {}, owner = scope) => owner.effect(() => owner.slots.inject(name, () => owner.slots.register({name,
|
|
538
556
|
id: 'dsh-session-rollover', order: 35, ...options}, Component)));
|
|
539
557
|
mount('conversation.session.header.actions', Header);
|
|
540
558
|
mount('conversation.input.dock', Dock);
|
|
@@ -543,20 +561,27 @@ function createRolloverUI(React) {
|
|
|
543
561
|
mount('sidebar.session.row.leading', props => slots.snapshot?.('sidebar.session.row.trailing')?.length
|
|
544
562
|
? null : h(Badges, {...props, compact: true}));
|
|
545
563
|
// 使用宿主公开 composer block 服务;覆盖输入组件会破坏其子槽授权。
|
|
546
|
-
|
|
564
|
+
inject(scope, ['conversation'], inputScope => {
|
|
565
|
+
if (typeof inputScope.conversation?.blocks?.storeFor !== 'function' || typeof inputScope.conversation?.blocks?.set !== 'function') {
|
|
566
|
+
throw new Error('composer blocks unavailable');
|
|
567
|
+
}
|
|
547
568
|
function ComposerGuard(props) {
|
|
548
569
|
const info = useInfo(props.sessionId);
|
|
549
570
|
const reason = !info ? '正在读取会话状态…' : info.readonly
|
|
550
571
|
? '归档会话:不可输入,可通过上方开关恢复写入。' : info.error;
|
|
551
|
-
useEffect(() =>
|
|
552
|
-
|
|
572
|
+
useEffect(() => {
|
|
573
|
+
if (props.sessionId && reason) {
|
|
574
|
+
try { return blockComposer(inputScope.conversation.blocks, props.sessionId, reason); }
|
|
575
|
+
catch (error) { unavailable('conversation.blocks', error); }
|
|
576
|
+
}
|
|
577
|
+
},
|
|
553
578
|
[props.sessionId, reason]);
|
|
554
579
|
return null;
|
|
555
580
|
}
|
|
556
581
|
inputScope.effect(() => slots.inject('conversation.input.dock', () => slots.register({
|
|
557
582
|
name: 'conversation.input.dock', id: 'dsr-readonly-guard', order: 34}, ComposerGuard)));
|
|
558
583
|
});
|
|
559
|
-
|
|
584
|
+
inject(scope, ['uiConversation'], textScope => {
|
|
560
585
|
if (!textScope.uiConversation.userText?.register) return;
|
|
561
586
|
textScope.effect(() => textScope.uiConversation.userText.register({id: 'dsr-handoff', order: -100,
|
|
562
587
|
match(text, from) {
|
|
@@ -564,8 +589,10 @@ function createRolloverUI(React) {
|
|
|
564
589
|
const payload = parseHandoff(text);
|
|
565
590
|
return payload ? {id: 'dsr-handoff', start: 0, end: text.length, data: payload} : undefined;
|
|
566
591
|
}}));
|
|
592
|
+
// 插槽必须随 uiConversation 的注入子作用域释放,不能挂在较长寿命的 slots 父作用域。
|
|
593
|
+
// 新宿主重连/热替换领域服务时会重建该子作用域;挂在父级会重复占用同一个 keyed slot。
|
|
567
594
|
mount('conversation.user.text.node', props => h(Card, {payload: props.node.data,
|
|
568
|
-
source: props.node.data.from_session_id && !props.node.data.cluster?.independent ? {sessionId: props.node.data.from_session_id, title: props.node.data.from_title ?? '上一卷'} : null}), {key: 'dsr-handoff'});
|
|
595
|
+
source: props.node.data.from_session_id && !props.node.data.cluster?.independent ? {sessionId: props.node.data.from_session_id, title: props.node.data.from_title ?? '上一卷'} : null}), {key: 'dsr-handoff'}, textScope);
|
|
569
596
|
renderInline = true;
|
|
570
597
|
textScope.effect?.(() => () => { renderInline = false; });
|
|
571
598
|
});
|
|
@@ -586,7 +613,7 @@ body[data-ds-dark-theme] :is(.dsr-header,.dsr-dock,.dsr-card,.dsr-row-badges),ht
|
|
|
586
613
|
@media(max-width:600px){.dsr-header{gap:3px;flex-wrap:nowrap;max-width:calc(100vw - 156px)}.dsr-header>.dsr-chip,.dsr-header>.dsr-source{display:none}.dsr-header>.dsr-chip:first-child{display:inline-flex;font-size:10px;padding:1px 3px}}
|
|
587
614
|
@media(prefers-reduced-motion:reduce){.dsr-card *{animation:none!important;transition:none!important}}
|
|
588
615
|
`;
|
|
589
|
-
return {apply, Header, Dock, Card, Badges, useInfo, parseHandoff, publish, blockComposer, CSS};
|
|
616
|
+
return {apply, Header, Dock, Card, Badges, useInfo, parseHandoff, publish, blockComposer, clientWarnings, CSS};
|
|
590
617
|
}
|
|
591
618
|
|
|
592
619
|
function apply(ctx) {
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 1,
|
|
3
|
+
"package": "@deepseek-ai/dsh",
|
|
4
|
+
"baseline": "0.2.1-rc.5",
|
|
5
|
+
"verifiedRuntimeVersions": ["0.2.1-rc.5", "0.2.2-rc.1"],
|
|
6
|
+
"registry": "https://registry.npmjs.org/",
|
|
7
|
+
"contracts": [
|
|
8
|
+
{"package": "@deepseek-ai/dsh-session", "methods": ["get", "list", "snapshotEvents"], "tokens": ["session/event"]},
|
|
9
|
+
{"package": "@deepseek-ai/dsh-session-title", "methods": ["get", "rename"]},
|
|
10
|
+
{"package": "@deepseek-ai/dsh-api-session-controller", "methods": ["create", "prompt", "selectModel"]},
|
|
11
|
+
{"package": "@deepseek-ai/dsh-session-projection", "methods": ["stateOf"]},
|
|
12
|
+
{"package": "@deepseek-ai/dsh-working-directory", "methods": ["get"]},
|
|
13
|
+
{"package": "@deepseek-ai/dsh-workspace", "methods": ["list"], "tokens": ["sessionIds"]},
|
|
14
|
+
{"package": "@deepseek-ai/dsh-agent", "methods": ["get", "current"], "tokens": ["agent/pre-step", "reject"],
|
|
15
|
+
"versionOverrides": {"0.2.1-rc.5": {"methods": ["get"]}}},
|
|
16
|
+
{"package": "@deepseek-ai/dsh-client-connection", "methods": ["admit"]},
|
|
17
|
+
{"package": "@deepseek-ai/dsh-client-ui-conversation", "methods": ["storeFor", "set"], "tokens": ["conversation.session.header.actions", "conversation.input.dock", "blocks"]},
|
|
18
|
+
{"package": "@deepseek-ai/dsh-client-ui-workspace", "methods": ["openSession"], "alternatives": [["sidebar.session.row.trailing", "sidebar.session.row.leading"]]},
|
|
19
|
+
{"package": "@deepseek-ai/dsh-client-ui-slots", "methods": ["register"]}
|
|
20
|
+
]
|
|
21
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"passed": true,
|
|
3
|
+
"runtimeVerified": true,
|
|
4
|
+
"checkedAt": "2026-11-11T07:26:36.784Z",
|
|
5
|
+
"dsh": "0.2.2-rc.1",
|
|
6
|
+
"plugin": "1.2.0",
|
|
7
|
+
"platform": "Windows",
|
|
8
|
+
"environment": "installed DSH CLI with isolated home and profile",
|
|
9
|
+
"model": "localhost fixture only; no provider credentials or real model quality assessment",
|
|
10
|
+
"checks": {
|
|
11
|
+
"tenHumanMessagesOneRollover": true,
|
|
12
|
+
"workspaceAndDirectoryInherited": true,
|
|
13
|
+
"modelInherited": true,
|
|
14
|
+
"frozenTodosAndActiveStep": true,
|
|
15
|
+
"continuationTodoProgress": true,
|
|
16
|
+
"archivedReadonly": true,
|
|
17
|
+
"restoredWritePersistsAcrossRefresh": true,
|
|
18
|
+
"restoredVolumeDoesNotRolloverAgain": true,
|
|
19
|
+
"realHostRestartPreservesIndexAndSnapshot": true,
|
|
20
|
+
"anonymousHTTPStatus": 401,
|
|
21
|
+
"sourceNavigationAndCopyJSON": true,
|
|
22
|
+
"inlineSlotLifecycleFixedAndRechecked": true
|
|
23
|
+
},
|
|
24
|
+
"browser": "manual automation through computer-use; no plugin warnings after fix and real restart",
|
|
25
|
+
"limitation": "Host reconnect logged a native released-session-reference error; plugin UI recovered after page reload. No full Bot or production provider acceptance.",
|
|
26
|
+
"sourceDigests": {
|
|
27
|
+
"client.js": "25a484847caf9a2ec71b11817d3c147c83fd546f2f80582826df124d742c2b69",
|
|
28
|
+
"src/server/plugin.mjs": "6afd4b8d5e6c03082240ca2f7304bff9fd3ec0e28bfa32e1fe369458f4a48ae5",
|
|
29
|
+
"src/server/host-adapter.mjs": "d145284cfa392d64b00d45f40ab308b320d768dc23606bbdbb048c0db6b25dd6",
|
|
30
|
+
"src/server/compatibility.mjs": "5572940c2bb6fc16dd0393f369417bd9acc025ab65fd7df5f370ededf454ce38"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# DSH 升级适配与验收
|
|
2
|
+
|
|
3
|
+
插件按“公开接口适配、运行前探测、逐版检查、真实宿主验收”维护。不能提前保证尚未发布的 DSH 一定兼容,也不根据版本号猜测新接口。
|
|
4
|
+
|
|
5
|
+
## 当前范围
|
|
6
|
+
|
|
7
|
+
- 历史真实宿主验收基线:`0.2.1-rc.5`;`1.2.0` 已在本机安装的 `0.2.2-rc.1` 独立 home / profile 中完成实机验收。
|
|
8
|
+
- `compatibility/policy.json` 的 `verifiedRuntimeVersions` 决定允许安装的 DSH 版本;`engines.dsh` 必须与其逐项一致,发布检查禁止悄悄放宽范围。
|
|
9
|
+
- npm 声明扫描、夹具回归和真实宿主验收分别记录。扫描通过不自动修改版本约束,不自动发布 npm 包,也不自动改用户 profile。
|
|
10
|
+
- `1.2.0` 增加新版宿主支持;已安装 `1.1.1` 的用户需更新插件,npm 同一版本不会被覆盖更新。发布核验记录见 [投稿材料](../marketplace/SUBMISSION.md)。
|
|
11
|
+
|
|
12
|
+
## 本次验证(2026-11-11)
|
|
13
|
+
|
|
14
|
+
| 检查 | 结果与范围 |
|
|
15
|
+
| --- | --- |
|
|
16
|
+
| 本地回归 | Windows、Node.js 24.22.1,100 项全部通过(含旧宿主有 agents、无 model.current 时的投影回退) |
|
|
17
|
+
| 公开声明 | `0.2.1-rc.5`、`0.2.2-alpha.3`、`0.2.2-rc.1` 的十一个组件包均通过 |
|
|
18
|
+
| 发布包 | `1.2.0` 实际 tgz 隔离安装、服务端导出、预构建客户端和展示资源通过 |
|
|
19
|
+
| 新版真实宿主 | `0.2.2-rc.1` 验收通过,保留旧基线并增加该确切版本;不放行其他未验收版本 |
|
|
20
|
+
| 远程 CI | 已编写工作流;本地提交不代表远程已启用,需推送默认分支后运行 |
|
|
21
|
+
|
|
22
|
+
声明报告和安装包校验报告分别保存在 `out/dsh-compatibility.json`、`out/package-verification.json`;它们不是实际宿主验收报告。其他平台及 Node 版本由远程矩阵执行,本轮未在本机验证。
|
|
23
|
+
|
|
24
|
+
真实宿主记录见 [runtime-0.2.2-rc.1.json](../compatibility/runtime-0.2.2-rc.1.json):十条用户消息仅分卷一次,继承工作区、目录及模型,待办快照保持不变,新卷实时待办可继续推进,来源跳转、复制 JSON、双向归档开关和宿主重启恢复正常,匿名 HTTP 请求返回 401。本轮使用本地模型夹具,不代表真实服务商模型或 Bot 端完整验收。
|
|
25
|
+
|
|
26
|
+
重启复验发现并修复 `uiConversation` 子作用域卸载后交接卡 keyed slot 未释放的问题;插槽现在随该子作用域清理,重复注入回归与实机重启复验通过。宿主热重连期间仍观察到原生会话引用已释放的日志,刷新后恢复;这与插件重复插槽问题分开记录,不宣称宿主全局无错误。
|
|
27
|
+
|
|
28
|
+
## 运行保护
|
|
29
|
+
|
|
30
|
+
服务端集中在 `src/server/compatibility.mjs` 检查会话存储、标题、创建/提示/模型选择、投影、工作区接口,以及同步读取的返回结构。关键能力不满足时,自动与手动分卷在创建会话、改名、保存重试之前停止;HTTP 建卷操作返回 `503 host-incompatible`,面板显示更新提示。
|
|
31
|
+
|
|
32
|
+
已有的交接快照与只读状态仍可读取;已注册的服务端归档写入拦截不因分卷暂停而关闭。方法存在不证明事件语义仍相同;如果升级导致插件本身无法加载,这些插件内保护也无法运行,所以仍需遵守宿主版本约束。
|
|
33
|
+
|
|
34
|
+
新版宿主优先通过 `workingDirectory.get(session)` 和活动 Agent 的 `model.current()` 读取领域状态。缺少这些可选服务时,回退到已验证的 `sessionProjections.stateOf`。后者在较新源码中已弃用,仍纳入声明检查;将来移除时明确失败,由维护者适配,而不是静默换成空目录或默认模型。
|
|
35
|
+
|
|
36
|
+
基线 `0.2.1-rc.5` 的 Agent 声明尚无 `current()`,检查策略对此确切版本保留显式例外,运行时使用投影回退;后续版本继续检查领域服务接口。未知模型结构会暂停分卷,合法的空选择保持可用。
|
|
37
|
+
|
|
38
|
+
浏览器针对插槽、导航与输入阻塞服务做能力检查。缺少可选界面能力时记录带插件名的警告,并在可用的面板中提示;不让缺失服务直接导致 React 渲染错误。服务端诊断可通过 `ctx.sessionRollover.compatibility()` 获取;每个会话状态的 `compatibility` 还包含返回结构检查。
|
|
39
|
+
|
|
40
|
+
## 自动检查
|
|
41
|
+
|
|
42
|
+
仓库工作流 `.github/workflows/compatibility.yml` 包含:
|
|
43
|
+
|
|
44
|
+
1. Windows / Linux、Node.js 22 / 24 上运行完整回归、发布格式检查和 tgz 隔离安装校验。
|
|
45
|
+
2. 从官方 npm 注册表枚举基线发布时刻之后的每个 DSH 版本,包含 alpha、rc 与后续补丁;不只检查 `latest`,避免遗漏中间版本。
|
|
46
|
+
3. 下载各版本的十一个公开组件包,核对 SHA512 后只读扫描类型声明,检查插件使用的方法、事件名及界面插槽,并保存报告。不会执行上游安装脚本。
|
|
47
|
+
|
|
48
|
+
工作流在代码推送、PR、手动触发和每六小时的定时任务中运行;需要提交到 GitHub 默认分支并启用 Actions 才会生效。本地创建文件不等于已启用远程任务。注册表请求或归档校验失败均使任务失败,不视为兼容通过。版本数超过 200 时要求维护基线,不静默截断。
|
|
49
|
+
|
|
50
|
+
```powershell
|
|
51
|
+
npm test
|
|
52
|
+
npm run check:release
|
|
53
|
+
npm run check:package
|
|
54
|
+
npm run check:dsh -- all
|
|
55
|
+
npm run check:dsh -- 0.2.2-rc.1
|
|
56
|
+
node scripts/check-dsh-compatibility.mjs --list
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
需要网络与系统 `tar`;Windows 自带的 tar 可用。报告默认写入忽略提交的 `out/dsh-compatibility.json`,也可用 `DSH_COMPATIBILITY_REPORT` 指定位置。`level: public-declaration-smoke`、`runtimeVerified: false` 明确说明证据边界。声明扫描不会证明方法签名、授权、事件顺序或视觉渲染完全兼容。
|
|
60
|
+
|
|
61
|
+
`check:package` 在自有临时目录安装实际 tgz,复核导出、预构建客户端和全部发布资源,结束后清理隔离目录;包与报告保存在 `out/`。此检查也不代表真实 DSH 宿主验收。
|
|
62
|
+
|
|
63
|
+
## 每次 DSH 发布后的维护步骤
|
|
64
|
+
|
|
65
|
+
1. 查看每个新版本的声明扫描和回归结果;发现方法、事件或插槽变化时更新集中适配层,保留旧宿主路径的测试。
|
|
66
|
+
2. 用该版本的真实 DSH,在隔离 home / profile 和本地模型夹具中验证:插件装载、十条真实用户消息只分一次、模型与工作区继承、待办交接、来源导航、重启恢复、归档只读及恢复写入、HTTP owner 授权。不得使用生产会话验收。
|
|
67
|
+
3. 记录 DSH 与插件版本、平台、结果及证据位置。通过后才向 `verifiedRuntimeVersions` 添加确切版本,并同步 `engines.dsh` 的 `||` 列表。旧基线仍用于回归,不能只因新版本号更大便放行。
|
|
68
|
+
4. 更新插件版本、构建预生成客户端、完成打包与隔离安装验证,再发布新 npm 版本。用户通过正常插件升级获得适配;不得覆盖同一 npm 版本。
|
|
69
|
+
|
|
70
|
+
宿主升级后遇到兼容提示时,应保留数据目录,使用已验收版本组合或更新插件;不要删除快照、绕过宿主版本检查或同时加载两份插件。
|
package/docs/DETAIL.md
CHANGED
|
@@ -35,7 +35,7 @@ Default storage: `<DSH_HOME>/plugin-data/dsh-session-rollover`, or `~/.dsh/plugi
|
|
|
35
35
|
|
|
36
36
|
Summaries use deterministic extraction, without another model call or API key. Conversation continuation uses the model configured in DSH. Background jobs are recorded as handles and states; processes are not moved, resumed or restarted. Summary text is limited to 1500 characters and the full handoff message to 16 KiB; oversized handoffs fail with a notice. Automatic failures get at most two further retries, triggered by subsequent user messages.
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
Version `1.2.0` supports DSH `0.2.1-rc.5` and `0.2.2-rc.1`. The latter was tested in an isolated host with a local model fixture for ten-message rollover, model and workspace inheritance, todo continuation, archive controls and restart recovery. Version `1.1.1` excludes DSH `0.2.2-rc.1`; upgrade the plugin while retaining its data directory. This is not full acceptance for production models, Bot group channels or future host versions. Older hosts may omit this long description; the conversation `⋯` menu includes a built-in usage guide.
|
|
39
39
|
|
|
40
40
|
Replace the old `@deepseek-ai/dsh-session-rollover` dependency and bundle rather than loading both versions. Back up profile configuration and plugin data before upgrading. See the Chinese [publishing and migration guide](./PUBLISHING.md).
|
|
41
41
|
|
package/docs/DETAIL.zh-CN.md
CHANGED
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
|
|
39
39
|
摘要是确定性摘录,不调用额外模型;继续对话仍使用 DSH 已配置的模型,无需额外 API Key。后台任务只继承句柄和状态,不迁移、恢复或重新启动进程。正文摘要上限 1500 字符、完整交接消息上限 16 KiB,超预算会拒绝交接并提示。自动失败最多再试两次,由后续用户轮次触发。
|
|
40
40
|
|
|
41
|
-
|
|
41
|
+
`1.2.0` 支持 DSH `0.2.1-rc.5` 与 `0.2.2-rc.1`;后者已在独立环境验证十轮分卷、模型和工作区继承、待办续接、归档开关及重启恢复。旧版 `1.1.1` 的版本约束不支持 `0.2.2-rc.1`,需升级插件并保留数据目录。不代表所有模型、Bot 群端或后续宿主版本均已验收。旧宿主可能不显示本长说明,可从会话 `⋯ → 使用说明` 查看内置指南。
|
|
42
42
|
|
|
43
43
|
## 升级与支持
|
|
44
44
|
|
package/docs/DEVELOPMENT.md
CHANGED
|
@@ -29,6 +29,8 @@ R2 面板可继续调用原有 rollover/info 服务;无实时 `todo/write` 时
|
|
|
29
29
|
|
|
30
30
|
`connection` 为显式必需注入;`jobs`、`agentDefaultModel` 通过独立可选注入获取,避免 Cordis 未声明服务访问报错。
|
|
31
31
|
|
|
32
|
+
`workingDirectory`、`agents` 同样通过可选注入获取,用于新版领域状态读取。`sessionRollover.compatibility()` 返回关键方法的能力报告;会话 `info().compatibility` 还检查读取结果形状。逐版检查、CI 与真实宿主验收流程见 [升级适配指南](./COMPATIBILITY.md)。
|
|
33
|
+
|
|
32
34
|
## 项目结构
|
|
33
35
|
|
|
34
36
|
```text
|
|
@@ -76,6 +78,7 @@ docs/r3-demo.html 固定数据交互原型
|
|
|
76
78
|
```powershell
|
|
77
79
|
npm test
|
|
78
80
|
npm run check:release
|
|
81
|
+
npm run check:package
|
|
79
82
|
npm pack --dry-run
|
|
80
83
|
npm pack --pack-destination <输出目录>
|
|
81
84
|
```
|
package/docs/PUBLISHING.md
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
# 发布与升级指南
|
|
2
2
|
|
|
3
|
-
公开包名为 `dsh-session-rollover
|
|
3
|
+
公开包名为 `dsh-session-rollover`,当前版本 1.2.0,维护者 Oak-liuhj。仓库:[Oak-liuhj/dsh-session-rollover](https://github.com/Oak-liuhj/dsh-session-rollover)。支持在宿主中直接填写 npm 包名安装;市场收录不代替 npm 发布。实际公开版本以 npm 官方源和 GitHub Release 为准,发布核验记录见 [投稿材料](../marketplace/SUBMISSION.md)。
|
|
4
4
|
|
|
5
5
|
## 构建与打包
|
|
6
6
|
|
|
7
7
|
```powershell
|
|
8
8
|
npm test
|
|
9
9
|
npm run check:release
|
|
10
|
+
npm run check:package
|
|
10
11
|
npm pack --pack-destination D:/release
|
|
11
12
|
```
|
|
12
13
|
|
|
@@ -21,8 +22,8 @@ npm pack --pack-destination D:/release
|
|
|
21
22
|
```powershell
|
|
22
23
|
npm login --auth-type=web --registry=https://registry.npmjs.org/
|
|
23
24
|
npm whoami --registry=https://registry.npmjs.org/
|
|
24
|
-
npm publish "D:/release/dsh-session-rollover-1.
|
|
25
|
-
npm view dsh-session-rollover@1.
|
|
25
|
+
npm publish "D:/release/dsh-session-rollover-1.2.0.tgz" --access public --registry=https://registry.npmjs.org/
|
|
26
|
+
npm view dsh-session-rollover@1.2.0 version dist.integrity --registry=https://registry.npmjs.org/
|
|
26
27
|
```
|
|
27
28
|
|
|
28
29
|
4. 在隔离目录从 npm 安装并核对包名、版本、挂载补丁、预构建客户端与模块导入;只有 npm 返回该版本且下载验证通过,才确认包名安装可用。
|
|
@@ -30,13 +31,29 @@ npm view dsh-session-rollover@1.1.1 version dist.integrity --registry=https://re
|
|
|
30
31
|
|
|
31
32
|
`publishConfig.registry` 固定为 npm 官方源;公开包名为 `dsh-session-rollover`。npm 已发布的同一版本不能覆盖,后续发布需先提升版本号。认证和双重验证失败时保留待发布包,完成认证后继续发布,不绕过验证。
|
|
32
33
|
|
|
34
|
+
### 已进入暂存区时
|
|
35
|
+
|
|
36
|
+
使用支持 `npm stage` 的 CLI,先查询已有暂存记录,再确认同一个包:
|
|
37
|
+
|
|
38
|
+
```powershell
|
|
39
|
+
npm stage list dsh-session-rollover --json --registry=https://registry.npmjs.org/
|
|
40
|
+
npm stage view <stage-id> --json --registry=https://registry.npmjs.org/
|
|
41
|
+
npm stage approve <stage-id> --registry=https://registry.npmjs.org/
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
核对版本、`latest` 标签和 shasum 与已审核 tgz 一致。`staged` 仅表示待审批,同一暂存版本不能再次普通发布;保留原 tgz 和 stage id,完成审批后再执行下载与隔离安装验证。首次暂存可能产生 `0.0.0-stage` 占位版本,不能以包名可查询作为正式版本发布成功的依据。
|
|
45
|
+
|
|
46
|
+
CLI 登录授权、注册安全密钥和本次发布的本人认证是不同步骤。账号已启用 2FA 但审批仍要求配置时,先核对账号状态并尝试全新登录,不反复注册密钥;认证 URL 失效也不代表 tgz 丢失。若全新登录后问题仍存在,保存不含凭据的错误、时间和 stage id,向 [npm 支持](https://www.npmjs.com/support)反馈。不要通过关闭 2FA 或创建绕过认证的令牌处理审批异常。
|
|
47
|
+
|
|
48
|
+
认证请求有有效期,应在本人准备好设备验证后再启动命令,并让 npm 直接打开系统浏览器。不要先生成地址再等待聊天确认;手工打开时核对完整地址,避免回调参数混入中文标点或正文。有效期已过时重新运行原审批命令,不复用旧地址。
|
|
49
|
+
|
|
33
50
|
## 从 scoped 旧包迁移
|
|
34
51
|
|
|
35
52
|
1. 记录真实运行的 profile,桌面通常是 `~/.dsh/profiles/desktop`。备份该 profile 的 package.json、锁文件、Cordis 配置和旧插件目录,并备份 `<DSH_HOME>/plugin-data/dsh-session-rollover`;自定义 dataDir 也需备份。
|
|
36
53
|
2. 通过桌面插件管理器/宿主支持的依赖管理渠道,将 `@deepseek-ai/dsh-session-rollover` 替换为新包。CLI 可使用 `dsh plugin --profile desktop remove @deepseek-ai/dsh-session-rollover`,然后 `dsh plugin --profile desktop add <本地tgz或GitHub源>`。分步操作时先停止宿主,避免短暂缺包。
|
|
37
54
|
3. profile 的 `dsh.profile.bundles` 只保留新包一次;显式 Cordis 节点的 `name` 改为新包名,原 `id: session-rollover`、用户配置值和相邻插件保留。不可同时加载两个包来注册同一个服务。不能只覆盖 node_modules 而保留过期依赖来源。
|
|
38
55
|
4. 从桌面客户端重启实际宿主;不要用普通 Web profile 的重启操作接管桌面生命周期。
|
|
39
|
-
5.
|
|
56
|
+
5. 核实已加载目标版本、面板图标/简介、会话 `⋯ → 使用说明`、旧卷快照和只读状态。不要为了安装验证向生产会话发送测试消息。
|
|
40
57
|
|
|
41
58
|
本次更名不迁移数据;数据目录、`sessionRollover`、HTTP 路由、Cordis id 和旧 `src/*` 导出保持兼容。卸载也不删除数据。回滚时停止宿主,恢复备份的 profile 配置、锁文件和依赖,再重启;不要覆盖更新后已产生的用户数据。
|
|
42
59
|
|
|
@@ -60,4 +77,4 @@ npm view dsh-session-rollover@1.1.1 version dist.integrity --registry=https://re
|
|
|
60
77
|
3. 等仓库创建满一天并确保 topic 为 `dsh-plugin`。按 [投稿材料](../marketplace/SUBMISSION.md) 向官方插件目录发起 PR。
|
|
61
78
|
4. 确认市场抓取后的简介、图标、截图及安装源正确;外部收录前不要宣称已上架。
|
|
62
79
|
|
|
63
|
-
`engines.dsh`
|
|
80
|
+
`engines.dsh` 由 `compatibility/policy.json` 中实际完成宿主验收的确切版本组成,不推测扩大兼容区间;声明扫描通过后仍需执行 [升级验收](./COMPATIBILITY.md)。许可证声明为 `(Apache-2.0 AND MIT)`,必须同时分发 LICENSE、LICENSE.upstream 和 NOTICE。
|
|
@@ -9,6 +9,17 @@
|
|
|
9
9
|
- 唯一新增文件:`data/plugins/Oak-liuhj__dsh-session-rollover.yml`。
|
|
10
10
|
- 校验与审核的实时状态以 PR 页面为准。提交 PR 不等于市场已收录;正式收录还需维护者合并及目录后续构建。
|
|
11
11
|
|
|
12
|
+
## npm 发布记录
|
|
13
|
+
|
|
14
|
+
2026-11-11 **13:51(北京时间)**,`dsh-session-rollover@1.1.1` 已正式发布到 [npm](https://www.npmjs.com/package/dsh-session-rollover/v/1.1.1)。npm 发布账号为 `oak-ai`,插件作者与 GitHub 仓库所有者为 `Oak-liuhj`。
|
|
15
|
+
|
|
16
|
+
- npm 官方源与腾讯镜像的 `latest` 均已确认为 `1.1.1`。
|
|
17
|
+
- 下载的 tgz 与审核通过的原包一致,SHA1 为 `cbe802fe44f8ae14da3eebc8d6a92e3468609ab5`,SHA512 integrity 也已核对。
|
|
18
|
+
- 两个源均已通过按包名隔离安装;已验证服务端入口导入、挂载补丁、预构建客户端、图标、双语资源及许可证文件。
|
|
19
|
+
- 用户可在「填入插件 npm 包名」中输入 `dsh-session-rollover`;CLI 桌面 profile 示例为 `dsh plugin --profile desktop add dsh-session-rollover`。
|
|
20
|
+
|
|
21
|
+
本次验证未改动生产 DSH profile;正式版本发布与市场目录收录分别核实,npm 的 `0.0.0-stage` 占位版本不作为发布成功依据。
|
|
22
|
+
|
|
12
23
|
## 投稿规则与材料
|
|
13
24
|
|
|
14
25
|
投稿目录为 [awesome-dsh-plugin/awesome-dsh-plugin](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin),市场应用代码仓库 `dsh-market/dsh-market` 不是插件条目的投稿目标。规则来源:[contributing.md](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin/blob/main/contributing.md)(核对于 2026-11-07)。
|
|
@@ -23,4 +34,4 @@
|
|
|
23
34
|
|
|
24
35
|
提交前确认公开 main 分支或安装目标包含 `client.js`、图标和截图;仓库 topic 包含 `dsh-plugin`,创建满一天。仓库创建时间核对为 2026-11-06 06:45:34 UTC,最早满一天时间为北京时间 **2026-11-07 14:45:34**。npm 发布可选,未发布 npm 时使用 GitHub 安装源。
|
|
25
36
|
|
|
26
|
-
本目录的 YAML 已通过上述 PR 提交。市场收录、npm 发布和 GitHub Release
|
|
37
|
+
本目录的 YAML 已通过上述 PR 提交。市场收录、npm 发布和 GitHub Release 是独立状态;首次市场投稿未包含 npm 发布,后续 npm 发布见上方记录。本记录不表示已创建 GitHub Release。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-session-rollover",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "DSH 会话分卷与续接:自动分卷、待办交接快照、来源回溯和归档只读,支持 Web 与桌面客户端。",
|
|
6
6
|
"main": "index.js",
|
|
@@ -39,6 +39,8 @@
|
|
|
39
39
|
"docs/screenshots/help-light.png",
|
|
40
40
|
"docs/screenshots/help-dark.png",
|
|
41
41
|
"marketplace/",
|
|
42
|
+
"compatibility/",
|
|
43
|
+
"docs/COMPATIBILITY.md",
|
|
42
44
|
"docs/r3-demo.html",
|
|
43
45
|
"docs/screenshots/prototype-fixed.png",
|
|
44
46
|
"docs/live-host-validation.json",
|
|
@@ -51,7 +53,9 @@
|
|
|
51
53
|
"pretest": "npm run build",
|
|
52
54
|
"prepack": "npm run build && node scripts/check-release.mjs",
|
|
53
55
|
"test": "node --test \"test/**/*.test.mjs\"",
|
|
54
|
-
"check:release": "npm run build:check && node scripts/check-release.mjs"
|
|
56
|
+
"check:release": "npm run build:check && node scripts/check-release.mjs",
|
|
57
|
+
"check:dsh": "node scripts/check-dsh-compatibility.mjs",
|
|
58
|
+
"check:package": "npm run check:release && node scripts/check-package.mjs"
|
|
55
59
|
},
|
|
56
60
|
"dsh": {
|
|
57
61
|
"manifestVersion": 1,
|
|
@@ -96,6 +100,6 @@
|
|
|
96
100
|
"registry": "https://registry.npmjs.org/"
|
|
97
101
|
},
|
|
98
102
|
"engines": {
|
|
99
|
-
"dsh": "0.2.1-rc.5"
|
|
103
|
+
"dsh": "0.2.1-rc.5 || 0.2.2-rc.1"
|
|
100
104
|
}
|
|
101
105
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import {tmpdir} from 'node:os';
|
|
4
|
+
import {registryJSON, publishedTargets, checkPackage} from './dsh-contracts.mjs';
|
|
5
|
+
const policy = JSON.parse(await fs.readFile(new URL('../compatibility/policy.json', import.meta.url), 'utf8'));
|
|
6
|
+
const args = process.argv.slice(2);
|
|
7
|
+
const list = args.includes('--list');
|
|
8
|
+
const target = args.find(value => !value.startsWith('--')) ?? 'latest';
|
|
9
|
+
const metadata = await registryJSON(policy.registry + encodeURIComponent(policy.package));
|
|
10
|
+
const versions = publishedTargets(metadata, policy.baseline);
|
|
11
|
+
if (!versions.length || versions.length > 200) throw new Error('DSH compatibility matrix requires baseline maintenance');
|
|
12
|
+
if (list) {
|
|
13
|
+
// 超出 GitHub matrix 上限时明确失败,要求维护者调整基线,不静默漏掉新版本。
|
|
14
|
+
console.log(JSON.stringify({version: versions}));
|
|
15
|
+
} else {
|
|
16
|
+
const selected = target === 'all' ? versions : [metadata['dist-tags']?.[target] ?? target];
|
|
17
|
+
const directory = await fs.mkdtemp(path.join(tmpdir(), 'dsh-contracts-'));
|
|
18
|
+
const report = {level: 'public-declaration-smoke', runtimeVerified: false,
|
|
19
|
+
checkedAt: new Date().toISOString(), baseline: policy.baseline, targets: []};
|
|
20
|
+
try {
|
|
21
|
+
for (const version of selected) {
|
|
22
|
+
const results = [];
|
|
23
|
+
for (const contract of policy.contracts) {
|
|
24
|
+
try { results.push(await checkPackage(contract, version, policy, directory)); }
|
|
25
|
+
catch (error) { results.push({package: contract.package, version, error: String(error.message)}); }
|
|
26
|
+
}
|
|
27
|
+
const passed = results.every(result => !result.error && !result.missing?.length);
|
|
28
|
+
report.targets.push({version, passed, packages: results});
|
|
29
|
+
console.log(`${version}: ${passed ? 'PASS' : 'FAIL'} (public declarations; not runtime verification)`);
|
|
30
|
+
for (const result of results.filter(r => r.error || r.missing?.length)) console.error(JSON.stringify(result));
|
|
31
|
+
}
|
|
32
|
+
report.passed = report.targets.every(target => target.passed);
|
|
33
|
+
const output = process.env.DSH_COMPATIBILITY_REPORT ?? path.resolve('out/dsh-compatibility.json');
|
|
34
|
+
await fs.mkdir(path.dirname(output), {recursive: true});
|
|
35
|
+
await fs.writeFile(output, JSON.stringify(report, null, 2) + '\n');
|
|
36
|
+
if (!report.passed) process.exitCode = 1;
|
|
37
|
+
} finally {
|
|
38
|
+
// 只删除 mkdtemp 创建并持有的独立目录。
|
|
39
|
+
if (path.dirname(path.resolve(directory)) !== path.resolve(tmpdir()) || !path.basename(directory).startsWith('dsh-contracts-')) {
|
|
40
|
+
throw new Error('Refusing cleanup outside the owned temporary directory');
|
|
41
|
+
}
|
|
42
|
+
await fs.rm(directory, {recursive: true, force: true});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/** 校验真正交付的 tgz:隔离安装、稳定导出、清单和预构建浏览器入口;不接触 DSH profile。 */
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import {tmpdir} from 'node:os';
|
|
6
|
+
import {execFile} from 'node:child_process';
|
|
7
|
+
import {promisify} from 'node:util';
|
|
8
|
+
import {pathToFileURL, fileURLToPath} from 'node:url';
|
|
9
|
+
import {createHash} from 'node:crypto';
|
|
10
|
+
const run = promisify(execFile);
|
|
11
|
+
const root = fileURLToPath(new URL('../', import.meta.url));
|
|
12
|
+
const npm = process.env.npm_execpath;
|
|
13
|
+
assert.ok(npm && path.basename(npm) === 'npm-cli.js', 'Run this check with npm run check:package');
|
|
14
|
+
const owned = await fs.mkdtemp(path.join(tmpdir(), 'dsh-rollover-package-'));
|
|
15
|
+
const output = path.join(root, 'out');
|
|
16
|
+
try {
|
|
17
|
+
// 调用方已完成 build:check 和发布检查;这里禁用生命周期,确保检查同一份构建产物。
|
|
18
|
+
const packed = JSON.parse((await run(process.execPath, [npm, 'pack', '--json', '--ignore-scripts', '--pack-destination', owned],
|
|
19
|
+
{cwd: root, windowsHide: true, maxBuffer: 4 * 1024 * 1024})).stdout)[0];
|
|
20
|
+
const archive = path.join(owned, path.basename(packed.filename));
|
|
21
|
+
const install = path.join(owned, 'install');
|
|
22
|
+
await fs.mkdir(install);
|
|
23
|
+
await fs.writeFile(path.join(install, 'package.json'), '{"name":"dsh-rollover-package-check","private":true}\n');
|
|
24
|
+
await run(process.execPath, [npm, 'install', archive, '--ignore-scripts', '--no-audit', '--no-fund', '--package-lock=false'],
|
|
25
|
+
{cwd: install, windowsHide: true, maxBuffer: 4 * 1024 * 1024});
|
|
26
|
+
const installed = path.join(install, 'node_modules', 'dsh-session-rollover');
|
|
27
|
+
const manifest = JSON.parse(await fs.readFile(path.join(installed, 'package.json'), 'utf8'));
|
|
28
|
+
const entry = await import(pathToFileURL(path.join(installed, manifest.main)).href);
|
|
29
|
+
assert.equal(entry.default, entry.SessionRolloverPlugin);
|
|
30
|
+
assert.equal(typeof entry.default, 'function');
|
|
31
|
+
const files = packed.files.map(file => file.path);
|
|
32
|
+
for (const required of ['client.js', 'src/server/compatibility.mjs', 'compatibility/policy.json', 'docs/COMPATIBILITY.md']) {
|
|
33
|
+
assert.ok(files.includes(required), `Missing packed resource: ${required}`);
|
|
34
|
+
}
|
|
35
|
+
// 从安装后的目录重新校验全部面板资源和 ModuleLoader,避免只检查源码目录。
|
|
36
|
+
await run(process.execPath, [path.join(installed, 'scripts/check-release.mjs')], {cwd: installed, windowsHide: true});
|
|
37
|
+
const bytes = await fs.readFile(archive);
|
|
38
|
+
await fs.mkdir(output, {recursive: true});
|
|
39
|
+
await fs.copyFile(archive, path.join(output, packed.filename));
|
|
40
|
+
const report = {passed: true, level: 'isolated-package-install', runtimeVerified: false,
|
|
41
|
+
checkedAt: new Date().toISOString(), platform: process.platform, node: process.version,
|
|
42
|
+
name: manifest.name, version: manifest.version, files: files.length,
|
|
43
|
+
sha256: createHash('sha256').update(bytes).digest('hex'), artifact: packed.filename};
|
|
44
|
+
await fs.writeFile(path.join(output, 'package-verification.json'), JSON.stringify(report, null, 2) + '\n');
|
|
45
|
+
console.log(`Package PASS: ${manifest.name}@${manifest.version}; isolated install, exports, client and resources (${files.length} files).`);
|
|
46
|
+
} finally {
|
|
47
|
+
if (path.dirname(path.resolve(owned)) !== path.resolve(tmpdir()) || !path.basename(owned).startsWith('dsh-rollover-package-')) {
|
|
48
|
+
throw new Error('Refusing cleanup outside the owned temporary directory');
|
|
49
|
+
}
|
|
50
|
+
await fs.rm(owned, {recursive: true, force: true});
|
|
51
|
+
}
|
|
@@ -25,7 +25,19 @@ assert.equal(pkg.bugs.url, repo + '/issues');
|
|
|
25
25
|
assert.ok(pkg.keywords.includes('dsh-plugin'));
|
|
26
26
|
assert.equal(pkg.dsh.manifestVersion, 1);
|
|
27
27
|
assert.equal(pkg.dsh.client.platform, 'web');
|
|
28
|
-
|
|
28
|
+
const compatibility = JSON.parse(await readFile(await localFile('compatibility/policy.json'), 'utf8'));
|
|
29
|
+
assert.equal(compatibility.baseline, pkg.dsh.verifiedDsh);
|
|
30
|
+
assert.ok(compatibility.verifiedRuntimeVersions.includes(compatibility.baseline));
|
|
31
|
+
assert.ok(compatibility.verifiedRuntimeVersions.every(version => /^\d+\.\d+\.\d+(?:-[\w.-]+)?$/.test(version)));
|
|
32
|
+
assert.equal(pkg.engines.dsh, compatibility.verifiedRuntimeVersions.join(' || '));
|
|
33
|
+
for (const version of compatibility.verifiedRuntimeVersions.filter(version => version !== compatibility.baseline)) {
|
|
34
|
+
const evidence = JSON.parse(await readFile(await localFile(`compatibility/runtime-${version}.json`), 'utf8'));
|
|
35
|
+
assert.equal(evidence.passed, true);
|
|
36
|
+
assert.equal(evidence.runtimeVerified, true);
|
|
37
|
+
assert.equal(evidence.dsh, version);
|
|
38
|
+
assert.equal(evidence.plugin, pkg.version);
|
|
39
|
+
}
|
|
40
|
+
await localFile('docs/COMPATIBILITY.md');
|
|
29
41
|
assert.deepEqual(Object.keys(pkg.dependencies ?? {}), []);
|
|
30
42
|
assert.equal(pkg.scripts.prepare, undefined);
|
|
31
43
|
assert.equal(pkg.scripts.postinstall, undefined);
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/** 检查 npm 公共声明中的依赖面;不执行上游包,不把声明扫描当作真实宿主验收。 */
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import {createHash} from 'node:crypto';
|
|
4
|
+
import {execFile} from 'node:child_process';
|
|
5
|
+
import {promisify} from 'node:util';
|
|
6
|
+
import fs from 'node:fs/promises';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
const run = promisify(execFile);
|
|
9
|
+
|
|
10
|
+
export async function registryJSON(url) {
|
|
11
|
+
const response = await fetch(url, {signal: AbortSignal.timeout(30000)});
|
|
12
|
+
assert.equal(response.status, 200, `Registry unavailable: ${url} (${response.status})`);
|
|
13
|
+
return response.json();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** 按官方发布时刻枚举基线之后的每个版本,包含 alpha/rc 和后续补丁。 */
|
|
17
|
+
export function publishedTargets(metadata, baseline) {
|
|
18
|
+
const since = Date.parse(metadata.time?.[baseline]);
|
|
19
|
+
assert.ok(Number.isFinite(since), 'Missing baseline publication time');
|
|
20
|
+
const versions = Object.keys(metadata.versions ?? {});
|
|
21
|
+
assert.ok(versions.includes(baseline), 'Missing baseline package version');
|
|
22
|
+
for (const version of versions) {
|
|
23
|
+
assert.match(version, /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/);
|
|
24
|
+
assert.ok(Number.isFinite(Date.parse(metadata.time?.[version])), `Missing publication time: ${version}`);
|
|
25
|
+
}
|
|
26
|
+
return versions.filter(version => Date.parse(metadata.time?.[version]) >= since)
|
|
27
|
+
.sort((a, b) => Date.parse(metadata.time[a]) - Date.parse(metadata.time[b]));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function inspectDeclarations(text, contract) {
|
|
31
|
+
// 去除注释,避免已删除的方法只在弃用说明中出现时误判通过。
|
|
32
|
+
const source = text.replace(/\/\*[\s\S]*?\*\/|\/\/[^\r\n]*/g, '');
|
|
33
|
+
const missing = [];
|
|
34
|
+
for (const method of contract.methods ?? []) {
|
|
35
|
+
assert.match(method, /^\w+$/);
|
|
36
|
+
if (!new RegExp(`\\b${method}\\s*(?:<[^;{}]*?>\\s*)?\\(`).test(source)) missing.push(method + '()');
|
|
37
|
+
}
|
|
38
|
+
for (const token of contract.tokens ?? []) if (!source.includes(token)) missing.push(token);
|
|
39
|
+
for (const group of contract.alternatives ?? []) {
|
|
40
|
+
if (!group.some(token => source.includes(token))) missing.push(group.join(' | '));
|
|
41
|
+
}
|
|
42
|
+
return missing;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function checkPackage(contract, version, policy, directory) {
|
|
46
|
+
const metadata = await registryJSON(policy.registry + encodeURIComponent(contract.package) + '/' + encodeURIComponent(version));
|
|
47
|
+
assert.equal(metadata.version, version);
|
|
48
|
+
assert.equal(new URL(metadata.dist.tarball).hostname, new URL(policy.registry).hostname);
|
|
49
|
+
assert.match(metadata.dist.integrity, /^sha512-[A-Za-z0-9+/=]+$/);
|
|
50
|
+
const response = await fetch(metadata.dist.tarball, {signal: AbortSignal.timeout(30000)});
|
|
51
|
+
assert.equal(response.status, 200);
|
|
52
|
+
const archive = Buffer.from(await response.arrayBuffer());
|
|
53
|
+
assert.equal('sha512-' + createHash('sha512').update(archive).digest('base64'), metadata.dist.integrity);
|
|
54
|
+
const file = path.join(directory, contract.package.replace(/[^\w-]/g, '_') + '.tgz');
|
|
55
|
+
await fs.writeFile(file, archive);
|
|
56
|
+
const {stdout} = await run('tar', ['-tzf', file], {windowsHide: true, maxBuffer: 4 * 1024 * 1024});
|
|
57
|
+
const declarations = stdout.split(/\r?\n/).filter(name => /^package\/lib\/.*\.d\.ts$/.test(name));
|
|
58
|
+
assert.ok(declarations.length > 0, 'Missing public type declarations');
|
|
59
|
+
let text = '';
|
|
60
|
+
for (let offset = 0; offset < declarations.length; offset += 40) {
|
|
61
|
+
const names = declarations.slice(offset, offset + 40);
|
|
62
|
+
assert.ok(names.every(name => !name.split('/').includes('..') && !name.includes('\\')));
|
|
63
|
+
// 只读 tar stdout,不把上游归档解压到任何宿主目录。
|
|
64
|
+
text += (await run('tar', ['-xOf', file, ...names], {windowsHide: true, maxBuffer: 16 * 1024 * 1024})).stdout + '\n';
|
|
65
|
+
}
|
|
66
|
+
return {package: contract.package, version, integrity: metadata.dist.integrity,
|
|
67
|
+
declarations: declarations.length,
|
|
68
|
+
missing: inspectDeclarations(text, {...contract, ...contract.versionOverrides?.[version]})};
|
|
69
|
+
}
|
|
@@ -5,6 +5,18 @@ export function createRolloverUI(React) {
|
|
|
5
5
|
const entries = new Map();
|
|
6
6
|
let navigate = null;
|
|
7
7
|
let renderInline = false;
|
|
8
|
+
const clientWarnings = new Set();
|
|
9
|
+
function unavailable(feature, error) {
|
|
10
|
+
clientWarnings.add(feature);
|
|
11
|
+
console.warn('[dsh-session-rollover] client capability unavailable:', feature, error?.message ?? '');
|
|
12
|
+
}
|
|
13
|
+
function inject(ctx, names, setup) {
|
|
14
|
+
try {
|
|
15
|
+
ctx.inject(names, scope => {
|
|
16
|
+
try { return setup(scope); } catch (error) { unavailable(names.join(','), error); }
|
|
17
|
+
});
|
|
18
|
+
} catch (error) { unavailable(names.join(','), error); }
|
|
19
|
+
}
|
|
8
20
|
|
|
9
21
|
async function request(sessionId, action) {
|
|
10
22
|
const response = await fetch(action ? API : `${API}?session=${encodeURIComponent(sessionId)}`, action ? {
|
|
@@ -122,8 +134,8 @@ export function createRolloverUI(React) {
|
|
|
122
134
|
h('p', {className: 'dsr-help-credit'}, 'dsh-session-rollover · Oak-liuhj')) : null,
|
|
123
135
|
h('div', {className: 'dsr-actions-body'},
|
|
124
136
|
h('button', {type: 'button', 'aria-expanded': showHelp, onClick: () => setShowHelp(!showHelp)}, showHelp ? '收起使用说明' : '使用说明'),
|
|
125
|
-
h('button', {type: 'button', disabled: busy || info.sealed || !navigate, onClick: () => action('rollover')}, '现在换会话'),
|
|
126
|
-
h('button', {type: 'button', disabled: busy || !navigate, onClick: () => action('new')}, '新建独立会话'),
|
|
137
|
+
h('button', {type: 'button', disabled: busy || info.sealed || !navigate || info.compatibility?.compatible === false, onClick: () => action('rollover')}, '现在换会话'),
|
|
138
|
+
h('button', {type: 'button', disabled: busy || !navigate || info.compatibility?.compatible === false, onClick: () => action('new')}, '新建独立会话'),
|
|
127
139
|
h('button', {type: 'button', onClick: () => dialog.current?.close()}, '关闭'))),
|
|
128
140
|
error ? h('span', {role: 'alert'}, error) : null);
|
|
129
141
|
}
|
|
@@ -183,6 +195,8 @@ export function createRolloverUI(React) {
|
|
|
183
195
|
if (!info) return null;
|
|
184
196
|
return h('div', {className: 'dsr-dock'},
|
|
185
197
|
info.error ? h('div', {role: 'alert'}, info.error) : null,
|
|
198
|
+
info.compatibility?.compatible === false ? h('div', {role: 'alert', className: 'dsr-warning'}, info.compatibility.message) : null,
|
|
199
|
+
clientWarnings.size ? h('div', {role: 'alert', className: 'dsr-warning'}, '部分会话界面功能与当前 DSH 不兼容,请更新插件。') : null,
|
|
186
200
|
h(ReadonlyControl, {key: props.sessionId, info}),
|
|
187
201
|
info.capped ? h('div', {role: 'status', className: 'dsr-warning'}, '同簇已达 9 卷上限(9/9),请通过会话操作新建独立会话。') : null,
|
|
188
202
|
info.retryLimitReached ? h('div', {role: 'status', className: 'dsr-warning'}, '摘要生成失败,已停止自动重试。可在修正内容后手动换会话。') : null,
|
|
@@ -200,7 +214,9 @@ export function createRolloverUI(React) {
|
|
|
200
214
|
|
|
201
215
|
/** 释放时仅撤销自己的禁用;保留模型选择等其他插件后来设置的阻塞原因。 */
|
|
202
216
|
function blockComposer(blocks, id, reason) {
|
|
217
|
+
if (typeof blocks?.storeFor !== 'function' || typeof blocks?.set !== 'function') throw new Error('composer blocks unavailable');
|
|
203
218
|
const store = blocks.storeFor(id);
|
|
219
|
+
if (typeof store?.getSnapshot !== 'function' || typeof store?.subscribe !== 'function') throw new Error('composer store unavailable');
|
|
204
220
|
let previous = store.getSnapshot();
|
|
205
221
|
const own = {reason};
|
|
206
222
|
let writing = false;
|
|
@@ -222,13 +238,15 @@ export function createRolloverUI(React) {
|
|
|
222
238
|
style.setAttribute('data-dsh-plugin', 'dsh-session-rollover-ui');
|
|
223
239
|
document.head.appendChild(style);
|
|
224
240
|
ctx.effect?.(() => () => { style.remove(); for (const item of entries.values()) item.stop?.(); entries.clear(); });
|
|
225
|
-
|
|
241
|
+
inject(ctx, ['uiWorkspace'], scope => {
|
|
242
|
+
if (typeof scope.uiWorkspace?.openSession !== 'function') throw new Error('session navigation unavailable');
|
|
226
243
|
navigate = id => scope.uiWorkspace.openSession(id);
|
|
227
244
|
scope.effect?.(() => () => { navigate = null; });
|
|
228
245
|
});
|
|
229
|
-
|
|
246
|
+
inject(ctx, ['slots'], scope => {
|
|
230
247
|
const slots = scope.slots;
|
|
231
|
-
|
|
248
|
+
if (typeof slots?.inject !== 'function' || typeof slots?.register !== 'function') throw new Error('slots unavailable');
|
|
249
|
+
const mount = (name, Component, options = {}, owner = scope) => owner.effect(() => owner.slots.inject(name, () => owner.slots.register({name,
|
|
232
250
|
id: 'dsh-session-rollover', order: 35, ...options}, Component)));
|
|
233
251
|
mount('conversation.session.header.actions', Header);
|
|
234
252
|
mount('conversation.input.dock', Dock);
|
|
@@ -237,20 +255,27 @@ export function createRolloverUI(React) {
|
|
|
237
255
|
mount('sidebar.session.row.leading', props => slots.snapshot?.('sidebar.session.row.trailing')?.length
|
|
238
256
|
? null : h(Badges, {...props, compact: true}));
|
|
239
257
|
// 使用宿主公开 composer block 服务;覆盖输入组件会破坏其子槽授权。
|
|
240
|
-
|
|
258
|
+
inject(scope, ['conversation'], inputScope => {
|
|
259
|
+
if (typeof inputScope.conversation?.blocks?.storeFor !== 'function' || typeof inputScope.conversation?.blocks?.set !== 'function') {
|
|
260
|
+
throw new Error('composer blocks unavailable');
|
|
261
|
+
}
|
|
241
262
|
function ComposerGuard(props) {
|
|
242
263
|
const info = useInfo(props.sessionId);
|
|
243
264
|
const reason = !info ? '正在读取会话状态…' : info.readonly
|
|
244
265
|
? '归档会话:不可输入,可通过上方开关恢复写入。' : info.error;
|
|
245
|
-
useEffect(() =>
|
|
246
|
-
|
|
266
|
+
useEffect(() => {
|
|
267
|
+
if (props.sessionId && reason) {
|
|
268
|
+
try { return blockComposer(inputScope.conversation.blocks, props.sessionId, reason); }
|
|
269
|
+
catch (error) { unavailable('conversation.blocks', error); }
|
|
270
|
+
}
|
|
271
|
+
},
|
|
247
272
|
[props.sessionId, reason]);
|
|
248
273
|
return null;
|
|
249
274
|
}
|
|
250
275
|
inputScope.effect(() => slots.inject('conversation.input.dock', () => slots.register({
|
|
251
276
|
name: 'conversation.input.dock', id: 'dsr-readonly-guard', order: 34}, ComposerGuard)));
|
|
252
277
|
});
|
|
253
|
-
|
|
278
|
+
inject(scope, ['uiConversation'], textScope => {
|
|
254
279
|
if (!textScope.uiConversation.userText?.register) return;
|
|
255
280
|
textScope.effect(() => textScope.uiConversation.userText.register({id: 'dsr-handoff', order: -100,
|
|
256
281
|
match(text, from) {
|
|
@@ -258,8 +283,10 @@ export function createRolloverUI(React) {
|
|
|
258
283
|
const payload = parseHandoff(text);
|
|
259
284
|
return payload ? {id: 'dsr-handoff', start: 0, end: text.length, data: payload} : undefined;
|
|
260
285
|
}}));
|
|
286
|
+
// 插槽必须随 uiConversation 的注入子作用域释放,不能挂在较长寿命的 slots 父作用域。
|
|
287
|
+
// 新宿主重连/热替换领域服务时会重建该子作用域;挂在父级会重复占用同一个 keyed slot。
|
|
261
288
|
mount('conversation.user.text.node', props => h(Card, {payload: props.node.data,
|
|
262
|
-
source: props.node.data.from_session_id && !props.node.data.cluster?.independent ? {sessionId: props.node.data.from_session_id, title: props.node.data.from_title ?? '上一卷'} : null}), {key: 'dsr-handoff'});
|
|
289
|
+
source: props.node.data.from_session_id && !props.node.data.cluster?.independent ? {sessionId: props.node.data.from_session_id, title: props.node.data.from_title ?? '上一卷'} : null}), {key: 'dsr-handoff'}, textScope);
|
|
263
290
|
renderInline = true;
|
|
264
291
|
textScope.effect?.(() => () => { renderInline = false; });
|
|
265
292
|
});
|
|
@@ -280,5 +307,5 @@ body[data-ds-dark-theme] :is(.dsr-header,.dsr-dock,.dsr-card,.dsr-row-badges),ht
|
|
|
280
307
|
@media(max-width:600px){.dsr-header{gap:3px;flex-wrap:nowrap;max-width:calc(100vw - 156px)}.dsr-header>.dsr-chip,.dsr-header>.dsr-source{display:none}.dsr-header>.dsr-chip:first-child{display:inline-flex;font-size:10px;padding:1px 3px}}
|
|
281
308
|
@media(prefers-reduced-motion:reduce){.dsr-card *{animation:none!important;transition:none!important}}
|
|
282
309
|
`;
|
|
283
|
-
return {apply, Header, Dock, Card, Badges, useInfo, parseHandoff, publish, blockComposer, CSS};
|
|
310
|
+
return {apply, Header, Dock, Card, Badges, useInfo, parseHandoff, publish, blockComposer, clientWarnings, CSS};
|
|
284
311
|
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// 只探测已声明的宿主服务;不根据版本号猜测方法签名,也不在探测时写会话。
|
|
2
|
+
const REQUIRED = {
|
|
3
|
+
sessions: ['get', 'list'],
|
|
4
|
+
sessionTitle: ['get', 'rename'],
|
|
5
|
+
sessionController: ['create', 'prompt', 'selectModel'],
|
|
6
|
+
sessionProjections: ['stateOf'],
|
|
7
|
+
workspaceRegistry: ['list'],
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export const INCOMPATIBLE_MESSAGE = '当前 DSH 的会话接口与插件不兼容,已暂停分卷。原会话与归档数据保留,请更新插件后重试。';
|
|
11
|
+
|
|
12
|
+
/** 方法存在只代表能力探测通过;事件语义、权限和界面仍需逐版本验收。 */
|
|
13
|
+
export function inspectHost(ctx, session) {
|
|
14
|
+
const missing = [];
|
|
15
|
+
for (const [service, methods] of Object.entries(REQUIRED)) {
|
|
16
|
+
for (const method of methods) {
|
|
17
|
+
try { if (typeof ctx[service]?.[method] !== 'function') missing.push(`${service}.${method}`); }
|
|
18
|
+
catch { missing.push(`${service}.${method}`); }
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
if (session && typeof session.snapshotEvents !== 'function') missing.push('session.snapshotEvents');
|
|
22
|
+
return {contractVersion: 1, compatible: missing.length === 0, missing,
|
|
23
|
+
message: missing.length ? INCOMPATIBLE_MESSAGE : null};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** 在创建新卷之前检查同步读取面的形状;遇到变化时停止,不用空数据继续交接。 */
|
|
27
|
+
export function preflightHost(ctx, session, services = ctx) {
|
|
28
|
+
const report = inspectHost(ctx, session);
|
|
29
|
+
if (!report.compatible) return report;
|
|
30
|
+
try {
|
|
31
|
+
if (!Array.isArray(session.snapshotEvents())) throw new Error('session.snapshotEvents:array-required');
|
|
32
|
+
const sessions = ctx.sessions.list();
|
|
33
|
+
if (!Array.isArray(sessions) || sessions.some(s => !s || typeof s.id !== 'string' || !s.id)) {
|
|
34
|
+
throw new Error('sessions.list:session-id-required');
|
|
35
|
+
}
|
|
36
|
+
const workspaces = ctx.workspaceRegistry.list();
|
|
37
|
+
if (!Array.isArray(workspaces) || workspaces.some(w => !w || typeof w.id !== 'string' || !w.id ||
|
|
38
|
+
!Array.isArray(w.sessionIds) || w.sessionIds.some(id => typeof id !== 'string'))) {
|
|
39
|
+
throw new Error('workspaceRegistry.list:sessionIds-required');
|
|
40
|
+
}
|
|
41
|
+
const title = ctx.sessionTitle.get(session);
|
|
42
|
+
if (title != null && (typeof title !== 'object' || typeof title.title !== 'string')) {
|
|
43
|
+
throw new Error('sessionTitle.get:title-required');
|
|
44
|
+
}
|
|
45
|
+
const cwd = readWorkingDirectory(ctx, services, session);
|
|
46
|
+
if (cwd != null && typeof cwd !== 'string') throw new Error('workingDirectory:string-required');
|
|
47
|
+
readModelSelection(ctx, services, session);
|
|
48
|
+
} catch (error) {
|
|
49
|
+
return {...report, compatible: false, missing: [String(error?.message ?? error)], message: INCOMPATIBLE_MESSAGE};
|
|
50
|
+
}
|
|
51
|
+
return report;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 新版优先使用领域服务;旧宿主回退到已验证的公开投影入口。 */
|
|
55
|
+
export function readWorkingDirectory(ctx, services, session) {
|
|
56
|
+
return typeof services.workingDirectory?.get === 'function'
|
|
57
|
+
? services.workingDirectory.get(session)
|
|
58
|
+
: ctx.sessionProjections.stateOf(session, 'workingDirectory');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function validateModel(selection) {
|
|
62
|
+
if (selection != null && (typeof selection.provider !== 'string' || typeof selection.model !== 'string' ||
|
|
63
|
+
!selection.provider || !selection.model ||
|
|
64
|
+
(selection.reasoningEffort != null && typeof selection.reasoningEffort !== 'string'))) {
|
|
65
|
+
throw new Error('modelSelection:provider-model-required');
|
|
66
|
+
}
|
|
67
|
+
return selection;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function readModelSelection(ctx, services, session) {
|
|
71
|
+
const agent = services.agents?.get?.(session.id);
|
|
72
|
+
// 旧宿主也可能提供 agents,但尚无 model.current;此时仍走经过结构校验的投影入口。
|
|
73
|
+
if (typeof agent?.model?.current === 'function') {
|
|
74
|
+
const current = validateModel(agent.model.current());
|
|
75
|
+
if (current != null) return current;
|
|
76
|
+
}
|
|
77
|
+
const selection = ctx.sessionProjections.stateOf(session, 'modelSelection');
|
|
78
|
+
if (selection == null) return undefined;
|
|
79
|
+
if (typeof selection !== 'object' || Array.isArray(selection) ||
|
|
80
|
+
!('pending' in selection || 'lastUsed' in selection)) throw new Error('modelSelection:projection-shape-required');
|
|
81
|
+
validateModel(selection.pending);
|
|
82
|
+
validateModel(selection.lastUsed);
|
|
83
|
+
return selection.pending ?? selection.lastUsed;
|
|
84
|
+
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import {eventType} from '../domain/turns.mjs';
|
|
3
3
|
import {openToolCalls} from '../domain/inflight.mjs';
|
|
4
4
|
import {lastTodos} from '../domain/todos.mjs';
|
|
5
|
+
import {preflightHost, readWorkingDirectory, readModelSelection} from './compatibility.mjs';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* 为一个来源会话创建适配器。新卷继承其工作区和模型选择。
|
|
@@ -14,6 +15,8 @@ import {lastTodos} from '../domain/todos.mjs';
|
|
|
14
15
|
* @returns {object} performRollover 所需的异步宿主接口
|
|
15
16
|
*/
|
|
16
17
|
export function createHostAdapter({ctx, session, state, sessionOf, log, services = ctx}) {
|
|
18
|
+
const compatibility = preflightHost(ctx, session, services);
|
|
19
|
+
if (!compatibility.compatible) throw new Error(`host-incompatible: ${compatibility.missing.join(', ')}`);
|
|
17
20
|
const id = session.id;
|
|
18
21
|
const sourceEvents = session.snapshotEvents().slice();
|
|
19
22
|
const registry = services.jobs;
|
|
@@ -101,16 +104,17 @@ export function createHostAdapter({ctx, session, state, sessionOf, log, services
|
|
|
101
104
|
},
|
|
102
105
|
createSession: async () => {
|
|
103
106
|
// 优先归入来源工作区;来源不属于工作区时才按其 cwd 建会话。
|
|
104
|
-
const cwd = ctx
|
|
107
|
+
const cwd = readWorkingDirectory(ctx, services, session);
|
|
105
108
|
const workspace = ctx.workspaceRegistry.list().find(w => w.sessionIds.includes(session.id));
|
|
106
|
-
|
|
109
|
+
const created = await ctx.sessionController.create(workspace ? {workspaceId: workspace.id} : typeof cwd === 'string' ? {cwd} : {});
|
|
110
|
+
if (!/^[\w-]{1,160}$/.test(created?.sessionId ?? '')) throw new Error('host-incompatible: create.sessionId');
|
|
111
|
+
return created;
|
|
107
112
|
},
|
|
108
113
|
injectFirstMessage: async (sessionId, text) => {
|
|
109
114
|
// 宿主 create() 建的是空壳会话,没有 model selection,会落到默认 provider
|
|
110
115
|
// (实测 "deepseek-official" 无 key → MISSING_CREDENTIAL)。先继承旧卷的模型,再注入。
|
|
111
116
|
try {
|
|
112
|
-
const
|
|
113
|
-
const pick = sel?.pending ?? sel?.lastUsed;
|
|
117
|
+
const pick = readModelSelection(ctx, services, session);
|
|
114
118
|
if (pick?.provider && pick?.model) {
|
|
115
119
|
await ctx.sessionController.selectModel({
|
|
116
120
|
sessionId, provider: pick.provider, model: pick.model,
|
|
@@ -54,6 +54,12 @@ export async function handleSessionRequest(plugin, req, res) {
|
|
|
54
54
|
try { input = JSON.parse(body); } catch { return send(400, {error: 'invalid-json'}); }
|
|
55
55
|
if (!/^[\w-]{1,160}$/.test(input?.sessionId ?? '')) return send(400, {error: 'invalid-session'});
|
|
56
56
|
if (!plugin.sessionOf(input.sessionId)) return send(404, {error: 'session-unavailable'});
|
|
57
|
+
if (['rollover', 'new'].includes(input.action)) {
|
|
58
|
+
const compatibility = plugin.handoffInfo(input.sessionId).compatibility;
|
|
59
|
+
if (compatibility && !compatibility.compatible) {
|
|
60
|
+
return send(503, {error: 'host-incompatible', message: compatibility.message, compatibility});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
57
63
|
if (input.action === 'readonly' && typeof input.readonly === 'boolean') {
|
|
58
64
|
if (!plugin.handoffInfo(input.sessionId).sealed) return send(409, {error: 'session-not-archived'});
|
|
59
65
|
return send(200, plugin.setReadonly(input.sessionId, input.readonly));
|
package/src/server/plugin.mjs
CHANGED
|
@@ -11,6 +11,7 @@ import {transitionInFlight} from '../domain/mode.mjs';
|
|
|
11
11
|
import {createNoticeStore} from '../storage/notices.mjs';
|
|
12
12
|
import {createHostAdapter} from './host-adapter.mjs';
|
|
13
13
|
import {attachNoticeRoutes, handlePendingNotices} from './notice-route.mjs';
|
|
14
|
+
import {inspectHost, preflightHost, INCOMPATIBLE_MESSAGE} from './compatibility.mjs';
|
|
14
15
|
|
|
15
16
|
const inflightKey = (id) => `rollover:${id}`;
|
|
16
17
|
|
|
@@ -27,6 +28,7 @@ const NOTICE_TEXT = {
|
|
|
27
28
|
'cluster-cap': '同簇已达 9 卷上限,请新建独立会话继续。',
|
|
28
29
|
'storage-unavailable': '分卷数据目录不可写,已保留原会话,请检查目录后重试。',
|
|
29
30
|
'user-renamed': '已保留手动设置的会话名称。',
|
|
31
|
+
'host-incompatible': INCOMPATIBLE_MESSAGE,
|
|
30
32
|
};
|
|
31
33
|
|
|
32
34
|
/**
|
|
@@ -52,9 +54,10 @@ export default class SessionRolloverPlugin {
|
|
|
52
54
|
this.ctx = ctx;
|
|
53
55
|
this.services = typeof ctx.inject === 'function' ? {} : ctx;
|
|
54
56
|
if (ctx.inject) {
|
|
55
|
-
for (const name of ['jobs', 'agentDefaultModel']) ctx.inject([name], scope => {
|
|
56
|
-
|
|
57
|
-
|
|
57
|
+
for (const name of ['jobs', 'agentDefaultModel', 'workingDirectory', 'agents']) ctx.inject([name], scope => {
|
|
58
|
+
const service = scope[name];
|
|
59
|
+
this.services[name] = service;
|
|
60
|
+
scope.effect?.(() => () => { if (this.services[name] === service) delete this.services[name]; });
|
|
58
61
|
});
|
|
59
62
|
}
|
|
60
63
|
this.config = {mode: 'auto', threshold: DEFAULT_THRESHOLD, ...config};
|
|
@@ -72,6 +75,7 @@ export default class SessionRolloverPlugin {
|
|
|
72
75
|
rollover: (id) => this.rolloverNow(id),
|
|
73
76
|
info: (id) => this.handoffInfo(id),
|
|
74
77
|
setReadonly: (id, value) => this.setReadonly(id, value),
|
|
78
|
+
compatibility: () => inspectHost(this.ctx),
|
|
75
79
|
});
|
|
76
80
|
ctx.effect(() => () => {
|
|
77
81
|
this.busy.clear();
|
|
@@ -87,6 +91,7 @@ export default class SessionRolloverPlugin {
|
|
|
87
91
|
this.log('boot', {
|
|
88
92
|
dataDir: this.dataDir, mode: this.config.mode, threshold: this.config.threshold,
|
|
89
93
|
degraded: this.persistence.degraded, notices: this.notices.path, noticesDegraded: this.notices.degraded,
|
|
94
|
+
compatibility: inspectHost(ctx),
|
|
90
95
|
});
|
|
91
96
|
}
|
|
92
97
|
|
|
@@ -126,13 +131,21 @@ export default class SessionRolloverPlugin {
|
|
|
126
131
|
|
|
127
132
|
/** 启动修复缓存缺项;失败注入产生的待清理卷不参与有效父链。 */
|
|
128
133
|
reconcileIndex() {
|
|
134
|
+
if (!inspectHost(this.ctx).compatible) return;
|
|
135
|
+
let sessions;
|
|
136
|
+
try { sessions = this.ctx.sessions.list(); } catch { return; }
|
|
137
|
+
if (!Array.isArray(sessions)) return;
|
|
129
138
|
let changed = false;
|
|
130
139
|
const abandoned = new Set(this.state.pendingList().map(r => r.sessionId));
|
|
131
140
|
const snapshots = new Map();
|
|
132
|
-
for (const session of
|
|
141
|
+
for (const session of sessions) {
|
|
142
|
+
if (!session || typeof session.id !== 'string') continue;
|
|
133
143
|
if (abandoned.has(session.id)) continue;
|
|
134
144
|
let h;
|
|
135
|
-
|
|
145
|
+
let events;
|
|
146
|
+
try { events = session.snapshotEvents(); } catch { continue; }
|
|
147
|
+
if (!Array.isArray(events)) continue;
|
|
148
|
+
for (const e of events) {
|
|
136
149
|
const text = textOf(e);
|
|
137
150
|
if (!text.startsWith('【分卷交接】')) continue;
|
|
138
151
|
try { h = JSON.parse(/```json\s*([\s\S]*?)```/.exec(text)?.[1]); } catch { /* 非本插件快照 */ }
|
|
@@ -204,20 +217,24 @@ export default class SessionRolloverPlugin {
|
|
|
204
217
|
const parent = handoff?.cluster?.independent ? null : handoff?.from_session_id ?? record?.parent_session_id;
|
|
205
218
|
let title = handoff?.from_title || parent;
|
|
206
219
|
const source = parent && this.sessionOf(parent);
|
|
207
|
-
if (source) title = this.ctx.sessionTitle.get(source)?.title || title;
|
|
220
|
+
if (source) { try { title = this.ctx.sessionTitle.get(source)?.title || title; } catch {} }
|
|
208
221
|
const session = this.sessionOf(id);
|
|
209
|
-
|
|
222
|
+
let events = [];
|
|
223
|
+
try { const snapshot = session?.snapshotEvents?.(); if (Array.isArray(snapshot)) events = snapshot; } catch {}
|
|
224
|
+
let sessionTitle = '';
|
|
225
|
+
try { sessionTitle = session ? this.ctx.sessionTitle.get(session)?.title ?? '' : ''; } catch {}
|
|
210
226
|
const sealed = record?.rollover_state === 'sealed';
|
|
211
227
|
const turnCount = countTurns(events);
|
|
212
228
|
const children = record ? this.index.cluster(record.cluster_id).filter(r => r.parent_session_id === id) : [];
|
|
213
229
|
const retry = this.state.retryGet(id).attempts;
|
|
214
230
|
return {source: parent ? {sessionId: parent, title} : null, handoff,
|
|
215
231
|
readonly: this.isReadonly(id), sealed, sessionId: id,
|
|
216
|
-
title:
|
|
232
|
+
title: sessionTitle,
|
|
233
|
+
compatibility: session ? preflightHost(this.ctx, session, this.services) : inspectHost(this.ctx),
|
|
217
234
|
turnCount, threshold: this.config.threshold, volume: record?.vol_no ?? null, cap: 9,
|
|
218
235
|
capped: record?.vol_no >= 9, summaryRetry: retry,
|
|
219
236
|
retryLimitReached: retry >= 3, degraded: this.persistence.degraded || !!this.stateDegraded?.has(id),
|
|
220
|
-
previousTurnCount:
|
|
237
|
+
previousTurnCount: source ? (() => { try { const e = source.snapshotEvents(); return Array.isArray(e) ? countTurns(e) : null; } catch { return null; } })() : null,
|
|
221
238
|
nextSessionId: children[0]?.sessionId ?? null};
|
|
222
239
|
}
|
|
223
240
|
|
|
@@ -241,6 +258,8 @@ export default class SessionRolloverPlugin {
|
|
|
241
258
|
async rolloverNow(id) {
|
|
242
259
|
const session = this.sessionOf(id);
|
|
243
260
|
if (!session) return {status: 'failed', reason: 'session-unavailable'};
|
|
261
|
+
const compatibility = preflightHost(this.ctx, session, this.services);
|
|
262
|
+
if (!compatibility.compatible) return {status: 'failed', reason: 'host-incompatible', compatibility};
|
|
244
263
|
if (this.busy.has(inflightKey(id))) return {status: 'skipped', reason: 'busy'};
|
|
245
264
|
if (this.index.get(id)?.rollover_state === 'sealed') return {status: 'skipped', reason: 'readonly'};
|
|
246
265
|
this.busy.add(inflightKey(id));
|
|
@@ -300,6 +319,11 @@ export default class SessionRolloverPlugin {
|
|
|
300
319
|
const session = agent?.session;
|
|
301
320
|
if (!session?.id) return;
|
|
302
321
|
const id = session.id;
|
|
322
|
+
const compatibility = preflightHost(this.ctx, session, this.services);
|
|
323
|
+
if (!compatibility.compatible) {
|
|
324
|
+
this.notifyOnce(session, {reason: 'host-incompatible'});
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
303
327
|
if (this.busy.has(inflightKey(id))) return;
|
|
304
328
|
// 已封卷的 -N 永不再分卷:B9 的手动开关只放行「在旧卷继续写」,不等于再开新卷。
|
|
305
329
|
try {
|