@kangyishuai/dsh-session-rewind 0.1.0 → 0.1.1
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 +5 -4
- package/README.zh-CN.md +5 -4
- package/lib/orphans.js +141 -0
- package/lib/service.js +52 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
Unofficial plugin for [DeepSeek Harness (DSH)](https://github.com/deepseek-ai/deepseek-harness). Not affiliated with DeepSeek.
|
|
10
10
|
|
|
11
|
-
> **Status: early.** Verified end to end on DSH 0.2.2-rc.1: 31 GUI assertions in a throwaway DSH home,
|
|
11
|
+
> **Status: early.** Verified end to end on DSH 0.2.2-rc.1: 31 GUI assertions in a throwaway DSH home, 158 unit tests, and a CDP-driven desktop pass (conflict refusal, conversation + file rewind, created-file trash, modified-file restore, rescue restore, panel/audit/diagnostics). Young software — keep backups of anything you cannot lose.
|
|
12
12
|
|
|
13
13
|
## What it does
|
|
14
14
|
|
|
@@ -79,7 +79,7 @@ All plugin data lives under `<DSH_HOME>/session-rewind/` (`DSH_HOME` defaults to
|
|
|
79
79
|
| `rescues/<session>/<rescueId>/` | rescue points (current files before a restore) |
|
|
80
80
|
| `audit.jsonl` | one line per rewind (append-only) |
|
|
81
81
|
|
|
82
|
-
Retention: the 20 latest message anchors per session, the 5 latest rescue points, the 20 latest trash operations (30 days), and a 1 GiB soft cap across blobs/trash/rescues. When the cap is exceeded, old trash → old rescues → unreferenced blobs → oldest anchors are cleaned in that order; if still over the cap, new captures stop and diagnostics report it (no referenced data is deleted). Uninstalling keeps the data. The data root can be overridden with the plugin's `dataRoot` config.
|
|
82
|
+
On startup the plugin also removes data for sessions that no longer exist (they were deleted); the audit log is kept. Retention: the 20 latest message anchors per session, the 5 latest rescue points, the 20 latest trash operations (30 days), and a 1 GiB soft cap across blobs/trash/rescues. When the cap is exceeded, old trash → old rescues → unreferenced blobs → oldest anchors are cleaned in that order; if still over the cap, new captures stop and diagnostics report it (no referenced data is deleted). Uninstalling keeps the data. The data root can be overridden with the plugin's `dataRoot` config.
|
|
83
83
|
|
|
84
84
|
## Side effects & risks
|
|
85
85
|
|
|
@@ -94,7 +94,7 @@ Retention: the 20 latest message anchors per session, the 5 latest rescue points
|
|
|
94
94
|
|
|
95
95
|
| DSH version | Status |
|
|
96
96
|
| ----------- | --------------------------------------------------------------------------------------------------------------------------- |
|
|
97
|
-
| 0.2.2-rc.1 | Verified: `pnpm smoke:session-rewind` (31/31) +
|
|
97
|
+
| 0.2.2-rc.1 | Verified: `pnpm smoke:session-rewind` (31/31) + 158 unit tests + CDP-driven desktop pass (conversation-only and file modes) |
|
|
98
98
|
|
|
99
99
|
The plugin declares no `engines.dsh` and no `@deepseek-ai/*` peer range: DSH is pre-release and semver ranges over prereleases are unreliable (see [ADR 0005](../../docs/adr/0005-dsh-version-compatibility.md)). Treat the table as the compatibility statement. After a DSH upgrade run `pnpm probe:runtime`, `pnpm smoke:session-rewind`, and check the plugin's diagnostics tab; if a private seam changed, the plugin degrades instead of breaking the session.
|
|
100
100
|
|
|
@@ -103,7 +103,8 @@ The plugin declares no `engines.dsh` and no `@deepseek-ai/*` peer range: DSH is
|
|
|
103
103
|
```bash
|
|
104
104
|
pnpm test # unit tests (host engine, host RPC, client helpers)
|
|
105
105
|
pnpm verify # repo-wide gates
|
|
106
|
-
pnpm smoke:session-rewind
|
|
106
|
+
pnpm smoke:session-rewind # temp DSH home + real GUI end-to-end (needs dsh & ego-browser)
|
|
107
|
+
pnpm smoke:session-rewind-desktop # read-only desktop check (needs --remote-debugging-port=9222)
|
|
107
108
|
```
|
|
108
109
|
|
|
109
110
|
Design notes: [ADR 0006](../../docs/adr/0006-session-rewind-architecture.md) and [ADR 0007](../../docs/adr/0007-session-rewind-file-restore.md) (Chinese; the repository docs are written in Chinese).
|
package/README.zh-CN.md
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
非官方插件,与 DeepSeek 无关。面向 [DeepSeek Harness (DSH)](https://github.com/deepseek-ai/deepseek-harness)。
|
|
10
10
|
|
|
11
|
-
> **状态:早期。** 已在 DSH 0.2.2-rc.1 上端到端验证:一次性 DSH 目录里 31 项 GUI 断言 +
|
|
11
|
+
> **状态:早期。** 已在 DSH 0.2.2-rc.1 上端到端验证:一次性 DSH 目录里 31 项 GUI 断言 + 158 个单测 + CDP 驱动的桌面版实测(冲突拒绝、对话+文件回退、新建文件进回收站、修改文件还原、救援点恢复、面板/审计/诊断)。插件还年轻,重要数据请自行备份。
|
|
12
12
|
|
|
13
13
|
## 它做什么
|
|
14
14
|
|
|
@@ -79,7 +79,7 @@ dsh plugin --profile desktop add /tmp/kangyishuai-dsh-session-rewind-*.tgz
|
|
|
79
79
|
| `rescues/<session>/<rescueId>/` | 还原前的救援点 |
|
|
80
80
|
| `audit.jsonl` | 每次回溯一行(只追加) |
|
|
81
81
|
|
|
82
|
-
|
|
82
|
+
启动时会自动清理已删除会话的残留数据(entries/trash/救援点);审计日志保留。保留策略:每会话最近 20 个消息锚点、最近 5 份救援点、最近 20 次回收站操作(30 天)、blobs/trash/rescues 合计 1 GiB 软上限。超限时按「旧回收站 → 旧救援点 → 无引用 blob → 最旧锚点」清理;仍超限则停止新捕获并在诊断里告警(不删有引用的数据)。卸载保留数据。数据根可用插件配置 `dataRoot` 覆盖。
|
|
83
83
|
|
|
84
84
|
## 副作用与风险
|
|
85
85
|
|
|
@@ -94,7 +94,7 @@ dsh plugin --profile desktop add /tmp/kangyishuai-dsh-session-rewind-*.tgz
|
|
|
94
94
|
|
|
95
95
|
| DSH 版本 | 状态 |
|
|
96
96
|
| ---------- | ------------------------------------------------------------------------------------------------- |
|
|
97
|
-
| 0.2.2-rc.1 | 已验证:`pnpm smoke:session-rewind` 31/31 +
|
|
97
|
+
| 0.2.2-rc.1 | 已验证:`pnpm smoke:session-rewind` 31/31 + 158 个单测 + CDP 驱动的桌面版实测(仅对话与文件模式) |
|
|
98
98
|
|
|
99
99
|
插件不声明 `engines.dsh`,也不声明 `@deepseek-ai/*` 的 peer 范围:DSH 还在预发布阶段,预发布版本的 semver 范围不可靠(见 [ADR 0005](../../docs/adr/0005-dsh-version-compatibility.md))。以这张表为准。DSH 升级后跑 `pnpm probe:runtime`、`pnpm smoke:session-rewind`,并看插件诊断页签;私有 seam 变了会降级而不是让会话出错。
|
|
100
100
|
|
|
@@ -103,7 +103,8 @@ dsh plugin --profile desktop add /tmp/kangyishuai-dsh-session-rewind-*.tgz
|
|
|
103
103
|
```bash
|
|
104
104
|
pnpm test # 单测(宿主文件引擎、宿主 RPC、客户端 helper)
|
|
105
105
|
pnpm verify # 仓库统一门禁
|
|
106
|
-
pnpm smoke:session-rewind
|
|
106
|
+
pnpm smoke:session-rewind # 临时 DSH 目录 + 真实 GUI 端到端(需要 dsh 与 ego-browser)
|
|
107
|
+
pnpm smoke:session-rewind-desktop # 只读桌面回归(需要以 --remote-debugging-port=9222 重启)
|
|
107
108
|
```
|
|
108
109
|
|
|
109
110
|
设计记录:[ADR 0006](../../docs/adr/0006-session-rewind-architecture.md)、[ADR 0007](../../docs/adr/0007-session-rewind-file-restore.md)。
|
package/lib/orphans.js
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// 孤儿数据清理:会话被删除后,`<DSH_HOME>/session-rewind/` 里可能还留着它的
|
|
3
|
+
// entries / trash / rescues。本模块只做“找出并删除这些残留”,不碰仍然存在的会话,
|
|
4
|
+
// 也不碰 audit.jsonl(审计是 append-only 的历史记录)。
|
|
5
|
+
//
|
|
6
|
+
// 安全性:
|
|
7
|
+
// - 只扫描 `pluginHome` 下的 sessions/ trash/ rescues/ 三个目录;
|
|
8
|
+
// - 会话 id 必须匹配安全字符集,避免路径穿越;
|
|
9
|
+
// - 只有「在 <dataRoot>/sessions/<workspace>/<sessionId> 里找不到」的 id 才算孤儿;
|
|
10
|
+
// - 删除前可以 dryRun 只统计;删除失败逐条记录,不中断其余清理。
|
|
11
|
+
import { readdir, rm, stat } from "node:fs/promises";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
|
|
14
|
+
/** 目录名安全字符集。 */
|
|
15
|
+
const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]*$/u;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @typedef {object} OrphanReport
|
|
19
|
+
* @property {string[]} sessionIds 插件 data 里存在、但会话已不存在的 id
|
|
20
|
+
* @property {string[]} trashIds 孤儿 trash 目录对应的 id
|
|
21
|
+
* @property {string[]} rescueIds 孤儿 rescue 目录对应的 id
|
|
22
|
+
* @property {number} bytes 估算占用字节数
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 读取目录下的子目录名;不存在时返回空数组。
|
|
27
|
+
* @param {string} dir
|
|
28
|
+
* @param {any} [fs]
|
|
29
|
+
* @returns {Promise<string[]>}
|
|
30
|
+
*/
|
|
31
|
+
async function listDirs(dir, fs = { readdir }) {
|
|
32
|
+
let entries;
|
|
33
|
+
try {
|
|
34
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
35
|
+
} catch (_error) {
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
return entries
|
|
39
|
+
.filter((/** @type {any} */ entry) => entry.isDirectory() && SAFE_ID.test(entry.name))
|
|
40
|
+
.map((/** @type {any} */ entry) => entry.name)
|
|
41
|
+
.sort();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 递归计算目录字节数(估算)。
|
|
46
|
+
* @param {string} target
|
|
47
|
+
* @param {any} [fs]
|
|
48
|
+
* @returns {Promise<number>}
|
|
49
|
+
*/
|
|
50
|
+
async function dirBytes(target, fs = { readdir, stat }) {
|
|
51
|
+
let info;
|
|
52
|
+
try {
|
|
53
|
+
info = await fs.stat(target);
|
|
54
|
+
} catch (_error) {
|
|
55
|
+
return 0;
|
|
56
|
+
}
|
|
57
|
+
if (!info.isDirectory()) return info.size;
|
|
58
|
+
let total = 0;
|
|
59
|
+
let entries;
|
|
60
|
+
try {
|
|
61
|
+
entries = await fs.readdir(target);
|
|
62
|
+
} catch (_error) {
|
|
63
|
+
return 0;
|
|
64
|
+
}
|
|
65
|
+
for (const entry of entries)
|
|
66
|
+
total += await dirBytes(join(target, /** @type {string} */ (entry)), fs);
|
|
67
|
+
return total;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 找出插件数据里已经没有对应会话的 id。
|
|
72
|
+
* @param {{dataRoot: string, pluginHome?: string, fs?: any}} input
|
|
73
|
+
* @returns {Promise<OrphanReport>}
|
|
74
|
+
*/
|
|
75
|
+
export async function findOrphans(input) {
|
|
76
|
+
/** @type {any} */
|
|
77
|
+
const fs = input.fs ?? { readdir, rm, stat };
|
|
78
|
+
const pluginHome = input.pluginHome ?? join(input.dataRoot, "session-rewind");
|
|
79
|
+
const existing = new Set();
|
|
80
|
+
const workspaceRoot = join(input.dataRoot, "sessions");
|
|
81
|
+
for (const workspace of await listDirs(workspaceRoot, fs)) {
|
|
82
|
+
for (const sessionId of await listDirs(join(workspaceRoot, workspace), fs)) {
|
|
83
|
+
existing.add(sessionId);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const sessionIds = [];
|
|
87
|
+
const trashIds = [];
|
|
88
|
+
const rescueIds = [];
|
|
89
|
+
let bytes = 0;
|
|
90
|
+
for (const id of await listDirs(join(pluginHome, "sessions"), fs)) {
|
|
91
|
+
if (existing.has(id)) continue;
|
|
92
|
+
sessionIds.push(id);
|
|
93
|
+
bytes += await dirBytes(join(pluginHome, "sessions", id), fs);
|
|
94
|
+
}
|
|
95
|
+
for (const id of await listDirs(join(pluginHome, "trash"), fs)) {
|
|
96
|
+
if (existing.has(id)) continue;
|
|
97
|
+
trashIds.push(id);
|
|
98
|
+
bytes += await dirBytes(join(pluginHome, "trash", id), fs);
|
|
99
|
+
}
|
|
100
|
+
for (const id of await listDirs(join(pluginHome, "rescues"), fs)) {
|
|
101
|
+
if (existing.has(id)) continue;
|
|
102
|
+
rescueIds.push(id);
|
|
103
|
+
bytes += await dirBytes(join(pluginHome, "rescues", id), fs);
|
|
104
|
+
}
|
|
105
|
+
return { sessionIds, trashIds, rescueIds, bytes };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* 删除孤儿数据。`dryRun` 只返回报告、不做任何删除。
|
|
110
|
+
* @param {{dataRoot: string, pluginHome?: string, dryRun?: boolean, fs?: any}} input
|
|
111
|
+
* @returns {Promise<{report: OrphanReport, removed: {sessions: string[], trash: string[], rescues: string[]}, failed: Array<{target: string, error: string}>}>}
|
|
112
|
+
*/
|
|
113
|
+
export async function pruneOrphans(input) {
|
|
114
|
+
/** @type {any} */
|
|
115
|
+
const fs = input.fs ?? { readdir, rm, stat };
|
|
116
|
+
const pluginHome = input.pluginHome ?? join(input.dataRoot, "session-rewind");
|
|
117
|
+
const report = await findOrphans({ ...input, pluginHome });
|
|
118
|
+
/** @type {{sessions: string[], trash: string[], rescues: string[]}} */
|
|
119
|
+
const removed = { sessions: [], trash: [], rescues: [] };
|
|
120
|
+
/** @type {Array<{target: string, error: string}>} */
|
|
121
|
+
const failed = [];
|
|
122
|
+
if (input.dryRun === true) return { report, removed, failed };
|
|
123
|
+
/** @type {Array<[string, string[], string[]]>} */
|
|
124
|
+
const plan = [
|
|
125
|
+
[join(pluginHome, "sessions"), report.sessionIds, removed.sessions],
|
|
126
|
+
[join(pluginHome, "trash"), report.trashIds, removed.trash],
|
|
127
|
+
[join(pluginHome, "rescues"), report.rescueIds, removed.rescues],
|
|
128
|
+
];
|
|
129
|
+
for (const [root, ids, sink] of plan) {
|
|
130
|
+
for (const id of ids) {
|
|
131
|
+
const target = join(root, id);
|
|
132
|
+
try {
|
|
133
|
+
await fs.rm(target, { recursive: true, force: true });
|
|
134
|
+
sink.push(id);
|
|
135
|
+
} catch (error) {
|
|
136
|
+
failed.push({ target, error: error instanceof Error ? error.message : String(error) });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return { report, removed, failed };
|
|
141
|
+
}
|
package/lib/service.js
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
resolveRewindTarget,
|
|
26
26
|
sessionEvents,
|
|
27
27
|
} from "./operations.js";
|
|
28
|
+
import { findOrphans, pruneOrphans } from "./orphans.js";
|
|
28
29
|
import { cutsOf, hasOpenToolCalls, listCandidatesPage, targetContent } from "./plan.js";
|
|
29
30
|
|
|
30
31
|
/**
|
|
@@ -159,6 +160,8 @@ export class SessionRewindService extends TypertRemoteService {
|
|
|
159
160
|
this._store = null;
|
|
160
161
|
this.installFileTracking();
|
|
161
162
|
this.installCommand();
|
|
163
|
+
// 启动时清一次已删除会话的残留数据(失败只告警,不阻塞插件)。
|
|
164
|
+
if (this.config.cleanupOrphans !== false) void this.cleanupOrphans();
|
|
162
165
|
}
|
|
163
166
|
|
|
164
167
|
/** 惰性创建文件引擎:数据根/配置在服务挂载后解析。 @returns {any} */
|
|
@@ -186,6 +189,44 @@ export class SessionRewindService extends TypertRemoteService {
|
|
|
186
189
|
return this._store;
|
|
187
190
|
}
|
|
188
191
|
|
|
192
|
+
/**
|
|
193
|
+
* 删除已不存在会话的插件数据(entries/trash/rescues)。audit.jsonl 保留。
|
|
194
|
+
* @returns {Promise<void>}
|
|
195
|
+
*/
|
|
196
|
+
async cleanupOrphans() {
|
|
197
|
+
try {
|
|
198
|
+
const result = await pruneOrphans({ dataRoot: this.dataRoot, dryRun: false });
|
|
199
|
+
const removed =
|
|
200
|
+
result.removed.sessions.length +
|
|
201
|
+
result.removed.trash.length +
|
|
202
|
+
result.removed.rescues.length;
|
|
203
|
+
if (removed > 0) {
|
|
204
|
+
this.ctx?.logger?.info?.(
|
|
205
|
+
`session-rewind: cleaned ${removed} orphan data dir(s) (${result.report.bytes} bytes)`,
|
|
206
|
+
);
|
|
207
|
+
try {
|
|
208
|
+
await this.store.appendAudit({
|
|
209
|
+
id: `cleanup-${Date.now()}`,
|
|
210
|
+
action: "cleanup-orphans",
|
|
211
|
+
time: Date.now(),
|
|
212
|
+
sessionId: null,
|
|
213
|
+
sessions: result.removed.sessions,
|
|
214
|
+
trash: result.removed.trash,
|
|
215
|
+
rescues: result.removed.rescues,
|
|
216
|
+
bytes: result.report.bytes,
|
|
217
|
+
failed: result.failed,
|
|
218
|
+
});
|
|
219
|
+
} catch (_error) {
|
|
220
|
+
/* 审计失败不影响清理 */
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
} catch (error) {
|
|
224
|
+
this.ctx?.logger?.warn?.(
|
|
225
|
+
`session-rewind: orphan cleanup failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
189
230
|
/** 文件追踪是否开启。 @returns {boolean} */
|
|
190
231
|
trackingEnabled() {
|
|
191
232
|
return this.config.fileTracking !== false;
|
|
@@ -766,6 +807,17 @@ export class SessionRewindService extends TypertRemoteService {
|
|
|
766
807
|
} catch (error) {
|
|
767
808
|
push("审计日志", false, error instanceof Error ? error.message : String(error));
|
|
768
809
|
}
|
|
810
|
+
try {
|
|
811
|
+
const orphans = await findOrphans({ dataRoot: this.dataRoot });
|
|
812
|
+
const count = orphans.sessionIds.length + orphans.trashIds.length + orphans.rescueIds.length;
|
|
813
|
+
push(
|
|
814
|
+
"孤儿数据",
|
|
815
|
+
count === 0,
|
|
816
|
+
count === 0 ? "none" : `${count} dir(s), ${orphans.bytes} bytes`,
|
|
817
|
+
);
|
|
818
|
+
} catch (error) {
|
|
819
|
+
push("孤儿数据", false, error instanceof Error ? error.message : String(error));
|
|
820
|
+
}
|
|
769
821
|
if (typeof request?.sessionId === "string" && request.sessionId !== "") {
|
|
770
822
|
try {
|
|
771
823
|
const { events, session } = sessionEvents(this.deps(), request.sessionId);
|
package/package.json
CHANGED