@mzzsfy/dsh-session-manager 0.9.4 → 0.9.5
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 +3 -2
- package/cordis.patch.yml +5 -4
- package/package.json +3 -3
- package/src/client.js +811 -811
- package/src/core.mjs +271 -271
- package/test/client-toast.test.mjs +6 -6
package/src/core.mjs
CHANGED
|
@@ -1,271 +1,271 @@
|
|
|
1
|
-
// 纯逻辑层:归档评估状态机、删除资格与失败矩阵、面板投影、归档集合差分。
|
|
2
|
-
// 全部为无副作用纯函数,host 与测试共用。
|
|
3
|
-
|
|
4
|
-
export const DAY_MS = 24 * 60 * 60 * 1000
|
|
5
|
-
export const DEFAULT_AUTO_ARCHIVE_DAYS = 7
|
|
6
|
-
export const DEFAULT_AUTO_ARCHIVE_INTERVAL_HOURS = 24
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* 自动归档配置的提交判定:输入框文本对照已提交值分类。
|
|
10
|
-
* @param committedText - 该字段已提交值的字符串形态
|
|
11
|
-
* @param text - 输入框当前文本
|
|
12
|
-
* @returns noop 值未变 | invalid 非非负整数 | post 可提交(携带解析后的数值)
|
|
13
|
-
*/
|
|
14
|
-
export function classifyAutoArchiveInput(committedText, text) {
|
|
15
|
-
const trimmed = text.trim()
|
|
16
|
-
if (trimmed === committedText) return { action: 'noop' }
|
|
17
|
-
const value = Number(trimmed)
|
|
18
|
-
if (trimmed === '' || !Number.isInteger(value) || value < 0) return { action: 'invalid' }
|
|
19
|
-
return { action: 'post', value }
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/** 更新时间 = max(createdAt, 最近活跃时间)。 */
|
|
23
|
-
export function updatedAtOf(header, activityAtMs) {
|
|
24
|
-
return Math.max(header.createdAt, activityAtMs ?? 0)
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
// 取消归档全局宽限时长:恢复会话到用户发出首条消息之间的评估窗口保护,
|
|
28
|
-
// 首条消息更新产物 mtime 后由阈值自然接管;只查阅不发言的会话宽限后正常归档
|
|
29
|
-
export const UNARCHIVE_GRACE_MS = 10 * 60 * 1000
|
|
30
|
-
|
|
31
|
-
/** 宽限期判定:取消归档后 graceMs 内暂停自动归档(用户恢复即意图信号)。 */
|
|
32
|
-
export function unarchiveGraceActive(unarchivedAtMs, nowMs, graceMs = UNARCHIVE_GRACE_MS) {
|
|
33
|
-
if (!Number.isFinite(unarchivedAtMs) || unarchivedAtMs <= 0) return false
|
|
34
|
-
return nowMs - unarchivedAtMs < graceMs
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* 归档评估状态机:按阈值筛出待归档会话 id。
|
|
39
|
-
* @param records - 候选行 {id, archived, running, blank, updatedAt}
|
|
40
|
-
*/
|
|
41
|
-
export function selectArchiveCandidates({ records, nowMs, thresholdDays }) {
|
|
42
|
-
if (!Number.isFinite(thresholdDays) || thresholdDays <= 0) return []
|
|
43
|
-
const cutoff = nowMs - thresholdDays * DAY_MS
|
|
44
|
-
return records
|
|
45
|
-
.filter((item) => !item.archived && !item.running && !item.blank && item.updatedAt < cutoff)
|
|
46
|
-
.map((item) => item.id)
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* 会话所属工作区标题解析(与 client 镜像同规):workspace 账本(sessionIds)
|
|
51
|
-
* 一级映射与官方 dsh-client-ui-workspace 的 workspaceBySession 同构;回退链为本
|
|
52
|
-
* 插件收紧决策——官方对账本缺失回退会话 cwd 的 basename(无需命中登记工作区),
|
|
53
|
-
* 此处要求 cwd 命中某登记工作区路径才回退其标题,否则未分组(null),避免把未
|
|
54
|
-
* 登记目录名冒充工作区。工作区 title 缺失时回退路径末段(官方 workspaceTitleOf
|
|
55
|
-
* 同构);title 与 path 皆缺返回 null。
|
|
56
|
-
*/
|
|
57
|
-
export function workspaceTitleOf(path) {
|
|
58
|
-
const trimmed = String(path).replace(/[/\\]+$/, '')
|
|
59
|
-
const separator = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'))
|
|
60
|
-
return trimmed.slice(separator + 1)
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
export function workspaceTitleForSession({ workspaces, sessionId, cwd }) {
|
|
64
|
-
const items = Array.isArray(workspaces) ? workspaces : []
|
|
65
|
-
for (const workspace of items) {
|
|
66
|
-
if (workspace && Array.isArray(workspace.sessionIds) && workspace.sessionIds.includes(sessionId)) {
|
|
67
|
-
return workspace.title || (workspace.path ? workspaceTitleOf(workspace.path) : null)
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
const normalizedCwd = cwd ? String(cwd).replace(/[/\\]+$/, '') : ''
|
|
71
|
-
if (!normalizedCwd) return null
|
|
72
|
-
for (const workspace of items) {
|
|
73
|
-
if (workspace && workspace.path && String(workspace.path).replace(/[/\\]+$/, '') === normalizedCwd) {
|
|
74
|
-
return workspace.title || workspaceTitleOf(workspace.path)
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
return null
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/** 归档面板行:session.list 行与归档集合的交集,按更新时间倒序;标题缺失回退会话 id;workspace 为所属工作区标题或 null(与 client 镜像同规)。 */
|
|
81
|
-
export function projectArchiveRows({ rows, archivedIds, workspaces }) {
|
|
82
|
-
const archived = new Set(archivedIds)
|
|
83
|
-
return rows
|
|
84
|
-
.filter((row) => archived.has(row.id))
|
|
85
|
-
.map((row) => ({
|
|
86
|
-
id: row.id,
|
|
87
|
-
title: row.title || row.id,
|
|
88
|
-
updatedAt: row.updatedAt,
|
|
89
|
-
workspace: workspaceTitleForSession({ workspaces, sessionId: row.id, cwd: row.cwd }),
|
|
90
|
-
}))
|
|
91
|
-
.sort((left, right) => right.updatedAt - left.updatedAt)
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/** 归档集合差分:previous 缺省(基线首帧)不产生提示。 */
|
|
95
|
-
export function diffArchived(previousIds, nextIds) {
|
|
96
|
-
if (previousIds === undefined) return []
|
|
97
|
-
const previous = new Set(previousIds)
|
|
98
|
-
return nextIds.filter((id) => !previous.has(id))
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/**
|
|
102
|
-
* Toast 差分步进:连续两个 ready 快照才计新增。
|
|
103
|
-
* 模型订阅即时发射 pending 空态,基线(存量归档)成为第二帧;基线是重连权威而非
|
|
104
|
-
* 归档事件,任一单帧都无法与事件区分,故以「上一帧也是 ready」为差分启用条件,
|
|
105
|
-
* 启动与重连首装不误报,重连基线携带的离期新增仍会提示。
|
|
106
|
-
* client.js 有一份镜像实现(单文件自包含无法跨文件 require),修改需两处同步。
|
|
107
|
-
*/
|
|
108
|
-
export function archiveToastStep(previous, snapshot) {
|
|
109
|
-
const ready = Boolean(snapshot && snapshot.phase === 'ready')
|
|
110
|
-
const ids = (snapshot && snapshot.archivedSessionIds) || []
|
|
111
|
-
const added = previous !== undefined && previous.ready && ready ? diffArchived(previous.ids, ids) : []
|
|
112
|
-
return { state: { ready, ids }, added }
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
/** 归档通知标题列举上限:超过该数只列前段并以「 等」收尾。 */
|
|
116
|
-
export const TOAST_MAX_TITLES = 3
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* 归档通知文案:单个报标题,多个为计数加列举标题,标题数超展示阈值只列前段并以
|
|
120
|
-
* 「 等」收尾,计数恒为真实总数。标题按去除首尾空白取值,缺失、空白或行不存在
|
|
121
|
-
* 回退会话 id,行数据非数组视为空。client.js 有镜像实现(单文件自包含无法跨文件
|
|
122
|
-
* require),修改需两处同步。
|
|
123
|
-
*/
|
|
124
|
-
export function archiveToastText(addedIds, rows) {
|
|
125
|
-
const added = Array.isArray(addedIds) ? addedIds : []
|
|
126
|
-
if (added.length === 0) return ''
|
|
127
|
-
const safeRows = Array.isArray(rows) ? rows : []
|
|
128
|
-
const titleById = new Map()
|
|
129
|
-
for (const row of safeRows) {
|
|
130
|
-
if (!row || row.id === undefined || row.id === null) continue
|
|
131
|
-
const trimmed = String(row.title ?? '').trim()
|
|
132
|
-
if (trimmed !== '') titleById.set(String(row.id), trimmed)
|
|
133
|
-
}
|
|
134
|
-
const names = added.map((id) => titleById.get(String(id)) || String(id))
|
|
135
|
-
if (names.length === 1) return '会话「' + names[0] + '」已归档'
|
|
136
|
-
const listed = names.slice(0, TOAST_MAX_TITLES)
|
|
137
|
-
return '有 ' + names.length + ' 个会话已归档:' + listed.join('、') + (names.length > TOAST_MAX_TITLES ? ' 等' : '')
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
/** 删除资格:仅已归档会话,其余拒绝(竞态防护的 client 前置与 host 权威共用判定)。 */
|
|
141
|
-
export function deleteEligibility({ archivedIds, sessionId }) {
|
|
142
|
-
return archivedIds.includes(sessionId)
|
|
143
|
-
? { ok: true }
|
|
144
|
-
: { ok: false, code: 'not-archived' }
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
/** 运行中判定:agent 注册表 status 为 running 即运行中,与归档评估同款判据。 */
|
|
148
|
-
export function isSessionRunning({ agents, sessionId }) {
|
|
149
|
-
const entry = agents ? agents.get(sessionId) : undefined
|
|
150
|
-
return Boolean(entry && entry.status === 'running')
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
// 删除半失败聚合文案:trash 成功后各收尾失败点的主文案与后缀,host 响应逐键消费。
|
|
154
|
-
// 基础前缀与警告措辞按处置模式区分(系统回收站 / 插件回收区):两种模式找回路径不同
|
|
155
|
-
export function deleteOutcomeMessages(quarantined) {
|
|
156
|
-
const base = quarantined ? '已移入插件回收区' : '已移入回收站'
|
|
157
|
-
return {
|
|
158
|
-
partial: base + ',但移除列表记录失败',
|
|
159
|
-
archiveCleanup: base + ',但移除归档记录失败',
|
|
160
|
-
ledgerFailed: base + ',但重挂载记录失败',
|
|
161
|
-
runningDuringTrash: quarantined
|
|
162
|
-
? '警告:回收期间会话恢复运行,产物已移入插件回收区;建议检查会话状态'
|
|
163
|
-
: '警告:回收期间会话恢复运行,产物已移入回收站;建议检查会话状态',
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
// OS 回收站模式文案:保留常量导出供既有测试与文案对照
|
|
168
|
-
export const DELETE_MESSAGES = {
|
|
169
|
-
...deleteOutcomeMessages(false),
|
|
170
|
-
ledgerSuffix: ',且重挂载记录失败',
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
/**
|
|
174
|
-
* 删除收尾半失败聚合:trash 成功后的失败矩阵折叠为单一响应体。
|
|
175
|
-
* 主文案优先级 detach → 归档清理 → 台账,台账失败以后缀并入前两者,运行中翻转
|
|
176
|
-
* 以警告后缀并入一切形态。三形态逐键一致:{ok} / {ok,message} / {ok,partial,message}。
|
|
177
|
-
* quarantined 为处置模式:系统回收站失败降级插件回收区时置真,基础前缀与警告措辞随之切换。
|
|
178
|
-
*/
|
|
179
|
-
export function aggregateDeleteOutcome({ detachFailed = false, archiveCleanupFailed = false, ledgerFailed = false, runningDuringTrash = false, quarantined = false }) {
|
|
180
|
-
const M = deleteOutcomeMessages(quarantined)
|
|
181
|
-
const warningSuffix = runningDuringTrash ? ';' + M.runningDuringTrash : ''
|
|
182
|
-
const ledgerSuffix = ledgerFailed ? DELETE_MESSAGES.ledgerSuffix : ''
|
|
183
|
-
if (detachFailed) return { ok: true, partial: true, message: M.partial + ledgerSuffix + warningSuffix }
|
|
184
|
-
if (archiveCleanupFailed) return { ok: true, partial: true, message: M.archiveCleanup + ledgerSuffix + warningSuffix }
|
|
185
|
-
if (ledgerFailed) return { ok: true, partial: true, message: M.ledgerFailed + warningSuffix }
|
|
186
|
-
return runningDuringTrash
|
|
187
|
-
? { ok: true, message: M.runningDuringTrash }
|
|
188
|
-
: { ok: true }
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
/** 空白产物判定:读到 EOF 且换行数不足两行,即仅 header(或空)视为空白。 */
|
|
192
|
-
export function artifactLooksBlank(headText, hasMore) {
|
|
193
|
-
let breaks = 0
|
|
194
|
-
for (const char of headText) {
|
|
195
|
-
if (char === '\n') breaks += 1
|
|
196
|
-
if (breaks >= 2) return false
|
|
197
|
-
}
|
|
198
|
-
return !hasMore && breaks < 2
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
/** 台账合并:同 id 替换,新条目置顶;返回新数组,不改入参。client.js 有镜像,修改需两处同步。 */
|
|
202
|
-
export function mergeDeletedEntry(deleted, entry) {
|
|
203
|
-
return [entry, ...deleted.filter((item) => item.sessionId !== entry.sessionId)]
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
/** 台账移除:幂等;removed 标记是否发生变化,调用方据此决定是否写回。 */
|
|
207
|
-
export function removeDeletedEntry(deleted, sessionId) {
|
|
208
|
-
const next = deleted.filter((item) => item.sessionId !== sessionId)
|
|
209
|
-
return { deleted: next, removed: next.length !== deleted.length }
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
/** 已删除面板行:标题取会话行展示标题,缺失回退会话 id,按删除时间倒序。client.js 有镜像,修改需两处同步。 */
|
|
213
|
-
export function projectDeletedRows(deleted, sessionsById) {
|
|
214
|
-
const byId = sessionsById || {}
|
|
215
|
-
return [...deleted]
|
|
216
|
-
.sort((left, right) => right.deletedAt - left.deletedAt)
|
|
217
|
-
.map((item) => ({
|
|
218
|
-
sessionId: item.sessionId,
|
|
219
|
-
path: item.path,
|
|
220
|
-
deletedAt: item.deletedAt,
|
|
221
|
-
title: (byId[item.sessionId] && byId[item.sessionId].displayTitle) || item.sessionId,
|
|
222
|
-
}))
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
// ── 归档库投影:主列表分页 / 工作区分组 / 标题搜索 ──
|
|
226
|
-
|
|
227
|
-
/** 归档库主列表默认展开行数与手动展开步长。 */
|
|
228
|
-
export const ARCHIVE_PAGE_SIZE = 100
|
|
229
|
-
|
|
230
|
-
/**
|
|
231
|
-
* 归档列表分页:取前 visibleCount 条并报告剩余量,非有限或非正可见数按 0 处理。
|
|
232
|
-
* client.js 有镜像实现(单文件自包含无法跨文件 require),修改需两处同步。
|
|
233
|
-
*/
|
|
234
|
-
export function pageArchiveRows(rows, visibleCount) {
|
|
235
|
-
const list = Array.isArray(rows) ? rows : []
|
|
236
|
-
const count = Number.isFinite(visibleCount) && visibleCount > 0 ? visibleCount : 0
|
|
237
|
-
const visible = list.slice(0, count)
|
|
238
|
-
return { visible, remaining: list.length - visible.length }
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
/**
|
|
242
|
-
* 归档列表按工作区分组:workspace 假值并桶为未分组(null),组内保持输入序
|
|
243
|
-
* (调用方输入恒为时间倒序投影),组间按组内最新 updatedAt 倒序。
|
|
244
|
-
* client.js 有镜像实现(单文件自包含无法跨文件 require),修改需两处同步。
|
|
245
|
-
*/
|
|
246
|
-
export function groupArchiveRowsByWorkspace(rows) {
|
|
247
|
-
const list = Array.isArray(rows) ? rows : []
|
|
248
|
-
const byKey = new Map()
|
|
249
|
-
for (const row of list) {
|
|
250
|
-
const title = row.workspace ? String(row.workspace) : ''
|
|
251
|
-
if (!byKey.has(title)) byKey.set(title, { workspace: title === '' ? null : title, latest: row.updatedAt, rows: [] })
|
|
252
|
-
const group = byKey.get(title)
|
|
253
|
-
group.rows.push(row)
|
|
254
|
-
if (row.updatedAt > group.latest) group.latest = row.updatedAt
|
|
255
|
-
}
|
|
256
|
-
return [...byKey.values()]
|
|
257
|
-
.sort((left, right) => right.latest - left.latest)
|
|
258
|
-
.map(({ workspace, rows: groupRows }) => ({ workspace, rows: groupRows }))
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
/**
|
|
262
|
-
* 归档列表标题搜索:查询词去首尾空白后按不区分大小写子串匹配标题,
|
|
263
|
-
* 空查询原样返回全量。client.js 有镜像实现(单文件自包含无法跨文件 require),修改需两处同步。
|
|
264
|
-
*/
|
|
265
|
-
export function filterArchiveRows(rows, query) {
|
|
266
|
-
const list = Array.isArray(rows) ? rows : []
|
|
267
|
-
const needle = String(query ?? '').trim().toLowerCase()
|
|
268
|
-
if (needle === '') return list
|
|
269
|
-
return list.filter((row) => String(row.title ?? '').toLowerCase().includes(needle))
|
|
270
|
-
}
|
|
271
|
-
|
|
1
|
+
// 纯逻辑层:归档评估状态机、删除资格与失败矩阵、面板投影、归档集合差分。
|
|
2
|
+
// 全部为无副作用纯函数,host 与测试共用。
|
|
3
|
+
|
|
4
|
+
export const DAY_MS = 24 * 60 * 60 * 1000
|
|
5
|
+
export const DEFAULT_AUTO_ARCHIVE_DAYS = 7
|
|
6
|
+
export const DEFAULT_AUTO_ARCHIVE_INTERVAL_HOURS = 24
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* 自动归档配置的提交判定:输入框文本对照已提交值分类。
|
|
10
|
+
* @param committedText - 该字段已提交值的字符串形态
|
|
11
|
+
* @param text - 输入框当前文本
|
|
12
|
+
* @returns noop 值未变 | invalid 非非负整数 | post 可提交(携带解析后的数值)
|
|
13
|
+
*/
|
|
14
|
+
export function classifyAutoArchiveInput(committedText, text) {
|
|
15
|
+
const trimmed = text.trim()
|
|
16
|
+
if (trimmed === committedText) return { action: 'noop' }
|
|
17
|
+
const value = Number(trimmed)
|
|
18
|
+
if (trimmed === '' || !Number.isInteger(value) || value < 0) return { action: 'invalid' }
|
|
19
|
+
return { action: 'post', value }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** 更新时间 = max(createdAt, 最近活跃时间)。 */
|
|
23
|
+
export function updatedAtOf(header, activityAtMs) {
|
|
24
|
+
return Math.max(header.createdAt, activityAtMs ?? 0)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// 取消归档全局宽限时长:恢复会话到用户发出首条消息之间的评估窗口保护,
|
|
28
|
+
// 首条消息更新产物 mtime 后由阈值自然接管;只查阅不发言的会话宽限后正常归档
|
|
29
|
+
export const UNARCHIVE_GRACE_MS = 10 * 60 * 1000
|
|
30
|
+
|
|
31
|
+
/** 宽限期判定:取消归档后 graceMs 内暂停自动归档(用户恢复即意图信号)。 */
|
|
32
|
+
export function unarchiveGraceActive(unarchivedAtMs, nowMs, graceMs = UNARCHIVE_GRACE_MS) {
|
|
33
|
+
if (!Number.isFinite(unarchivedAtMs) || unarchivedAtMs <= 0) return false
|
|
34
|
+
return nowMs - unarchivedAtMs < graceMs
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 归档评估状态机:按阈值筛出待归档会话 id。
|
|
39
|
+
* @param records - 候选行 {id, archived, running, blank, updatedAt}
|
|
40
|
+
*/
|
|
41
|
+
export function selectArchiveCandidates({ records, nowMs, thresholdDays }) {
|
|
42
|
+
if (!Number.isFinite(thresholdDays) || thresholdDays <= 0) return []
|
|
43
|
+
const cutoff = nowMs - thresholdDays * DAY_MS
|
|
44
|
+
return records
|
|
45
|
+
.filter((item) => !item.archived && !item.running && !item.blank && item.updatedAt < cutoff)
|
|
46
|
+
.map((item) => item.id)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 会话所属工作区标题解析(与 client 镜像同规):workspace 账本(sessionIds)
|
|
51
|
+
* 一级映射与官方 dsh-client-ui-workspace 的 workspaceBySession 同构;回退链为本
|
|
52
|
+
* 插件收紧决策——官方对账本缺失回退会话 cwd 的 basename(无需命中登记工作区),
|
|
53
|
+
* 此处要求 cwd 命中某登记工作区路径才回退其标题,否则未分组(null),避免把未
|
|
54
|
+
* 登记目录名冒充工作区。工作区 title 缺失时回退路径末段(官方 workspaceTitleOf
|
|
55
|
+
* 同构);title 与 path 皆缺返回 null。
|
|
56
|
+
*/
|
|
57
|
+
export function workspaceTitleOf(path) {
|
|
58
|
+
const trimmed = String(path).replace(/[/\\]+$/, '')
|
|
59
|
+
const separator = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'))
|
|
60
|
+
return trimmed.slice(separator + 1)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function workspaceTitleForSession({ workspaces, sessionId, cwd }) {
|
|
64
|
+
const items = Array.isArray(workspaces) ? workspaces : []
|
|
65
|
+
for (const workspace of items) {
|
|
66
|
+
if (workspace && Array.isArray(workspace.sessionIds) && workspace.sessionIds.includes(sessionId)) {
|
|
67
|
+
return workspace.title || (workspace.path ? workspaceTitleOf(workspace.path) : null)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const normalizedCwd = cwd ? String(cwd).replace(/[/\\]+$/, '') : ''
|
|
71
|
+
if (!normalizedCwd) return null
|
|
72
|
+
for (const workspace of items) {
|
|
73
|
+
if (workspace && workspace.path && String(workspace.path).replace(/[/\\]+$/, '') === normalizedCwd) {
|
|
74
|
+
return workspace.title || workspaceTitleOf(workspace.path)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return null
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** 归档面板行:session.list 行与归档集合的交集,按更新时间倒序;标题缺失回退会话 id;workspace 为所属工作区标题或 null(与 client 镜像同规)。 */
|
|
81
|
+
export function projectArchiveRows({ rows, archivedIds, workspaces }) {
|
|
82
|
+
const archived = new Set(archivedIds)
|
|
83
|
+
return rows
|
|
84
|
+
.filter((row) => archived.has(row.id))
|
|
85
|
+
.map((row) => ({
|
|
86
|
+
id: row.id,
|
|
87
|
+
title: row.title || row.id,
|
|
88
|
+
updatedAt: row.updatedAt,
|
|
89
|
+
workspace: workspaceTitleForSession({ workspaces, sessionId: row.id, cwd: row.cwd }),
|
|
90
|
+
}))
|
|
91
|
+
.sort((left, right) => right.updatedAt - left.updatedAt)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** 归档集合差分:previous 缺省(基线首帧)不产生提示。 */
|
|
95
|
+
export function diffArchived(previousIds, nextIds) {
|
|
96
|
+
if (previousIds === undefined) return []
|
|
97
|
+
const previous = new Set(previousIds)
|
|
98
|
+
return nextIds.filter((id) => !previous.has(id))
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Toast 差分步进:连续两个 ready 快照才计新增。
|
|
103
|
+
* 模型订阅即时发射 pending 空态,基线(存量归档)成为第二帧;基线是重连权威而非
|
|
104
|
+
* 归档事件,任一单帧都无法与事件区分,故以「上一帧也是 ready」为差分启用条件,
|
|
105
|
+
* 启动与重连首装不误报,重连基线携带的离期新增仍会提示。
|
|
106
|
+
* client.js 有一份镜像实现(单文件自包含无法跨文件 require),修改需两处同步。
|
|
107
|
+
*/
|
|
108
|
+
export function archiveToastStep(previous, snapshot) {
|
|
109
|
+
const ready = Boolean(snapshot && snapshot.phase === 'ready')
|
|
110
|
+
const ids = (snapshot && snapshot.archivedSessionIds) || []
|
|
111
|
+
const added = previous !== undefined && previous.ready && ready ? diffArchived(previous.ids, ids) : []
|
|
112
|
+
return { state: { ready, ids }, added }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** 归档通知标题列举上限:超过该数只列前段并以「 等」收尾。 */
|
|
116
|
+
export const TOAST_MAX_TITLES = 3
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* 归档通知文案:单个报标题,多个为计数加列举标题,标题数超展示阈值只列前段并以
|
|
120
|
+
* 「 等」收尾,计数恒为真实总数。标题按去除首尾空白取值,缺失、空白或行不存在
|
|
121
|
+
* 回退会话 id,行数据非数组视为空。client.js 有镜像实现(单文件自包含无法跨文件
|
|
122
|
+
* require),修改需两处同步。
|
|
123
|
+
*/
|
|
124
|
+
export function archiveToastText(addedIds, rows) {
|
|
125
|
+
const added = Array.isArray(addedIds) ? addedIds : []
|
|
126
|
+
if (added.length === 0) return ''
|
|
127
|
+
const safeRows = Array.isArray(rows) ? rows : []
|
|
128
|
+
const titleById = new Map()
|
|
129
|
+
for (const row of safeRows) {
|
|
130
|
+
if (!row || row.id === undefined || row.id === null) continue
|
|
131
|
+
const trimmed = String(row.title ?? '').trim()
|
|
132
|
+
if (trimmed !== '') titleById.set(String(row.id), trimmed)
|
|
133
|
+
}
|
|
134
|
+
const names = added.map((id) => titleById.get(String(id)) || String(id))
|
|
135
|
+
if (names.length === 1) return '会话「' + names[0] + '」已归档'
|
|
136
|
+
const listed = names.slice(0, TOAST_MAX_TITLES)
|
|
137
|
+
return '有 ' + names.length + ' 个会话已归档:' + listed.join('、') + (names.length > TOAST_MAX_TITLES ? ' 等' : '')
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** 删除资格:仅已归档会话,其余拒绝(竞态防护的 client 前置与 host 权威共用判定)。 */
|
|
141
|
+
export function deleteEligibility({ archivedIds, sessionId }) {
|
|
142
|
+
return archivedIds.includes(sessionId)
|
|
143
|
+
? { ok: true }
|
|
144
|
+
: { ok: false, code: 'not-archived' }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** 运行中判定:agent 注册表 status 为 running 即运行中,与归档评估同款判据。 */
|
|
148
|
+
export function isSessionRunning({ agents, sessionId }) {
|
|
149
|
+
const entry = agents ? agents.get(sessionId) : undefined
|
|
150
|
+
return Boolean(entry && entry.status === 'running')
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// 删除半失败聚合文案:trash 成功后各收尾失败点的主文案与后缀,host 响应逐键消费。
|
|
154
|
+
// 基础前缀与警告措辞按处置模式区分(系统回收站 / 插件回收区):两种模式找回路径不同
|
|
155
|
+
export function deleteOutcomeMessages(quarantined) {
|
|
156
|
+
const base = quarantined ? '已移入插件回收区' : '已移入回收站'
|
|
157
|
+
return {
|
|
158
|
+
partial: base + ',但移除列表记录失败',
|
|
159
|
+
archiveCleanup: base + ',但移除归档记录失败',
|
|
160
|
+
ledgerFailed: base + ',但重挂载记录失败',
|
|
161
|
+
runningDuringTrash: quarantined
|
|
162
|
+
? '警告:回收期间会话恢复运行,产物已移入插件回收区;建议检查会话状态'
|
|
163
|
+
: '警告:回收期间会话恢复运行,产物已移入回收站;建议检查会话状态',
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// OS 回收站模式文案:保留常量导出供既有测试与文案对照
|
|
168
|
+
export const DELETE_MESSAGES = {
|
|
169
|
+
...deleteOutcomeMessages(false),
|
|
170
|
+
ledgerSuffix: ',且重挂载记录失败',
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* 删除收尾半失败聚合:trash 成功后的失败矩阵折叠为单一响应体。
|
|
175
|
+
* 主文案优先级 detach → 归档清理 → 台账,台账失败以后缀并入前两者,运行中翻转
|
|
176
|
+
* 以警告后缀并入一切形态。三形态逐键一致:{ok} / {ok,message} / {ok,partial,message}。
|
|
177
|
+
* quarantined 为处置模式:系统回收站失败降级插件回收区时置真,基础前缀与警告措辞随之切换。
|
|
178
|
+
*/
|
|
179
|
+
export function aggregateDeleteOutcome({ detachFailed = false, archiveCleanupFailed = false, ledgerFailed = false, runningDuringTrash = false, quarantined = false }) {
|
|
180
|
+
const M = deleteOutcomeMessages(quarantined)
|
|
181
|
+
const warningSuffix = runningDuringTrash ? ';' + M.runningDuringTrash : ''
|
|
182
|
+
const ledgerSuffix = ledgerFailed ? DELETE_MESSAGES.ledgerSuffix : ''
|
|
183
|
+
if (detachFailed) return { ok: true, partial: true, message: M.partial + ledgerSuffix + warningSuffix }
|
|
184
|
+
if (archiveCleanupFailed) return { ok: true, partial: true, message: M.archiveCleanup + ledgerSuffix + warningSuffix }
|
|
185
|
+
if (ledgerFailed) return { ok: true, partial: true, message: M.ledgerFailed + warningSuffix }
|
|
186
|
+
return runningDuringTrash
|
|
187
|
+
? { ok: true, message: M.runningDuringTrash }
|
|
188
|
+
: { ok: true }
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** 空白产物判定:读到 EOF 且换行数不足两行,即仅 header(或空)视为空白。 */
|
|
192
|
+
export function artifactLooksBlank(headText, hasMore) {
|
|
193
|
+
let breaks = 0
|
|
194
|
+
for (const char of headText) {
|
|
195
|
+
if (char === '\n') breaks += 1
|
|
196
|
+
if (breaks >= 2) return false
|
|
197
|
+
}
|
|
198
|
+
return !hasMore && breaks < 2
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** 台账合并:同 id 替换,新条目置顶;返回新数组,不改入参。client.js 有镜像,修改需两处同步。 */
|
|
202
|
+
export function mergeDeletedEntry(deleted, entry) {
|
|
203
|
+
return [entry, ...deleted.filter((item) => item.sessionId !== entry.sessionId)]
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** 台账移除:幂等;removed 标记是否发生变化,调用方据此决定是否写回。 */
|
|
207
|
+
export function removeDeletedEntry(deleted, sessionId) {
|
|
208
|
+
const next = deleted.filter((item) => item.sessionId !== sessionId)
|
|
209
|
+
return { deleted: next, removed: next.length !== deleted.length }
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** 已删除面板行:标题取会话行展示标题,缺失回退会话 id,按删除时间倒序。client.js 有镜像,修改需两处同步。 */
|
|
213
|
+
export function projectDeletedRows(deleted, sessionsById) {
|
|
214
|
+
const byId = sessionsById || {}
|
|
215
|
+
return [...deleted]
|
|
216
|
+
.sort((left, right) => right.deletedAt - left.deletedAt)
|
|
217
|
+
.map((item) => ({
|
|
218
|
+
sessionId: item.sessionId,
|
|
219
|
+
path: item.path,
|
|
220
|
+
deletedAt: item.deletedAt,
|
|
221
|
+
title: (byId[item.sessionId] && byId[item.sessionId].displayTitle) || item.sessionId,
|
|
222
|
+
}))
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ── 归档库投影:主列表分页 / 工作区分组 / 标题搜索 ──
|
|
226
|
+
|
|
227
|
+
/** 归档库主列表默认展开行数与手动展开步长。 */
|
|
228
|
+
export const ARCHIVE_PAGE_SIZE = 100
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* 归档列表分页:取前 visibleCount 条并报告剩余量,非有限或非正可见数按 0 处理。
|
|
232
|
+
* client.js 有镜像实现(单文件自包含无法跨文件 require),修改需两处同步。
|
|
233
|
+
*/
|
|
234
|
+
export function pageArchiveRows(rows, visibleCount) {
|
|
235
|
+
const list = Array.isArray(rows) ? rows : []
|
|
236
|
+
const count = Number.isFinite(visibleCount) && visibleCount > 0 ? visibleCount : 0
|
|
237
|
+
const visible = list.slice(0, count)
|
|
238
|
+
return { visible, remaining: list.length - visible.length }
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* 归档列表按工作区分组:workspace 假值并桶为未分组(null),组内保持输入序
|
|
243
|
+
* (调用方输入恒为时间倒序投影),组间按组内最新 updatedAt 倒序。
|
|
244
|
+
* client.js 有镜像实现(单文件自包含无法跨文件 require),修改需两处同步。
|
|
245
|
+
*/
|
|
246
|
+
export function groupArchiveRowsByWorkspace(rows) {
|
|
247
|
+
const list = Array.isArray(rows) ? rows : []
|
|
248
|
+
const byKey = new Map()
|
|
249
|
+
for (const row of list) {
|
|
250
|
+
const title = row.workspace ? String(row.workspace) : ''
|
|
251
|
+
if (!byKey.has(title)) byKey.set(title, { workspace: title === '' ? null : title, latest: row.updatedAt, rows: [] })
|
|
252
|
+
const group = byKey.get(title)
|
|
253
|
+
group.rows.push(row)
|
|
254
|
+
if (row.updatedAt > group.latest) group.latest = row.updatedAt
|
|
255
|
+
}
|
|
256
|
+
return [...byKey.values()]
|
|
257
|
+
.sort((left, right) => right.latest - left.latest)
|
|
258
|
+
.map(({ workspace, rows: groupRows }) => ({ workspace, rows: groupRows }))
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* 归档列表标题搜索:查询词去首尾空白后按不区分大小写子串匹配标题,
|
|
263
|
+
* 空查询原样返回全量。client.js 有镜像实现(单文件自包含无法跨文件 require),修改需两处同步。
|
|
264
|
+
*/
|
|
265
|
+
export function filterArchiveRows(rows, query) {
|
|
266
|
+
const list = Array.isArray(rows) ? rows : []
|
|
267
|
+
const needle = String(query ?? '').trim().toLowerCase()
|
|
268
|
+
if (needle === '') return list
|
|
269
|
+
return list.filter((row) => String(row.title ?? '').toLowerCase().includes(needle))
|
|
270
|
+
}
|
|
271
|
+
|
|
@@ -19,9 +19,9 @@ function loadClient() {
|
|
|
19
19
|
const reactStub = { useState: (value) => [value, () => {}], useEffect: () => {}, useSyncExternalStore: () => [], createElement: () => null }
|
|
20
20
|
const requireStub = (name) => {
|
|
21
21
|
required.push(name)
|
|
22
|
-
// 通知出口:公共依赖 @mzzsfy/dsh-
|
|
23
|
-
if (name === '@mzzsfy/dsh-
|
|
24
|
-
return { show: (text, opts) => { shown.push({ text, opts }); return shown.length } }
|
|
22
|
+
// 通知出口:公共依赖 @mzzsfy/dsh-common-tools 的捕获桩
|
|
23
|
+
if (name === '@mzzsfy/dsh-common-tools/client') {
|
|
24
|
+
return { toast: { show: (text, opts) => { shown.push({ text, opts }); return shown.length } } }
|
|
25
25
|
}
|
|
26
26
|
return reactStub
|
|
27
27
|
}
|
|
@@ -76,10 +76,10 @@ function loadClient() {
|
|
|
76
76
|
|
|
77
77
|
const READY = (ids) => ({ phase: 'ready', archivedSessionIds: ids })
|
|
78
78
|
|
|
79
|
-
test('apply:external require specifier 锁定为 @mzzsfy/dsh-
|
|
79
|
+
test('apply:external require specifier 锁定为 @mzzsfy/dsh-common-tools/client', () => {
|
|
80
80
|
const client = loadClient()
|
|
81
|
-
assert.ok(client.required.includes('@mzzsfy/dsh-
|
|
82
|
-
'client.js 未按约定 specifier require
|
|
81
|
+
assert.ok(client.required.includes('@mzzsfy/dsh-common-tools/client'),
|
|
82
|
+
'client.js 未按约定 specifier require 公共依赖包: ' + client.required.join(', '))
|
|
83
83
|
})
|
|
84
84
|
|
|
85
85
|
test('apply:归档差分经 toast 出口,连续 ready 才计新增', () => {
|