dsh-manage-session 2.0.3 → 2.0.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/CHANGELOG.ja.md +33 -33
- package/CHANGELOG.ko.md +33 -33
- package/CHANGELOG.md +33 -33
- package/INSTALL.ja.md +164 -164
- package/INSTALL.ko.md +164 -164
- package/INSTALL.md +164 -164
- package/INSTALL.zh.md +164 -164
- package/LICENSE +21 -21
- package/README.en.md +76 -62
- package/README.ja.md +53 -39
- package/README.ko.md +52 -38
- package/README.md +18 -4
- package/cordis.patch.yml +19 -19
- package/dsh.plugin.json +2 -2
- package/locale/de.json +6 -6
- package/locale/en.json +6 -6
- package/locale/es.json +6 -6
- package/locale/fr.json +6 -6
- package/locale/it.json +6 -6
- package/locale/ja.json +6 -6
- package/locale/ko.json +6 -6
- package/locale/ru.json +6 -6
- package/locale/zh.json +6 -6
- package/package.json +9 -9
- package/screenshots.json +3 -3
- package/src/client/bridge-scope.ts +202 -202
- package/src/client/card.tsx +154 -154
- package/src/client/host-api.ts +61 -61
- package/src/client/index.ts +594 -594
- package/src/client/locales.ts +893 -893
- package/src/client/manage-locales.ts +178 -178
- package/src/client/manage.tsx +430 -430
- package/src/client/panel.tsx +131 -131
- package/src/config.ts +43 -43
- package/src/host/health/attribution.ts +118 -118
- package/src/host/health/lossless.ts +128 -128
- package/src/host/health/source-kind.ts +350 -350
- package/src/host/history/archive-source.ts +250 -250
- package/src/host/history/archive.ts +249 -249
- package/src/host/history/favorites.ts +71 -71
- package/src/host/history/purge.ts +370 -370
- package/src/host/registry/archive-registry.ts +317 -317
- package/src/host/settings-bridge.ts +222 -222
|
@@ -1,317 +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?.(`[manage-session] ${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
|
|
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?.(`[manage-session] ${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
|