dsh-session-remover 1.0.1 → 1.0.2
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.en.md +10 -19
- package/README.md +10 -19
- package/lib/client.js +2 -2
- package/lib/index.js +67 -72
- package/package.json +1 -1
package/README.en.md
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
Adds "Delete conversation" and "Batch delete…" to the sidebar session row "…" menu, moving a conversation and its subagent sessions into the Windows recycle bin, from which they can be restored.
|
|
4
4
|
|
|
5
5
|
- Package: `dsh-session-remover`
|
|
6
|
+
- Version: `1.0.2`
|
|
6
7
|
- Repository: <https://github.com/sailoumili/dsh-session-remover>
|
|
7
8
|
- Compatible with DSH 0.1.7 and later; available for the web and desktop editions
|
|
8
9
|
- Interface text follows the DSH interface language; Chinese and English are both provided
|
|
@@ -10,27 +11,17 @@ Adds "Delete conversation" and "Batch delete…" to the sidebar session row "…
|
|
|
10
11
|
|
|
11
12
|
## Installation
|
|
12
13
|
|
|
13
|
-
### Web
|
|
14
|
-
|
|
15
14
|
1. Click **Plugins** in the left sidebar.
|
|
16
15
|
2. Click **Add plugin**.
|
|
17
|
-
3. Enter
|
|
18
|
-
4. Enable the plugin on its page.
|
|
19
|
-
5. Restart DSH.
|
|
20
|
-
|
|
21
|
-
Equivalent command line:
|
|
22
|
-
|
|
23
|
-
```cmd
|
|
24
|
-
dsh plugin --profile web add dsh-session-remover
|
|
25
|
-
```
|
|
26
|
-
|
|
27
|
-
### Desktop
|
|
16
|
+
3. Enter the following in the package field:
|
|
28
17
|
|
|
29
|
-
|
|
18
|
+
```text
|
|
19
|
+
dsh-session-remover
|
|
20
|
+
```
|
|
30
21
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
22
|
+
4. Click **Install** and wait for the installation to complete.
|
|
23
|
+
5. Enable the plugin on its page.
|
|
24
|
+
6. Restart DSH.
|
|
34
25
|
|
|
35
26
|
## Usage
|
|
36
27
|
|
|
@@ -45,6 +36,7 @@ dsh plugin --profile desktop add dsh-session-remover
|
|
|
45
36
|
|
|
46
37
|
- **Deletion moves the data to the recycle bin; it does not erase it.** The move stays on the same volume, so no data is copied and nothing is left behind in `.dsh`. Before deleting, the plugin checks two things on the volume that holds the session (Windows stores the recycle-bin setting per drive): whether the recycle bin has been disabled, and whether the volume has a usable recycle bin at all; if either check fails the plugin **refuses to delete** rather than falling back to permanent deletion. Afterwards each item is verified by original location and name; the verification reads the local recycle bin index directly (listing file names only, not reading each entry), so its cost is essentially independent of how many items the bin already holds.
|
|
47
38
|
- **Subagent sessions are removed together.** Subagent sessions are not shown in the sidebar or search, so they accumulate unless removed together. Forked conversations are independent and are **never** deleted along with their source.
|
|
39
|
+
- **The session directory is located by session id.** The id recorded in the session log header is authoritative. If one id maps to more than one session directory (for example a bare id and a directory with the `session-` prefix side by side), the directory whose name equals the requested id exactly is deleted; when a unique target cannot be determined, the plugin **refuses to delete** and reports an error without changing any data.
|
|
48
40
|
- **Three records are cleaned:** the projection cache, the workspace registry, and the archived set. The projection cache is derived data that DSH rebuilds after a restore; the other two are cleared to avoid leaving traces locally, at the cost of a restored conversation being filed under "Ungrouped" in the sidebar.
|
|
49
41
|
- **Deleting the currently open conversation is supported.** Its running task is stopped and flushed to disk first, and no DSH restart is required.
|
|
50
42
|
|
|
@@ -53,9 +45,8 @@ dsh plugin --profile desktop add dsh-session-remover
|
|
|
53
45
|
- **Windows only.** Moving to the recycle bin relies on the system script host `cscript.exe`, which ships with Windows; on other systems the plugin refuses to delete and reports an error rather than falling back to permanent deletion.
|
|
54
46
|
- **Deletion is refused when the volume has no recycle bin.** If the session directory sits on a network drive, a USB stick, or a volume with no usable recycle bin (such as FAT/exFAT), moving it to the recycle bin would degrade into permanent deletion, so the plugin always refuses. To proceed, move the session directory to a local disk, or delete any file on that volume once to initialise its recycle bin, and try again.
|
|
55
47
|
- **An emptied recycle bin cannot be restored.** The plugin only moves the files into the bin; whether to empty it is the user's decision. The bin also has a size cap, and Windows discards the oldest entries once the quota is exceeded.
|
|
56
|
-
- **A restored conversation may not be in its original workspace.** The plugin clears the workspace registry entry, so a restored conversation appears under "Ungrouped". To restore it to its original workspace,
|
|
48
|
+
- **A restored conversation may not be in its original workspace.** The plugin clears the workspace registry entry, so a restored conversation appears under "Ungrouped". To restore it to its original workspace, ask the AI assistant to update `~/.dsh/storages/workspace.json` while DSH is stopped.
|
|
57
49
|
- **Attachments are not reclaimed.** DSH attachments are content-addressed, shared across conversations, and carry no reference counting, so the plugin cannot safely determine whether they can be reclaimed.
|
|
58
|
-
- **On a failed delete, the reason text returned by the server also follows the interface language**: the server returns only an error code and parameters, and the interface text is resolved by the front end in the active language.
|
|
59
50
|
- **The endpoint accepts loopback requests only.** `/api/session.delete` allows only `127.0.0.1` / `localhost` / `[::1]` and returns 403 for LAN origins. The plugin registers its route outside the official authentication fence, while the official browser-cookie check depends on a signing secret the plugin cannot obtain; deleting data is high-risk, so it fails closed. If DSH is bound to `0.0.0.0` and accessed from another machine, deletion returns 403.
|
|
60
51
|
- **Nothing is touched when the session directory cannot be read.** A failed session-tree scan aborts the deletion as a whole and never treats "read failure" as "absent from disk".
|
|
61
52
|
- The batch delete panel does not trap focus; `Tab` may move to page elements behind the panel.
|
package/README.md
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
在侧栏会话行的「…」菜单中增加「删除对话」与「批量删除…」两项,将对话及其子代理会话移入 Windows 回收站,需要时可予以还原。
|
|
4
4
|
|
|
5
5
|
- 包名:`dsh-session-remover`
|
|
6
|
+
- 版本:`1.0.2`
|
|
6
7
|
- 仓库:<https://github.com/sailoumili/dsh-session-remover>
|
|
7
8
|
- 兼容 DSH 0.1.7 及以上版本,适用于网页版与桌面版
|
|
8
9
|
- 界面文字随 DSH 界面语言切换,提供中文与英文两套
|
|
@@ -10,27 +11,17 @@
|
|
|
10
11
|
|
|
11
12
|
## 安装
|
|
12
13
|
|
|
13
|
-
### 网页版
|
|
14
|
-
|
|
15
14
|
1. 在左侧栏单击「插件」。
|
|
16
15
|
2. 单击「添加插件」。
|
|
17
|
-
3.
|
|
18
|
-
4. 在该插件页面中启用该插件。
|
|
19
|
-
5. 重启 DSH。
|
|
20
|
-
|
|
21
|
-
等效命令行:
|
|
22
|
-
|
|
23
|
-
```cmd
|
|
24
|
-
dsh plugin --profile web add dsh-session-remover
|
|
25
|
-
```
|
|
26
|
-
|
|
27
|
-
### 桌面版
|
|
16
|
+
3. 在「包名或地址」输入框中填写:
|
|
28
17
|
|
|
29
|
-
|
|
18
|
+
```text
|
|
19
|
+
dsh-session-remover
|
|
20
|
+
```
|
|
30
21
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
22
|
+
4. 单击「安装」,等待安装完成。
|
|
23
|
+
5. 在该插件页面中启用该插件。
|
|
24
|
+
6. 重启 DSH。
|
|
34
25
|
|
|
35
26
|
## 用法
|
|
36
27
|
|
|
@@ -45,6 +36,7 @@ dsh plugin --profile desktop add dsh-session-remover
|
|
|
45
36
|
|
|
46
37
|
- **删除为移入回收站,并非抹除**:同盘移动,不复制数据,`.dsh` 内不留副本。删除前先按会话所在磁盘核对两件事:该磁盘是否关闭了回收站(Windows 该开关按磁盘逐个设置)、该磁盘是否有可用的回收站;任一不满足即**拒绝删除**,不会退化为永久删除。移入后另按「原位置 + 名字」逐项核对,确认确实进入回收站;核对直接读取本机回收站索引(只列文件名,不逐项读取),耗时基本不受回收站已存项数影响。
|
|
47
38
|
- **一并删除子代理会话**:子代理会话不显示于侧栏与搜索,未一并清理将持续累积。由 fork 产生的分叉会话属独立对话,**不被连带删除**。
|
|
39
|
+
- **按会话编号定位目录**:编号以会话日志头部记录的编号为准。同一编号若对应多个会话目录(例如裸编号与带 `session-` 前缀的目录并存),优先删除目录名与所传编号完全一致的那个;无法确定唯一目标时**拒绝删除**并报错,不改动任何数据。
|
|
48
40
|
- **清理三处记录**:投影缓存、工作区记账、归档集合。投影缓存为派生数据,还原后由 DSH 自动重建;后两处予以清理以避免在本地留存痕迹,代价是还原后的对话归入侧栏的「未分组」。
|
|
49
41
|
- **支持删除当前打开的对话**:删除前先停止其运行中的任务并落盘,无需重启 DSH。
|
|
50
42
|
|
|
@@ -53,9 +45,8 @@ dsh plugin --profile desktop add dsh-session-remover
|
|
|
53
45
|
- **仅支持 Windows**。移入回收站依赖系统脚本宿主 `cscript.exe`(Windows 自带);在其他系统上插件拒绝删除并报错,不会退化为永久删除。
|
|
54
46
|
- **所在磁盘没有回收站时拒绝删除**。会话目录若位于网络盘、U 盘或未启用回收站的磁盘(如 FAT/exFAT),移入回收站会退化为永久删除,插件一律拒绝。处理办法:先把会话目录搬到本地磁盘,或在该磁盘上删除任意一个文件以启用回收站,再重试。
|
|
55
47
|
- **回收站清空后不可还原**。插件仅负责将文件移入回收站,是否清空由用户决定;回收站存在容量上限,超出配额时 Windows 将丢弃最早的项。
|
|
56
|
-
-
|
|
48
|
+
- **还原后可能不在原工作区**。插件清除了工作区记账,还原的对话将出现在「未分组」中。如需恢复至原工作区,可在 DSH 停止运行后请 AI 助手协助修改 `~/.dsh/storages/workspace.json`。
|
|
57
49
|
- **附件不予回收**。DSH 附件按内容寻址、跨会话共享,且无引用计数,插件无法安全判断其可否回收。
|
|
58
|
-
- **删除失败时服务端返回的原因文本同样随界面语言切换**:服务端只回错误码与参数,界面文字统一由前端按当前语言取词。
|
|
59
50
|
- **端点仅接受本机请求**。`/api/session.delete` 仅放行回环地址(`127.0.0.1` / `localhost` / `[::1]`),局域网来源一律返回 403。本插件的路由位于官方鉴权栅栏之外,而官方基于浏览器 cookie 的鉴权依赖插件无法获取的签名密钥;删除数据属高风险操作,故从严处理。将 DSH 绑定至 `0.0.0.0` 并从其他机器访问时,删除将返回 403。
|
|
60
51
|
- **无法读取会话目录时不作任何改动**。会话树收集失败即整体放弃删除,不会将「读取失败」视为「磁盘上不存在」。
|
|
61
52
|
- 批量删除浮层不锁定焦点,按 `Tab` 键可能移至浮层后方的页面元素。
|
package/lib/client.js
CHANGED
|
@@ -101,7 +101,6 @@ window.__ModuleLoader__.load({
|
|
|
101
101
|
"err.E_RECYCLE_MISMATCH": "回收站中仅找到 {matched}/{total} 个会话,可能有目录已被永久删除,请立即检查回收站",
|
|
102
102
|
"err.E_WS_DETACH_FAILED": "从工作区摘除失败:{reason}",
|
|
103
103
|
"err.E_DETACH_FAILED": "会话 {id} 未能从工作区摘除:{reason}",
|
|
104
|
-
"err.E_ABORTED_NO_CHANGE": "{reason};已放弃删除,未改动任何数据",
|
|
105
104
|
"err.E_NOT_FOUND": "未找到该会话",
|
|
106
105
|
"err.E_MOVE_ABORTED": "{reason};已中止,存储域与登记表保持原样",
|
|
107
106
|
"err.E_LOOPBACK_ONLY": "已拒绝:该接口仅接受本机(回环)请求",
|
|
@@ -110,6 +109,7 @@ window.__ModuleLoader__.load({
|
|
|
110
109
|
"err.E_BODY_READ_FAILED": "读取请求体失败",
|
|
111
110
|
"err.E_SESSION_ID_REQUIRED": "缺少会话编号",
|
|
112
111
|
"err.E_SESSION_ID_INVALID": "会话编号格式不正确:{id}",
|
|
112
|
+
"err.E_AMBIGUOUS_TARGET": "会话编号 {id} 对应多个会话目录({dirs}),无法确定删除哪一个,已拒绝删除,未改动任何数据",
|
|
113
113
|
"err.E_INTERNAL": "服务端内部错误:{reason}",
|
|
114
114
|
"leftover.L_SESSION_DIR": "会话目录",
|
|
115
115
|
"leftover.L_PROJ_CACHE": "投影缓存",
|
|
@@ -178,7 +178,6 @@ window.__ModuleLoader__.load({
|
|
|
178
178
|
"err.E_RECYCLE_MISMATCH": "Only {matched}/{total} sessions were found in the recycle bin; some directories may have been permanently deleted, so check the recycle bin immediately",
|
|
179
179
|
"err.E_WS_DETACH_FAILED": "Failed to detach from the workspace: {reason}",
|
|
180
180
|
"err.E_DETACH_FAILED": "Session {id} could not be detached from the workspace: {reason}",
|
|
181
|
-
"err.E_ABORTED_NO_CHANGE": "{reason}; deletion was abandoned and no data was changed",
|
|
182
181
|
"err.E_NOT_FOUND": "Session not found",
|
|
183
182
|
"err.E_MOVE_ABORTED": "{reason}; the operation was stopped and no records were deleted",
|
|
184
183
|
"err.E_LOOPBACK_ONLY": "Refused: this endpoint accepts loopback requests only",
|
|
@@ -187,6 +186,7 @@ window.__ModuleLoader__.load({
|
|
|
187
186
|
"err.E_BODY_READ_FAILED": "Failed to read the request body",
|
|
188
187
|
"err.E_SESSION_ID_REQUIRED": "Missing session id",
|
|
189
188
|
"err.E_SESSION_ID_INVALID": "Invalid session id format: {id}",
|
|
189
|
+
"err.E_AMBIGUOUS_TARGET": "Session id {id} matches more than one session directory ({dirs}), so the target is ambiguous. Deletion was refused and no data was changed",
|
|
190
190
|
"err.E_INTERNAL": "Internal server error: {reason}",
|
|
191
191
|
"leftover.L_SESSION_DIR": "session directory",
|
|
192
192
|
"leftover.L_PROJ_CACHE": "projection cache",
|
package/lib/index.js
CHANGED
|
@@ -74,28 +74,6 @@ function isInside(root, target) {
|
|
|
74
74
|
const t = resolve(target).toLowerCase();
|
|
75
75
|
return t.startsWith(r + "\\") || t.startsWith(r + "/");
|
|
76
76
|
}
|
|
77
|
-
// 读磁盘的函数一律在读失败时返回 ok:false。将「读不出来」当作「磁盘上没有」会误判活动会话。
|
|
78
|
-
function findSessionDirs(sessionId) {
|
|
79
|
-
const root = sessionsRoot();
|
|
80
|
-
const variants = idVariants(sessionId);
|
|
81
|
-
const out = [];
|
|
82
|
-
let slugs = [];
|
|
83
|
-
try {
|
|
84
|
-
slugs = readdirSync(root, { withFileTypes: true });
|
|
85
|
-
} catch (error) {
|
|
86
|
-
return { ...fail("E_SESSION_ROOT_UNREADABLE", { reason: String(error?.message || error) }), dirs: out };
|
|
87
|
-
}
|
|
88
|
-
for (const slug of slugs) {
|
|
89
|
-
if (!slug.isDirectory()) continue;
|
|
90
|
-
for (const v of variants) {
|
|
91
|
-
const p = join(root, slug.name, v);
|
|
92
|
-
try {
|
|
93
|
-
if (statSync(p).isDirectory() && !out.includes(p)) out.push(p);
|
|
94
|
-
} catch {}
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
return { ok: true, dirs: out };
|
|
98
|
-
}
|
|
99
77
|
|
|
100
78
|
const ZSTD_MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd]);
|
|
101
79
|
// 复用同一块头缓冲:readSessionHeader 为同步函数,不会重入;数百个会话逐个分配将浪费上百 MB。
|
|
@@ -134,14 +112,16 @@ function readSessionHeader(dir) {
|
|
|
134
112
|
}
|
|
135
113
|
return null;
|
|
136
114
|
}
|
|
115
|
+
const normId = (value) => String(value).replace(/^session-/, "");
|
|
116
|
+
const sessionKey = (value) => "session-" + normId(value);
|
|
117
|
+
// 读磁盘的函数一律在读失败时返回 ok:false。将「读不出来」当作「磁盘上没有」会误判活动会话。
|
|
137
118
|
function indexSessionsOnDisk() {
|
|
138
|
-
const
|
|
139
|
-
const byDir = new Map();
|
|
119
|
+
const entries = [];
|
|
140
120
|
let slugs = [];
|
|
141
121
|
try {
|
|
142
122
|
slugs = readdirSync(sessionsRoot(), { withFileTypes: true });
|
|
143
123
|
} catch (error) {
|
|
144
|
-
return { ...fail("E_SESSION_ROOT_UNREADABLE", { reason: String(error?.message || error) }),
|
|
124
|
+
return { ...fail("E_SESSION_ROOT_UNREADABLE", { reason: String(error?.message || error) }), entries };
|
|
145
125
|
}
|
|
146
126
|
for (const slug of slugs) {
|
|
147
127
|
if (!slug.isDirectory()) continue;
|
|
@@ -150,17 +130,29 @@ function indexSessionsOnDisk() {
|
|
|
150
130
|
try {
|
|
151
131
|
dirs = readdirSync(slugDir, { withFileTypes: true });
|
|
152
132
|
} catch (error) {
|
|
153
|
-
return { ...fail("E_WORKSPACE_DIR_UNREADABLE", { name: slug.name, reason: String(error?.message || error) }),
|
|
133
|
+
return { ...fail("E_WORKSPACE_DIR_UNREADABLE", { name: slug.name, reason: String(error?.message || error) }), entries };
|
|
154
134
|
}
|
|
155
135
|
for (const d of dirs) {
|
|
156
136
|
if (!d.isDirectory()) continue;
|
|
157
137
|
const dir = join(slugDir, d.name);
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
138
|
+
const header = readSessionHeader(dir);
|
|
139
|
+
// 编号以 header 为权威:目录名可能是另一种写法,归一化后两者会撞成同一个编号。
|
|
140
|
+
const id = typeof header?.id === "string" && header.id.length > 0 ? header.id : sessionKey(d.name);
|
|
141
|
+
entries.push({ dir, dirName: d.name, id, header });
|
|
161
142
|
}
|
|
162
143
|
}
|
|
163
|
-
return { ok: true,
|
|
144
|
+
return { ok: true, entries };
|
|
145
|
+
}
|
|
146
|
+
// 点名优先:同一编号可能对应多个目录(裸名与带前缀并存),取目录名与请求完全一致的那个;
|
|
147
|
+
// 没有完全一致的才取唯一候选;仍有多个即拒绝,一个字节都不动。
|
|
148
|
+
function resolveSessionRoot(sessionId, entries) {
|
|
149
|
+
const want = normId(sessionId);
|
|
150
|
+
const candidates = entries.filter((e) => normId(e.id) === want);
|
|
151
|
+
if (candidates.length === 0) return { ok: true, entry: null };
|
|
152
|
+
const exact = candidates.filter((e) => e.dirName === sessionId);
|
|
153
|
+
if (exact.length === 1) return { ok: true, entry: exact[0] };
|
|
154
|
+
if (candidates.length === 1) return { ok: true, entry: candidates[0] };
|
|
155
|
+
return fail("E_AMBIGUOUS_TARGET", { id: sessionId, dirs: candidates.map((e) => e.dirName).join(", ") }, { status: 409 });
|
|
164
156
|
}
|
|
165
157
|
function permanentSessionKeys(ctx) {
|
|
166
158
|
const keys = new Set();
|
|
@@ -187,49 +179,59 @@ function permanentSessionKeys(ctx) {
|
|
|
187
179
|
}
|
|
188
180
|
return { ok: true, keys };
|
|
189
181
|
}
|
|
190
|
-
//
|
|
182
|
+
// 收集该会话及其磁盘上的全部子代理后代,返回「后代在前、父在后」的目录与编号清单。
|
|
191
183
|
function collectSessionTree(ctx, sessionId) {
|
|
192
184
|
const perm = permanentSessionKeys(ctx);
|
|
193
185
|
if (!perm.ok) return fail("E_PROTECTED_LIST_UNREADABLE", { reason: perm });
|
|
194
186
|
const protectedKeys = perm.keys;
|
|
195
187
|
const index = indexSessionsOnDisk();
|
|
196
188
|
if (!index.ok) return fail("E_INDEX_UNREADABLE", { reason: index });
|
|
197
|
-
const
|
|
198
|
-
|
|
189
|
+
const root = resolveSessionRoot(sessionId, index.entries);
|
|
190
|
+
if (!root.ok) return root;
|
|
191
|
+
if (!root.entry) return { ok: true, dirs: [], ids: [] };
|
|
192
|
+
const dirs = [];
|
|
193
|
+
const ids = [];
|
|
194
|
+
// 空壳目录没有日志,不可能有子代理,只删它自己。
|
|
195
|
+
if (!root.entry.header) {
|
|
196
|
+
dirs.push(root.entry.dir);
|
|
197
|
+
ids.push(root.entry.id);
|
|
198
|
+
return { ok: true, dirs, ids };
|
|
199
|
+
}
|
|
199
200
|
const childrenOf = new Map();
|
|
200
|
-
for (const entry of
|
|
201
|
-
if (
|
|
201
|
+
for (const entry of index.entries) {
|
|
202
|
+
if (!entry.header) continue;
|
|
202
203
|
// 仅收 origin === "subagent":由 fork 产生的分叉会话 header 中同样有 parentSession,但其属独立对话,不得连带删除。
|
|
203
|
-
if (entry.header
|
|
204
|
-
const parent = entry.header
|
|
204
|
+
if (entry.header.origin !== "subagent") continue;
|
|
205
|
+
const parent = entry.header.parentSession;
|
|
205
206
|
if (typeof parent !== "string" || parent.length === 0) continue;
|
|
206
|
-
|
|
207
|
-
const list = childrenOf.get(
|
|
208
|
-
list.push(entry
|
|
209
|
-
childrenOf.set(
|
|
207
|
+
if (protectedKeys.has(sessionKey(entry.id))) continue;
|
|
208
|
+
const list = childrenOf.get(parent) ?? [];
|
|
209
|
+
list.push(entry);
|
|
210
|
+
childrenOf.set(parent, list);
|
|
210
211
|
}
|
|
211
|
-
|
|
212
|
+
// 先按 header 里的原样编号配对,配不上再试另一种写法,避免两种写法混用时漏掉后代。
|
|
213
|
+
const childrenFor = (entry) => {
|
|
214
|
+
for (const k of [entry.id, normId(entry.id), sessionKey(entry.id)]) {
|
|
215
|
+
const list = childrenOf.get(k);
|
|
216
|
+
if (list && list.length > 0) return list;
|
|
217
|
+
}
|
|
218
|
+
return [];
|
|
219
|
+
};
|
|
212
220
|
const seen = new Set();
|
|
213
|
-
const stack = [
|
|
221
|
+
const stack = [[root.entry, false]];
|
|
214
222
|
while (stack.length > 0) {
|
|
215
|
-
const
|
|
216
|
-
if (
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
const entry
|
|
225
|
-
if (entry) dirs.push(entry.dir);
|
|
226
|
-
}
|
|
227
|
-
const rootDirs = new Set(dirs);
|
|
228
|
-
for (const name of idVariants(sessionId)) {
|
|
229
|
-
const entry = byDir.get(name);
|
|
230
|
-
if (entry && !rootDirs.has(entry.dir)) { dirs.push(entry.dir); rootDirs.add(entry.dir); }
|
|
223
|
+
const [entry, expanded] = stack.pop();
|
|
224
|
+
if (expanded) {
|
|
225
|
+
dirs.push(entry.dir);
|
|
226
|
+
ids.push(entry.id);
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
if (seen.has(entry.dir)) continue;
|
|
230
|
+
seen.add(entry.dir);
|
|
231
|
+
stack.push([entry, true]);
|
|
232
|
+
for (const child of childrenFor(entry)) if (!seen.has(child.dir)) stack.push([child, false]);
|
|
231
233
|
}
|
|
232
|
-
return { ok: true, dirs };
|
|
234
|
+
return { ok: true, dirs, ids };
|
|
233
235
|
}
|
|
234
236
|
|
|
235
237
|
// 移入回收站依赖系统脚本宿主 cscript:node 无回收站接口,PowerShell 每次启动开销过大。
|
|
@@ -369,7 +371,7 @@ function verifyRecycled(before, dirs) {
|
|
|
369
371
|
function recycledAll(before, dirs) {
|
|
370
372
|
return verifyRecycled(before, dirs).matched === dirs.length && !dirs.some((d) => existsSync(d));
|
|
371
373
|
}
|
|
372
|
-
// cscript
|
|
374
|
+
// cscript 无回应不等于搬移失败:先查一次索引,全部命中即按成功收尾,否则将误报失败并留下残留记录。
|
|
373
375
|
function salvageMoved(before, dirs) {
|
|
374
376
|
return recycledAll(before, dirs) ? { ok: true, moved: dirs.length, salvaged: true } : null;
|
|
375
377
|
}
|
|
@@ -418,7 +420,7 @@ async function recycleBinMove(dirs) {
|
|
|
418
420
|
}
|
|
419
421
|
const m = text.match(/RS\|(\d+)\|?([^\r\n]*)/);
|
|
420
422
|
if (!m) {
|
|
421
|
-
//
|
|
423
|
+
// 结果文件存在但内容不完整(写入中途进程被终止):同样先查索引再判失败。
|
|
422
424
|
const salvaged = salvageMoved(before, dirs);
|
|
423
425
|
if (salvaged) return salvaged;
|
|
424
426
|
return fail("E_RECYCLE_UNRECOGNIZED");
|
|
@@ -427,7 +429,7 @@ async function recycleBinMove(dirs) {
|
|
|
427
429
|
const lastErr = (m[2] || "").trim();
|
|
428
430
|
// 部分成功是可能发生的:搬移逐项进行,失败项数须如实报出。
|
|
429
431
|
if (left > 0) {
|
|
430
|
-
// 脚本按「原路径是否仍存在」统计 left
|
|
432
|
+
// 脚本按「原路径是否仍存在」统计 left,句柄释放存在延迟时会误判为仍存在,故再用索引查一次。
|
|
431
433
|
const { matched } = verifyRecycled(before, dirs);
|
|
432
434
|
if (matched === dirs.length && !dirs.some((d) => existsSync(d))) {
|
|
433
435
|
return { ok: true, moved: dirs.length, salvaged: true };
|
|
@@ -570,17 +572,10 @@ async function removeFromArchiveSet(ctx, sessionId) {
|
|
|
570
572
|
// → 最后清理记账。磁盘这一步失败即整体中止,后续记录一律不动,供用户重试。
|
|
571
573
|
async function deleteSessionCore(ctx, sessionId) {
|
|
572
574
|
const tree = collectSessionTree(ctx, sessionId);
|
|
573
|
-
|
|
574
|
-
const
|
|
575
|
-
if (fallback && !fallback.ok) {
|
|
576
|
-
return fail("E_ABORTED_NO_CHANGE", { reason: fallback });
|
|
577
|
-
}
|
|
578
|
-
const dirs = tree.ok && tree.dirs.length > 0 ? tree.dirs : fallback.dirs;
|
|
579
|
-
if (treeFailed) ctx.logger?.warn?.(`[dsh-session-remover] ${sessionId}:连带展开失败 ${treeFailed.code}`);
|
|
575
|
+
if (!tree.ok) return tree;
|
|
576
|
+
const { dirs, ids } = tree;
|
|
580
577
|
if (dirs.length === 0) return fail("E_NOT_FOUND", {}, { status: 404 });
|
|
581
578
|
|
|
582
|
-
const ids = dirs.map((d) => "session-" + dirName(d).replace(/^session-/, ""));
|
|
583
|
-
|
|
584
579
|
let stopped = false, flushed = false, detached = false;
|
|
585
580
|
for (const id of ids) {
|
|
586
581
|
stopped = (await stopAgent(ctx, id)) || stopped;
|