@ybbmm/dsh-session-delete 0.1.0 → 0.2.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 +83 -32
- package/client.js +217 -80
- package/index.js +482 -223
- package/package.json +5 -1
package/README.md
CHANGED
|
@@ -14,33 +14,61 @@ log accumulates on disk forever. This plugin adds the missing action.
|
|
|
14
14
|
|
|
15
15
|
| Where | What |
|
|
16
16
|
|---|---|
|
|
17
|
-
| Session **"…" menu** | A red **Delete conversation** row,
|
|
17
|
+
| Session **"…" menu** | A red **Delete conversation** row, after the shipped pin / rename / fork / archive rows |
|
|
18
18
|
| Session **row hover** | A trash button beside the shipped archive and pin buttons |
|
|
19
|
-
| **Confirmation dialog** | Shows the conversation title
|
|
19
|
+
| **Confirmation dialog** | Shows the conversation title, states that deletion is permanent, then reports the outcome in place |
|
|
20
20
|
|
|
21
|
-
Deleting the conversation that is currently open
|
|
22
|
-
|
|
21
|
+
Deleting the conversation that is currently open reopens a new session in the
|
|
22
|
+
same workspace; the dialog stays open just long enough to acknowledge the
|
|
23
|
+
result.
|
|
23
24
|
|
|
24
25
|
## What exactly is removed
|
|
25
26
|
|
|
26
27
|
| Artifact | Effect |
|
|
27
28
|
|---|---|
|
|
28
|
-
| Session log
|
|
29
|
-
| Projection cache record (`~/.dsh/storages/session_projcache/sessions/<id>.json`) |
|
|
29
|
+
| Session log artifact (`~/.dsh/sessions/<project-key>/<session-id>/`, log + lock file) | removed |
|
|
30
|
+
| Projection cache record (`~/.dsh/storages/session_projcache/sessions/<id>.json`) | dropped, then re-checked against a late checkpoint write |
|
|
30
31
|
| Workspace accounting — membership, saved order, pin, archive mark | released |
|
|
31
|
-
| Live session and agent store entries | detached *
|
|
32
|
+
| Live session and agent store entries | detached *before* the files are removed, so no writer re-creates the log |
|
|
32
33
|
| Attachments (`~/.dsh/attachments`) | **kept** — content-addressed and shared between sessions |
|
|
33
34
|
| Temp spill files | not touched (the host sweeps them by age) |
|
|
35
|
+
| Conversations forked *from* this one | **kept**; the dialog names how many still point at the deleted parent |
|
|
34
36
|
|
|
35
37
|
## Safety
|
|
36
38
|
|
|
39
|
+
Deletion is a transaction with one irreversible step, and that step runs
|
|
40
|
+
before anything becomes user-visible:
|
|
41
|
+
|
|
42
|
+
1. resolve the target (live or stored), 2. refuse while work is running,
|
|
43
|
+
3. stop and detach a live session, 4. **remove the log artifact**, 5. release
|
|
44
|
+
workspace accounting, 6. drop the projection cache, 7. announce the removal.
|
|
45
|
+
|
|
46
|
+
- **A failed removal changes nothing.** If the log cannot be removed (typically
|
|
47
|
+
a writer still closing), the request fails with `session-delete/io` *before*
|
|
48
|
+
accounting and cache are touched, so the row is still listed and retrying is
|
|
49
|
+
meaningful.
|
|
50
|
+
- **Retries only transient failures.** `EBUSY` / `EPERM` / `EACCES` /
|
|
51
|
+
`ENOTEMPTY` are retried with back-off (8 attempts, ~1.8 s); anything else
|
|
52
|
+
fails immediately instead of burning the budget.
|
|
37
53
|
- **Permanent.** There is no undo and no trash. Archiving is still the
|
|
38
54
|
reversible option and is untouched by this plugin.
|
|
39
|
-
- **Refuses while the conversation is busy.**
|
|
40
|
-
job or scheduled reminder
|
|
41
|
-
|
|
42
|
-
-
|
|
43
|
-
|
|
55
|
+
- **Refuses while the conversation is busy.** A running turn, subagent,
|
|
56
|
+
background job or scheduled reminder makes the Host reject the request, and
|
|
57
|
+
the dialog says why.
|
|
58
|
+
- **No double deletion.** A repeated click joins the deletion already in
|
|
59
|
+
flight instead of interleaving two runs.
|
|
60
|
+
|
|
61
|
+
## Error codes
|
|
62
|
+
|
|
63
|
+
The Host answers with stable codes; the dialog localizes them.
|
|
64
|
+
|
|
65
|
+
| Code | Meaning |
|
|
66
|
+
|---|---|
|
|
67
|
+
| `session-delete/active` | The conversation still owns running work |
|
|
68
|
+
| `session-delete/not-found` | It was already deleted (or never existed) |
|
|
69
|
+
| `session-delete/io` | The log could not be removed; nothing was changed |
|
|
70
|
+
| `session-delete/unsupported-store` | This build's store cannot name the artifact; nothing was changed |
|
|
71
|
+
| `session-delete/invalid-request` | Malformed request (defensive) |
|
|
44
72
|
|
|
45
73
|
## Install
|
|
46
74
|
|
|
@@ -52,11 +80,10 @@ Manually:
|
|
|
52
80
|
dsh plugin add @ybbmm/dsh-session-delete
|
|
53
81
|
```
|
|
54
82
|
|
|
55
|
-
From a local
|
|
83
|
+
From a local directory (development):
|
|
56
84
|
|
|
57
85
|
```bash
|
|
58
|
-
|
|
59
|
-
dsh plugin add ./ybbmm-dsh-session-delete-0.1.0.tgz
|
|
86
|
+
dsh plugin add ./session-delete
|
|
60
87
|
```
|
|
61
88
|
|
|
62
89
|
Restart the Harness if your profile does not hot-reload plugin bundles.
|
|
@@ -68,50 +95,74 @@ Restart the Harness if your profile does not hot-reload plugin bundles.
|
|
|
68
95
|
- **No runtime dependencies.** The bundle imports only `node:fs/promises` and
|
|
69
96
|
`node:path`, and loads nothing from `@deepseek-ai/*` at runtime.
|
|
70
97
|
- It deliberately builds on a few host internals: the session store and
|
|
71
|
-
persistence services,
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
version if you depend
|
|
98
|
+
persistence services, the workspace registry, the storage-domain projection
|
|
99
|
+
cache, and the API Gateway's runtime *SRC* dispatch for services that carry a
|
|
100
|
+
`typertRemote` binding plus Remote method markers. A host release that
|
|
101
|
+
reshapes those may need a plugin update — pin your host version if you depend
|
|
102
|
+
on this.
|
|
75
103
|
|
|
76
104
|
## How it works
|
|
77
105
|
|
|
78
106
|
- **Host half** (`index.js`) registers a `sessionDelete` service with a
|
|
79
107
|
`typertRemote` binding and a hand-written Remote method marker, which makes
|
|
80
108
|
`sessionDelete/delete` reachable through the Gateway without generated
|
|
81
|
-
Typert artifacts. The method
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
`locate()` path, drops the projection-cache row, and announces
|
|
85
|
-
`api-session/removed` so the sidebar drops the row.
|
|
109
|
+
Typert artifacts. The method implements the ordering above; the log is
|
|
110
|
+
located through the store's own `locate()`, which names the session directory
|
|
111
|
+
for the shipped JSONL backend and is honoured as-is for other layouts.
|
|
86
112
|
- **Browser half** (`client.js`) registers into
|
|
87
113
|
`sidebar.workspaces.session.menu.item`,
|
|
88
114
|
`sidebar.workspaces.session.row.action` and `shell.overlay`, and calls the
|
|
89
|
-
endpoint over the shared connection RPC.
|
|
115
|
+
endpoint over the shared connection RPC. The dialog is keyed by the Session
|
|
116
|
+
it deletes, so one request's in-flight/error/success state never leaks into
|
|
117
|
+
the next.
|
|
90
118
|
- Styles copy the host's own geometry, renamed under a plugin prefix, and use
|
|
91
119
|
only `--dsw-alias-*` theme tokens, so light/dark and theme switching follow
|
|
92
120
|
the app.
|
|
93
121
|
|
|
94
122
|
## Known limitations
|
|
95
123
|
|
|
96
|
-
- **No
|
|
97
|
-
|
|
124
|
+
- **No toast.** The workspace toast slot's key domain is closed to third-party
|
|
125
|
+
plugins; the dialog's in-place acknowledgement is the confirmation instead.
|
|
98
126
|
- **UI copy is English and Chinese only.**
|
|
99
127
|
- Deleting a conversation does not delete files the conversation *produced* in
|
|
100
128
|
your workspace — only the conversation itself.
|
|
129
|
+
- A conversation created but never written to disk has no artifact; deleting it
|
|
130
|
+
releases the row and reports `removed: false`, but the empty slot can linger
|
|
131
|
+
in the Host's in-memory pending list until the next restart.
|
|
132
|
+
- The search index (`dsh-session-query-sqlite`) reconciles against the
|
|
133
|
+
persistence listing on every query, so it drops the deleted conversation on
|
|
134
|
+
the next search rather than eagerly.
|
|
135
|
+
|
|
136
|
+
## Development
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
npm test # node --test: host ordering/retry/error tests + browser dialog tests
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
The tests run against the real modules with fake Cordis contexts and a minimal
|
|
143
|
+
React stand-in; they need no Harness installation.
|
|
101
144
|
|
|
102
145
|
## 中文说明
|
|
103
146
|
|
|
104
147
|
DeepSeek Harness 只能归档会话、无法真正删除,日志会一直堆积在磁盘上。这个插件补上了缺失的删除能力。
|
|
105
148
|
|
|
106
|
-
**新增内容**:会话「…」菜单里的**红色「删除对话」**、会话行悬停时的垃圾桶按钮,以及一个显示对话标题、写明"永久删除"
|
|
149
|
+
**新增内容**:会话「…」菜单里的**红色「删除对话」**、会话行悬停时的垃圾桶按钮,以及一个显示对话标题、写明"永久删除"的确认框;删除后确认框会原地显示"已删除"以及保留的分叉会话数量。删除当前正在看的对话后,会在同一工作区重新打开一个新会话。
|
|
150
|
+
|
|
151
|
+
**删除顺序是事务式的**:解析目标 → 拒绝运行中的工作 → 停并将活跃会话摘除 → **删除日志文件** → 释放工作区记账 → 删除投影缓存 → 广播移除事件。日志删除失败时会**先失败、不动记账和缓存**,所以侧边栏行仍在、可以直接重试。
|
|
152
|
+
|
|
153
|
+
**会被删掉的**:会话日志目录(日志 + 锁文件)、投影缓存记录、工作区记账(成员关系/排序/置顶/归档标记)。
|
|
154
|
+
|
|
155
|
+
**不会删的**:附件目录(按内容寻址、被多个会话共享)、临时 spill 文件、由该对话分叉出的子会话(确认框会提示数量)。
|
|
156
|
+
|
|
157
|
+
**错误码**:`active`(仍有运行中的工作)、`not-found`(已删除或不存在)、`io`(文件无法删除,未做任何改动)、`unsupported-store`(当前版本无法定位文件)、`invalid-request`(请求非法)。
|
|
107
158
|
|
|
108
|
-
|
|
159
|
+
**重试策略**:只对 `EBUSY`/`EPERM`/`EACCES`/`ENOTEMPTY` 这类"句柄还没关"的瞬时错误做退避重试(8 次,约 1.8 秒),其他错误立即报错,不会把时间耗在无效重试上。
|
|
109
160
|
|
|
110
|
-
|
|
161
|
+
**安全性**:永久删除,无回收站、不可撤销——归档依然是可逆选项。同一会话的重复点击会合并为一次删除,不会交叉执行。
|
|
111
162
|
|
|
112
|
-
|
|
163
|
+
**兼容性**:在 DeepSeek Harness `0.2.1-rc.2`(Desktop/Web,Windows,中英文界面)上验证;零运行时依赖。实现依赖少量宿主内部机制(会话存储/持久化服务、工作区注册表、投影缓存域、以及 API Gateway 对带 `typertRemote` 绑定的服务的运行时派发),宿主大版本变更时可能需要跟进更新。
|
|
113
164
|
|
|
114
|
-
|
|
165
|
+
**已知限制**:没有全局 toast(插件无法使用该槽位),确认框内的原地提示即反馈;界面文案仅中英文;从未落盘的空白会话删除后,宿主内存里的 pending 条目可能要等重启才彻底消失。
|
|
115
166
|
|
|
116
167
|
## License
|
|
117
168
|
|
package/client.js
CHANGED
|
@@ -4,7 +4,12 @@
|
|
|
4
4
|
* Adds a "Delete conversation" entry to the sidebar session list: one row in
|
|
5
5
|
* the Session "..." menu, one hover button, and one confirmation dialog. The
|
|
6
6
|
* deletion itself runs on the Host through the `sessionDelete/delete` Remote
|
|
7
|
-
* endpoint; this half
|
|
7
|
+
* endpoint; this half collects the request and shows the outcome.
|
|
8
|
+
*
|
|
9
|
+
* The dialog follows the shipped archive action's shape: a thin entry that
|
|
10
|
+
* reads the pending request, and one inner form keyed by the Session id that
|
|
11
|
+
* owns the in-flight, error and success state. Keying it means one request's
|
|
12
|
+
* outcome can never leak into the next one.
|
|
8
13
|
*/
|
|
9
14
|
window.__ModuleLoader__.load({
|
|
10
15
|
id: '@ybbmm/dsh-session-delete',
|
|
@@ -27,6 +32,16 @@ window.__ModuleLoader__.load({
|
|
|
27
32
|
'dialog.cancel': 'Cancel',
|
|
28
33
|
'dialog.close': 'Close',
|
|
29
34
|
'dialog.pending': 'Deleting…',
|
|
35
|
+
'dialog.done': 'Deleted. The conversation is gone from this device.',
|
|
36
|
+
'dialog.children':
|
|
37
|
+
'{count} conversation(s) forked from this one remain and still point at the deleted parent.',
|
|
38
|
+
'error.offline': 'The Harness is not connected to its Host.',
|
|
39
|
+
'error.active': 'This conversation is still running work. Stop it, then delete it.',
|
|
40
|
+
'error.notFound': 'This conversation no longer exists.',
|
|
41
|
+
'error.io': 'The conversation could not be removed from disk. Nothing was deleted — please try again.',
|
|
42
|
+
'error.unsupported': 'This build cannot locate conversation files, so nothing was deleted.',
|
|
43
|
+
'error.unreadable': 'This conversation log cannot be read by this build, so nothing was deleted.',
|
|
44
|
+
'error.unknown': 'The conversation could not be deleted.',
|
|
30
45
|
}
|
|
31
46
|
const zh = {
|
|
32
47
|
'menu.delete': '删除对话',
|
|
@@ -37,11 +52,20 @@ window.__ModuleLoader__.load({
|
|
|
37
52
|
'dialog.cancel': '取消',
|
|
38
53
|
'dialog.close': '关闭',
|
|
39
54
|
'dialog.pending': '正在删除…',
|
|
55
|
+
'dialog.done': '已删除,该对话已从本机移除。',
|
|
56
|
+
'dialog.children': '由该对话分叉出的 {count} 个会话会保留,并仍指向一个已删除的父对话。',
|
|
57
|
+
'error.offline': 'Harness 尚未连接到宿主。',
|
|
58
|
+
'error.active': '该对话仍有正在运行的工作,请先停止再删除。',
|
|
59
|
+
'error.notFound': '该对话已不存在。',
|
|
60
|
+
'error.io': '无法从磁盘移除该对话,本次未删除任何内容,请重试。',
|
|
61
|
+
'error.unsupported': '当前版本无法定位对话文件,本次未删除任何内容。',
|
|
62
|
+
'error.unreadable': '当前版本无法读取该对话的日志,本次未删除任何内容。',
|
|
63
|
+
'error.unknown': '该对话未能删除。',
|
|
40
64
|
}
|
|
41
65
|
|
|
42
66
|
// The hover button and the dialog copy the host's own geometry, renamed
|
|
43
67
|
// under this plugin's prefix and keeping only theme-token references.
|
|
44
|
-
const css = '.dshSessionDelete_iconButton{border-radius:var(--dsw-radius-xs);cursor:pointer;width:16px;height:16px;color:var(--dsw-alias-label-tertiary);background:0 0;border:none;flex:none;justify-content:center;align-items:center;padding:0;display:inline-flex}.dshSessionDelete_iconButton:hover{color:var(--dsw-alias-label-primary)}.dshSessionDelete_deleteAction:not(:disabled){color:var(--dsw-alias-state-error-primary)}.dshSessionDelete_status{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}.dshSessionDelete_error{color:var(--dsw-alias-state-error-primary);margin-top:8px;font-size:12px;line-height:18px}'
|
|
68
|
+
const css = '.dshSessionDelete_iconButton{border-radius:var(--dsw-radius-xs);cursor:pointer;width:16px;height:16px;color:var(--dsw-alias-label-tertiary);background:0 0;border:none;flex:none;justify-content:center;align-items:center;padding:0;display:inline-flex}.dshSessionDelete_iconButton:hover{color:var(--dsw-alias-label-primary)}.dshSessionDelete_deleteAction:not(:disabled){color:var(--dsw-alias-state-error-primary)}.dshSessionDelete_status{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}.dshSessionDelete_error{color:var(--dsw-alias-state-error-primary);margin-top:8px;font-size:12px;line-height:18px}.dshSessionDelete_note{margin-top:8px;font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary)}'
|
|
45
69
|
const cssTagId = `${PLUGIN}/session-delete.css`
|
|
46
70
|
if (typeof document !== 'undefined' && document.querySelector(`style[data-plugin-css=${JSON.stringify(cssTagId)}]`) === null) {
|
|
47
71
|
const tag = document.createElement('style')
|
|
@@ -87,18 +111,57 @@ window.__ModuleLoader__.load({
|
|
|
87
111
|
return typeof key === 'string' && key.length > 0 ? key : undefined
|
|
88
112
|
}
|
|
89
113
|
|
|
114
|
+
/** The workspace that accounts for one Session, when it is listed. */
|
|
115
|
+
function workspaceOf(ctx, sessionId) {
|
|
116
|
+
const uiWorkspace = optionalService(ctx, 'uiWorkspace')
|
|
117
|
+
const items = uiWorkspace?.workspaces?.list?.getSnapshot?.()?.items
|
|
118
|
+
if (!Array.isArray(items)) return undefined
|
|
119
|
+
for (const item of items) {
|
|
120
|
+
if (Array.isArray(item?.sessionIds) && item.sessionIds.includes(sessionId)) return item.workspaceId
|
|
121
|
+
}
|
|
122
|
+
return undefined
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Map one Remote failure onto a localized sentence.
|
|
127
|
+
*
|
|
128
|
+
* The Host sends a stable code for every deletion refusal; anything else
|
|
129
|
+
* (a carrier failure, or a host older than this bundle) falls back to the
|
|
130
|
+
* diagnostic the Host attached, then to generic copy.
|
|
131
|
+
* @param t - the slot's locale function.
|
|
132
|
+
* @param failure - the rejection raised by the RPC call.
|
|
133
|
+
* @returns the sentence to show in the dialog.
|
|
134
|
+
*/
|
|
135
|
+
function describeFailure(t, failure) {
|
|
136
|
+
const known = {
|
|
137
|
+
'client/offline': 'error.offline',
|
|
138
|
+
'session-delete/active': 'error.active',
|
|
139
|
+
'session-delete/not-found': 'error.notFound',
|
|
140
|
+
'session-delete/io': 'error.io',
|
|
141
|
+
'session-delete/unsupported-store': 'error.unsupported',
|
|
142
|
+
'session-delete/unreadable': 'error.unreadable',
|
|
143
|
+
'session-delete/invalid-request': 'error.unknown',
|
|
144
|
+
}
|
|
145
|
+
const code = typeof failure?.code === 'string' ? failure.code : undefined
|
|
146
|
+
if (code !== undefined && known[code] !== undefined) return t(known[code])
|
|
147
|
+
const message = failure instanceof Error ? failure.message : typeof failure === 'string' ? failure : ''
|
|
148
|
+
return message.length > 0 ? message : t('error.unknown')
|
|
149
|
+
}
|
|
150
|
+
|
|
90
151
|
/** Ask the Host to delete one conversation. */
|
|
91
152
|
async function requestDeletion(ctx, sessionId) {
|
|
92
153
|
const connection = optionalService(ctx, 'connection')
|
|
93
|
-
if (!connection)
|
|
94
|
-
|
|
95
|
-
'/
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
)
|
|
154
|
+
if (!connection) {
|
|
155
|
+
const failure = new Error('The Harness is not connected to its Host.')
|
|
156
|
+
failure.code = 'client/offline'
|
|
157
|
+
throw failure
|
|
158
|
+
}
|
|
159
|
+
const result = await connection.rpc.call('/api', 'sessionDelete/delete', { args: { sessionId } })
|
|
99
160
|
if (!result?.ok) {
|
|
100
|
-
const failure = result?.error
|
|
101
|
-
|
|
161
|
+
const failure = new Error(result?.error?.message ?? 'The conversation could not be deleted.')
|
|
162
|
+
failure.code = result?.error?.code
|
|
163
|
+
failure.details = result?.error?.details
|
|
164
|
+
throw failure
|
|
102
165
|
}
|
|
103
166
|
return result.value
|
|
104
167
|
}
|
|
@@ -141,94 +204,168 @@ window.__ModuleLoader__.load({
|
|
|
141
204
|
)
|
|
142
205
|
}
|
|
143
206
|
|
|
144
|
-
/**
|
|
207
|
+
/**
|
|
208
|
+
* One request's dialog: confirming deletes, and the dialog then reports the
|
|
209
|
+
* outcome instead of closing silently, so the user gets an acknowledgement.
|
|
210
|
+
* @param props - the request in flight and its settlement callbacks.
|
|
211
|
+
* @returns the dialog.
|
|
212
|
+
*/
|
|
213
|
+
function DeleteConfirmForm({ request, settleDelete, confirmDelete, t }) {
|
|
214
|
+
const [deleting, setDeleting] = React.useState(false)
|
|
215
|
+
const [error, setError] = React.useState(undefined)
|
|
216
|
+
const [done, setDone] = React.useState(undefined)
|
|
217
|
+
|
|
218
|
+
const close = () => {
|
|
219
|
+
if (deleting) return
|
|
220
|
+
settleDelete()
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const confirm = () => {
|
|
224
|
+
if (deleting || done !== undefined) return
|
|
225
|
+
setDeleting(true)
|
|
226
|
+
setError(undefined)
|
|
227
|
+
confirmDelete(request).then(
|
|
228
|
+
(outcome) => {
|
|
229
|
+
setDeleting(false)
|
|
230
|
+
const children = Array.isArray(outcome?.children) ? outcome.children.length : 0
|
|
231
|
+
setDone({ children })
|
|
232
|
+
},
|
|
233
|
+
(failure) => {
|
|
234
|
+
setDeleting(false)
|
|
235
|
+
setError(describeFailure(t, failure))
|
|
236
|
+
},
|
|
237
|
+
)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const body = []
|
|
241
|
+
if (deleting) {
|
|
242
|
+
body.push(
|
|
243
|
+
h(
|
|
244
|
+
'div',
|
|
245
|
+
{ role: 'status', className: 'dshSessionDelete_status', key: 'pending' },
|
|
246
|
+
t('dialog.pending'),
|
|
247
|
+
),
|
|
248
|
+
)
|
|
249
|
+
}
|
|
250
|
+
if (error !== undefined) {
|
|
251
|
+
body.push(
|
|
252
|
+
h('div', { role: 'alert', className: 'dshSessionDelete_error', key: 'error' }, error),
|
|
253
|
+
)
|
|
254
|
+
}
|
|
255
|
+
if (done !== undefined) {
|
|
256
|
+
body.push(
|
|
257
|
+
h(
|
|
258
|
+
'div',
|
|
259
|
+
{ role: 'status', className: 'dshSessionDelete_status', key: 'done' },
|
|
260
|
+
t('dialog.done'),
|
|
261
|
+
),
|
|
262
|
+
)
|
|
263
|
+
if (done.children > 0) {
|
|
264
|
+
body.push(
|
|
265
|
+
h(
|
|
266
|
+
'div',
|
|
267
|
+
{ className: 'dshSessionDelete_note', key: 'children' },
|
|
268
|
+
t('dialog.children', { count: done.children }),
|
|
269
|
+
),
|
|
270
|
+
)
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const footer =
|
|
275
|
+
done !== undefined
|
|
276
|
+
? h(Button, { variant: 'outline', onClick: close }, t('dialog.close'))
|
|
277
|
+
: h(
|
|
278
|
+
React.Fragment,
|
|
279
|
+
null,
|
|
280
|
+
h(
|
|
281
|
+
Button,
|
|
282
|
+
{ variant: 'outline', disabled: deleting, onClick: close },
|
|
283
|
+
t('dialog.cancel'),
|
|
284
|
+
),
|
|
285
|
+
h(
|
|
286
|
+
Button,
|
|
287
|
+
{
|
|
288
|
+
variant: 'outline',
|
|
289
|
+
className: 'dshSessionDelete_deleteAction',
|
|
290
|
+
disabled: deleting,
|
|
291
|
+
onClick: confirm,
|
|
292
|
+
},
|
|
293
|
+
t('dialog.confirm'),
|
|
294
|
+
),
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
return h(Modal, {
|
|
298
|
+
open: true,
|
|
299
|
+
onClose: close,
|
|
300
|
+
closeLabel: t('dialog.close'),
|
|
301
|
+
title: t('dialog.title'),
|
|
302
|
+
description: t('dialog.description', { title: request.displayTitle }),
|
|
303
|
+
footer,
|
|
304
|
+
children: body,
|
|
305
|
+
})
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* The `shell.overlay` entry: nothing while no confirmation is pending,
|
|
310
|
+
* otherwise one dialog per request, keyed by the Session it targets.
|
|
311
|
+
* @param props - the request hook, its settlement, and the deletion hop.
|
|
312
|
+
* @returns the open dialog, or null.
|
|
313
|
+
*/
|
|
145
314
|
function DeleteSessionDialog({ useDeleteRequest, settleDelete, confirmDelete, t }) {
|
|
146
315
|
const request = useDeleteRequest((pending) => pending)
|
|
147
316
|
if (!request) return null
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
React.Fragment,
|
|
158
|
-
null,
|
|
159
|
-
h(
|
|
160
|
-
Button,
|
|
161
|
-
{ variant: 'outline', disabled: request.pending, onClick: settleDelete },
|
|
162
|
-
t('dialog.cancel'),
|
|
163
|
-
),
|
|
164
|
-
h(
|
|
165
|
-
Button,
|
|
166
|
-
{
|
|
167
|
-
variant: 'outline',
|
|
168
|
-
className: 'dshSessionDelete_deleteAction',
|
|
169
|
-
disabled: request.pending,
|
|
170
|
-
onClick: confirmDelete,
|
|
171
|
-
},
|
|
172
|
-
t('dialog.confirm'),
|
|
173
|
-
),
|
|
174
|
-
),
|
|
175
|
-
children: h(
|
|
176
|
-
React.Fragment,
|
|
177
|
-
null,
|
|
178
|
-
request.pending
|
|
179
|
-
? h(
|
|
180
|
-
'div',
|
|
181
|
-
{ role: 'status', className: 'dshSessionDelete_status', key: 'pending' },
|
|
182
|
-
t('dialog.pending'),
|
|
183
|
-
)
|
|
184
|
-
: null,
|
|
185
|
-
request.error
|
|
186
|
-
? h(
|
|
187
|
-
'div',
|
|
188
|
-
{ role: 'alert', className: 'dshSessionDelete_error', key: 'error' },
|
|
189
|
-
request.error,
|
|
190
|
-
)
|
|
191
|
-
: null,
|
|
192
|
-
),
|
|
193
|
-
},
|
|
194
|
-
)
|
|
317
|
+
// `key` (not a child) so a new request remounts the form: its in-flight,
|
|
318
|
+
// error and success state belong to exactly one request.
|
|
319
|
+
return h(DeleteConfirmForm, {
|
|
320
|
+
key: request.sessionId,
|
|
321
|
+
request,
|
|
322
|
+
settleDelete,
|
|
323
|
+
confirmDelete,
|
|
324
|
+
t,
|
|
325
|
+
})
|
|
195
326
|
}
|
|
196
327
|
|
|
197
328
|
return {
|
|
198
329
|
inject: ['slots', 'locale'],
|
|
199
330
|
apply(ctx) {
|
|
200
|
-
ctx.effect(
|
|
201
|
-
() => ctx.locale.register(NS, { zh, en }),
|
|
202
|
-
'ybbmm.dsh-session-delete: dictionaries',
|
|
203
|
-
)
|
|
331
|
+
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ybbmm.dsh-session-delete: dictionaries')
|
|
204
332
|
|
|
205
333
|
const pending = createObservable(null)
|
|
206
334
|
|
|
207
335
|
const requestDelete = (sessionId, displayTitle) => {
|
|
208
|
-
pending.set({
|
|
336
|
+
pending.set({
|
|
337
|
+
sessionId,
|
|
338
|
+
displayTitle,
|
|
339
|
+
workspaceId: workspaceOf(ctx, sessionId),
|
|
340
|
+
})
|
|
209
341
|
}
|
|
210
342
|
const settleDelete = () => {
|
|
211
343
|
pending.set(null)
|
|
212
344
|
}
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Delete one requested conversation and, when the main view showed it,
|
|
348
|
+
* open a fresh session in the same workspace.
|
|
349
|
+
*
|
|
350
|
+
* The pending request stays set so the dialog can report the outcome;
|
|
351
|
+
* the user closes it.
|
|
352
|
+
* @param request - the pending request being confirmed.
|
|
353
|
+
* @returns the Host outcome.
|
|
354
|
+
* @throws the Host failure, for the dialog to localize.
|
|
355
|
+
*/
|
|
356
|
+
const confirmDelete = async (request) => {
|
|
357
|
+
const outcome = await requestDeletion(ctx, request.sessionId)
|
|
358
|
+
if (currentSessionId(ctx) === request.sessionId) {
|
|
359
|
+
const uiWorkspace = optionalService(ctx, 'uiWorkspace')
|
|
360
|
+
try {
|
|
361
|
+
// Reopen inside the same workspace, reusing its blank session
|
|
362
|
+
// exactly like the sidebar's own New Session action.
|
|
363
|
+
uiWorkspace?.startSession(request.workspaceId)
|
|
364
|
+
} catch (failure) {
|
|
365
|
+
console.warn('ybbmm.dsh-session-delete: could not open a new session:', failure)
|
|
227
366
|
}
|
|
228
|
-
} catch (error) {
|
|
229
|
-
const message = error instanceof Error ? error.message : String(error)
|
|
230
|
-
pending.set({ ...request, pending: false, error: message })
|
|
231
367
|
}
|
|
368
|
+
return outcome
|
|
232
369
|
}
|
|
233
370
|
|
|
234
371
|
const actionInjected = () => ({ requestDelete })
|
package/index.js
CHANGED
|
@@ -1,223 +1,482 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Host half of the session-delete bundle.
|
|
3
|
-
*
|
|
4
|
-
* Owns one operation: permanently delete a stored conversation. The session's
|
|
5
|
-
* durable log
|
|
6
|
-
* archive) is released, its projection-cache record is dropped, and the
|
|
7
|
-
* browser is told the row is gone.
|
|
8
|
-
*
|
|
9
|
-
* The browser half calls `sessionDelete/delete` over the shared Remote
|
|
10
|
-
* carrier. The endpoint is reachable through the API Gateway's runtime "SRC"
|
|
11
|
-
* dispatch: the Gateway looks for a live Service that carries a
|
|
12
|
-
* `typertRemote` binding plus Remote method markers, so this bundle needs no
|
|
13
|
-
* generated Typert artifact.
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
const
|
|
30
|
-
/**
|
|
31
|
-
const
|
|
32
|
-
/**
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
/** Pause
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
* @
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Host half of the session-delete bundle.
|
|
3
|
+
*
|
|
4
|
+
* Owns one operation: permanently delete a stored conversation. The session's
|
|
5
|
+
* durable log artifact goes away, its workspace accounting (membership, pin,
|
|
6
|
+
* archive) is released, its projection-cache record is dropped, and the
|
|
7
|
+
* browser is told the row is gone.
|
|
8
|
+
*
|
|
9
|
+
* The browser half calls `sessionDelete/delete` over the shared Remote
|
|
10
|
+
* carrier. The endpoint is reachable through the API Gateway's runtime "SRC"
|
|
11
|
+
* dispatch: the Gateway looks for a live Service that carries a
|
|
12
|
+
* `typertRemote` binding plus Remote method markers, so this bundle needs no
|
|
13
|
+
* generated Typert artifact.
|
|
14
|
+
*
|
|
15
|
+
* Ordering is transactional on purpose: every step before the log removal can
|
|
16
|
+
* still be abandoned without leaving a user-visible inconsistency, so the one
|
|
17
|
+
* irreversible step runs first among the mutating steps and everything after
|
|
18
|
+
* it is fail-soft. A failed deletion therefore leaves the conversation exactly
|
|
19
|
+
* as it was, and retrying is meaningful.
|
|
20
|
+
*
|
|
21
|
+
* The bundle deliberately has no package dependencies: a bundle installed from
|
|
22
|
+
* a local directory resolves its imports from that directory, so every import
|
|
23
|
+
* would have to be a Node built-in.
|
|
24
|
+
*/
|
|
25
|
+
import { rm, stat } from 'node:fs/promises'
|
|
26
|
+
import { dirname } from 'node:path'
|
|
27
|
+
|
|
28
|
+
/** Cordis service key of this plugin's service. */
|
|
29
|
+
const SERVICE_KEY = 'sessionDelete'
|
|
30
|
+
/** Wire namespace the API Gateway exposes for this service. */
|
|
31
|
+
const NAMESPACE = 'sessionDelete'
|
|
32
|
+
/** Property the Typert protocol stores Remote method markers under. */
|
|
33
|
+
const REMOTE_METHODS = '@deepseek-ai/dsh-typert-protocol/remote-methods'
|
|
34
|
+
/** Shared symbol Cordis uses to bind a service value to its owning context. */
|
|
35
|
+
const TRACKER = Symbol.for('cordis.tracker')
|
|
36
|
+
/** Bounded retries for a removal a closing writer may still be holding open. */
|
|
37
|
+
const REMOVE_ATTEMPTS = 8
|
|
38
|
+
/** First back-off delay; doubled per attempt up to the ceiling. */
|
|
39
|
+
const REMOVE_RETRY_BASE_MS = 40
|
|
40
|
+
/** Ceiling of the removal back-off delay. */
|
|
41
|
+
const REMOVE_RETRY_MAX_MS = 400
|
|
42
|
+
/** How many times the projection-cache row is re-checked after a delete. */
|
|
43
|
+
const CACHE_ATTEMPTS = 4
|
|
44
|
+
/** Pause between projection-cache checks. */
|
|
45
|
+
const CACHE_RETRY_MS = 60
|
|
46
|
+
/** Errors that mean "a handle is still open", not "this will never work". */
|
|
47
|
+
const RETRYABLE_REMOVE_CODES = new Set([
|
|
48
|
+
'EBUSY',
|
|
49
|
+
'EPERM',
|
|
50
|
+
'EACCES',
|
|
51
|
+
'ENOTEMPTY',
|
|
52
|
+
'EEXIST',
|
|
53
|
+
'EMFILE',
|
|
54
|
+
'ENFILE',
|
|
55
|
+
])
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* One deletion failure with a stable code and caller-facing message.
|
|
59
|
+
*
|
|
60
|
+
* The Gateway forwards any thrown value carrying the protocol's structural
|
|
61
|
+
* Remote marker unchanged, so `code` crosses the wire instead of folding into
|
|
62
|
+
* `gateway/internal`; a plain Error would lose it.
|
|
63
|
+
*/
|
|
64
|
+
class SessionDeleteError extends Error {
|
|
65
|
+
/**
|
|
66
|
+
* @param code - stable failure code the browser localizes.
|
|
67
|
+
* @param message - diagnostic text, also the browser's fallback copy.
|
|
68
|
+
* @param details - structured payload for the dialog.
|
|
69
|
+
*/
|
|
70
|
+
constructor(code, message, details = {}) {
|
|
71
|
+
super(message)
|
|
72
|
+
this.name = 'SessionDeleteError'
|
|
73
|
+
this.code = code
|
|
74
|
+
this.details = details
|
|
75
|
+
// Cross-bundle identification: the Gateway tests this marker rather than
|
|
76
|
+
// `instanceof`, because the protocol package resolves separately.
|
|
77
|
+
this.isDSHRemoteError = true
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Read an optional service without failing when the composition lacks it. */
|
|
82
|
+
function optionalService(ctx, name) {
|
|
83
|
+
try {
|
|
84
|
+
return ctx.get(name)
|
|
85
|
+
} catch {
|
|
86
|
+
return undefined
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Pause for `ms` milliseconds. */
|
|
91
|
+
function delay(ms) {
|
|
92
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Whether a filesystem failure is worth retrying while the writer settles. */
|
|
96
|
+
function isRetryableRemoveFailure(error) {
|
|
97
|
+
const code = error?.code
|
|
98
|
+
return typeof code === 'string' && RETRYABLE_REMOVE_CODES.has(code)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The back-off delay before attempt `attempt` (0-based). */
|
|
102
|
+
function removeRetryDelay(attempt) {
|
|
103
|
+
return Math.min(REMOVE_RETRY_BASE_MS * 2 ** attempt, REMOVE_RETRY_MAX_MS)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
class SessionDeleteService {
|
|
107
|
+
/**
|
|
108
|
+
* @param ctx - the owning plugin context.
|
|
109
|
+
*/
|
|
110
|
+
constructor(ctx) {
|
|
111
|
+
this.ctx = ctx
|
|
112
|
+
this.name = SERVICE_KEY
|
|
113
|
+
/** Deletions in flight, so two clicks cannot interleave their steps. */
|
|
114
|
+
this.inflight = new Map()
|
|
115
|
+
this.typertRemote = Object.freeze({
|
|
116
|
+
service: this,
|
|
117
|
+
serviceKey: SERVICE_KEY,
|
|
118
|
+
namespace: NAMESPACE,
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Delete one conversation.
|
|
124
|
+
*
|
|
125
|
+
* Steps run in an order where only the log removal is irreversible, and it
|
|
126
|
+
* runs before the accounting release: a failure there aborts with the row
|
|
127
|
+
* still listed and retryable.
|
|
128
|
+
* @param sessionId - durable Session identity.
|
|
129
|
+
* @returns the deleted identity, what was removed, and surviving children.
|
|
130
|
+
*/
|
|
131
|
+
async delete(sessionId) {
|
|
132
|
+
if (typeof sessionId !== 'string' || sessionId.length === 0) {
|
|
133
|
+
throw new SessionDeleteError(
|
|
134
|
+
'session-delete/invalid-request',
|
|
135
|
+
'sessionDelete: a session id is required',
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
const running = this.inflight.get(sessionId)
|
|
139
|
+
if (running !== undefined) return running
|
|
140
|
+
const task = this.runDelete(sessionId)
|
|
141
|
+
this.inflight.set(sessionId, task)
|
|
142
|
+
try {
|
|
143
|
+
return await task
|
|
144
|
+
} finally {
|
|
145
|
+
if (this.inflight.get(sessionId) === task) this.inflight.delete(sessionId)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The deletion proper; see {@link delete} for the ordering contract.
|
|
151
|
+
* @param sessionId - durable Session identity.
|
|
152
|
+
* @returns the deletion outcome.
|
|
153
|
+
*/
|
|
154
|
+
async runDelete(sessionId) {
|
|
155
|
+
const ctx = this.ctx
|
|
156
|
+
|
|
157
|
+
// 1. Resolve the target: a live session, otherwise a stored one.
|
|
158
|
+
const live = ctx.sessions.get(sessionId)
|
|
159
|
+
let header = live?.header
|
|
160
|
+
let stored = false
|
|
161
|
+
if (!header) {
|
|
162
|
+
let snapshot
|
|
163
|
+
try {
|
|
164
|
+
snapshot = await ctx.sessionPersistence.stat(sessionId)
|
|
165
|
+
} catch (error) {
|
|
166
|
+
// A log this build cannot interpret is not a missing log; refuse with
|
|
167
|
+
// its own code so the dialog does not claim the conversation is gone.
|
|
168
|
+
throw new SessionDeleteError(
|
|
169
|
+
'session-delete/unreadable',
|
|
170
|
+
`sessionDelete: session "${sessionId}" could not be read: ${error instanceof Error ? error.message : String(error)}`,
|
|
171
|
+
{ sessionId },
|
|
172
|
+
)
|
|
173
|
+
}
|
|
174
|
+
if (!snapshot?.header) {
|
|
175
|
+
throw new SessionDeleteError(
|
|
176
|
+
'session-delete/not-found',
|
|
177
|
+
`sessionDelete: session "${sessionId}" was not found`,
|
|
178
|
+
{ sessionId },
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
header = snapshot.header
|
|
182
|
+
stored = true
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// 2. Refuse while the conversation still owns running work. The same gate
|
|
186
|
+
// the shipped archive action uses, so the answer matches the UI's own.
|
|
187
|
+
const activity = await ctx.waterfall(
|
|
188
|
+
'workspace/session-activity',
|
|
189
|
+
{ sessionId },
|
|
190
|
+
() => Promise.resolve([]),
|
|
191
|
+
)
|
|
192
|
+
const reported = Array.isArray(activity) ? activity : []
|
|
193
|
+
if (reported.length > 0) {
|
|
194
|
+
throw new SessionDeleteError(
|
|
195
|
+
'session-delete/active',
|
|
196
|
+
`sessionDelete: session "${sessionId}" is still running work; stop it before deleting`,
|
|
197
|
+
{
|
|
198
|
+
sessionId,
|
|
199
|
+
activity: reported.map((entry) => (typeof entry?.kind === 'string' ? entry.kind : 'unknown')),
|
|
200
|
+
},
|
|
201
|
+
)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// 3. Children survive their parent: name them so the browser can say so.
|
|
205
|
+
const children = await this.listChildren(sessionId)
|
|
206
|
+
|
|
207
|
+
// 4. Stop what is left, then let a live session leave the stores so its
|
|
208
|
+
// writer closes. The writer itself removes its handle on
|
|
209
|
+
// `session/disposed`; the removal below retries through that delay.
|
|
210
|
+
await ctx.parallel('workspace/session-stop', { sessionId }).catch(() => {})
|
|
211
|
+
if (live) {
|
|
212
|
+
await ctx.sessions.flush(live).catch(() => {})
|
|
213
|
+
this.detach(sessionId)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// 5. Remove the stored conversation. This is the irreversible step; a
|
|
217
|
+
// session that never materialized has no artifact, which counts as
|
|
218
|
+
// success rather than failure.
|
|
219
|
+
const removed = await this.removeStoredLog(header, sessionId)
|
|
220
|
+
|
|
221
|
+
// 6. Release the durable workspace accounting. Fail-soft: the log is
|
|
222
|
+
// already gone, so a stale membership must not fail the deletion.
|
|
223
|
+
await this.releaseWorkspaceAccounting(sessionId)
|
|
224
|
+
|
|
225
|
+
// 7. Drop the projection-cache record so no stale hint survives. A live
|
|
226
|
+
// session's disposal checkpoint may land after this call, so the row is
|
|
227
|
+
// re-checked before the deletion reports success.
|
|
228
|
+
await this.removeProjectionCache(sessionId)
|
|
229
|
+
|
|
230
|
+
// 8. A live session announces its own removal through `session/disposed`;
|
|
231
|
+
// a stored one has no such event, so announce it here.
|
|
232
|
+
if (!live) ctx.emit('api-session/removed', sessionId)
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
sessionId,
|
|
236
|
+
removed,
|
|
237
|
+
wasLive: live !== undefined,
|
|
238
|
+
stored,
|
|
239
|
+
children,
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Remove one session from the live session and agent stores.
|
|
245
|
+
* @param sessionId - durable Session identity.
|
|
246
|
+
*/
|
|
247
|
+
detach(sessionId) {
|
|
248
|
+
const ctx = this.ctx
|
|
249
|
+
const sessions = optionalService(ctx, 'sessions')
|
|
250
|
+
const entry = sessions?.store?.get(sessionId)
|
|
251
|
+
if (entry && typeof sessions.detachEntered === 'function') {
|
|
252
|
+
try {
|
|
253
|
+
sessions.detachEntered(entry)
|
|
254
|
+
} catch (error) {
|
|
255
|
+
ctx.logger?.warn(`sessionDelete: detaching session "${sessionId}" failed: ${String(error)}`)
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
const agents = optionalService(ctx, 'agents')
|
|
259
|
+
const agentEntry = agents?.store?.get(sessionId)
|
|
260
|
+
if (agentEntry && typeof agents.detachEntered === 'function') {
|
|
261
|
+
try {
|
|
262
|
+
agents.detachEntered(agentEntry)
|
|
263
|
+
} catch (error) {
|
|
264
|
+
ctx.logger?.warn(`sessionDelete: detaching agent "${sessionId}" failed: ${String(error)}`)
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Release membership, saved order, pin and archive mark for one session.
|
|
271
|
+
*
|
|
272
|
+
* Every call is fail-soft and in a stable order: membership first, so the
|
|
273
|
+
* row leaves the sidebar even when a later step throws.
|
|
274
|
+
* @param sessionId - durable Session identity.
|
|
275
|
+
*/
|
|
276
|
+
async releaseWorkspaceAccounting(sessionId) {
|
|
277
|
+
const ctx = this.ctx
|
|
278
|
+
const registry = optionalService(ctx, 'workspaceRegistry')
|
|
279
|
+
if (!registry) return
|
|
280
|
+
const attempt = async (label, run) => {
|
|
281
|
+
try {
|
|
282
|
+
await run()
|
|
283
|
+
} catch (error) {
|
|
284
|
+
ctx.logger?.warn(`sessionDelete: releasing ${label} for "${sessionId}" failed: ${String(error)}`)
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
for (const workspace of registry.list()) {
|
|
288
|
+
if (workspace.record?.sessionIds?.includes(sessionId)) {
|
|
289
|
+
await attempt('workspace membership', () => workspace.detachSession(sessionId))
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
await attempt('pin', () => registry.unpinSession(sessionId))
|
|
293
|
+
await attempt('archive mark', () => registry.unarchiveSession(sessionId))
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Remove the session's stored log artifact.
|
|
298
|
+
*
|
|
299
|
+
* The store owns the on-disk layout, so its own locator names the artifact.
|
|
300
|
+
* The shipped JSONL backend keeps one directory per session (holding the
|
|
301
|
+
* log, its kernel lock file and any future session-local artifact), so the
|
|
302
|
+
* removal targets that directory; a backend that names a session-owned
|
|
303
|
+
* directory directly, or a single file, is honoured as-is. A writer that is
|
|
304
|
+
* still closing can recreate the artifact, so transient failures are
|
|
305
|
+
* retried with back-off instead of failing the whole deletion.
|
|
306
|
+
* @param header - the stored session header.
|
|
307
|
+
* @param sessionId - durable Session identity, for diagnostics.
|
|
308
|
+
* @returns whether a stored artifact was removed.
|
|
309
|
+
*/
|
|
310
|
+
async removeStoredLog(header, sessionId) {
|
|
311
|
+
const persistence = this.ctx.sessionPersistence
|
|
312
|
+
const locate = persistence?.locate
|
|
313
|
+
if (typeof locate !== 'function') {
|
|
314
|
+
throw new SessionDeleteError(
|
|
315
|
+
'session-delete/unsupported-store',
|
|
316
|
+
'sessionDelete: this session store does not expose its on-disk layout',
|
|
317
|
+
{ sessionId },
|
|
318
|
+
)
|
|
319
|
+
}
|
|
320
|
+
const location = locate.call(persistence, header)
|
|
321
|
+
if (!location?.path) return false
|
|
322
|
+
const target =
|
|
323
|
+
location.kind === 'dir' ? location.path : location.kind === 'file' ? location.path : dirname(location.path)
|
|
324
|
+
// A session created but never materialized has no artifact; `rm` would
|
|
325
|
+
// report success for a path that never existed, which would overstate the
|
|
326
|
+
// outcome. Nothing on disk is still a successful deletion.
|
|
327
|
+
if (!(await this.pathExists(target))) return false
|
|
328
|
+
|
|
329
|
+
let lastFailure
|
|
330
|
+
for (let attempt = 0; attempt < REMOVE_ATTEMPTS; attempt++) {
|
|
331
|
+
try {
|
|
332
|
+
await this.removeArtifact(target)
|
|
333
|
+
return true
|
|
334
|
+
} catch (error) {
|
|
335
|
+
lastFailure = error
|
|
336
|
+
// `force: true` already ignores ENOENT, so a non-transient failure
|
|
337
|
+
// means retrying cannot help: report it instead of burning the budget.
|
|
338
|
+
if (!isRetryableRemoveFailure(error)) break
|
|
339
|
+
if (attempt < REMOVE_ATTEMPTS - 1) await delay(removeRetryDelay(attempt))
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
const reason = lastFailure instanceof Error ? lastFailure.message : String(lastFailure)
|
|
343
|
+
throw new SessionDeleteError(
|
|
344
|
+
'session-delete/io',
|
|
345
|
+
`sessionDelete: removing the log of session "${sessionId}" failed: ${reason}`,
|
|
346
|
+
{ sessionId, path: target, attempts: REMOVE_ATTEMPTS },
|
|
347
|
+
)
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Remove one artifact path.
|
|
352
|
+
*
|
|
353
|
+
* The single filesystem call the deletion makes, kept as its own method so
|
|
354
|
+
* the retry loop above stays testable without provoking real Windows sharing
|
|
355
|
+
* violations.
|
|
356
|
+
* @param target - absolute artifact path (session directory or file).
|
|
357
|
+
* @returns resolution once the artifact is absent.
|
|
358
|
+
*/
|
|
359
|
+
removeArtifact(target) {
|
|
360
|
+
return rm(target, { recursive: true, force: true })
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Whether a path still exists.
|
|
365
|
+
*
|
|
366
|
+
* A non-ENOENT failure counts as present: the caller then attempts the
|
|
367
|
+
* removal and reports its real outcome instead of silently skipping it.
|
|
368
|
+
* @param target - absolute artifact path.
|
|
369
|
+
* @returns whether the path exists.
|
|
370
|
+
*/
|
|
371
|
+
async pathExists(target) {
|
|
372
|
+
try {
|
|
373
|
+
await stat(target)
|
|
374
|
+
return true
|
|
375
|
+
} catch (error) {
|
|
376
|
+
return error?.code === 'ENOENT' ? false : true
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Drop the stored projection-cache record of one session.
|
|
382
|
+
*
|
|
383
|
+
* A live session's disposal checkpoint is written asynchronously right after
|
|
384
|
+
* the detach, so one delete can be followed by the row reappearing. The row
|
|
385
|
+
* is re-checked until it stays gone; a row that survives the whole budget is
|
|
386
|
+
* only logged, because the log is already deleted and every reader
|
|
387
|
+
* re-validates a cached row against the header — a stale hint must not turn
|
|
388
|
+
* a successful deletion into a failure.
|
|
389
|
+
* @param sessionId - durable Session identity.
|
|
390
|
+
*/
|
|
391
|
+
async removeProjectionCache(sessionId) {
|
|
392
|
+
const storageDomain = optionalService(this.ctx, 'storageDomain')
|
|
393
|
+
if (!storageDomain || typeof storageDomain.get !== 'function') return
|
|
394
|
+
let table
|
|
395
|
+
try {
|
|
396
|
+
const domain = storageDomain.get('session_projcache')
|
|
397
|
+
if (!domain || typeof domain.table !== 'function') return
|
|
398
|
+
table = domain.table('sessions')
|
|
399
|
+
} catch {
|
|
400
|
+
return
|
|
401
|
+
}
|
|
402
|
+
for (let attempt = 0; attempt < CACHE_ATTEMPTS; attempt++) {
|
|
403
|
+
try {
|
|
404
|
+
if (table.get(sessionId) === undefined) return
|
|
405
|
+
await table.delete(sessionId)
|
|
406
|
+
} catch (error) {
|
|
407
|
+
this.ctx.logger?.warn(
|
|
408
|
+
`sessionDelete: dropping the projection cache of "${sessionId}" failed: ${String(error)}`,
|
|
409
|
+
)
|
|
410
|
+
return
|
|
411
|
+
}
|
|
412
|
+
if (attempt < CACHE_ATTEMPTS - 1) await delay(CACHE_RETRY_MS)
|
|
413
|
+
}
|
|
414
|
+
try {
|
|
415
|
+
if (table.get(sessionId) !== undefined) {
|
|
416
|
+
this.ctx.logger?.warn(
|
|
417
|
+
`sessionDelete: projection cache row for "${sessionId}" survived deletion; it is re-validated on the next read`,
|
|
418
|
+
)
|
|
419
|
+
}
|
|
420
|
+
} catch {
|
|
421
|
+
// A closed domain cannot hold a stale row for the browser either.
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Stored sessions whose header names `sessionId` as their parent.
|
|
427
|
+
*
|
|
428
|
+
* Deleting a parent does not delete its forks; naming them lets the browser
|
|
429
|
+
* say so instead of leaving a silently orphaned lineage.
|
|
430
|
+
* @param sessionId - durable Session identity being deleted.
|
|
431
|
+
* @returns child session ids, or an empty list when the store cannot list.
|
|
432
|
+
*/
|
|
433
|
+
async listChildren(sessionId) {
|
|
434
|
+
const persistence = this.ctx.sessionPersistence
|
|
435
|
+
if (!persistence || typeof persistence.list !== 'function') return []
|
|
436
|
+
try {
|
|
437
|
+
const snapshots = await persistence.list()
|
|
438
|
+
return snapshots
|
|
439
|
+
.map((snapshot) => snapshot?.header)
|
|
440
|
+
.filter((header) => header && header.id !== sessionId && header.parentSession === sessionId)
|
|
441
|
+
.map((header) => header.id)
|
|
442
|
+
} catch {
|
|
443
|
+
return []
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Declare the Remote method for the API Gateway's runtime dispatch. The
|
|
449
|
+
// marker format is the Typert protocol's public, versioned descriptor: a
|
|
450
|
+
// frozen record on the class prototype naming the exported methods. The
|
|
451
|
+
// `resources` reader is called with the method's positional arguments
|
|
452
|
+
// (`read(reader, positional)` in the Gateway), so for this one-parameter
|
|
453
|
+
// method the reader receives the id string itself.
|
|
454
|
+
Object.defineProperty(SessionDeleteService.prototype, REMOTE_METHODS, {
|
|
455
|
+
configurable: true,
|
|
456
|
+
value: Object.freeze({
|
|
457
|
+
version: 1,
|
|
458
|
+
methods: Object.freeze([
|
|
459
|
+
Object.freeze({
|
|
460
|
+
method: 'delete',
|
|
461
|
+
invocation: Object.freeze({ kind: 'direct' }),
|
|
462
|
+
resources: Object.freeze({
|
|
463
|
+
session: (sessionId) => (typeof sessionId === 'string' ? sessionId : undefined),
|
|
464
|
+
}),
|
|
465
|
+
}),
|
|
466
|
+
]),
|
|
467
|
+
}),
|
|
468
|
+
})
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Register the deletion service and let the browser reach it.
|
|
472
|
+
* @param ctx - the owning plugin context.
|
|
473
|
+
*/
|
|
474
|
+
export function apply(ctx) {
|
|
475
|
+
const service = new SessionDeleteService(ctx)
|
|
476
|
+
// Bind the service to the calling context the way a Cordis Service does, so
|
|
477
|
+
// the API Gateway reaches it through the same traced proxy.
|
|
478
|
+
Object.defineProperty(service, TRACKER, {
|
|
479
|
+
value: { associate: SERVICE_KEY, property: 'ctx' },
|
|
480
|
+
})
|
|
481
|
+
ctx.reflect.provide(SERVICE_KEY, service, undefined)
|
|
482
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ybbmm/dsh-session-delete",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Permanently delete a DeepSeek Harness conversation from the sidebar session menu: removes the session log, its projection cache and its workspace accounting. | 在 DeepSeek Harness 侧边栏永久删除对话,清除会话日志、投影缓存与工作区记账。",
|
|
6
6
|
"license": "MIT",
|
|
@@ -26,6 +26,9 @@
|
|
|
26
26
|
"engines": {
|
|
27
27
|
"node": ">=22"
|
|
28
28
|
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"test": "node --test"
|
|
31
|
+
},
|
|
29
32
|
"exports": {
|
|
30
33
|
".": "./index.js",
|
|
31
34
|
"./client": "./client.js",
|
|
@@ -54,6 +57,7 @@
|
|
|
54
57
|
"inject": [
|
|
55
58
|
"@deepseek-ai/dsh-client-ui-workspace",
|
|
56
59
|
"@deepseek-ai/dsh-client-ui-renderer",
|
|
60
|
+
"@deepseek-ai/dsh-client-ui-primitives",
|
|
57
61
|
"@deepseek-ai/dsh-client-locale"
|
|
58
62
|
]
|
|
59
63
|
}
|