@mzzsfy/dsh-session-manager 0.9.6 → 0.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/package.json +11 -3
- package/src/client.js +63 -17
- package/src/index.js +93 -36
- package/src/list-slim.mjs +106 -0
- package/test/client-row-actions.test.mjs +20 -0
- package/test/client-switch.test.mjs +49 -0
- package/test/index.test.mjs +114 -62
- package/test/list-slim.test.mjs +174 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mzzsfy/dsh-session-manager",
|
|
3
3
|
"description": "会话自动归档与管理:超期自动归档、归档面板、删除进系统回收站、还原后一键重挂载",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.10.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
7
7
|
"exports": {
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
"./package.json": "./package.json"
|
|
11
11
|
},
|
|
12
12
|
"dsh": {
|
|
13
|
+
"hostMin": "0.2.1-rc.2",
|
|
13
14
|
"bundle": {
|
|
14
15
|
"patch": "./cordis.patch.yml"
|
|
15
16
|
},
|
|
@@ -22,8 +23,11 @@
|
|
|
22
23
|
"compatibility": {
|
|
23
24
|
"dshReleases": {
|
|
24
25
|
"0.2.0-rc.1": "unknown",
|
|
25
|
-
"0.2.0-rc.2": "
|
|
26
|
-
"0.2.1-alpha.1": "unknown"
|
|
26
|
+
"0.2.0-rc.2": "unknown",
|
|
27
|
+
"0.2.1-alpha.1": "unknown",
|
|
28
|
+
"0.2.1-rc.2": "compatible",
|
|
29
|
+
"0.2.1-rc.5": "compatible",
|
|
30
|
+
"0.2.2-rc.1": "compatible"
|
|
27
31
|
}
|
|
28
32
|
}
|
|
29
33
|
},
|
|
@@ -31,6 +35,10 @@
|
|
|
31
35
|
"test": "node --test \"test/*.test.mjs\"",
|
|
32
36
|
"npmPublish": "npm publish --access public --provenance=false --registry=https://registry.npmjs.org"
|
|
33
37
|
},
|
|
38
|
+
"publishConfig": {
|
|
39
|
+
"access": "public",
|
|
40
|
+
"registry": "https://registry.npmjs.org"
|
|
41
|
+
},
|
|
34
42
|
"license": "MIT",
|
|
35
43
|
"repository": {
|
|
36
44
|
"type": "git",
|
package/src/client.js
CHANGED
|
@@ -123,6 +123,20 @@ const CSS = [
|
|
|
123
123
|
' border:1px solid var(--dsw-alias-border-l2);',
|
|
124
124
|
' background:transparent; color:inherit; font:inherit; }',
|
|
125
125
|
'.sm-cfg__field input:focus-visible { outline:2px solid var(--dsw-alias-state-business-primary); outline-offset:0; }',
|
|
126
|
+
// 列表瘦身开关:原生 checkbox 保语义与可访问性,视觉由 track/thumb 呈现
|
|
127
|
+
'.sm-switch { display:inline-flex; align-items:center; gap:8px; min-height:28px; cursor:pointer;',
|
|
128
|
+
' font:var(--dsw-font-xxs-12, 12px/18px sans-serif); color:var(--dsw-alias-label-caption); }',
|
|
129
|
+
'.sm-switch input[type="checkbox"] { position:absolute; opacity:0; width:0; height:0; }',
|
|
130
|
+
'.sm-switch__track { position:relative; flex-shrink:0; width:34px; height:19px; border-radius:999px; box-sizing:border-box;',
|
|
131
|
+
' background:transparent; border:1px solid var(--dsw-alias-border-l2); transition:background 0.15s,border-color 0.15s; }',
|
|
132
|
+
'.sm-switch__thumb { position:absolute; top:50%; left:2px; width:13px; height:13px; border-radius:50%;',
|
|
133
|
+
' background:var(--dsw-alias-label-caption); transform:translateY(-50%); transition:left 0.15s,background 0.15s; }',
|
|
134
|
+
'.sm-switch:hover .sm-switch__track { border-color:var(--dsw-alias-state-business-primary); }',
|
|
135
|
+
'.sm-switch input[type="checkbox"]:checked + .sm-switch__track { background:var(--dsw-alias-state-business-primary); border-color:var(--dsw-alias-state-business-primary); }',
|
|
136
|
+
'.sm-switch input[type="checkbox"]:checked + .sm-switch__track .sm-switch__thumb { left:17px; background:#fff; }',
|
|
137
|
+
'.sm-switch input[type="checkbox"]:focus-visible + .sm-switch__track { outline:2px solid var(--dsw-alias-state-business-primary); outline-offset:2px; }',
|
|
138
|
+
'.sm-switch:has(input[type="checkbox"]:disabled) { opacity:0.5; cursor:not-allowed; }',
|
|
139
|
+
'@media (prefers-reduced-motion: reduce) { .sm-switch__track, .sm-switch__thumb { transition:none; } }',
|
|
126
140
|
].join('\n')
|
|
127
141
|
|
|
128
142
|
const UNARCHIVE_URL = '/api/session-manager/unarchive'
|
|
@@ -136,6 +150,11 @@ const STATUS_URL = '/api/session-manager/status'
|
|
|
136
150
|
// 删除确认链文案:两击行内确认后弹窗强提示,弹窗内确认才发请求。
|
|
137
151
|
// 删除产物不可再生,其他插件(用量统计等)依赖会话产物,删后其会话级数据一并消失
|
|
138
152
|
const DELETE_BUTTON_TITLE = '删除会话产物会影响其他插件的工作(用量统计等依赖会话产物);若无特殊需求,请不要删除。删除需再次行内确认与弹窗确认。'
|
|
153
|
+
// 行操作按钮悬停说明:文案与行为同源维护,由源码契约测试锁定
|
|
154
|
+
const RESTORE_BUTTON_TITLE = '把该会话从归档移回会话列表;恢复后 10 分钟内暂停自动归档,保护「恢复 → 首条消息」窗口,期间发言即恢复正常活跃计时。'
|
|
155
|
+
const REMOUNT_BUTTON_TITLE = '把这条已删除记录对应的会话重新挂回列表:产物仍在原位置时直接挂载,产物已进系统回收站时先还原再挂载。'
|
|
156
|
+
const FORGET_BUTTON_TITLE = '只移除这条已删除记录,不动磁盘上的会话产物;用于产物已手动找回、或不再需要追踪的情况。'
|
|
157
|
+
const MORE_BUTTON_TITLE = '展开该工作区剩余的归档会话;只改变面板显示条数,不改变任何会话的归档状态。'
|
|
139
158
|
const DELETE_DIALOG_TITLE = '确认删除该会话?'
|
|
140
159
|
const DELETE_DIALOG_BODY_PREFIX = '即将删除会话「'
|
|
141
160
|
const DELETE_DIALOG_BODY_SUFFIX = '」。'
|
|
@@ -347,7 +366,7 @@ function ArchiveRow(props) {
|
|
|
347
366
|
]
|
|
348
367
|
: [
|
|
349
368
|
h('button', { key: 'delete', className: 'sm-btn sm-btn--danger', disabled: busy, title: DELETE_BUTTON_TITLE, onClick: props.onDelete }, '删除'),
|
|
350
|
-
h('button', { key: 'restore', className: 'sm-btn sm-btn--restore', disabled: busy, onClick: props.onUnarchive }, '恢复'),
|
|
369
|
+
h('button', { key: 'restore', className: 'sm-btn sm-btn--restore', disabled: busy, title: RESTORE_BUTTON_TITLE, onClick: props.onUnarchive }, '恢复'),
|
|
351
370
|
]
|
|
352
371
|
return h('div', {
|
|
353
372
|
className: 'sm-row' + (armed ? ' sm-row--armed' : '') + (busy ? ' sm-row--busy' : ''),
|
|
@@ -402,8 +421,8 @@ function DeletedRow(props) {
|
|
|
402
421
|
h('span', { key: 'meta', className: 'sm-row__time', title: new Date(row.deletedAt).toLocaleString() }, fmtTime(row.deletedAt)),
|
|
403
422
|
h('span', { className: 'sm-row__title', title: row.title }, row.title),
|
|
404
423
|
h('div', { className: 'sm-row__actions' },
|
|
405
|
-
h('button', { key: 'remount', className: 'sm-btn sm-btn--restore', disabled: busy, onClick: props.onRemount }, '重新挂载'),
|
|
406
|
-
h('button', { key: 'forget', className: 'sm-btn', disabled: busy, onClick: props.onForget }, '移除记录'),
|
|
424
|
+
h('button', { key: 'remount', className: 'sm-btn sm-btn--restore', disabled: busy, title: REMOUNT_BUTTON_TITLE, onClick: props.onRemount }, '重新挂载'),
|
|
425
|
+
h('button', { key: 'forget', className: 'sm-btn', disabled: busy, title: FORGET_BUTTON_TITLE, onClick: props.onForget }, '移除记录'),
|
|
407
426
|
),
|
|
408
427
|
h('span', { className: 'sm-row__path', title: row.path }, '原位置 ' + row.path),
|
|
409
428
|
)
|
|
@@ -554,7 +573,7 @@ function SessionManagerApp(props) {
|
|
|
554
573
|
// 免得数组子节点内无 key 元素随页扩位触发不必要的卸载重挂
|
|
555
574
|
const expandMore = (remaining, onMore) => remaining <= 0
|
|
556
575
|
? null
|
|
557
|
-
: h('button', { key: 'more', className: 'sm-more', onClick: onMore }, '展开更多(剩 ' + remaining + ' 条)')
|
|
576
|
+
: h('button', { key: 'more', className: 'sm-more', title: MORE_BUTTON_TITLE, onClick: onMore }, '展开更多(剩 ' + remaining + ' 条)')
|
|
558
577
|
|
|
559
578
|
// 主列表(最近归档):固定首页不翻页,尾部「按工作区浏览」入口跳转二级视图看全部
|
|
560
579
|
const recentPage = pageArchiveRows(rows, ARCHIVE_PAGE_SIZE)
|
|
@@ -668,11 +687,13 @@ function SessionManagerApp(props) {
|
|
|
668
687
|
const ARCHIVE_OVERVIEW_TITLE = '超过阈值天数未活跃的会话自动移入归档;触发时机为新会话创建、插件启动与周期检查;归档会话在本面板管理,可恢复或删除;恢复后 10 分钟内暂停自动归档,期间发言即恢复正常活跃计时。'
|
|
669
688
|
const ARCHIVE_DAYS_TITLE = '会话超过该天数未活跃(产物无更新)即自动归档,空白(无消息)会话不参与归档;0 = 关闭自动归档。仅对产物可读的会话评估,产物不可读的跳过以免误归档,已归档的会话不会重复处理。'
|
|
670
689
|
const ARCHIVE_INTERVAL_TITLE = '周期检查的间隔小时数,到期会话在下一轮检查时归档;0 = 关闭周期检查(新会话创建与插件启动时的检查不受影响);调小最迟等新间隔即可提前触发,调大自下一轮排期起生效。'
|
|
690
|
+
const LIST_SLIM_TITLE = '会话列表瘦身:列表接口原本把每个会话的完整投影快照(上下文请求头、回合大纲等)一并下发,会话多时单次响应数十 MB、服务端重建要数十秒,每次打开页面都重来一遍。开启后只下发列表真正用到的字段并缓存结果,重复打开不再重建,响应体积下降九成以上;被裁掉的字段在会话详情里照旧完整返回。关闭即恢复原始行为,不影响归档、删除等其它功能。'
|
|
671
691
|
|
|
672
692
|
// 自动归档配置:阈值天数与检查周期小时数,数值语义,失焦或 Enter 单字段即时提交;
|
|
673
693
|
// 值存宿主 settings(与评估逻辑同源,0 = 关闭),非法输入前端拒绝不发包,
|
|
674
694
|
// 服务端失败后回读生效值回滚显示
|
|
675
695
|
const AUTO_ARCHIVE_URL = '/api/session-manager/auto-archive'
|
|
696
|
+
const LIST_SLIM_URL = '/api/session-manager/list-slim'
|
|
676
697
|
|
|
677
698
|
// 提交判定三态(镜像 core.mjs classifyAutoArchiveInput):noop 值未变 /
|
|
678
699
|
// invalid 非非负整数 / post 可提交;受控输入下显示值随击键同步,提交守卫
|
|
@@ -685,29 +706,30 @@ function classifyAutoArchiveInput(committedText, text) {
|
|
|
685
706
|
return { action: 'post', value }
|
|
686
707
|
}
|
|
687
708
|
|
|
709
|
+
// 面板状态:数值项取字符串供受控输入,开关项取布尔
|
|
710
|
+
const configStateOf = (archive, slim) => ({
|
|
711
|
+
days: String(archive.days),
|
|
712
|
+
intervalHours: String(archive.intervalHours),
|
|
713
|
+
enabled: slim.enabled !== false,
|
|
714
|
+
})
|
|
715
|
+
|
|
688
716
|
function AutoArchiveConfig() {
|
|
689
717
|
const [state, setState] = useState(null)
|
|
690
718
|
// 已提交值快照:非法输入与保存失败的回滚基准,与编辑中的显示值分离
|
|
691
719
|
const committedRef = React.useRef(null)
|
|
692
720
|
useEffect(() => {
|
|
693
|
-
|
|
694
|
-
.then((payload) => {
|
|
695
|
-
if (!payload) return
|
|
696
|
-
const next = { days: String(payload.days), intervalHours: String(payload.intervalHours) }
|
|
697
|
-
committedRef.current = next
|
|
698
|
-
setState(next)
|
|
699
|
-
})
|
|
700
|
-
.catch((error) => { console.warn('[session-manager] 自动归档配置读取失败', error) })
|
|
721
|
+
void refresh()
|
|
701
722
|
}, [])
|
|
723
|
+
// 两个接口合并成一个面板状态:数值项与开关项一次读齐,提交后各自回读
|
|
702
724
|
function refresh() {
|
|
703
|
-
return api(AUTO_ARCHIVE_URL)
|
|
704
|
-
.then((
|
|
705
|
-
if (!
|
|
706
|
-
const next =
|
|
725
|
+
return Promise.all([api(AUTO_ARCHIVE_URL), api(LIST_SLIM_URL)])
|
|
726
|
+
.then(([archive, slim]) => {
|
|
727
|
+
if (!archive || !slim) return
|
|
728
|
+
const next = configStateOf(archive, slim)
|
|
707
729
|
committedRef.current = next
|
|
708
730
|
setState(next)
|
|
709
731
|
})
|
|
710
|
-
.catch((error) => { console.warn('[session-manager]
|
|
732
|
+
.catch((error) => { console.warn('[session-manager] 面板配置回读失败', error) })
|
|
711
733
|
}
|
|
712
734
|
function commit(field, text) {
|
|
713
735
|
const committed = committedRef.current
|
|
@@ -735,6 +757,20 @@ function AutoArchiveConfig() {
|
|
|
735
757
|
void refresh()
|
|
736
758
|
})
|
|
737
759
|
}
|
|
760
|
+
// 开关提交:布尔值直接落 settings,成功后按回显值收敛,失败回读生效值
|
|
761
|
+
function commitEnabled(next) {
|
|
762
|
+
api(LIST_SLIM_URL, { method: 'POST', body: JSON.stringify({ enabled: next }) })
|
|
763
|
+
.then((payload) => {
|
|
764
|
+
const saved = payload.enabled !== false
|
|
765
|
+
committedRef.current = { ...committedRef.current, enabled: saved }
|
|
766
|
+
setState((prev) => (prev === null ? prev : { ...prev, enabled: saved }))
|
|
767
|
+
toast(saved ? '会话列表瘦身已开启' : '会话列表瘦身已关闭')
|
|
768
|
+
})
|
|
769
|
+
.catch(() => {
|
|
770
|
+
toast('会话列表瘦身开关保存失败', { kind: 'error' })
|
|
771
|
+
void refresh()
|
|
772
|
+
})
|
|
773
|
+
}
|
|
738
774
|
if (state === null) return null
|
|
739
775
|
const numberInput = (field) => h('input', {
|
|
740
776
|
type: 'number', min: 0, step: 1, value: state[field],
|
|
@@ -747,6 +783,16 @@ function AutoArchiveConfig() {
|
|
|
747
783
|
h('span', { title: ARCHIVE_OVERVIEW_TITLE }, '自动归档'),
|
|
748
784
|
h('label', { className: 'sm-cfg__field', title: ARCHIVE_DAYS_TITLE }, '阈值', numberInput('days'), '天未活跃(0 关闭)'),
|
|
749
785
|
h('label', { className: 'sm-cfg__field', title: ARCHIVE_INTERVAL_TITLE }, '检查周期', numberInput('intervalHours'), '小时(0 关闭)'),
|
|
786
|
+
h('label', { className: 'sm-switch', title: LIST_SLIM_TITLE },
|
|
787
|
+
h('input', {
|
|
788
|
+
type: 'checkbox',
|
|
789
|
+
checked: state.enabled,
|
|
790
|
+
'aria-label': '会话列表瘦身',
|
|
791
|
+
onChange: (event) => commitEnabled(event.target.checked),
|
|
792
|
+
}),
|
|
793
|
+
h('span', { className: 'sm-switch__track' }, h('span', { className: 'sm-switch__thumb' })),
|
|
794
|
+
h('span', { title: LIST_SLIM_TITLE }, '会话列表瘦身'),
|
|
795
|
+
),
|
|
750
796
|
)
|
|
751
797
|
}
|
|
752
798
|
|
package/src/index.js
CHANGED
|
@@ -31,13 +31,13 @@ import {
|
|
|
31
31
|
updatedAtOf,
|
|
32
32
|
} from './core.mjs'
|
|
33
33
|
import { moveToQuarantine, restoreFromQuarantine, trashPath } from './trash.mjs'
|
|
34
|
+
import { installListSlim } from './list-slim.mjs'
|
|
34
35
|
|
|
35
36
|
export const name = 'dsh-session-manager'
|
|
36
37
|
|
|
37
38
|
export const inject = ['webServer', 'workspaceRegistry', 'sessionQuery', 'storageDomain', 'agents', 'sessions', 'sessionPersistence']
|
|
38
39
|
|
|
39
40
|
const NAMESPACE = 'session-manager'
|
|
40
|
-
const WORKSPACE_DOMAIN_NAME = 'workspace'
|
|
41
41
|
|
|
42
42
|
// 已删除台账域:global 单列表,条目为回收站还原后重挂载所需的最小信息;
|
|
43
43
|
// heldPath 仅回收区降级条目携带(暂存路径,重挂载时移回 path 原位置)
|
|
@@ -98,6 +98,8 @@ const SETTINGS_SCHEMA = schemastery.object({
|
|
|
98
98
|
.description('自动归档阈值天数,0 表示关闭;新会话创建、启动与周期评估时生效'),
|
|
99
99
|
autoArchiveIntervalHours: schemastery.number().min(0).step(1).default(DEFAULT_AUTO_ARCHIVE_INTERVAL_HOURS)
|
|
100
100
|
.description('周期评估间隔小时数,0 表示关闭周期评估;变更经周期 tick 对账,关闭即时暂停、重启用最迟下个 tick 生效'),
|
|
101
|
+
listSlimEnabled: schemastery.boolean().default(true)
|
|
102
|
+
.description('会话列表瘦身:裁剪 session/list 的投影并缓存,降低列表响应体积与重建耗时;上游结构变更时自动降级为原行为'),
|
|
101
103
|
})
|
|
102
104
|
|
|
103
105
|
// 0.1.7 宿主以静态 Config 导出生成设置节表单(maintain 同构);legacy 宿主经
|
|
@@ -142,6 +144,7 @@ function readSettings(ctx, config) {
|
|
|
142
144
|
return {
|
|
143
145
|
days: Number.isInteger(days) && days >= 0 ? days : DEFAULT_AUTO_ARCHIVE_DAYS,
|
|
144
146
|
intervalHours: Number.isInteger(hours) && hours >= 0 ? hours : DEFAULT_AUTO_ARCHIVE_INTERVAL_HOURS,
|
|
147
|
+
listSlimEnabled: value?.listSlimEnabled !== false,
|
|
145
148
|
}
|
|
146
149
|
}
|
|
147
150
|
|
|
@@ -161,6 +164,21 @@ function readAutoArchivePatch(body) {
|
|
|
161
164
|
return patch
|
|
162
165
|
}
|
|
163
166
|
|
|
167
|
+
// settings 双形写(maintain 同构,判定延迟到使用点):legacy 方法面以 register 为准
|
|
168
|
+
// (0.1.7 的 settings.update 名义在场但按命名空间找条目必败,不可作判据);
|
|
169
|
+
// 0.1.7+ 走 configEditor.edit 落 profile 条目(fiber.entry,与命名空间解耦)
|
|
170
|
+
async function persistSettings(ctx, patch) {
|
|
171
|
+
const settings = ctx.get('settings')
|
|
172
|
+
if (typeof settings?.register === 'function' && typeof settings?.update === 'function') {
|
|
173
|
+
await settings.update(NAMESPACE, patch)
|
|
174
|
+
return
|
|
175
|
+
}
|
|
176
|
+
const editor = ctx.get('configEditor')
|
|
177
|
+
const entry = ctx.fiber?.entry
|
|
178
|
+
if (!editor || typeof editor.edit !== 'function' || !entry) throw new Error('宿主设置服务不可用')
|
|
179
|
+
await editor.edit(entry, (current) => ({ ...current, ...patch }))
|
|
180
|
+
}
|
|
181
|
+
|
|
164
182
|
function readBody(req) {
|
|
165
183
|
const BODY_MAX_BYTES = 64 * 1024
|
|
166
184
|
return new Promise((resolve, reject) => {
|
|
@@ -216,9 +234,44 @@ function rejectMethod(req, res, method) {
|
|
|
216
234
|
return false
|
|
217
235
|
}
|
|
218
236
|
|
|
237
|
+
// 写路由守卫(全仓同构):浏览器跨站表单可发 text/plain 且载荷为合法 JSON,
|
|
238
|
+
// 仅方法守卫不足以阻断 drive-by 写,补同源比对与 JSON 内容类型双校验。
|
|
239
|
+
// 无 Origin 的非浏览器调用放行(本地 CLI/脚本);无 headers 的直调桩同样放行。
|
|
240
|
+
function rejectCrossOrigin(req, res) {
|
|
241
|
+
const origin = req.headers ? req.headers.origin : undefined
|
|
242
|
+
if (!origin) return false
|
|
243
|
+
let sameOrigin = false
|
|
244
|
+
try {
|
|
245
|
+
sameOrigin = new URL(origin).host === req.headers.host
|
|
246
|
+
} catch {
|
|
247
|
+
sameOrigin = false
|
|
248
|
+
}
|
|
249
|
+
if (sameOrigin) return false
|
|
250
|
+
sendJson(res, 403, { error: '跨源请求被拒绝' })
|
|
251
|
+
return true
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// 内容类型守卫:浏览器发起的跨站请求(表单/fetch 字符串体)必带 text/plain 等类型,
|
|
255
|
+
// 借此阻断 drive-by 写;完全不带 content-type 的调用(本地 CLI/无头脚本)放行,
|
|
256
|
+
// 与"无 Origin 放行"同口径
|
|
257
|
+
function rejectNonJson(req, res) {
|
|
258
|
+
const type = req.headers ? req.headers['content-type'] : undefined
|
|
259
|
+
if (typeof type !== 'string' || type.trim() === '') return false
|
|
260
|
+
if (type.split(';')[0].trim().toLowerCase() === 'application/json') return false
|
|
261
|
+
sendJson(res, 415, { error: '仅接受 application/json 请求体' })
|
|
262
|
+
return true
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function guardWrite(req, res) {
|
|
266
|
+
return rejectCrossOrigin(req, res) || rejectNonJson(req, res)
|
|
267
|
+
}
|
|
268
|
+
|
|
219
269
|
/** @param {import('@deepseek-ai/cordis').Context} ctx */
|
|
220
270
|
export function apply(ctx, config) {
|
|
221
271
|
let evaluating = false
|
|
272
|
+
// 列表瘦身:开关从设置实时读取,设置页改动即时生效;装载失败只关闭该能力,
|
|
273
|
+
// 归档与删除等其它功能不受影响
|
|
274
|
+
installListSlim(ctx, { isEnabled: () => readSettings(ctx, config).listSlimEnabled })
|
|
222
275
|
// 取消归档全局宽限起点(进程内,重启失效可接受):恢复会话到用户发出首条
|
|
223
276
|
// 消息之间的评估窗口保护,首条消息更新产物 mtime 后由阈值自然接管
|
|
224
277
|
let unarchivedAtMs = 0
|
|
@@ -226,9 +279,9 @@ export function apply(ctx, config) {
|
|
|
226
279
|
const inFlightDeletes = new Set()
|
|
227
280
|
|
|
228
281
|
// 插件回收区:系统回收站不可用环境的删除降级暂存地,重挂载时移回原位置。
|
|
229
|
-
// 测试经 env DSH_QUARANTINE_DIR
|
|
282
|
+
// 测试经 env DSH_QUARANTINE_DIR 注入临时目录;缺省从宿主 home 派生(DSH_HOME 即 .dsh 根)
|
|
230
283
|
const quarantineDir = process.env.DSH_QUARANTINE_DIR
|
|
231
|
-
|| join(homedir(), '.dsh', 'trash', 'session-manager')
|
|
284
|
+
|| join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'trash', 'session-manager')
|
|
232
285
|
|
|
233
286
|
// header 查找缓存:归档/删除/重挂载路由共用(inputs 浮层已迁 dsh-context-manager)
|
|
234
287
|
const headerCache = new Map()
|
|
@@ -332,27 +385,11 @@ export function apply(ctx, config) {
|
|
|
332
385
|
evaluateArchives(cwd)
|
|
333
386
|
})
|
|
334
387
|
|
|
335
|
-
//
|
|
336
|
-
//
|
|
337
|
-
//
|
|
338
|
-
// 判定也会因旧快照静默跳过。合并 durable 与快照再移除,可自愈历史失同步与清理
|
|
339
|
-
// 半失败(经取消归档重试即补全),代价是快照中的陈旧 id 会随任一次清理重新落盘,
|
|
340
|
-
// 可经再次取消归档清除。inject 保证注册表就绪,快照缺失即上游形态变更,loud fail;
|
|
341
|
-
// 域 global 仅支持整体写回,读改写窗口若与注册表两阶段变更交错,理论上可回退其
|
|
342
|
-
// pendingMutation/workspaceIds 中间态,下次启动 validateStoredState 将 loud fail,
|
|
343
|
-
// 窗口极窄且域 API 无原子原语可用,属已知取舍(详见 README)。
|
|
388
|
+
// 取消归档:官方 workspaceRegistry.unarchiveSession(0.2.1-rc.2+ 串行实现,
|
|
389
|
+
// 未归档 id 幂等不写)写穿 state,无私有直写的读改写窗口与快照失同步问题;
|
|
390
|
+
// 合并自愈语义随私有写一并退役(开发守则:移除被宿主能力取代的实现)
|
|
344
391
|
async function removeArchivedId(sessionId) {
|
|
345
|
-
|
|
346
|
-
if (domain === undefined) throw new Error('workspace 域未打开')
|
|
347
|
-
const durable = domain.global.get()
|
|
348
|
-
const cached = ctx.workspaceRegistry.state
|
|
349
|
-
if (cached === undefined) throw new Error('workspace 注册表快照不可用')
|
|
350
|
-
const merged = new Set(durable.archivedSessionIds.map(String))
|
|
351
|
-
for (const id of cached.archivedSessionIds) merged.add(String(id))
|
|
352
|
-
if (!merged.delete(String(sessionId))) return
|
|
353
|
-
const next = { ...durable, archivedSessionIds: [...merged] }
|
|
354
|
-
await domain.global.set(next)
|
|
355
|
-
ctx.workspaceRegistry.state = next
|
|
392
|
+
await ctx.workspaceRegistry.unarchiveSession(sessionId)
|
|
356
393
|
}
|
|
357
394
|
|
|
358
395
|
// 台账记账:trash 成功即记录,路径为回收站「原位置」还原所需。
|
|
@@ -462,6 +499,7 @@ export function apply(ctx, config) {
|
|
|
462
499
|
path: '/api/session-manager/unarchive',
|
|
463
500
|
handler: async (req, res) => {
|
|
464
501
|
if (!rejectMethod(req, res, 'POST')) return
|
|
502
|
+
if (guardWrite(req, res)) return
|
|
465
503
|
try {
|
|
466
504
|
const sessionId = await requireSessionId(req)
|
|
467
505
|
await removeArchivedId(sessionId)
|
|
@@ -476,6 +514,7 @@ export function apply(ctx, config) {
|
|
|
476
514
|
path: '/api/session-manager/info',
|
|
477
515
|
handler: async (req, res) => {
|
|
478
516
|
if (!rejectMethod(req, res, 'POST')) return
|
|
517
|
+
if (guardWrite(req, res)) return
|
|
479
518
|
try {
|
|
480
519
|
const sessionId = await requireSessionId(req)
|
|
481
520
|
const header = await findHeader(ctx, sessionId, headerCache)
|
|
@@ -510,6 +549,7 @@ export function apply(ctx, config) {
|
|
|
510
549
|
path: '/api/session-manager/delete',
|
|
511
550
|
handler: async (req, res) => {
|
|
512
551
|
if (!rejectMethod(req, res, 'POST')) return
|
|
552
|
+
if (guardWrite(req, res)) return
|
|
513
553
|
const sessionId = await requireSessionId(req).catch((error) => {
|
|
514
554
|
respondError(ctx, res, error)
|
|
515
555
|
return undefined
|
|
@@ -661,6 +701,7 @@ export function apply(ctx, config) {
|
|
|
661
701
|
return
|
|
662
702
|
}
|
|
663
703
|
if (!rejectMethod(req, res, 'POST')) return
|
|
704
|
+
if (guardWrite(req, res)) return
|
|
664
705
|
let body
|
|
665
706
|
try {
|
|
666
707
|
body = JSON.parse(await readBody(req))
|
|
@@ -668,19 +709,7 @@ export function apply(ctx, config) {
|
|
|
668
709
|
throw new Error(MESSAGES.badJsonBody)
|
|
669
710
|
}
|
|
670
711
|
const patch = readAutoArchivePatch(body)
|
|
671
|
-
|
|
672
|
-
// (0.1.7 的 settings.update 名义在场但按命名空间找条目必败,不可作判据);
|
|
673
|
-
// 0.1.7+ 走 configEditor.edit 落 profile 条目(fiber.entry,与命名空间解耦)。
|
|
674
|
-
// await 保证回显为新值,persist 失败走 respondError
|
|
675
|
-
const settings = ctx.get('settings')
|
|
676
|
-
if (typeof settings?.register === 'function' && typeof settings?.update === 'function') {
|
|
677
|
-
await settings.update(NAMESPACE, patch)
|
|
678
|
-
} else {
|
|
679
|
-
const editor = ctx.get('configEditor')
|
|
680
|
-
const entry = ctx.fiber?.entry
|
|
681
|
-
if (!editor || typeof editor.edit !== 'function' || !entry) throw new Error('宿主设置服务不可用')
|
|
682
|
-
await editor.edit(entry, (current) => ({ ...current, ...patch }))
|
|
683
|
-
}
|
|
712
|
+
await persistSettings(ctx, patch)
|
|
684
713
|
const current = readSettings(ctx, config)
|
|
685
714
|
sendJson(res, 200, { days: current.days, intervalHours: current.intervalHours })
|
|
686
715
|
} catch (error) {
|
|
@@ -688,6 +717,32 @@ export function apply(ctx, config) {
|
|
|
688
717
|
}
|
|
689
718
|
},
|
|
690
719
|
},
|
|
720
|
+
{
|
|
721
|
+
// 会话列表瘦身开关:读回生效值,写经 settings 落 profile 条目;
|
|
722
|
+
// 关闭即恢复原始列表语义(不裁剪、不缓存),无需卸载插件
|
|
723
|
+
path: '/api/session-manager/list-slim',
|
|
724
|
+
handler: async (req, res) => {
|
|
725
|
+
try {
|
|
726
|
+
if (req.method === 'GET') {
|
|
727
|
+
sendJson(res, 200, { enabled: readSettings(ctx, config).listSlimEnabled })
|
|
728
|
+
return
|
|
729
|
+
}
|
|
730
|
+
if (!rejectMethod(req, res, 'POST')) return
|
|
731
|
+
if (guardWrite(req, res)) return
|
|
732
|
+
let body
|
|
733
|
+
try {
|
|
734
|
+
body = JSON.parse(await readBody(req))
|
|
735
|
+
} catch {
|
|
736
|
+
throw new Error(MESSAGES.badJsonBody)
|
|
737
|
+
}
|
|
738
|
+
if (typeof body?.enabled !== 'boolean') throw new Error('列表瘦身开关须为布尔值')
|
|
739
|
+
await persistSettings(ctx, { listSlimEnabled: body.enabled })
|
|
740
|
+
sendJson(res, 200, { enabled: readSettings(ctx, config).listSlimEnabled })
|
|
741
|
+
} catch (error) {
|
|
742
|
+
respondError(ctx, res, error)
|
|
743
|
+
}
|
|
744
|
+
},
|
|
745
|
+
},
|
|
691
746
|
{
|
|
692
747
|
path: '/api/session-manager/deleted',
|
|
693
748
|
handler: async (req, res) => {
|
|
@@ -704,6 +759,7 @@ export function apply(ctx, config) {
|
|
|
704
759
|
path: '/api/session-manager/remount',
|
|
705
760
|
handler: async (req, res) => {
|
|
706
761
|
if (!rejectMethod(req, res, 'POST')) return
|
|
762
|
+
if (guardWrite(req, res)) return
|
|
707
763
|
try {
|
|
708
764
|
const sessionId = await requireSessionId(req)
|
|
709
765
|
// 移回依据是台账条目,台账域不可用时整个重挂载拒绝:
|
|
@@ -747,6 +803,7 @@ export function apply(ctx, config) {
|
|
|
747
803
|
path: '/api/session-manager/forget',
|
|
748
804
|
handler: async (req, res) => {
|
|
749
805
|
if (!rejectMethod(req, res, 'POST')) return
|
|
806
|
+
if (guardWrite(req, res)) return
|
|
750
807
|
try {
|
|
751
808
|
await removeLedgerEntry(await requireSessionId(req))
|
|
752
809
|
sendJson(res, 200, { ok: true })
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// 会话列表瘦身:session/list 为每个会话下发完整投影快照,列表行只消费其中少数几项。
|
|
2
|
+
// 本模块在 remote 方法返回前按白名单裁剪并缓存结果。任何失败路径都降级为原行为:
|
|
3
|
+
// 未装载、形状不符、原方法抛错一律透传,不影响本插件的其它功能。
|
|
4
|
+
// sessionController 经 ctx.inject 可选依赖取得,宿主缺该服务时本能力静默关闭。
|
|
5
|
+
|
|
6
|
+
// 列表行消费的投影;其余键不下发。被裁掉的投影仍由会话详情接口完整返回。
|
|
7
|
+
export const KEEP_PROJECTIONS = ['title', 'sessionListMetadata', 'inbox', 'modelSelection', 'tokenUsage']
|
|
8
|
+
|
|
9
|
+
// 缓存有效期:会话增删立即失效,TTL 只兜底标题与运行时状态的漂移。
|
|
10
|
+
export const TTL_MS = 15 * 1000
|
|
11
|
+
|
|
12
|
+
// 启动后预热延迟:让宿主先完成装载,再让出一次列表重建。
|
|
13
|
+
export const WARMUP_DELAY_MS = 10 * 1000
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 裁剪一条列表项的投影,只保留白名单里的键。
|
|
17
|
+
* @param item - 宿主返回的列表项。
|
|
18
|
+
* @param keep - 保留的投影键。
|
|
19
|
+
* @returns 投影已裁剪的列表项;形状不符时原样返回。
|
|
20
|
+
*/
|
|
21
|
+
export function slimItem(item, keep = KEEP_PROJECTIONS) {
|
|
22
|
+
const values = item?.projections?.values
|
|
23
|
+
if (values === null || typeof values !== 'object' || Array.isArray(values)) return item
|
|
24
|
+
const picked = {}
|
|
25
|
+
for (const key of keep) if (key in values) picked[key] = values[key]
|
|
26
|
+
return { ...item, projections: { ...item.projections, values: picked } }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 给 sessionController.list 装上裁剪与缓存。
|
|
31
|
+
* @param ctx - 宿主上下文。
|
|
32
|
+
* @param options - 开关、白名单、缓存有效期与预热延迟。
|
|
33
|
+
* @returns 是否提交了装载回调;sessionController 缺席时回调不执行,宿主行为不变。
|
|
34
|
+
*/
|
|
35
|
+
export function installListSlim(ctx, options = {}) {
|
|
36
|
+
const {
|
|
37
|
+
enabled = true,
|
|
38
|
+
isEnabled,
|
|
39
|
+
keep = KEEP_PROJECTIONS,
|
|
40
|
+
ttlMs = TTL_MS,
|
|
41
|
+
warmupDelayMs = WARMUP_DELAY_MS,
|
|
42
|
+
} = options
|
|
43
|
+
if (!enabled) return false
|
|
44
|
+
const warn = (message) => ctx.logger?.warn?.('session-manager: 列表瘦身 ' + message)
|
|
45
|
+
try {
|
|
46
|
+
ctx.inject(['sessionController'], (scope) => {
|
|
47
|
+
try {
|
|
48
|
+
const controller = scope.sessionController
|
|
49
|
+
const original = controller?.list
|
|
50
|
+
if (typeof original !== 'function') return
|
|
51
|
+
let cache
|
|
52
|
+
let cachedAt = 0
|
|
53
|
+
let inflight
|
|
54
|
+
|
|
55
|
+
const fresh = () => cache !== undefined && Date.now() - cachedAt < ttlMs
|
|
56
|
+
const invalidate = () => {
|
|
57
|
+
cachedAt = 0
|
|
58
|
+
}
|
|
59
|
+
scope.on('api-session/added', invalidate)
|
|
60
|
+
scope.on('api-session/removed', invalidate)
|
|
61
|
+
|
|
62
|
+
// 只在调用方这一次请求上构建并缓存:不额外探测一次原方法,形状不符仅本次透传
|
|
63
|
+
controller.list = async (request, signal) => {
|
|
64
|
+
// 开关每次读取:设置页改动即时生效,无需重载插件
|
|
65
|
+
if (isEnabled !== undefined && !isEnabled()) return original.call(controller, request, signal)
|
|
66
|
+
if (fresh()) return cache
|
|
67
|
+
if (inflight !== undefined) return inflight
|
|
68
|
+
inflight = Promise.resolve(original.call(controller, request, signal))
|
|
69
|
+
.then((value) => {
|
|
70
|
+
if (!Array.isArray(value?.items)) return value
|
|
71
|
+
cache = { items: value.items.map((item) => slimItem(item, keep)) }
|
|
72
|
+
cachedAt = Date.now()
|
|
73
|
+
return cache
|
|
74
|
+
})
|
|
75
|
+
.finally(() => {
|
|
76
|
+
inflight = undefined
|
|
77
|
+
})
|
|
78
|
+
return inflight
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let timer
|
|
82
|
+
if (warmupDelayMs >= 0) {
|
|
83
|
+
timer = setTimeout(() => {
|
|
84
|
+
if (isEnabled !== undefined && !isEnabled()) return
|
|
85
|
+
if (fresh() || inflight !== undefined) return
|
|
86
|
+
void controller.list({}, undefined).catch(() => {})
|
|
87
|
+
}, warmupDelayMs)
|
|
88
|
+
timer.unref?.()
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// 卸载恢复原方法:本能力退出不改变宿主的列表语义
|
|
92
|
+
scope.effect(() => () => {
|
|
93
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
94
|
+
controller.list = original
|
|
95
|
+
})
|
|
96
|
+
ctx.logger?.info?.(`session-manager: 列表瘦身已启用 (keep=${keep.join(',')} ttl=${ttlMs}ms)`)
|
|
97
|
+
} catch (error) {
|
|
98
|
+
warn('装载失败,保持原行为: ' + String(error?.message ?? error))
|
|
99
|
+
}
|
|
100
|
+
})
|
|
101
|
+
return true
|
|
102
|
+
} catch (error) {
|
|
103
|
+
warn('未启用: ' + String(error?.message ?? error))
|
|
104
|
+
return false
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -44,3 +44,23 @@ test('源码契约:已删除区保持悬停显现且减弱动效覆盖新选择
|
|
|
44
44
|
assert.ok(/prefers-reduced-motion: reduce.*\{ \.sm-btn--danger, \.sm-deleted \.sm-row__actions \{ transition:none/.test(CLIENT_SRC),
|
|
45
45
|
'减弱动效未覆盖删除按钮与已删除区过渡')
|
|
46
46
|
})
|
|
47
|
+
|
|
48
|
+
// Given 归档行与已删除行的操作按钮, When 渲染, Then 每个按钮都带 title 悬停说明
|
|
49
|
+
test('源码契约:行操作按钮都带 title 悬停说明', () => {
|
|
50
|
+
const buttons = [
|
|
51
|
+
['restore', 'RESTORE_BUTTON_TITLE'],
|
|
52
|
+
['remount', 'REMOUNT_BUTTON_TITLE'],
|
|
53
|
+
['forget', 'FORGET_BUTTON_TITLE'],
|
|
54
|
+
['delete', 'DELETE_BUTTON_TITLE'],
|
|
55
|
+
['more', 'MORE_BUTTON_TITLE'],
|
|
56
|
+
]
|
|
57
|
+
for (const [key, constant] of buttons) {
|
|
58
|
+
const pattern = new RegExp("key: '" + key + "'[^\\n]*title: " + constant)
|
|
59
|
+
assert.ok(pattern.test(CLIENT_SRC), key + ' 按钮缺少 title(' + constant + ')')
|
|
60
|
+
}
|
|
61
|
+
for (const constant of ['RESTORE_BUTTON_TITLE', 'REMOUNT_BUTTON_TITLE', 'FORGET_BUTTON_TITLE', 'MORE_BUTTON_TITLE']) {
|
|
62
|
+
const matched = CLIENT_SRC.match(new RegExp('const ' + constant + " = '([^']+)'"))
|
|
63
|
+
assert.ok(matched, constant + ' 未定义')
|
|
64
|
+
assert.ok(matched[1].length >= 20, constant + ' 文案须讲清用途')
|
|
65
|
+
}
|
|
66
|
+
})
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// 开关形态守卫:布尔偏好一律 switch(track+thumb),原生 checkbox 视觉隐藏保语义与
|
|
2
|
+
// 可访问性,状态选择器以 input[type="checkbox"] 锚定,防止组合进含其他 input 的 label 时误伤。
|
|
3
|
+
import { test } from 'node:test'
|
|
4
|
+
import assert from 'node:assert/strict'
|
|
5
|
+
import { readFileSync } from 'node:fs'
|
|
6
|
+
import { fileURLToPath } from 'node:url'
|
|
7
|
+
import { dirname, join } from 'node:path'
|
|
8
|
+
|
|
9
|
+
const CLIENT_SOURCE = readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'src', 'client.js'), 'utf8')
|
|
10
|
+
|
|
11
|
+
test('开关结构:label 包原生 checkbox + track + thumb', () => {
|
|
12
|
+
assert.match(CLIENT_SOURCE, /className: 'sm-switch'/, '开关 label 存在')
|
|
13
|
+
assert.match(CLIENT_SOURCE, /type: 'checkbox'/, '原生 checkbox 保语义')
|
|
14
|
+
assert.match(CLIENT_SOURCE, /className: 'sm-switch__track'/, 'track 存在')
|
|
15
|
+
assert.match(CLIENT_SOURCE, /className: 'sm-switch__thumb'/, 'thumb 存在')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
test('原生 input 被视觉隐藏,不以裸 checkbox 呈现', () => {
|
|
19
|
+
assert.match(CLIENT_SOURCE, /\.sm-switch input\[type="checkbox"\] \{ position:absolute; opacity:0;/, 'input 视觉隐藏')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test('开关状态选择器以 input[type="checkbox"] 锚定', () => {
|
|
23
|
+
for (const state of [':checked', ':focus-visible', ':disabled']) {
|
|
24
|
+
const pattern = new RegExp('\\.sm-switch[^{]*\\' + state + '[^{]*\\{', 'g')
|
|
25
|
+
const matches = [...CLIENT_SOURCE.matchAll(pattern)].map((match) => match[0])
|
|
26
|
+
assert.ok(matches.length > 0, state + ' 选择器存在')
|
|
27
|
+
for (const selector of matches) {
|
|
28
|
+
assert.match(selector, /input\[type="checkbox"\]/, state + ' 必须以 input[type="checkbox"] 锚定: ' + selector)
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('开关样式的 :hover 锚定 label 而非隐藏的 input', () => {
|
|
34
|
+
assert.match(CLIENT_SOURCE, /\.sm-switch:hover \.sm-switch__track/, 'hover 锚定 label')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('开关带悬停说明与无障碍标签', () => {
|
|
38
|
+
assert.match(CLIENT_SOURCE, /className: 'sm-switch', title: LIST_SLIM_TITLE/, 'label 带 title 悬停说明')
|
|
39
|
+
assert.match(CLIENT_SOURCE, /'aria-label': '会话列表瘦身'/, 'input 带 aria-label')
|
|
40
|
+
const matched = CLIENT_SOURCE.match(/const LIST_SLIM_TITLE = '([^']+)'/)
|
|
41
|
+
assert.ok(matched, '说明文案常量存在')
|
|
42
|
+
assert.ok(matched[1].length >= 40, '说明文案须讲清用途,不少于 40 字')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('样式类名带本包前缀 sm-', () => {
|
|
46
|
+
for (const selector of ['.sm-switch {', '.sm-switch__track', '.sm-switch__thumb']) {
|
|
47
|
+
assert.ok(CLIENT_SOURCE.includes(selector), selector + ' 存在')
|
|
48
|
+
}
|
|
49
|
+
})
|
package/test/index.test.mjs
CHANGED
|
@@ -39,17 +39,26 @@ const GHOST_DIR = path.join(tmpdir(), 'sm-ghost-fixture', 's1')
|
|
|
39
39
|
const GHOST_PATH = path.join(GHOST_DIR, 'session.jsonl.zstd')
|
|
40
40
|
|
|
41
41
|
function makeRegistry(initialIds) {
|
|
42
|
-
//
|
|
43
|
-
// archiveSession
|
|
42
|
+
// 真实注册表契约(0.2.1-rc.2+):archivedSessionIds 是进程内快照的 getter;
|
|
43
|
+
// archiveSession 归档即入集合,unarchiveSession 串行移除(未归档 id 幂等不写),
|
|
44
|
+
// 两个官方入口都写穿 state——快照与持久集由注册表自身保证一致
|
|
44
45
|
const registry = {
|
|
45
46
|
state: { initialized: true, workspaceIds: [], archivedSessionIds: initialIds },
|
|
46
47
|
list: () => [],
|
|
47
48
|
archiveCalls: [],
|
|
49
|
+
unarchiveCalls: [],
|
|
48
50
|
archiveSession: async (id) => {
|
|
49
51
|
const sid = String(id)
|
|
50
52
|
registry.archiveCalls.push(sid)
|
|
51
53
|
registry.state.archivedSessionIds = [...registry.state.archivedSessionIds, sid]
|
|
52
54
|
},
|
|
55
|
+
unarchiveSession: async (id) => {
|
|
56
|
+
const sid = String(id)
|
|
57
|
+
const next = registry.state.archivedSessionIds.filter((existing) => String(existing) !== sid)
|
|
58
|
+
if (next.length === registry.state.archivedSessionIds.length) return
|
|
59
|
+
registry.unarchiveCalls.push(sid)
|
|
60
|
+
registry.state.archivedSessionIds = next
|
|
61
|
+
},
|
|
53
62
|
}
|
|
54
63
|
Object.defineProperty(registry, 'archivedSessionIds', {
|
|
55
64
|
get: () => registry.state.archivedSessionIds,
|
|
@@ -399,17 +408,17 @@ test('多工作区 detach:首个失败不终止后续,全部失败进日志', sk
|
|
|
399
408
|
}
|
|
400
409
|
})
|
|
401
410
|
|
|
402
|
-
test('
|
|
403
|
-
const domain = makeDomain(['s1'])
|
|
404
|
-
const { handlers, registry } = makeCtx({ archivedIds: [], headers: [HEADER], agents: new Map(), domain })
|
|
411
|
+
test('取消归档路由:走官方 unarchiveSession 写穿注册表', skipMissingDeps, async () => {
|
|
412
|
+
const domain = makeDomain(['s1', 's2'])
|
|
413
|
+
const { handlers, registry } = makeCtx({ archivedIds: ['s1', 's2'], headers: [HEADER], agents: new Map(), domain })
|
|
405
414
|
const res = response()
|
|
406
415
|
await handlers.get('/api/session-manager/unarchive')(request('s1'), res)
|
|
407
416
|
assert.equal(res.status, 200)
|
|
408
417
|
assert.deepEqual(res.body, { ok: true })
|
|
409
|
-
//
|
|
410
|
-
assert.deepEqual(
|
|
411
|
-
assert.
|
|
412
|
-
assert.deepEqual(registry.
|
|
418
|
+
// 官方 API 写穿:注册表快照移除,无私有直写路径
|
|
419
|
+
assert.deepEqual(registry.unarchiveCalls, ['s1'])
|
|
420
|
+
assert.deepEqual(registry.state.archivedSessionIds, ['s2'])
|
|
421
|
+
assert.deepEqual(registry.archivedSessionIds, ['s2'])
|
|
413
422
|
})
|
|
414
423
|
|
|
415
424
|
test('台账 deletedAt 为删除时刻的数值(面板排序依据)', skipMissingDeps, async () => {
|
|
@@ -435,52 +444,16 @@ test('台账 deletedAt 为删除时刻的数值(面板排序依据)', skipMissin
|
|
|
435
444
|
}
|
|
436
445
|
})
|
|
437
446
|
|
|
438
|
-
test('
|
|
439
|
-
const
|
|
440
|
-
const { handlers, registry } = makeCtx({ archivedIds: ['s1'], headers: [HEADER], agents: new Map(), domain })
|
|
441
|
-
const res = response()
|
|
442
|
-
await handlers.get('/api/session-manager/unarchive')(request('s1'), res)
|
|
443
|
-
assert.equal(res.status, 200)
|
|
444
|
-
assert.deepEqual(res.body, { ok: true })
|
|
445
|
-
assert.deepEqual(domain.state.archivedSessionIds, ['s2'])
|
|
446
|
-
assert.equal(domain.writes, 1)
|
|
447
|
-
// 复活守卫:注册表进程内快照必须同步移除,否则官方全量写回与 list 基线会恢复该 id
|
|
448
|
-
assert.deepEqual(registry.state.archivedSessionIds, ['s2'])
|
|
449
|
-
assert.deepEqual(registry.archivedSessionIds, ['s2'])
|
|
450
|
-
})
|
|
451
|
-
|
|
452
|
-
test('取消归档路由:workspace 域未打开时拒绝', skipMissingDeps, async () => {
|
|
453
|
-
const { handlers } = makeCtx({ archivedIds: ['s1'], headers: [HEADER], agents: new Map(), domain: null })
|
|
454
|
-
const res = response()
|
|
455
|
-
await handlers.get('/api/session-manager/unarchive')(request('s1'), res)
|
|
456
|
-
assert.equal(res.status, 400)
|
|
457
|
-
assert.equal(res.body.error, 'workspace 域未打开')
|
|
458
|
-
})
|
|
459
|
-
|
|
460
|
-
test('取消归档路由:未归档 id 幂等跳过,不产生写回', skipMissingDeps, async () => {
|
|
461
|
-
const domain = makeDomain(['s1'])
|
|
462
|
-
const { handlers, registry } = makeCtx({ archivedIds: ['s1'], headers: [HEADER], agents: new Map(), domain })
|
|
447
|
+
test('取消归档路由:未归档 id 幂等,注册表零写', skipMissingDeps, async () => {
|
|
448
|
+
const { handlers, registry } = makeCtx({ archivedIds: ['s1'], headers: [HEADER], agents: new Map() })
|
|
463
449
|
const res = response()
|
|
464
450
|
await handlers.get('/api/session-manager/unarchive')(request('s9'), res)
|
|
465
451
|
assert.equal(res.status, 200)
|
|
466
452
|
assert.deepEqual(res.body, { ok: true })
|
|
467
|
-
assert.deepEqual(
|
|
468
|
-
assert.equal(domain.writes, 0)
|
|
453
|
+
assert.deepEqual(registry.unarchiveCalls, [])
|
|
469
454
|
assert.deepEqual(registry.state.archivedSessionIds, ['s1'])
|
|
470
455
|
})
|
|
471
456
|
|
|
472
|
-
test('取消归档路由:快照领先域时补全清理(重试路径)', skipMissingDeps, async () => {
|
|
473
|
-
const domain = makeDomain([])
|
|
474
|
-
const { handlers, registry } = makeCtx({ archivedIds: ['s1'], headers: [HEADER], agents: new Map(), domain })
|
|
475
|
-
const res = response()
|
|
476
|
-
await handlers.get('/api/session-manager/unarchive')(request('s1'), res)
|
|
477
|
-
assert.equal(res.status, 200)
|
|
478
|
-
assert.deepEqual(res.body, { ok: true })
|
|
479
|
-
assert.deepEqual(domain.state.archivedSessionIds, [])
|
|
480
|
-
assert.equal(domain.writes, 1)
|
|
481
|
-
assert.deepEqual(registry.state.archivedSessionIds, [])
|
|
482
|
-
})
|
|
483
|
-
|
|
484
457
|
test('取消归档宽限:恢复后 graceMs 内同进程评估暂停,不秒归档', skipMissingDeps, async () => {
|
|
485
458
|
const dir = await mkdtemp(path.join(tmpdir(), 'sm-grace-'))
|
|
486
459
|
try {
|
|
@@ -1077,7 +1050,7 @@ test('删除成功:trash 目标为会话目录,台账记录目录路径', skipMi
|
|
|
1077
1050
|
try {
|
|
1078
1051
|
const domain = makeDomain(['s1'])
|
|
1079
1052
|
const workspace = makeWorkspace('C:\\x', ['s1'])
|
|
1080
|
-
const { handlers, ledger } = makeCtx({
|
|
1053
|
+
const { handlers, ledger, registry } = makeCtx({
|
|
1081
1054
|
archivedIds: ['s1'],
|
|
1082
1055
|
headers: [HEADER],
|
|
1083
1056
|
agents: IDLE_S1,
|
|
@@ -1097,7 +1070,8 @@ test('删除成功:trash 目标为会话目录,台账记录目录路径', skipMi
|
|
|
1097
1070
|
assert.equal(ledger.state.deleted.length, 1)
|
|
1098
1071
|
assert.equal(ledger.state.deleted[0].sessionId, 's1')
|
|
1099
1072
|
assert.equal(ledger.state.deleted[0].path, artifact.locatedDir)
|
|
1100
|
-
|
|
1073
|
+
// 取消归档走官方 unarchiveSession:注册表快照为事实源,域不再被插件直写
|
|
1074
|
+
assert.deepEqual(registry.state.archivedSessionIds, [])
|
|
1101
1075
|
assert.deepEqual(workspace.sessionIds, [])
|
|
1102
1076
|
} finally {
|
|
1103
1077
|
await artifact.cleanup()
|
|
@@ -1251,17 +1225,18 @@ test('删除:产物已缺失时运行中守卫仍然生效', skipMissingDeps, as
|
|
|
1251
1225
|
assert.equal(res.body.error, MESSAGES.running)
|
|
1252
1226
|
})
|
|
1253
1227
|
|
|
1254
|
-
test('
|
|
1228
|
+
test('删除:产物已缺失且归档清理半失败时聚合失败点', skipMissingDeps, async () => {
|
|
1255
1229
|
const workspace = makeWorkspace('C:\\x', ['s1'], { detachError: new Error('detach boom') })
|
|
1256
|
-
const { handlers, logger } = makeCtx({
|
|
1257
|
-
archivedIds: [],
|
|
1230
|
+
const { handlers, logger, registry } = makeCtx({
|
|
1231
|
+
archivedIds: ['s1'],
|
|
1258
1232
|
headers: [HEADER],
|
|
1259
1233
|
agents: IDLE_S1,
|
|
1260
|
-
domain: null,
|
|
1261
1234
|
sessionPersistence: { locate: (header) => header.id === 's1' ? { path: GHOST_PATH } : undefined },
|
|
1262
1235
|
ledger: makeLedgerDomain([{ sessionId: 's1', path: GHOST_DIR, deletedAt: 1 }]),
|
|
1263
1236
|
workspaces: [workspace],
|
|
1264
1237
|
})
|
|
1238
|
+
// 官方取消归档半失败:注入 unarchiveSession 抛错
|
|
1239
|
+
registry.unarchiveSession = async () => { throw new Error('unarchive boom') }
|
|
1265
1240
|
const res = response()
|
|
1266
1241
|
await withTrashStub(async () => {}, async () => {
|
|
1267
1242
|
await handlers.get('/api/session-manager/delete')(request('s1'), res)
|
|
@@ -1323,16 +1298,16 @@ test('归档评估:超期运行中会话零产物 IO(早退,core 过滤为第二
|
|
|
1323
1298
|
assert.deepEqual(registry.archiveCalls, [])
|
|
1324
1299
|
})
|
|
1325
1300
|
|
|
1326
|
-
test('
|
|
1301
|
+
test('删除:取消归档半失败时响应 partial 且台账已记录', skipMissingDeps, async () => {
|
|
1327
1302
|
const artifact = await makeLocatedArtifact()
|
|
1328
1303
|
try {
|
|
1329
|
-
const { handlers, ledger } = makeCtx({
|
|
1304
|
+
const { handlers, ledger, registry } = makeCtx({
|
|
1330
1305
|
archivedIds: ['s1'],
|
|
1331
1306
|
headers: [HEADER],
|
|
1332
1307
|
agents: IDLE_S1,
|
|
1333
|
-
domain: null,
|
|
1334
1308
|
sessionPersistence: { locate: (header) => header.id === 's1' ? { path: artifact.locatedPath } : undefined },
|
|
1335
1309
|
})
|
|
1310
|
+
registry.unarchiveSession = async () => { throw new Error('unarchive boom') }
|
|
1336
1311
|
const res = response()
|
|
1337
1312
|
await withTrashStub(async () => {}, async () => {
|
|
1338
1313
|
await handlers.get('/api/session-manager/delete')(request('s1'), res)
|
|
@@ -1383,12 +1358,10 @@ test('删除:detach 与台账同时失败时,partial 消息聚合两个失败点
|
|
|
1383
1358
|
test('台账域打开失败:删除其余成功仍响应 partial', skipMissingDeps, async () => {
|
|
1384
1359
|
const artifact = await makeLocatedArtifact()
|
|
1385
1360
|
try {
|
|
1386
|
-
const
|
|
1387
|
-
const { handlers } = makeCtx({
|
|
1361
|
+
const { handlers, registry } = makeCtx({
|
|
1388
1362
|
archivedIds: ['s1'],
|
|
1389
1363
|
headers: [HEADER],
|
|
1390
1364
|
agents: IDLE_S1,
|
|
1391
|
-
domain,
|
|
1392
1365
|
sessionPersistence: { locate: (header) => header.id === 's1' ? { path: artifact.locatedPath } : undefined },
|
|
1393
1366
|
openRejected: new Error('ledger down'),
|
|
1394
1367
|
})
|
|
@@ -1399,8 +1372,8 @@ test('台账域打开失败:删除其余成功仍响应 partial', skipMissingDep
|
|
|
1399
1372
|
assert.equal(res.status, 200)
|
|
1400
1373
|
assert.equal(res.body.partial, true)
|
|
1401
1374
|
assert.equal(res.body.message, '已移入回收站,但重挂载记录失败')
|
|
1402
|
-
//
|
|
1403
|
-
assert.deepEqual(
|
|
1375
|
+
// 台账失败不阻断删除主链路:归档清理经官方 API 照常完成
|
|
1376
|
+
assert.deepEqual(registry.state.archivedSessionIds, [])
|
|
1404
1377
|
} finally {
|
|
1405
1378
|
await artifact.cleanup()
|
|
1406
1379
|
}
|
|
@@ -1915,3 +1888,82 @@ test('0.1.7 形态:settings.configure 在场即关闭原生自动页', skipMissi
|
|
|
1915
1888
|
assert.equal(configureCalls.length, 1)
|
|
1916
1889
|
assert.deepEqual(configureCalls[0].presentation, { auto: false })
|
|
1917
1890
|
})
|
|
1891
|
+
|
|
1892
|
+
// ── 写路由同源 + JSON 守卫回归 ──
|
|
1893
|
+
|
|
1894
|
+
// POST 夹具:带 headers 的请求体走 readBody 的 data/end 事件流
|
|
1895
|
+
function guardedRequest(routePath, headers, body = { sessionId: 's1' }) {
|
|
1896
|
+
const req = new EventEmitter()
|
|
1897
|
+
req.method = 'POST'
|
|
1898
|
+
req.url = routePath
|
|
1899
|
+
req.headers = headers
|
|
1900
|
+
process.nextTick(() => {
|
|
1901
|
+
req.emit('data', Buffer.from(JSON.stringify(body)))
|
|
1902
|
+
req.emit('end')
|
|
1903
|
+
})
|
|
1904
|
+
return req
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
test('写路由守卫:跨源 POST 403 / 非 JSON 内容类型 415 / 同源 JSON 正常受理', skipMissingDeps, async () => {
|
|
1908
|
+
// Given 全部写路由(浏览器表单可发 text/plain 且载荷为合法 JSON,仅方法守卫不足)
|
|
1909
|
+
const POST_ROUTES = [
|
|
1910
|
+
'/api/session-manager/unarchive',
|
|
1911
|
+
'/api/session-manager/info',
|
|
1912
|
+
'/api/session-manager/delete',
|
|
1913
|
+
'/api/session-manager/auto-archive',
|
|
1914
|
+
'/api/session-manager/remount',
|
|
1915
|
+
'/api/session-manager/forget',
|
|
1916
|
+
]
|
|
1917
|
+
const ORIGIN = 'http://127.0.0.1:9291'
|
|
1918
|
+
const sameOrigin = {
|
|
1919
|
+
origin: ORIGIN,
|
|
1920
|
+
host: '127.0.0.1:9291',
|
|
1921
|
+
'content-type': 'application/json; charset=utf-8',
|
|
1922
|
+
}
|
|
1923
|
+
|
|
1924
|
+
for (const routePath of POST_ROUTES) {
|
|
1925
|
+
const { handlers } = makeCtx({ archivedIds: ['s1'], headers: [HEADER], agents: new Map() })
|
|
1926
|
+
|
|
1927
|
+
// When Origin 与 Host 不同 Then 403,且响应为统一拒绝文案
|
|
1928
|
+
const crossOrigin = response()
|
|
1929
|
+
await handlers.get(routePath)(guardedRequest(routePath, { ...sameOrigin, origin: 'http://evil.example' }), crossOrigin)
|
|
1930
|
+
assert.equal(crossOrigin.status, 403, `${routePath} 跨源 POST 必须 403`)
|
|
1931
|
+
|
|
1932
|
+
// When 同源但 content-type: text/plain Then 415(drive-by 表单形态)
|
|
1933
|
+
const textPlain = response()
|
|
1934
|
+
await handlers.get(routePath)(guardedRequest(routePath, { ...sameOrigin, 'content-type': 'text/plain' }), textPlain)
|
|
1935
|
+
assert.equal(textPlain.status, 415, `${routePath} text/plain POST 必须 415`)
|
|
1936
|
+
|
|
1937
|
+
// When 无 Origin(本地 CLI)但带 text/plain Then 仍 415(内容类型守卫独立生效)
|
|
1938
|
+
const cliTextPlain = response()
|
|
1939
|
+
await handlers.get(routePath)(guardedRequest(routePath, { host: '127.0.0.1:9291', 'content-type': 'text/plain' }), cliTextPlain)
|
|
1940
|
+
assert.equal(cliTextPlain.status, 415, `${routePath} 无 Origin 的 text/plain 仍必须 415`)
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
// When 同源 application/json Then 正常受理(unarchive 可幂等成功)
|
|
1944
|
+
const { handlers } = makeCtx({ archivedIds: ['s1'], headers: [HEADER], agents: new Map() })
|
|
1945
|
+
const accepted = response()
|
|
1946
|
+
await handlers.get('/api/session-manager/unarchive')(guardedRequest('/api/session-manager/unarchive', sameOrigin), accepted)
|
|
1947
|
+
assert.equal(accepted.status, 200)
|
|
1948
|
+
assert.deepEqual(accepted.body, { ok: true })
|
|
1949
|
+
})
|
|
1950
|
+
|
|
1951
|
+
test('列表瘦身开关:GET 回显默认开启,POST 持久,GET 反映新值', skipMissingDeps, async () => {
|
|
1952
|
+
const { handlers } = makeCtx({ archivedIds: [], headers: [{ id: 's1', cwd: 'C:\\x', createdAt: 0 }], agents: new Map() })
|
|
1953
|
+
const initial = response()
|
|
1954
|
+
await handlers.get('/api/session-manager/list-slim')(getRequest2('/api/session-manager/list-slim', 'GET'), initial)
|
|
1955
|
+
assert.deepEqual(initial.body, { enabled: true }, '未配置时默认开启')
|
|
1956
|
+
const off = await postJson(handlers, '/api/session-manager/list-slim', { enabled: false })
|
|
1957
|
+
assert.deepEqual(off.body, { enabled: false })
|
|
1958
|
+
const reread = response()
|
|
1959
|
+
await handlers.get('/api/session-manager/list-slim')(getRequest2('/api/session-manager/list-slim', 'GET'), reread)
|
|
1960
|
+
assert.deepEqual(reread.body, { enabled: false }, 'settings 持久化开关')
|
|
1961
|
+
})
|
|
1962
|
+
|
|
1963
|
+
test('列表瘦身开关:非布尔载荷一律 400', skipMissingDeps, async () => {
|
|
1964
|
+
const { handlers } = makeCtx({ archivedIds: [], headers: [{ id: 's1', cwd: 'C:\\x', createdAt: 0 }], agents: new Map() })
|
|
1965
|
+
for (const payload of [{ enabled: 'yes' }, { enabled: 1 }, {}, { enabled: null }]) {
|
|
1966
|
+
const rejected = await postJson(handlers, '/api/session-manager/list-slim', payload)
|
|
1967
|
+
assert.equal(rejected.status, 400, JSON.stringify(payload))
|
|
1968
|
+
}
|
|
1969
|
+
})
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// 列表瘦身的契约:只裁白名单、命中缓存不重建、增删事件失效、任何异常回落到原行为。
|
|
2
|
+
import { test } from 'node:test'
|
|
3
|
+
import assert from 'node:assert/strict'
|
|
4
|
+
import { installListSlim, slimItem } from '../src/list-slim.mjs'
|
|
5
|
+
|
|
6
|
+
/** 一条带完整投影的列表项。 */
|
|
7
|
+
const item = (id, values) => ({ sessionId: id, updatedAt: 1, projections: { kind: 'cached', asOfSeq: 1, values } })
|
|
8
|
+
|
|
9
|
+
/** 造一个只实现本模块用到表面的上下文(inject 立即回调,等价于服务已就绪)。 */
|
|
10
|
+
function makeContext(list, { withController = true } = {}) {
|
|
11
|
+
const handlers = new Map()
|
|
12
|
+
const disposers = []
|
|
13
|
+
const controller = withController ? { list } : undefined
|
|
14
|
+
const ctx = {
|
|
15
|
+
get: (name) => (name === 'sessionController' ? controller : undefined),
|
|
16
|
+
on: (event, handler) => handlers.set(event, handler),
|
|
17
|
+
logger: { info() {}, warn() {} },
|
|
18
|
+
inject: (deps, callback) => {
|
|
19
|
+
callback({
|
|
20
|
+
sessionController: controller,
|
|
21
|
+
on: (event, handler) => handlers.set(event, handler),
|
|
22
|
+
effect: (factory) => {
|
|
23
|
+
disposers.push(factory())
|
|
24
|
+
},
|
|
25
|
+
})
|
|
26
|
+
},
|
|
27
|
+
}
|
|
28
|
+
return { ctx, controller, handlers, disposers }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const sample = () => [
|
|
32
|
+
item('a', { title: 'A', sessionListMetadata: { blank: false }, contextHeaders: [{ pad: 'x'.repeat(64) }] }),
|
|
33
|
+
item('b', { title: 'B', inbox: { 'next-turn': [], 'next-step': [] } }),
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
test('裁剪只保留白名单里的投影', () => {
|
|
37
|
+
const slim = slimItem(sample()[0], ['title'])
|
|
38
|
+
assert.deepEqual(Object.keys(slim.projections.values), ['title'])
|
|
39
|
+
assert.equal(slim.projections.kind, 'cached')
|
|
40
|
+
assert.equal(slim.sessionId, 'a')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test('形状不符的条目原样返回', () => {
|
|
44
|
+
const bare = { sessionId: 'c' }
|
|
45
|
+
assert.equal(slimItem(bare, ['title']), bare)
|
|
46
|
+
assert.equal(slimItem({ projections: { values: null } }, ['title']).projections.values, null)
|
|
47
|
+
const array = { projections: { values: ['x'] } }
|
|
48
|
+
assert.equal(slimItem(array, ['title']), array)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test('开关关闭时不接管 controller.list', async () => {
|
|
52
|
+
const original = async () => ({ items: sample() })
|
|
53
|
+
const { ctx, controller } = makeContext(original)
|
|
54
|
+
assert.equal(installListSlim(ctx, { enabled: false }), false)
|
|
55
|
+
assert.equal(controller.list, original)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('宿主没有 sessionController 时不接管任何方法', () => {
|
|
59
|
+
const { ctx } = makeContext(undefined, { withController: false })
|
|
60
|
+
installListSlim(ctx)
|
|
61
|
+
assert.equal(ctx.get('sessionController'), undefined)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('controller 缺 list 方法时静默放弃', () => {
|
|
65
|
+
const { ctx, controller } = makeContext(undefined)
|
|
66
|
+
installListSlim(ctx)
|
|
67
|
+
assert.equal(controller.list, undefined)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
test('第二次调用命中缓存,不重建', async () => {
|
|
71
|
+
let built = 0
|
|
72
|
+
const { ctx, controller } = makeContext(async () => {
|
|
73
|
+
built += 1
|
|
74
|
+
return { items: sample() }
|
|
75
|
+
})
|
|
76
|
+
installListSlim(ctx, { warmupDelayMs: -1 })
|
|
77
|
+
await controller.list({}, undefined)
|
|
78
|
+
const value = await controller.list({}, undefined)
|
|
79
|
+
assert.equal(built, 1)
|
|
80
|
+
assert.equal(value.items[0].projections.values.contextHeaders, undefined)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test('过期后重建', async () => {
|
|
84
|
+
let built = 0
|
|
85
|
+
const { ctx, controller } = makeContext(async () => {
|
|
86
|
+
built += 1
|
|
87
|
+
return { items: sample() }
|
|
88
|
+
})
|
|
89
|
+
installListSlim(ctx, { ttlMs: 0, warmupDelayMs: -1 })
|
|
90
|
+
await controller.list({}, undefined)
|
|
91
|
+
await controller.list({}, undefined)
|
|
92
|
+
assert.equal(built, 2)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test('会话增删让缓存失效', async () => {
|
|
96
|
+
let built = 0
|
|
97
|
+
const { ctx, controller, handlers } = makeContext(async () => {
|
|
98
|
+
built += 1
|
|
99
|
+
return { items: sample() }
|
|
100
|
+
})
|
|
101
|
+
installListSlim(ctx, { warmupDelayMs: -1 })
|
|
102
|
+
await controller.list({}, undefined)
|
|
103
|
+
handlers.get('api-session/added')()
|
|
104
|
+
await controller.list({}, undefined)
|
|
105
|
+
assert.equal(built, 2)
|
|
106
|
+
handlers.get('api-session/removed')()
|
|
107
|
+
await controller.list({}, undefined)
|
|
108
|
+
assert.equal(built, 3)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
test('并发请求只触发一次重建', async () => {
|
|
112
|
+
let built = 0
|
|
113
|
+
const { ctx, controller } = makeContext(async () => {
|
|
114
|
+
built += 1
|
|
115
|
+
return { items: sample() }
|
|
116
|
+
})
|
|
117
|
+
installListSlim(ctx, { warmupDelayMs: -1 })
|
|
118
|
+
await Promise.all([controller.list({}, undefined), controller.list({}, undefined), controller.list({}, undefined)])
|
|
119
|
+
assert.equal(built, 1)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
test('原方法抛错时错误照旧透传给调用方', async () => {
|
|
123
|
+
const { ctx, controller } = makeContext(async () => {
|
|
124
|
+
throw new Error('宿主故障')
|
|
125
|
+
})
|
|
126
|
+
installListSlim(ctx, { warmupDelayMs: -1 })
|
|
127
|
+
await assert.rejects(() => controller.list({}, undefined), /宿主故障/)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
test('响应形状不符时原样透传,不缓存', async () => {
|
|
131
|
+
let built = 0
|
|
132
|
+
const { ctx, controller } = makeContext(async () => {
|
|
133
|
+
built += 1
|
|
134
|
+
return { sessions: sample() }
|
|
135
|
+
})
|
|
136
|
+
installListSlim(ctx, { warmupDelayMs: -1 })
|
|
137
|
+
const first = await controller.list({}, undefined)
|
|
138
|
+
const second = await controller.list({}, undefined)
|
|
139
|
+
assert.deepEqual(Object.keys(first), ['sessions'])
|
|
140
|
+
assert.equal(built, 2)
|
|
141
|
+
assert.notEqual(second, first)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
test('把调用方 request 原样交给原方法', async () => {
|
|
145
|
+
const seen = []
|
|
146
|
+
const { ctx, controller } = makeContext(async (request) => {
|
|
147
|
+
seen.push(request)
|
|
148
|
+
return { items: sample() }
|
|
149
|
+
})
|
|
150
|
+
installListSlim(ctx, { warmupDelayMs: -1 })
|
|
151
|
+
const request = { cursor: 'abc' }
|
|
152
|
+
await controller.list(request, undefined)
|
|
153
|
+
assert.equal(seen[0], request)
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
test('预热在延迟后构建一次', async () => {
|
|
157
|
+
let built = 0
|
|
158
|
+
const { ctx } = makeContext(async () => {
|
|
159
|
+
built += 1
|
|
160
|
+
return { items: sample() }
|
|
161
|
+
})
|
|
162
|
+
installListSlim(ctx, { warmupDelayMs: 0 })
|
|
163
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
164
|
+
assert.equal(built, 1)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
test('卸载时恢复原方法', async () => {
|
|
168
|
+
const original = async () => ({ items: sample() })
|
|
169
|
+
const { ctx, controller, disposers } = makeContext(original)
|
|
170
|
+
installListSlim(ctx, { warmupDelayMs: -1 })
|
|
171
|
+
assert.notEqual(controller.list, original)
|
|
172
|
+
for (const dispose of disposers) dispose()
|
|
173
|
+
assert.equal(controller.list, original)
|
|
174
|
+
})
|