@mzzsfy/dsh-session-manager 0.3.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/client.js +50 -5
- package/src/core.mjs +39 -3
- package/test/core.test.mjs +73 -3
- package/test/index.test.mjs +11 -7
- package/test/parity.test.mjs +36 -7
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@ DeepSeek Harness 会话生命周期管理插件:自动归档 + 归档面板 +
|
|
|
8
8
|
- 常用提示词收藏:`prompts` 以外的范围,历史行鼠标悬停时行尾出现收藏星标(已收藏实星、未收藏空心),点击即收藏/取消(经 `POST /api/session-manager/prompts/toggle`),toast 反馈并同步星标;`prompts` 范围右上角「编辑」按钮进入编辑态:行内文本框直接改(Enter/失焦保存,实现为移除旧文本 + 收藏新文本)、行尾 `×` 删除、底部输入框 Enter 新增,「完成」退出;上限 `HISTORY_PROMPTS_MAX` 条,超出裁掉最旧;收藏与自动对齐的历史缓存互不覆写(`listWorkspaceCaches` 跳过 prompts.json);client 与 core 的 `HISTORY_SCOPES` 镜像由 parity 测试锁定。
|
|
9
9
|
- 历史浮层启停开关:低频功能,归档面板末行小尺寸开关(规约 switch 形态 `sm-histsw`,状态选择器锚定 `input[type=checkbox]`);值存宿主 settings(`historyEnabled`,默认启用,原生设置页与本面板同存储即时同值),读写经本插件 `GET/POST /api/session-manager/history-enabled`(client 写 settings 不经宿主 remotes,零版本脆弱依赖);浮层挂载拉取一次,停用即锚点/快捷键/浮层整体不渲染、键盘监听放行,变更刷新页面生效。
|
|
10
10
|
- 自动归档:host 半区对超期会话走官方 `workspace.archiveSession` 通道,三个触发源共用同一评估与门闩:新会话创建(`session/created`,按该会话所属工作区限定)、settings 就绪后的启动补扫(全量,清掉停机期间积压)、每日周期轮(全量,经宿主 timer 服务固定短 tick + 到期判断)。幂等:已归档会话不参与评估。阈值 `autoArchiveDays` 默认 7,`0` 关闭;周期间隔 `autoArchiveIntervalHours` 默认 24,`0` 关闭周期轮,变更经周期 tick 对账(关闭即时暂停、重启用最迟下个 tick 生效、缩短经锚定钳制前移,最长等新间隔而非等满旧周期)。评估筛选:createdAt 纯内存预筛 + 已归档跳过 + stat 后活跃度判定,locate 缺失(第三方后端)与产物不可读的会话一律跳过,宁可漏归档不可误归档。经 settings 命名空间 `session-manager` 注册(schema 拒绝负数与非整数)。timer 为软依赖:宿主定时服务不可用时仅周期轮停用,面板顶部提示,插件其余能力不受影响。
|
|
11
|
-
- 归档面板:设置页「会话归档」分区,数据为 client 侧 `session.list` 行 ∩ `workspaces.follow`
|
|
11
|
+
- 归档面板:设置页「会话归档」分区,数据为 client 侧 `session.list` 行 ∩ `workspaces.follow` 归档快照的交集,按更新时间倒序;行内标题前显示所属工作区徽标——映射优先走 workspace 账本(`sessionIds`,与官方 dsh-client-ui-workspace 的 workspaceBySession 同构),cwd 命中某登记工作区路径时回退该工作区标题(较官方「cwd basename 直接回退」收紧,防未登记目录名冒充工作区),均未命中显示「未分组」;工作区 title 缺失回退路径末段。镜像函数 client 与 core 双实现由 parity 测试锁定。行内支持取消归档与删除。
|
|
12
12
|
- 归档与操作通知:host 归档动作经 `workspace.follow` 的 `archived` 增量帧到达 client,集合差分得到新增条数 N,经公共通知依赖 `@mzzsfy/dsh-toast` 顶部居中显示「有 N 个会话已归档」;面板操作(恢复 / 删除 / 重新挂载等)的成功与失败反馈同样经该依赖展示,失败提示常驻待确认。dsh-toast 为普通 npm 依赖随本插件安装,其宿主占位条目由本插件 cordis.patch.yml 代挂。
|
|
13
13
|
- 删除:仅对已归档会话生效(运行中守卫先于执行,执行前 TOCTOU 复检,运行中的会话不可删除;OS 回收存在数百 ms 异步窗口,复检通过后仍可能恢复运行——此时回收已完成,响应附警告并落服务端日志),两段式确认(展示标题 / 日志体积,产物已缺失时以「产物已丢失」替代体积)后按 locate → trash → 台账 → detach → 归档清理单序执行;同 id 并发删除仅首个生效,其余以「正在删除中」拒绝。会话产物目录整体移入系统回收站(Windows PowerShell VisualBasic / macOS Finder / Linux gio;Windows 路径经环境变量传入 PowerShell,含空格或特殊字符的路径安全),可还原,不做直接删除降级。trash 成功即写入已删除台账(独立 storage domain `session_manager`,读改写经进程内互斥链串行化且单次操作带超时兜底,条目上限 200 裁最旧),记录会话 id 与产物原位置。产物**目录**已缺失的会话,仅当已归档或台账有记录(同 id 重删的残留收尾)时跳过回收与台账、仅解除列表关联,提示「产物已不存在,已完成列表清理」;两者皆无的缺失可能是新建会话尚未落盘,直接拒绝以防剥离活会话。
|
|
14
14
|
- 已删除分区与重挂载:面板「已删除」区列出台账条目(标题 / 原位置 / 删除时间)。用户在系统回收站将会话文件夹还原到原位置后点「重新挂载」,经官方 `workspace.attachSession` 挂回工作区列表并清除台账;「移除记录」幂等清除单条。还原动作本身始终由用户在系统回收站完成,插件不驱动系统回收站。
|
package/package.json
CHANGED
package/src/client.js
CHANGED
|
@@ -47,6 +47,11 @@ const CSS = [
|
|
|
47
47
|
'.sm-row--busy { opacity:.45; pointer-events:none; }',
|
|
48
48
|
'.sm-row__time, .sm-row__size { font:12px/18px var(--ds-font-family-code, monospace); color:var(--dsw-alias-label-tertiary); font-variant-numeric:tabular-nums; }',
|
|
49
49
|
'.sm-row__size { color:var(--dsw-alias-state-error-primary); }',
|
|
50
|
+
'.sm-row__title-wrap { display:flex; align-items:center; gap:6px; min-width:0; }',
|
|
51
|
+
'.sm-row__ws { flex:none; max-width:40%; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;',
|
|
52
|
+
' padding:0 6px; border-radius:999px; font:11px/18px var(--ds-font-family-code, monospace);',
|
|
53
|
+
' color:var(--dsw-alias-label-tertiary); background:var(--dsw-alias-interactive-bg-hover); }',
|
|
54
|
+
'.sm-row__ws--none { background:transparent; box-shadow:inset 0 0 0 1px var(--dsw-alias-border-l3); }',
|
|
50
55
|
'.sm-row__title { font:var(--dsw-font-s-strong-14); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }',
|
|
51
56
|
'.sm-row__error { grid-column:2 / -1; font:var(--dsw-font-xxs-12); color:var(--dsw-alias-state-error-primary); }',
|
|
52
57
|
'.sm-row__path { grid-column:2 / -1; font:12px/16px var(--ds-font-family-code, monospace); color:var(--dsw-alias-label-caption);',
|
|
@@ -205,13 +210,48 @@ function useSnapshot(source) {
|
|
|
205
210
|
)
|
|
206
211
|
}
|
|
207
212
|
|
|
208
|
-
//
|
|
209
|
-
function
|
|
213
|
+
// 工作区路径末段(官方 workspaceTitleOf 同构,镜像 core.mjs 同名函数)
|
|
214
|
+
function workspaceTitleOf(path) {
|
|
215
|
+
const trimmed = String(path).replace(/[/\\]+$/, '')
|
|
216
|
+
const separator = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'))
|
|
217
|
+
return trimmed.slice(separator + 1)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// 会话所属工作区标题:workspace 账本(sessionIds)一级映射与官方
|
|
221
|
+
// dsh-client-ui-workspace workspaceBySession 同构;cwd 回退为本插件收紧决策
|
|
222
|
+
// (官方回退 cwd basename 无需命中工作区,此处要求命中登记路径,防未登记目录名
|
|
223
|
+
// 冒充工作区),未命中 null(未分组)(镜像 core.mjs workspaceTitleForSession)
|
|
224
|
+
function workspaceTitleForSession(workspaces, sessionId, cwd) {
|
|
225
|
+
const items = Array.isArray(workspaces) ? workspaces : []
|
|
226
|
+
for (const workspace of items) {
|
|
227
|
+
if (workspace && Array.isArray(workspace.sessionIds) && workspace.sessionIds.includes(sessionId)) {
|
|
228
|
+
return workspace.title || (workspace.path ? workspaceTitleOf(workspace.path) : null)
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
const normalizedCwd = cwd ? String(cwd).replace(/[/\\]+$/, '') : ''
|
|
232
|
+
if (!normalizedCwd) return null
|
|
233
|
+
for (const workspace of items) {
|
|
234
|
+
if (workspace && workspace.path && String(workspace.path).replace(/[/\\]+$/, '') === normalizedCwd) {
|
|
235
|
+
return workspace.title || workspaceTitleOf(workspace.path)
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return null
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// 已归档会话面板行:官方会话行 ∩ 归档集合,按更新时间倒序;workspace 为所属
|
|
242
|
+
// 工作区标题或 null(镜像 core.mjs projectArchiveRows)
|
|
243
|
+
function projectRows(listState, archivedIds, workspaceState) {
|
|
210
244
|
const archived = new Set(archivedIds)
|
|
211
245
|
const byId = (listState && listState.byId) || {}
|
|
246
|
+
const workspaces = (workspaceState && workspaceState.items) || []
|
|
212
247
|
return Object.keys(byId)
|
|
213
248
|
.filter((id) => archived.has(id))
|
|
214
|
-
.map((id) => ({
|
|
249
|
+
.map((id) => ({
|
|
250
|
+
id,
|
|
251
|
+
title: byId[id].displayTitle || id,
|
|
252
|
+
updatedAt: byId[id].updatedAt,
|
|
253
|
+
workspace: workspaceTitleForSession(workspaces, id, byId[id].cwd),
|
|
254
|
+
}))
|
|
215
255
|
.sort((left, right) => right.updatedAt - left.updatedAt)
|
|
216
256
|
}
|
|
217
257
|
|
|
@@ -264,7 +304,12 @@ function ArchiveRow(props) {
|
|
|
264
304
|
armed && confirm && !confirm.error
|
|
265
305
|
? h('span', { key: 'meta', className: 'sm-row__size' }, confirm.missing ? '产物已丢失' : fmtSize(confirm.sizeBytes))
|
|
266
306
|
: h('span', { key: 'meta', className: 'sm-row__time', title: new Date(row.updatedAt).toLocaleString() }, fmtTime(row.updatedAt)),
|
|
267
|
-
h('span', { className: 'sm-row__title'
|
|
307
|
+
h('span', { className: 'sm-row__title-wrap' },
|
|
308
|
+
row.workspace
|
|
309
|
+
? h('span', { className: 'sm-row__ws', title: row.workspace }, row.workspace)
|
|
310
|
+
: h('span', { className: 'sm-row__ws sm-row__ws--none', title: '该会话不属于任何已登记工作区' }, '未分组'),
|
|
311
|
+
h('span', { className: 'sm-row__title', title: row.title }, row.title),
|
|
312
|
+
),
|
|
268
313
|
h('div', { className: 'sm-row__actions' }, actions),
|
|
269
314
|
confirm && confirm.error ? h('span', { className: 'sm-row__error' }, confirm.error) : null,
|
|
270
315
|
)
|
|
@@ -825,7 +870,7 @@ function HistoryDock({ session, inputActions }) {
|
|
|
825
870
|
const listState = useSnapshot(sessionSvc.list)
|
|
826
871
|
const workspaceState = useSnapshot(workspaceSvc.list)
|
|
827
872
|
return React.createElement(SessionManagerApp, {
|
|
828
|
-
rows: projectRows(listState, (workspaceState && workspaceState.archivedSessionIds) || []),
|
|
873
|
+
rows: projectRows(listState, (workspaceState && workspaceState.archivedSessionIds) || [], workspaceState),
|
|
829
874
|
listState,
|
|
830
875
|
})
|
|
831
876
|
}
|
package/src/core.mjs
CHANGED
|
@@ -23,12 +23,48 @@ export function selectArchiveCandidates({ records, nowMs, thresholdDays }) {
|
|
|
23
23
|
.map((item) => item.id)
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
/**
|
|
27
|
-
|
|
26
|
+
/**
|
|
27
|
+
* 会话所属工作区标题解析(与 client 镜像同规):workspace 账本(sessionIds)
|
|
28
|
+
* 一级映射与官方 dsh-client-ui-workspace 的 workspaceBySession 同构;回退链为本
|
|
29
|
+
* 插件收紧决策——官方对账本缺失回退会话 cwd 的 basename(无需命中登记工作区),
|
|
30
|
+
* 此处要求 cwd 命中某登记工作区路径才回退其标题,否则未分组(null),避免把未
|
|
31
|
+
* 登记目录名冒充工作区。工作区 title 缺失时回退路径末段(官方 workspaceTitleOf
|
|
32
|
+
* 同构);title 与 path 皆缺返回 null。
|
|
33
|
+
*/
|
|
34
|
+
export function workspaceTitleOf(path) {
|
|
35
|
+
const trimmed = String(path).replace(/[/\\]+$/, '')
|
|
36
|
+
const separator = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'))
|
|
37
|
+
return trimmed.slice(separator + 1)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function workspaceTitleForSession({ workspaces, sessionId, cwd }) {
|
|
41
|
+
const items = Array.isArray(workspaces) ? workspaces : []
|
|
42
|
+
for (const workspace of items) {
|
|
43
|
+
if (workspace && Array.isArray(workspace.sessionIds) && workspace.sessionIds.includes(sessionId)) {
|
|
44
|
+
return workspace.title || (workspace.path ? workspaceTitleOf(workspace.path) : null)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const normalizedCwd = cwd ? String(cwd).replace(/[/\\]+$/, '') : ''
|
|
48
|
+
if (!normalizedCwd) return null
|
|
49
|
+
for (const workspace of items) {
|
|
50
|
+
if (workspace && workspace.path && String(workspace.path).replace(/[/\\]+$/, '') === normalizedCwd) {
|
|
51
|
+
return workspace.title || workspaceTitleOf(workspace.path)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return null
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** 归档面板行:session.list 行与归档集合的交集,按更新时间倒序;标题缺失回退会话 id;workspace 为所属工作区标题或 null(与 client 镜像同规)。 */
|
|
58
|
+
export function projectArchiveRows({ rows, archivedIds, workspaces }) {
|
|
28
59
|
const archived = new Set(archivedIds)
|
|
29
60
|
return rows
|
|
30
61
|
.filter((row) => archived.has(row.id))
|
|
31
|
-
.map((row) => ({
|
|
62
|
+
.map((row) => ({
|
|
63
|
+
id: row.id,
|
|
64
|
+
title: row.title || row.id,
|
|
65
|
+
updatedAt: row.updatedAt,
|
|
66
|
+
workspace: workspaceTitleForSession({ workspaces, sessionId: row.id, cwd: row.cwd }),
|
|
67
|
+
}))
|
|
32
68
|
.sort((left, right) => right.updatedAt - left.updatedAt)
|
|
33
69
|
}
|
|
34
70
|
|
package/test/core.test.mjs
CHANGED
|
@@ -22,6 +22,8 @@ import {
|
|
|
22
22
|
removeDeletedEntry,
|
|
23
23
|
selectArchiveCandidates,
|
|
24
24
|
updatedAtOf,
|
|
25
|
+
workspaceTitleForSession,
|
|
26
|
+
workspaceTitleOf,
|
|
25
27
|
} from '../src/core.mjs'
|
|
26
28
|
|
|
27
29
|
const NOW = Date.parse('2026-01-10T00:00:00Z')
|
|
@@ -88,14 +90,82 @@ test('归档面板投影:交集过滤且按更新时间倒序', () => {
|
|
|
88
90
|
]
|
|
89
91
|
const projected = projectArchiveRows({ rows, archivedIds: ['b', 'c', 'gone'] })
|
|
90
92
|
assert.deepEqual(projected, [
|
|
91
|
-
{ id: 'b', title: 'B', updatedAt: 200 },
|
|
92
|
-
{ id: 'c', title: 'C', updatedAt: 100 },
|
|
93
|
+
{ id: 'b', title: 'B', updatedAt: 200, workspace: null },
|
|
94
|
+
{ id: 'c', title: 'C', updatedAt: 100, workspace: null },
|
|
93
95
|
])
|
|
94
96
|
})
|
|
95
97
|
|
|
96
98
|
test('归档面板投影:标题缺失回退会话 id(与 client 镜像同规)', () => {
|
|
97
99
|
const projected = projectArchiveRows({ rows: [{ id: 'a', title: '', updatedAt: 5 }], archivedIds: ['a'] })
|
|
98
|
-
assert.deepEqual(projected, [{ id: 'a', title: 'a', updatedAt: 5 }])
|
|
100
|
+
assert.deepEqual(projected, [{ id: 'a', title: 'a', updatedAt: 5, workspace: null }])
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
const WORKSPACES = [
|
|
104
|
+
{ workspaceId: 'w1', path: 'C:\\work\\alpha', title: 'alpha', sessionIds: ['a', 'b'] },
|
|
105
|
+
{ workspaceId: 'w2', path: 'C:\\work\\beta', title: 'beta', sessionIds: ['c'] },
|
|
106
|
+
]
|
|
107
|
+
|
|
108
|
+
test('归档面板投影:会话经 workspace 账本映射到工作区标题', () => {
|
|
109
|
+
const rows = [
|
|
110
|
+
{ id: 'a', title: 'A', updatedAt: 300 },
|
|
111
|
+
{ id: 'c', title: 'C', updatedAt: 100 },
|
|
112
|
+
]
|
|
113
|
+
const projected = projectArchiveRows({ rows, archivedIds: ['a', 'c'], workspaces: WORKSPACES })
|
|
114
|
+
assert.deepEqual(projected, [
|
|
115
|
+
{ id: 'a', title: 'A', updatedAt: 300, workspace: 'alpha' },
|
|
116
|
+
{ id: 'c', title: 'C', updatedAt: 100, workspace: 'beta' },
|
|
117
|
+
])
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
test('归档面板投影:账本缺失时 cwd 匹配工作区路径回退', () => {
|
|
121
|
+
const rows = [{ id: 'x', title: 'X', updatedAt: 1, cwd: 'C:\\work\\beta' }]
|
|
122
|
+
const projected = projectArchiveRows({ rows, archivedIds: ['x'], workspaces: WORKSPACES })
|
|
123
|
+
assert.deepEqual(projected, [{ id: 'x', title: 'X', updatedAt: 1, workspace: 'beta' }])
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
test('归档面板投影:无匹配工作区输出 null,账本缺 title 回退路径末段', () => {
|
|
127
|
+
const rows = [
|
|
128
|
+
{ id: 'x', title: 'X', updatedAt: 3 },
|
|
129
|
+
{ id: 'y', title: 'Y', updatedAt: 2, cwd: 'C:\\nowhere\\lost\\' },
|
|
130
|
+
{ id: 'z', title: 'Z', updatedAt: 1 },
|
|
131
|
+
]
|
|
132
|
+
const workspaces = [
|
|
133
|
+
...WORKSPACES,
|
|
134
|
+
{ workspaceId: 'w3', path: 'C:\\work\\titleless', sessionIds: ['z'] },
|
|
135
|
+
]
|
|
136
|
+
const projected = projectArchiveRows({ rows, archivedIds: ['x', 'y', 'z'], workspaces })
|
|
137
|
+
assert.deepEqual(projected, [
|
|
138
|
+
{ id: 'x', title: 'X', updatedAt: 3, workspace: null },
|
|
139
|
+
{ id: 'y', title: 'Y', updatedAt: 2, workspace: null },
|
|
140
|
+
{ id: 'z', title: 'Z', updatedAt: 1, workspace: 'titleless' },
|
|
141
|
+
])
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
test('归档面板投影:workspaces 缺省时全部回退 null', () => {
|
|
145
|
+
const rows = [{ id: 'a', title: 'A', updatedAt: 1, cwd: 'C:\\work\\alpha' }]
|
|
146
|
+
assert.deepEqual(
|
|
147
|
+
projectArchiveRows({ rows, archivedIds: ['a'] }),
|
|
148
|
+
[{ id: 'a', title: 'A', updatedAt: 1, workspace: null }],
|
|
149
|
+
)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
test('workspaceTitleOf 路径末段边界', () => {
|
|
153
|
+
assert.equal(workspaceTitleOf('C:\\work\\alpha'), 'alpha')
|
|
154
|
+
assert.equal(workspaceTitleOf('/home/user/project/'), 'project')
|
|
155
|
+
assert.equal(workspaceTitleOf('C:\\work\\mixed/slash\\path'), 'path')
|
|
156
|
+
assert.equal(workspaceTitleOf('C:\\'), 'C:')
|
|
157
|
+
assert.equal(workspaceTitleOf('/'), '')
|
|
158
|
+
assert.equal(workspaceTitleOf('///'), '')
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
test('workspaceTitleForSession 直接边界:title 与 path 皆缺为 null,title 空串回退路径末段', () => {
|
|
162
|
+
assert.equal(workspaceTitleForSession({ workspaces: [{ sessionIds: ['a'] }], sessionId: 'a' }), null)
|
|
163
|
+
assert.equal(
|
|
164
|
+
workspaceTitleForSession({ workspaces: [{ path: 'C:\\work\\x', title: '', sessionIds: ['a'] }], sessionId: 'a' }),
|
|
165
|
+
'x',
|
|
166
|
+
)
|
|
167
|
+
assert.equal(workspaceTitleForSession({ workspaces: 'bad', sessionId: 'a' }), null)
|
|
168
|
+
assert.equal(workspaceTitleForSession({ workspaces: [{ path: 'C:\\w\\a', sessionIds: ['a'] }], sessionId: 'a', cwd: undefined }), 'a')
|
|
99
169
|
})
|
|
100
170
|
|
|
101
171
|
test('归档集合差分只报新增,首帧基线不提示', () => {
|
package/test/index.test.mjs
CHANGED
|
@@ -32,6 +32,10 @@ process.env.DSH_HISTORY_CACHE_DIR = sharedCacheDir
|
|
|
32
32
|
test.after(() => { rmSync(sharedCacheDir, { recursive: true, force: true }) })
|
|
33
33
|
// 台账/重挂载夹具路径仅作数据,不落盘;形态与实现一致(会话目录)
|
|
34
34
|
const LEDGER_FIXTURE_PATH = 'C:\\store\\s1'
|
|
35
|
+
// 幽灵产物夹具:tmpdir 下未创建的路径,跨平台 stat 必 ENOENT
|
|
36
|
+
// (POSIX 对纯反斜杠 Windows 字面量取 dirname 会落回 '.',stat 成功使幽灵分支不可达)
|
|
37
|
+
const GHOST_DIR = path.join(tmpdir(), 'sm-ghost-fixture', 's1')
|
|
38
|
+
const GHOST_PATH = path.join(GHOST_DIR, 'session.jsonl.zstd')
|
|
35
39
|
|
|
36
40
|
function makeRegistry(initialIds) {
|
|
37
41
|
// 真实注册表契约:archivedSessionIds 是进程内快照的 getter,官方读路径全部经快照;
|
|
@@ -469,7 +473,7 @@ test('info 路由:产物已缺失按 missing 返回,不裸抛', skipMissingDeps,
|
|
|
469
473
|
archivedIds: [],
|
|
470
474
|
headers: [HEADER],
|
|
471
475
|
agents: new Map(),
|
|
472
|
-
sessionPersistence: { locate: (header) => header.id === 's1' ? { path:
|
|
476
|
+
sessionPersistence: { locate: (header) => header.id === 's1' ? { path: GHOST_PATH } : undefined },
|
|
473
477
|
})
|
|
474
478
|
const res = response()
|
|
475
479
|
await handlers.get('/api/session-manager/info')(request('s1'), res)
|
|
@@ -993,8 +997,8 @@ test('删除:产物已缺失时跳过 trash 与台账,完成列表清理(同 id
|
|
|
993
997
|
headers: [HEADER],
|
|
994
998
|
agents: IDLE_S1,
|
|
995
999
|
domain,
|
|
996
|
-
sessionPersistence: { locate: (header) => header.id === 's1' ? { path:
|
|
997
|
-
ledger: makeLedgerDomain([{ sessionId: 's1', path:
|
|
1000
|
+
sessionPersistence: { locate: (header) => header.id === 's1' ? { path: GHOST_PATH } : undefined },
|
|
1001
|
+
ledger: makeLedgerDomain([{ sessionId: 's1', path: GHOST_DIR, deletedAt: 1 }]),
|
|
998
1002
|
workspaces: [workspace],
|
|
999
1003
|
})
|
|
1000
1004
|
const trashed = []
|
|
@@ -1018,7 +1022,7 @@ test('删除:产物缺失且无归档记录无台账时拒绝(防新建会话未
|
|
|
1018
1022
|
headers: [HEADER],
|
|
1019
1023
|
agents: IDLE_S1,
|
|
1020
1024
|
domain: makeDomain([]),
|
|
1021
|
-
sessionPersistence: { locate: (header) => header.id === 's1' ? { path:
|
|
1025
|
+
sessionPersistence: { locate: (header) => header.id === 's1' ? { path: GHOST_PATH } : undefined },
|
|
1022
1026
|
workspaces: [workspace],
|
|
1023
1027
|
})
|
|
1024
1028
|
const res = response()
|
|
@@ -1037,7 +1041,7 @@ test('删除:产物已缺失时运行中守卫仍然生效', skipMissingDeps, as
|
|
|
1037
1041
|
headers: [HEADER],
|
|
1038
1042
|
agents: new Map([['s1', { status: 'running' }]]),
|
|
1039
1043
|
domain: makeDomain([]),
|
|
1040
|
-
sessionPersistence: { locate: (header) => header.id === 's1' ? { path:
|
|
1044
|
+
sessionPersistence: { locate: (header) => header.id === 's1' ? { path: GHOST_PATH } : undefined },
|
|
1041
1045
|
})
|
|
1042
1046
|
const res = response()
|
|
1043
1047
|
await withTrashStub(async () => {}, async () => {
|
|
@@ -1054,8 +1058,8 @@ test('删除:产物已缺失且清理半失败时聚合失败点', skipMissingDe
|
|
|
1054
1058
|
headers: [HEADER],
|
|
1055
1059
|
agents: IDLE_S1,
|
|
1056
1060
|
domain: null,
|
|
1057
|
-
sessionPersistence: { locate: (header) => header.id === 's1' ? { path:
|
|
1058
|
-
ledger: makeLedgerDomain([{ sessionId: 's1', path:
|
|
1061
|
+
sessionPersistence: { locate: (header) => header.id === 's1' ? { path: GHOST_PATH } : undefined },
|
|
1062
|
+
ledger: makeLedgerDomain([{ sessionId: 's1', path: GHOST_DIR, deletedAt: 1 }]),
|
|
1059
1063
|
workspaces: [workspace],
|
|
1060
1064
|
})
|
|
1061
1065
|
const res = response()
|
package/test/parity.test.mjs
CHANGED
|
@@ -18,8 +18,9 @@ const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
|
18
18
|
const CLIENT_SRC = readFileSync(join(PKG_ROOT, 'src', 'client.js'), 'utf8')
|
|
19
19
|
const INDEX_SRC = readFileSync(join(PKG_ROOT, 'src', 'index.js'), 'utf8')
|
|
20
20
|
|
|
21
|
-
//
|
|
22
|
-
|
|
21
|
+
// 切片:从首个镜像纯函数(workspaceTitleOf)起到最后一个镜像纯函数止,
|
|
22
|
+
// 均为无外部依赖纯函数
|
|
23
|
+
const MIRROR_START = CLIENT_SRC.indexOf('function workspaceTitleOf(')
|
|
23
24
|
const MIRROR_END_MARKER = 'function ArchiveRow('
|
|
24
25
|
const MIRROR_END = CLIENT_SRC.indexOf(MIRROR_END_MARKER)
|
|
25
26
|
assert.ok(MIRROR_START >= 0 && MIRROR_END > MIRROR_START, 'client.js 镜像函数切片定位失败')
|
|
@@ -29,16 +30,28 @@ const mirror = new Function(
|
|
|
29
30
|
+ '; return { projectRows: projectRows, projectDeletedRows: projectDeletedRows, archiveToastStep: archiveToastStep }',
|
|
30
31
|
)()
|
|
31
32
|
|
|
32
|
-
// client 侧输入形态 byId 字典,core 侧 rows 数组:按 title 约定构造等价输入
|
|
33
|
+
// client 侧输入形态 byId 字典,core 侧 rows 数组:按 title/cwd 约定构造等价输入
|
|
33
34
|
function byIdOf(rows) {
|
|
34
35
|
const byId = {}
|
|
35
|
-
for (const row of rows)
|
|
36
|
+
for (const row of rows) {
|
|
37
|
+
byId[row.id] = { displayTitle: row.title, updatedAt: row.updatedAt }
|
|
38
|
+
if (row.cwd !== undefined) byId[row.id].cwd = row.cwd
|
|
39
|
+
}
|
|
36
40
|
return byId
|
|
37
41
|
}
|
|
38
42
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
43
|
+
const PARITY_WORKSPACES = [
|
|
44
|
+
{ workspaceId: 'w1', path: 'C:\\work\\alpha', title: 'alpha', sessionIds: ['b'] },
|
|
45
|
+
{ workspaceId: 'w2', path: 'C:\\work\\beta', title: 'beta', sessionIds: ['c'] },
|
|
46
|
+
{ workspaceId: 'w3', path: 'C:\\work\\gamma', sessionIds: ['t'] },
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
function assertArchiveProjectionParity(rows, archivedIds, workspaceState) {
|
|
50
|
+
// workspaceState 缺省时传 undefined,同步覆盖 client 侧快照缺省守卫与 core 侧
|
|
51
|
+
// workspaces 缺省守卫(双侧均回退无工作区,输出 workspace 全 null)
|
|
52
|
+
const clientWorkspaceState = workspaceState === undefined ? undefined : { items: workspaceState }
|
|
53
|
+
const clientRows = mirror.projectRows({ byId: byIdOf(rows) }, archivedIds, clientWorkspaceState ?? { items: PARITY_WORKSPACES })
|
|
54
|
+
const coreRows = projectArchiveRows({ rows, archivedIds, workspaces: workspaceState ?? PARITY_WORKSPACES })
|
|
42
55
|
assert.deepEqual(clientRows, coreRows)
|
|
43
56
|
}
|
|
44
57
|
|
|
@@ -56,6 +69,11 @@ test('parity 归档投影:空输入与空集合', () => {
|
|
|
56
69
|
assertArchiveProjectionParity([{ id: 'a', title: 'A', updatedAt: 1 }], [])
|
|
57
70
|
})
|
|
58
71
|
|
|
72
|
+
test('parity 归档投影:workspaceState 缺省守卫(双侧均回退全 null)', () => {
|
|
73
|
+
assertArchiveProjectionParity([{ id: 'a', title: 'A', updatedAt: 1, cwd: 'C:\\work\\alpha' }], ['a'], undefined)
|
|
74
|
+
assertArchiveProjectionParity([{ id: 'a', title: 'A', updatedAt: 1 }], ['a'], [])
|
|
75
|
+
})
|
|
76
|
+
|
|
59
77
|
test('parity 归档投影:标题缺失回退会话 id', () => {
|
|
60
78
|
assertArchiveProjectionParity([{ id: 'a', title: '', updatedAt: 5 }], ['a'])
|
|
61
79
|
})
|
|
@@ -68,6 +86,17 @@ test('parity 归档投影:updatedAt 并列时排序不漂移(同数组序)', ()
|
|
|
68
86
|
], ['x', 'y', 'z'])
|
|
69
87
|
})
|
|
70
88
|
|
|
89
|
+
test('parity 归档投影:工作区映射账本/cwd/未分组/缺 title 回退全路径', () => {
|
|
90
|
+
assertArchiveProjectionParity([
|
|
91
|
+
{ id: 'b', title: 'B', updatedAt: 400 },
|
|
92
|
+
{ id: 'a', title: 'A', updatedAt: 300, cwd: 'C:\\work\\alpha' },
|
|
93
|
+
{ id: 'u', title: 'U', updatedAt: 200 },
|
|
94
|
+
{ id: 't', title: 'T', updatedAt: 100 },
|
|
95
|
+
{ id: 'm', title: 'M', updatedAt: 50, cwd: 'C:\\elsewhere\\other' },
|
|
96
|
+
], ['b', 'a', 'u', 't', 'm'])
|
|
97
|
+
assertArchiveProjectionParity([{ id: 'a', title: 'A', updatedAt: 1, cwd: 'C:\\work\\alpha' }], ['a'])
|
|
98
|
+
})
|
|
99
|
+
|
|
71
100
|
function assertDeletedProjectionParity(deleted, sessionsById) {
|
|
72
101
|
const clientRows = mirror.projectDeletedRows(deleted, { byId: sessionsById })
|
|
73
102
|
const coreRows = projectDeletedRows(deleted, sessionsById)
|