dsh-manage-session 0.4.8 → 0.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +36 -0
- package/README.en.md +4 -0
- package/README.md +4 -0
- package/cordis.patch.yml +6 -1
- package/dsh.plugin.json +22 -22
- package/lib/client.js +3848 -202
- package/lib/index.d.ts +759 -29
- package/lib/index.js +2604 -93
- package/locale/de.json +6 -0
- package/locale/en.json +6 -0
- package/locale/es.json +6 -0
- package/locale/fr.json +6 -0
- package/locale/it.json +6 -0
- package/locale/ja.json +6 -0
- package/locale/ko.json +6 -0
- package/locale/ru.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +43 -32
- package/src/client/bridge-scope.ts +213 -0
- package/src/client/index.ts +154 -26
- package/src/client/locales.ts +857 -223
- package/src/client/panel.tsx +139 -118
- package/src/client/search/card.tsx +563 -0
- package/src/client/search/host-api.ts +208 -0
- package/src/client/search/locales.ts +780 -0
- package/src/client/search/manage.tsx +400 -0
- package/src/client/search/panel.tsx +815 -0
- package/src/client/search/platform.ts +136 -0
- package/src/config.ts +54 -5
- package/src/host/history/archive-source.ts +48 -0
- package/src/host/history/archive.ts +56 -1
- package/src/host/history/favorites.ts +71 -0
- package/src/host/index/archive-source.ts +90 -0
- package/src/host/index/engine.ts +571 -0
- package/src/host/index/extract.ts +272 -0
- package/src/host/index/peers.ts +62 -0
- package/src/host/index/rebuild.ts +368 -0
- package/src/host/index/schema.ts +136 -0
- package/src/host/index/snapshot.ts +135 -0
- package/src/host/index/sync.ts +286 -0
- package/src/host/registry/archive-registry.ts +317 -0
- package/src/host/settings-bridge.ts +237 -0
- package/src/index.ts +1421 -532
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Watermark-driven incremental sync between the live-preferred sessionQuery
|
|
3
|
+
* corpus and the independent switch-search index.
|
|
4
|
+
*
|
|
5
|
+
* One pass lists the logical corpus, diffs stored `version` watermarks, and
|
|
6
|
+
* re-reads only new or changed sessions. Per-session failures are isolated and
|
|
7
|
+
* reported; one broken log never stalls the whole sync.
|
|
8
|
+
*/
|
|
9
|
+
import type { SwitchIndexEngine } from './engine.ts'
|
|
10
|
+
import { extractTitleFromEvents, type SwitchRawEvent } from './extract.ts'
|
|
11
|
+
|
|
12
|
+
/** 标题折叠的分块大小(一次 readTitleSnapshots 的 id 数上限,防整批一坏全坏)。 */
|
|
13
|
+
const TITLE_CHUNK = 50
|
|
14
|
+
|
|
15
|
+
/** The sessionQuery faces the sync reads (structural mirrors). */
|
|
16
|
+
export interface SwitchSyncSessionQuery {
|
|
17
|
+
listSessions(): Promise<readonly { header: { id: string; version: number; createdAt?: number; cwd?: string } }[]>
|
|
18
|
+
readSession(sessionId: string): Promise<{
|
|
19
|
+
session: { id: string; version: number; createdAt?: number; cwd?: string }
|
|
20
|
+
events: readonly SwitchRawEvent[]
|
|
21
|
+
}>
|
|
22
|
+
readTitleSnapshots?(sessionIds: readonly string[]): Promise<readonly {
|
|
23
|
+
status: 'fulfilled' | 'rejected'
|
|
24
|
+
value?: { session: { id: string }; title?: { title: string } }
|
|
25
|
+
}[]>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Live sync progress reported to the status endpoint. */
|
|
29
|
+
export interface SwitchSyncState {
|
|
30
|
+
/** What the syncer is doing right now. */
|
|
31
|
+
state: 'idle' | 'syncing' | 'error'
|
|
32
|
+
/** Epoch ms of the last completed pass. */
|
|
33
|
+
lastSyncAt: number
|
|
34
|
+
/** Sessions currently in the index. */
|
|
35
|
+
indexed: number
|
|
36
|
+
/** Sessions seen in the corpus at the last pass. */
|
|
37
|
+
total: number
|
|
38
|
+
/** Sessions re-read in the last pass. */
|
|
39
|
+
updated: number
|
|
40
|
+
/** Per-session failures from the last pass. */
|
|
41
|
+
failures: { sessionId: string; error: string }[]
|
|
42
|
+
/** Last pass error (whole-pass abort), if any. */
|
|
43
|
+
error?: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The official archive-set face (workspaceRegistry mirror): read-only.
|
|
48
|
+
* Resolved lazily per pass — the registry may mount after this plugin.
|
|
49
|
+
*/
|
|
50
|
+
export interface SwitchArchiveSource {
|
|
51
|
+
readonly archivedSessionIds: readonly string[]
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* One watermark syncer bound to one open engine. `poll()` is re-entrant-safe:
|
|
56
|
+
* overlapping calls collapse into the running pass.
|
|
57
|
+
*/
|
|
58
|
+
export class SwitchWatermarkSync {
|
|
59
|
+
private running: Promise<SwitchSyncState> | undefined
|
|
60
|
+
private readonly state: SwitchSyncState = {
|
|
61
|
+
state: 'idle',
|
|
62
|
+
lastSyncAt: 0,
|
|
63
|
+
indexed: 0,
|
|
64
|
+
total: 0,
|
|
65
|
+
updated: 0,
|
|
66
|
+
failures: [],
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* @param readSessionFromFile - 可选的**文件级兜底读取器**(管家 health 侧的
|
|
71
|
+
* 多帧 zstd 读取器):sessionQuery.readSession 对某些会话(典型:归档会话,
|
|
72
|
+
* 或服务面退化)失败时改读转录文件。返回 undefined 表示文件也不可用。
|
|
73
|
+
*/
|
|
74
|
+
constructor(
|
|
75
|
+
private readonly engine: SwitchIndexEngine,
|
|
76
|
+
private readonly sessionQuery: SwitchSyncSessionQuery,
|
|
77
|
+
private readonly readArchiveSource?: () => SwitchArchiveSource | undefined,
|
|
78
|
+
private readonly log?: (msg: string) => void,
|
|
79
|
+
private readonly readSessionFromFile?: (sessionId: string) => Promise<{
|
|
80
|
+
session: { id: string; version: number; createdAt?: number; cwd?: string }
|
|
81
|
+
events: readonly SwitchRawEvent[]
|
|
82
|
+
} | undefined>,
|
|
83
|
+
/**
|
|
84
|
+
* 第三标题源(可选):官方投影缓存里的 title 行。事件流没有 session/title
|
|
85
|
+
* 的老会话,快照服务又整片不可用时,这是最后兜底。
|
|
86
|
+
*/
|
|
87
|
+
private readonly readProjectionTitle?: (sessionId: string) => string,
|
|
88
|
+
) {
|
|
89
|
+
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* 读取一个会话的日志:服务面优先,失败落文件兜底。两条路都失败时抛最后
|
|
94
|
+
* 一个错误,由调用方按会话隔离记失败。
|
|
95
|
+
*/
|
|
96
|
+
private async readSessionLog(header: { id: string; version: number; createdAt?: number; cwd?: string }): Promise<{
|
|
97
|
+
session: { id: string; version: number; createdAt?: number; cwd?: string }
|
|
98
|
+
events: readonly SwitchRawEvent[]
|
|
99
|
+
}> {
|
|
100
|
+
try {
|
|
101
|
+
return await this.sessionQuery.readSession(header.id)
|
|
102
|
+
} catch (serviceError) {
|
|
103
|
+
if (this.readSessionFromFile === undefined) throw serviceError
|
|
104
|
+
const fromFile = await this.readSessionFromFile(header.id)
|
|
105
|
+
if (fromFile !== undefined) {
|
|
106
|
+
this.log?.(`session ${header.id}: sessionQuery read failed, served from transcript file (${String(serviceError instanceof Error ? serviceError.message : serviceError)})`)
|
|
107
|
+
return fromFile
|
|
108
|
+
}
|
|
109
|
+
throw serviceError
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Current progress snapshot (cloned). */
|
|
114
|
+
snapshot(): SwitchSyncState {
|
|
115
|
+
return { ...this.state, failures: [...this.state.failures] }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Fold titles for an explicit id set without running a full pass.
|
|
120
|
+
*
|
|
121
|
+
* Used by the `session/title` event listener so a rename lands immediately
|
|
122
|
+
* rather than at the next poll. It deliberately leaves watermarks alone: a
|
|
123
|
+
* title-only refresh can never make the index claim content it has not read,
|
|
124
|
+
* and the next poll still re-ingests the session off its bumped version.
|
|
125
|
+
* Safe for unknown ids — the header write is a no-op when no row exists.
|
|
126
|
+
* @param sessionIds - sessions whose titles should be re-folded.
|
|
127
|
+
*/
|
|
128
|
+
async refreshTitles(sessionIds: readonly string[]): Promise<void> {
|
|
129
|
+
await this.backfillTitles(sessionIds)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Run one incremental pass (or await the running one).
|
|
134
|
+
* @returns the state after the pass completes.
|
|
135
|
+
*/
|
|
136
|
+
poll(): Promise<SwitchSyncState> {
|
|
137
|
+
if (this.running !== undefined) return this.running
|
|
138
|
+
this.running = this.runPass().finally(() => {
|
|
139
|
+
this.running = undefined
|
|
140
|
+
})
|
|
141
|
+
return this.running
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
private async runPass(): Promise<SwitchSyncState> {
|
|
145
|
+
this.state.state = 'syncing'
|
|
146
|
+
const passStart = Date.now()
|
|
147
|
+
try {
|
|
148
|
+
const records = await this.sessionQuery.listSessions()
|
|
149
|
+
this.state.total = records.length
|
|
150
|
+
// 官方归档集合:纯 flag 翻转(R1 合表复用语义——归档保留 docs,恢复不重灌)。
|
|
151
|
+
// Registry absent -> no-op。
|
|
152
|
+
const archiveSource = this.readArchiveSource?.()
|
|
153
|
+
const archivedSet = new Set(archiveSource?.archivedSessionIds ?? [])
|
|
154
|
+
this.engine.setArchived(archivedSet)
|
|
155
|
+
const failures: { sessionId: string; error: string }[] = []
|
|
156
|
+
let updated = 0
|
|
157
|
+
const changedIds: string[] = []
|
|
158
|
+
for (const record of records) {
|
|
159
|
+
const header = record.header
|
|
160
|
+
const existing = this.engine.getSession(header.id)
|
|
161
|
+
// 归档与活跃同一读取通路:readSession 对归档会话是 replay-validate 不激活
|
|
162
|
+
// (宿主 session-query 保证)。归档正文因此入索引(可被 archived 域检索)。
|
|
163
|
+
if (existing !== undefined && existing.version === header.version) continue
|
|
164
|
+
changedIds.push(header.id)
|
|
165
|
+
try {
|
|
166
|
+
const log = await this.readSessionLog(header)
|
|
167
|
+
this.engine.upsertSession({
|
|
168
|
+
sessionId: header.id,
|
|
169
|
+
version: header.version,
|
|
170
|
+
cwd: log.session.cwd ?? header.cwd ?? '',
|
|
171
|
+
updatedAt: log.session.createdAt ?? header.createdAt ?? 0,
|
|
172
|
+
// 标题基础层:事件流白拿(归档 id 也能拿到);快照折叠只做精修。
|
|
173
|
+
title: extractTitleFromEvents(log.events),
|
|
174
|
+
events: log.events,
|
|
175
|
+
// 新行插入时带上归档标记(ON CONFLICT 不动旧行的 flag,翻转仍归
|
|
176
|
+
// setArchived)—— 免掉"插行要等下一轮 setArchived 才翻 flag"的单轮滞后。
|
|
177
|
+
archived: archivedSet.has(header.id),
|
|
178
|
+
})
|
|
179
|
+
updated += 1
|
|
180
|
+
} catch (err) {
|
|
181
|
+
failures.push({ sessionId: header.id, error: String(err instanceof Error ? err.message : err) })
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
// 归档覆盖:某些宿主线的 listSessions 可能不含归档会话 —— 归档集合里的
|
|
185
|
+
// id 若还没有索引行,这里显式补齐(readSession 对归档是 replay-validate,
|
|
186
|
+
// 失败时走文件兜底)。
|
|
187
|
+
for (const archivedId of archivedSet) {
|
|
188
|
+
if (this.engine.getSession(archivedId) !== undefined) continue
|
|
189
|
+
try {
|
|
190
|
+
const log = await this.readSessionLog({ id: archivedId, version: -1 })
|
|
191
|
+
this.engine.upsertSession({
|
|
192
|
+
sessionId: archivedId,
|
|
193
|
+
version: log.session.version,
|
|
194
|
+
cwd: log.session.cwd ?? '',
|
|
195
|
+
updatedAt: log.session.createdAt ?? 0,
|
|
196
|
+
title: extractTitleFromEvents(log.events),
|
|
197
|
+
events: log.events,
|
|
198
|
+
archived: true,
|
|
199
|
+
})
|
|
200
|
+
updated += 1
|
|
201
|
+
} catch (err) {
|
|
202
|
+
failures.push({ sessionId: archivedId, error: `archived: ${String(err instanceof Error ? err.message : err)}` })
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
// Drop sessions that vanished from the corpus —— 但**归档集合里的行不删**:
|
|
206
|
+
// 某些宿主线的 listSessions 可能过滤归档,归档集合才是删除真值(purge 会把
|
|
207
|
+
// id 从归档数组里摘掉,彼时下一轮自然清行)。
|
|
208
|
+
const corpusIds = new Set(records.map(record => record.header.id))
|
|
209
|
+
for (const indexed of this.engine.listIndexedSessions()) {
|
|
210
|
+
if (!corpusIds.has(indexed.sessionId) && !archivedSet.has(indexed.sessionId)) {
|
|
211
|
+
this.engine.removeSession(indexed.sessionId)
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
await this.backfillTitles(changedIds)
|
|
215
|
+
// 空标题扫尾:重建行/老会话可能没有 session/title 事件,快照精修后仍空
|
|
216
|
+
// 的行用投影缓存兜底。每轮封顶 100 行,标题落地后集合自然收缩。
|
|
217
|
+
const emptyTitles = this.engine.listIndexedSessions()
|
|
218
|
+
.filter(session => session.title.trim() === '')
|
|
219
|
+
.map(session => session.sessionId)
|
|
220
|
+
.slice(0, 100)
|
|
221
|
+
if (emptyTitles.length > 0) {
|
|
222
|
+
await this.backfillTitles(emptyTitles)
|
|
223
|
+
let filled = 0
|
|
224
|
+
for (const id of emptyTitles) {
|
|
225
|
+
const row = this.engine.getSession(id)
|
|
226
|
+
if (row !== undefined && row.title.trim() !== '') continue
|
|
227
|
+
const fromProjection = this.readProjectionTitle?.(id) ?? ''
|
|
228
|
+
if (fromProjection.trim() !== '') {
|
|
229
|
+
this.engine.updateSessionHeader({ sessionId: id, title: fromProjection })
|
|
230
|
+
filled += 1
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
if (filled > 0) this.log?.(`title sweep: ${filled}/${emptyTitles.length} filled from projection cache`)
|
|
234
|
+
}
|
|
235
|
+
this.state.updated = updated
|
|
236
|
+
this.state.failures = failures
|
|
237
|
+
this.state.indexed = this.engine.countSessions()
|
|
238
|
+
this.state.lastSyncAt = Date.now()
|
|
239
|
+
this.state.state = 'idle'
|
|
240
|
+
this.state.error = undefined
|
|
241
|
+
this.log?.(`sync pass: scanned=${this.state.total} updated=${updated} archived=${archivedSet.size} failures=${failures.length} indexed=${this.state.indexed} duration=${Date.now() - passStart}ms driver=${this.engine.driverLabel}`)
|
|
242
|
+
} catch (err) {
|
|
243
|
+
this.state.state = 'error'
|
|
244
|
+
this.state.error = String(err instanceof Error ? err.message : err)
|
|
245
|
+
this.log?.(`sync pass FAILED: ${this.state.error}`)
|
|
246
|
+
}
|
|
247
|
+
return this.snapshot()
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Fold latest titles for changed sessions into the index header rows.
|
|
252
|
+
*
|
|
253
|
+
* beta.4 教训:整批一次调用,任何一个坏 id(典型:归档 id)让整个 promise
|
|
254
|
+
* reject,catch 一吞就是**全部**标题丢失。改为分块 + 块失败时逐 id 重试 ——
|
|
255
|
+
* 单点坏 id 最多损失它自己的精修标题(入索引时的 extractTitleFromEvents
|
|
256
|
+
* 基础层仍然在)。
|
|
257
|
+
*/
|
|
258
|
+
private async backfillTitles(sessionIds: readonly string[]): Promise<void> {
|
|
259
|
+
const readTitles = this.sessionQuery.readTitleSnapshots
|
|
260
|
+
if (readTitles === undefined || sessionIds.length === 0) return
|
|
261
|
+
const ids = [...new Set(sessionIds)]
|
|
262
|
+
const foldOne = (observation: { status: 'fulfilled' | 'rejected'; value?: { session: { id: string }; title?: { title: string } } }): void => {
|
|
263
|
+
if (observation.status !== 'fulfilled' || observation.value === undefined) return
|
|
264
|
+
const title = observation.value.title?.title
|
|
265
|
+
// 归档行的标题同样折叠(归档会话仍可改名,索引标题要跟上)。
|
|
266
|
+
if (typeof title === 'string' && title.trim().length > 0) {
|
|
267
|
+
this.engine.updateSessionHeader({ sessionId: observation.value.session.id, title })
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
for (let start = 0; start < ids.length; start += TITLE_CHUNK) {
|
|
271
|
+
const chunk = ids.slice(start, start + TITLE_CHUNK)
|
|
272
|
+
try {
|
|
273
|
+
const observations = await readTitles(chunk)
|
|
274
|
+
observations.forEach(foldOne)
|
|
275
|
+
} catch {
|
|
276
|
+
// 整块失败 → 逐 id 重试,坏 id 只损失自己。
|
|
277
|
+
for (const id of chunk) {
|
|
278
|
+
try {
|
|
279
|
+
const observations = await readTitles([id])
|
|
280
|
+
observations.forEach(foldOne)
|
|
281
|
+
} catch { /* 该 id 的精修标题放弃,基础层标题仍在 */ }
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 替换官方 workspace 注册表的管家子类(P1 服务替换,AM 架构轻量版)。
|
|
3
|
+
*
|
|
4
|
+
* cordis.patch.yml 禁用官方 `workspace` 行并插入本类(`dsh-manage-session/registry`):
|
|
5
|
+
* - 归档/取消归档/记账完全继承官方行为(setState 自带持久化,免重启);
|
|
6
|
+
* - 覆写 sessionKnown / indexHeader 挂**墓碑**:本进程已物理删除的会话不被
|
|
7
|
+
* stale `persistence.list()` 重新编入索引、不被重新写回归档集合;
|
|
8
|
+
* - 冷复用探针:其他进程以同 id 重建且日志身份不同(createdAt/cwd)的新会话
|
|
9
|
+
* 撤墓碑放行,避免误挡;
|
|
10
|
+
* - `deleteSession`:全痕删除七步 —— 实时 flush/detach → 等投影写尾 → 删缓存
|
|
11
|
+
* 文件 → subagent 后代级联(fork 不级联)→ 清 spill → 删转录目录(越界防护)
|
|
12
|
+
* → 记账清理(归档集合 + 工作区成员表)+ 父类内存索引遗忘。物理成功才提交
|
|
13
|
+
* 记账,失败保留现场可重试。
|
|
14
|
+
*
|
|
15
|
+
* 已知边界:官方投影缓存服务无 delete API,缓存行走"等写尾 + 删文件"直删
|
|
16
|
+
* (官方缓存 fail-soft 自愈兜底);whenIdle 只覆盖本类感知的在途写。
|
|
17
|
+
*
|
|
18
|
+
* 类型策略(AM 同款):官方把 sessionKnown/indexHeader/requireState 等声明为
|
|
19
|
+
* TS private,子类用结构化镜像接口松化基类构造器 —— 运行时成员逐一存在
|
|
20
|
+
* (0.1.7/0.2.0 实证),逐段 try/catch 容忍老线形状漂移。
|
|
21
|
+
*/
|
|
22
|
+
import { rmSync } from 'node:fs'
|
|
23
|
+
import { join } from 'node:path'
|
|
24
|
+
import type { Context } from 'cordis'
|
|
25
|
+
import { WorkspaceRegistry } from '@deepseek-ai/dsh-workspace'
|
|
26
|
+
import { dirKey, isSafeChild, locateSessionUsage, projCacheRootFor, sessionsRootFor } from '../history/purge.ts'
|
|
27
|
+
|
|
28
|
+
/** 官方 sessions 服务面(结构化镜像,全线存在)。 */
|
|
29
|
+
interface RegistrySessionsFace {
|
|
30
|
+
get?(id: string): { id?: string; header?: StewardHeaderLike } | undefined
|
|
31
|
+
list?(): { id?: string; header?: StewardHeaderLike }[]
|
|
32
|
+
flush?(live: unknown): Promise<void>
|
|
33
|
+
liveEntryFor?(live: unknown): unknown
|
|
34
|
+
detachEntered?(entry: unknown): void
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 会话头部子集(父类内部 headers / persistence.list 的公共形状)。 */
|
|
38
|
+
interface StewardHeaderLike {
|
|
39
|
+
id?: string
|
|
40
|
+
createdAt?: number
|
|
41
|
+
cwd?: string
|
|
42
|
+
parentSession?: string
|
|
43
|
+
origin?: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 父类运行时面的结构化镜像:private 成员在此公开化。成员按 0.1.7/0.2.0
|
|
48
|
+
* 实测形状声明;调用点逐段 try/catch,老线缺失时删除路径报错可重试。
|
|
49
|
+
*/
|
|
50
|
+
interface StewardRegistryCompat {
|
|
51
|
+
ctx: Context
|
|
52
|
+
headers?: Map<string, StewardHeaderLike>
|
|
53
|
+
sessionPaths?: Map<string, unknown>
|
|
54
|
+
invalidSessionPaths?: Set<string>
|
|
55
|
+
entities?: Map<string, { record?: unknown }>
|
|
56
|
+
sessionKnown(id: string): Promise<boolean>
|
|
57
|
+
indexHeader(header: StewardHeaderLike): Promise<void>
|
|
58
|
+
listStoredHeaders?(): Promise<StewardHeaderLike[]>
|
|
59
|
+
requireState?(): { archivedSessionIds?: string[]; workspaceIds?: readonly string[] }
|
|
60
|
+
setState?(state: unknown): Promise<void>
|
|
61
|
+
requireTable?(): {
|
|
62
|
+
get?(id: string): { sessionIds?: string[] } | undefined
|
|
63
|
+
update?(id: string, fn: (current: { sessionIds: string[] }) => unknown): Promise<unknown>
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
type StewardWorkspaceConstructor = { new (ctx: Context): StewardRegistryCompat }
|
|
68
|
+
|
|
69
|
+
const TOMBSTONE_LIMIT = 4096
|
|
70
|
+
|
|
71
|
+
export const StewardWorkspaceRegistry = class extends (WorkspaceRegistry as unknown as StewardWorkspaceConstructor) {
|
|
72
|
+
static inject = ['storageDomain', 'sessionPersistence']
|
|
73
|
+
|
|
74
|
+
/** 本进程已物理删除的会话墓碑(FIFO 上限淘汰,宁多挡不错放)。 */
|
|
75
|
+
deletedSessionIds = new Set<string>()
|
|
76
|
+
deletedSessionOrder: string[] = []
|
|
77
|
+
/** 被删生命周期的日志身份:冷复用探针区分"同 id 新会话"与 stale list()。 */
|
|
78
|
+
deletedIdentities = new Map<string, { createdAt?: number; cwd?: string }>()
|
|
79
|
+
/** 转录/缓存文件定位根(apply 装配时注入;缺失时文件删除跳过并记日志)。 */
|
|
80
|
+
dshHome = ''
|
|
81
|
+
|
|
82
|
+
private log(message: string): void {
|
|
83
|
+
try {
|
|
84
|
+
(this.ctx as unknown as { logger?: { info?: (m: string) => void; warn?: (m: string) => void } })
|
|
85
|
+
.logger?.info?.(`[session-steward] ${message}`)
|
|
86
|
+
} catch { /* 日志绝不破坏宿主 */ }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** ── 墓碑层:覆写父类的已知性判定与头部索引 ── */
|
|
90
|
+
|
|
91
|
+
async sessionKnown(id: string): Promise<boolean> {
|
|
92
|
+
const sessions = this.ctx.get('sessions') as { get?: (id: string) => unknown } | undefined
|
|
93
|
+
if (sessions?.get?.(id) !== undefined && sessions.get!(id) !== null) {
|
|
94
|
+
this.clearTombstone(id)
|
|
95
|
+
return true
|
|
96
|
+
}
|
|
97
|
+
if (this.deletedSessionIds.has(id)) return await this.coldReuseKnown(id)
|
|
98
|
+
return await super.sessionKnown(id)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** 冷复用探针:同 id 重建且日志身份不同 → 撤墓碑重新编入;身份不可考则保守维持未知。 */
|
|
102
|
+
private async coldReuseKnown(id: string): Promise<boolean> {
|
|
103
|
+
const deleted = this.deletedIdentities.get(id)
|
|
104
|
+
if (deleted === undefined) return false
|
|
105
|
+
const headers = await this.allStoredHeaders()
|
|
106
|
+
const header = headers.find(item => item.id === id)
|
|
107
|
+
if (header === undefined) return false
|
|
108
|
+
if (header.createdAt === deleted.createdAt && header.cwd === deleted.cwd) return false
|
|
109
|
+
this.clearTombstone(id)
|
|
110
|
+
try {
|
|
111
|
+
await this.indexHeader(header)
|
|
112
|
+
} catch { /* 重编入失败不阻断已知性 */ }
|
|
113
|
+
return true
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async indexHeader(header: StewardHeaderLike): Promise<void> {
|
|
117
|
+
if (header.id !== undefined && this.deletedSessionIds.has(header.id)) return
|
|
118
|
+
await super.indexHeader(header)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
clearTombstone(sessionId: string): void {
|
|
122
|
+
this.deletedSessionIds.delete(sessionId)
|
|
123
|
+
this.deletedIdentities.delete(sessionId)
|
|
124
|
+
const idx = this.deletedSessionOrder.indexOf(sessionId)
|
|
125
|
+
if (idx >= 0) this.deletedSessionOrder.splice(idx, 1)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** ── 全痕删除 ── */
|
|
129
|
+
|
|
130
|
+
/** 逐会话入口:任何一步失败都返回 ok:false + error,记账保留现场可重试。 */
|
|
131
|
+
async deleteSession(sessionId: string): Promise<{ ok: boolean; skipped?: boolean; error?: string }> {
|
|
132
|
+
try {
|
|
133
|
+
return await this.deleteCore(sessionId)
|
|
134
|
+
} catch (err) {
|
|
135
|
+
const message = String(err instanceof Error ? err.message : err)
|
|
136
|
+
this.log(`delete ${sessionId} failed: ${message}`)
|
|
137
|
+
return { ok: false, error: message }
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private async deleteCore(sessionId: string, descendantHeaders?: StewardHeaderLike[]): Promise<{ ok: boolean; skipped?: boolean }> {
|
|
142
|
+
if (!(await this.sessionKnown(sessionId))) {
|
|
143
|
+
// 未知会话:清掉可能残留的记账(归档集合/工作区表),不留僵尸 id。
|
|
144
|
+
await this.forgetBookkeeping(sessionId)
|
|
145
|
+
this.log(`delete ${sessionId}: unknown session, residual bookkeeping cleared`)
|
|
146
|
+
return { ok: true, skipped: true }
|
|
147
|
+
}
|
|
148
|
+
const sessions = this.ctx.get('sessions') as RegistrySessionsFace | undefined
|
|
149
|
+
const live = sessions?.get?.(sessionId)
|
|
150
|
+
const deletedHeader = this.headers?.get(sessionId) ?? live?.header
|
|
151
|
+
if (live !== undefined) {
|
|
152
|
+
// 持久化屏障先行:flush 过的会话不再持有打开的日志文件,目录删除不竞争。
|
|
153
|
+
await sessions!.flush!(live)
|
|
154
|
+
const entry = sessions!.liveEntryFor!(live)
|
|
155
|
+
sessions!.detachEntered!(entry)
|
|
156
|
+
}
|
|
157
|
+
// 投影缓存:等 dispose 写尾落盘,再直删缓存文件(官方服务无 delete API)。
|
|
158
|
+
const projCache = this.ctx.get('sessionProjectionCache') as { whenIdle?: () => Promise<void> } | undefined
|
|
159
|
+
await projCache?.whenIdle?.()
|
|
160
|
+
this.deleteProjectionCacheFile(sessionId)
|
|
161
|
+
await this.deleteDescendants(sessionId, descendantHeaders)
|
|
162
|
+
await this.cleanSpill(sessionId)
|
|
163
|
+
this.removeTranscriptDirectory(sessionId)
|
|
164
|
+
// 物理工件删除成功后才提交记账清理;失败时保留归档标记/成员表以便重试。
|
|
165
|
+
await this.forgetBookkeeping(sessionId)
|
|
166
|
+
this.forgetIndexedSession(sessionId)
|
|
167
|
+
if (deletedHeader !== undefined) {
|
|
168
|
+
this.deletedIdentities.set(sessionId, { createdAt: deletedHeader.createdAt, cwd: deletedHeader.cwd })
|
|
169
|
+
}
|
|
170
|
+
try {
|
|
171
|
+
(this.ctx as unknown as { emit?: (name: string, payload?: unknown) => void }).emit?.('api-session/removed', sessionId)
|
|
172
|
+
} catch { /* 通知失败不阻断删除结论 */ }
|
|
173
|
+
return { ok: true }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** subagent 后代级联:live 列表优先,stored headers 兜底;fork(parentSession 有值但 origin 非 subagent)不级联。 */
|
|
177
|
+
private async deleteDescendants(sessionId: string, storedHeaders?: StewardHeaderLike[]): Promise<void> {
|
|
178
|
+
try {
|
|
179
|
+
const descendants: string[] = []
|
|
180
|
+
const sessions = this.ctx.get('sessions') as RegistrySessionsFace & { list?: () => { id?: string; header?: StewardHeaderLike }[] } | undefined
|
|
181
|
+
const listed = typeof sessions?.list === 'function' ? sessions.list() : []
|
|
182
|
+
for (const session of listed) {
|
|
183
|
+
const header = session?.header
|
|
184
|
+
if (header?.parentSession === sessionId && header.origin === 'subagent' && typeof session.id === 'string') {
|
|
185
|
+
descendants.push(session.id)
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (descendants.length === 0) {
|
|
189
|
+
const headers = storedHeaders ?? await this.allStoredHeaders()
|
|
190
|
+
for (const header of headers) {
|
|
191
|
+
if (header.parentSession === sessionId && header.origin === 'subagent' && typeof header.id === 'string') {
|
|
192
|
+
descendants.push(header.id)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
for (const child of descendants) {
|
|
197
|
+
if (child === sessionId) continue
|
|
198
|
+
await this.deleteCore(child, storedHeaders)
|
|
199
|
+
}
|
|
200
|
+
if (descendants.length > 0) this.log(`delete ${sessionId}: cascaded ${descendants.length} subagent descendant(s)`)
|
|
201
|
+
} catch (err) {
|
|
202
|
+
this.log(`descendant cascade for ${sessionId} failed: ${String(err instanceof Error ? err.message : err)}`)
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** 父类 listStoredHeaders(0.1.5+)→ sessionPersistence.list 兜底 → 空数组。 */
|
|
207
|
+
private async allStoredHeaders(): Promise<StewardHeaderLike[]> {
|
|
208
|
+
try {
|
|
209
|
+
const listed = await this.listStoredHeaders?.()
|
|
210
|
+
if (Array.isArray(listed)) return listed
|
|
211
|
+
} catch { /* 老线无此方法 → 兜底 */ }
|
|
212
|
+
try {
|
|
213
|
+
const persistence = this.ctx.get('sessionPersistence') as { list?: () => Promise<StewardHeaderLike[]> } | undefined
|
|
214
|
+
const listed = await persistence?.list?.()
|
|
215
|
+
if (Array.isArray(listed)) return listed
|
|
216
|
+
} catch { /* 读不到就当空 */ }
|
|
217
|
+
return []
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** spill 目录直删(sessionDir(root, sessionId) — spill-local 包自己的签名)。 */
|
|
221
|
+
private async cleanSpill(sessionId: string): Promise<void> {
|
|
222
|
+
try {
|
|
223
|
+
const spill = this.ctx.get('spillStore') as { root?: string } | undefined
|
|
224
|
+
if (spill === undefined || typeof spill.root !== 'string') return
|
|
225
|
+
const spec = '@deepseek-ai/dsh-spill-local'
|
|
226
|
+
const mod = (await import(spec)) as { sessionDir?: (root: string, id: string) => string }
|
|
227
|
+
if (typeof mod.sessionDir !== 'function') return
|
|
228
|
+
rmSync(mod.sessionDir(spill.root, sessionId), { recursive: true, force: true })
|
|
229
|
+
} catch (err) {
|
|
230
|
+
this.log(`spill cleanup for ${sessionId} failed: ${String(err instanceof Error ? err.message : err)}`)
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** 转录目录删除:文件扫描定位(与文件路径同一套越界防护)。 */
|
|
235
|
+
private removeTranscriptDirectory(sessionId: string): void {
|
|
236
|
+
if (this.dshHome === '') {
|
|
237
|
+
this.log(`transcript deletion for ${sessionId} skipped: dshHome not wired`)
|
|
238
|
+
return
|
|
239
|
+
}
|
|
240
|
+
const usage = locateSessionUsage([sessionId], this.dshHome)
|
|
241
|
+
const entry = usage.get(sessionId)
|
|
242
|
+
if (entry?.dir === undefined) return
|
|
243
|
+
if (!isSafeChild(sessionsRootFor(this.dshHome), entry.dir, 2)) {
|
|
244
|
+
this.log(`refusing out-of-root transcript path: ${entry.dir}`)
|
|
245
|
+
return
|
|
246
|
+
}
|
|
247
|
+
rmSync(entry.dir, { recursive: true, force: true })
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** 缓存文件直删:双候选文件名(带/不带 session- 前缀,与 purge.ts 同纪律)。 */
|
|
251
|
+
private deleteProjectionCacheFile(sessionId: string): void {
|
|
252
|
+
if (this.dshHome === '') return
|
|
253
|
+
const cacheRoot = projCacheRootFor(this.dshHome)
|
|
254
|
+
for (const name of [`${sessionId}.json`, `${dirKey(sessionId)}.json`]) {
|
|
255
|
+
const candidate = join(cacheRoot, name)
|
|
256
|
+
if (!isSafeChild(cacheRoot, candidate, 1)) continue
|
|
257
|
+
try {
|
|
258
|
+
rmSync(candidate, { force: true })
|
|
259
|
+
} catch { /* 竞态:文件刚被删 */ }
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** 记账清理:归档集合(setState)+ 工作区成员表(requireTable)。逐段容错。 */
|
|
264
|
+
private async forgetBookkeeping(sessionId: string): Promise<void> {
|
|
265
|
+
try {
|
|
266
|
+
const state = this.requireState?.()
|
|
267
|
+
if (state?.archivedSessionIds !== undefined && this.setState !== undefined
|
|
268
|
+
&& Array.isArray(state.archivedSessionIds) && state.archivedSessionIds.includes(sessionId)) {
|
|
269
|
+
await this.setState({
|
|
270
|
+
...state,
|
|
271
|
+
archivedSessionIds: state.archivedSessionIds.filter(id => id !== sessionId),
|
|
272
|
+
})
|
|
273
|
+
}
|
|
274
|
+
} catch (err) {
|
|
275
|
+
this.log(`archive-set bookkeeping cleanup for ${sessionId} failed: ${String(err instanceof Error ? err.message : err)}`)
|
|
276
|
+
}
|
|
277
|
+
try {
|
|
278
|
+
const table = this.requireTable?.()
|
|
279
|
+
const state = this.requireState?.()
|
|
280
|
+
const workspaceIds = state?.workspaceIds
|
|
281
|
+
if (table?.update === undefined || !Array.isArray(workspaceIds)) return
|
|
282
|
+
for (const workspaceId of workspaceIds) {
|
|
283
|
+
const record = table.get?.(workspaceId)
|
|
284
|
+
if (record === undefined || !Array.isArray(record.sessionIds) || !record.sessionIds.includes(sessionId)) continue
|
|
285
|
+
const next = await table.update(workspaceId, (current: { sessionIds: string[] }) => ({
|
|
286
|
+
...current,
|
|
287
|
+
sessionIds: current.sessionIds.filter(id => id !== sessionId),
|
|
288
|
+
}))
|
|
289
|
+
const entity = this.entities?.get(workspaceId)
|
|
290
|
+
if (entity !== undefined) entity.record = next
|
|
291
|
+
}
|
|
292
|
+
} catch (err) {
|
|
293
|
+
this.log(`workspace bookkeeping cleanup for ${sessionId} failed: ${String(err instanceof Error ? err.message : err)}`)
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** 父类内存索引遗忘 + 墓碑登记(FIFO 上限淘汰)。 */
|
|
298
|
+
private forgetIndexedSession(sessionId: string): void {
|
|
299
|
+
if (!this.deletedSessionIds.has(sessionId)) {
|
|
300
|
+
this.deletedSessionIds.add(sessionId)
|
|
301
|
+
this.deletedSessionOrder.push(sessionId)
|
|
302
|
+
}
|
|
303
|
+
while (this.deletedSessionOrder.length > TOMBSTONE_LIMIT) {
|
|
304
|
+
const oldest = this.deletedSessionOrder.shift()
|
|
305
|
+
// id 自身被挤出队列时保留在集合中:宁多挡不错放。
|
|
306
|
+
if (oldest !== undefined && oldest !== sessionId) {
|
|
307
|
+
this.deletedSessionIds.delete(oldest)
|
|
308
|
+
this.deletedIdentities.delete(oldest)
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
try { this.headers?.delete(sessionId) } catch { /* 内部形状漂移容忍 */ }
|
|
312
|
+
try { this.sessionPaths?.delete?.(sessionId) } catch { /* 同上 */ }
|
|
313
|
+
try { this.invalidSessionPaths?.delete?.(sessionId) } catch { /* 同上 */ }
|
|
314
|
+
}
|
|
315
|
+
} as unknown as typeof WorkspaceRegistry
|
|
316
|
+
|
|
317
|
+
export type StewardWorkspaceRegistry = WorkspaceRegistry
|