dsh-conversation-recycle-bin 0.3.8 → 0.3.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +33 -0
- package/README.md +2 -0
- package/client.js +15 -1
- package/config.schema.json +1 -1
- package/lib/core/config.js +3 -0
- package/lib/core/retention.js +74 -0
- package/lib/host/api.js +93 -4
- package/lib/index.js +33 -3
- package/lib/version.js +1 -1
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,39 @@
|
|
|
3
3
|
All notable changes to `dsh-conversation-recycle-bin` are documented here.
|
|
4
4
|
The format follows Keep a Changelog; the project uses Semantic Versioning.
|
|
5
5
|
|
|
6
|
+
## [0.3.10] — 2026-10-27
|
|
7
|
+
|
|
8
|
+
回收站「30 天后自动清理」。这一版把一个**从 0.3.0 起就写在配置里、但从来没人读**的旋钮(`trashRetentionDays`)真正接上了逻辑。
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **`lib/core/retention.js`**:纯函数选期 —— `selectExpired(entries, { retentionDays, now, limit })` 只挑 `status: 'trashed'`、`deletedAt` 可解析且早于窗口的条目,最旧优先;`daysUntilExpiry()` 供 UI 显示剩余天数。任何"说不准"的输入(`retentionDays <= 0`、`deletedAt` 缺失/非法、`trashId` 为空、`now` 非有限值)一律**不清**,宁可留着让人自己删。
|
|
13
|
+
- **Host 侧清扫**:`lib/host/api.js` 新增 `sweepExpiredTrash({ force })`,`trashList()` 每次先扫(5 分钟节流);`lib/index.js` 另加 6 小时定时(启动后 60 秒先扫一次,`unref` 不影响退出)。清扫走与「永久清除」**完全相同**的 `performPurge`,并且在跨进程 mutation 锁内**重新读 manifest、复判 `entryCapabilities().canPurge`**(scan 发生在锁外,期间条目可能已被恢复),单次最多 20 条,单条失败不中断整轮。
|
|
14
|
+
- **UI**:回收站面板顶部写明"条目在删除 N 天后自动清理"(`trashRetentionDays: 0` 时不显示),每行显示"N 天后自动清理"。
|
|
15
|
+
- 审计:自动清理的记录与手动彻底删除同形,额外带 `source: 'retention'`,可与用户操作区分。
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- `config.schema.json` 里 `trashRetentionDays` 的说明改为实际行为(每 6 小时 + 每次列表时检查,0 = 关闭)。
|
|
20
|
+
|
|
21
|
+
### Tests
|
|
22
|
+
|
|
23
|
+
- 新增 `test/retention.test.mjs`(8 条纯函数用例),`test/api.test.mjs` 追加 3 条集成用例(过期条目被下一次列表清掉、`0` 关闭清扫、从未提交的残留条目永不清扫)。测试 153 → **164**。
|
|
24
|
+
|
|
25
|
+
## [0.3.9] — 2026-10-27
|
|
26
|
+
|
|
27
|
+
0.3.8 的紧急修复:**所有删除都会被拒绝**,返回 `409 E_ABANDONED`("The client disconnected before this request ran; nothing was changed.")。0.3.8 发布后不到一天即被用户在使用中撞到。
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
|
|
31
|
+
- **`clientGone()` 把每一个正常请求都当成"客户端已断开"**:0.3.8 为了修 R-01(客户端中止后 Host 照样执行),在 `lib/host/api.js` 里加了断连检测,其中一条是 `req.destroyed === true`。但 Node 的 `IncomingMessage` 在请求体被读完后就自动 destroy 可读侧,所以**任何一次正常 POST 在读完 body 之后 `req.destroyed` 都是 `true`**(Node 24 实测:`{req.destroyed:true, req.aborted:false, req.complete:true, res.destroyed:false, res.socket.destroyed:false}`)。结果是删除、恢复、彻底删除、丢弃四条路由全部在开跑前被判为"客户端已断开"。
|
|
32
|
+
现在只用**能在正常请求中存活**的信号:`res.destroyed`、`res.writableEnded`、`res.socket`/`req.socket` 的 `destroyed`。`req.destroyed` 与已废弃的 `req.aborted` 不再参与判定,代码注释里记了实测值,避免以后有人再加回去。
|
|
33
|
+
- 新增回归用例 **"queue: a fully read body is not a disconnect"**:构造一个 `req.destroyed/readableEnded/complete` 全为 `true` 的请求,断言删除仍然成功、会话确实进了回收站。0.3.8 的写法会让这条用例失败。
|
|
34
|
+
|
|
35
|
+
### Maintenance
|
|
36
|
+
|
|
37
|
+
- 测试 152 → **153**;`lib/version.js` 与 `package.json` 同步到 0.3.9。
|
|
38
|
+
|
|
6
39
|
## [0.3.8] — 2026-10-26
|
|
7
40
|
|
|
8
41
|
技术方案评审团(架构 / 安全 / 验收三方)对 0.3.7 的复核结论是 15 条问题、142 条测试全绿但 **NEEDS WORK**。本版把它们全部修掉,测试 142 → **152**。
|
package/README.md
CHANGED
|
@@ -149,6 +149,8 @@ memory → messages → session/catalog
|
|
|
149
149
|
files/<nodeId>.content
|
|
150
150
|
```
|
|
151
151
|
|
|
152
|
+
**保留期(自动清理)**:`trashRetentionDays`(默认 **30**)现在真的生效 —— Host 每 6 小时清扫一次(启动后 60 秒先扫一次),并且**每次打开回收站列表前也会扫一次**(5 分钟内不重复)。只有 `status: 'trashed'` 且 `deletedAt` 可解析、且早于 `now - trashRetentionDays` 的条目会被清走,走的是与「永久清除」**完全相同**的 `performPurge` 路径(重新读 manifest、复判 `capabilities.canPurge`、写审计 `action: 'purge'` + `source: 'retention'`),单次最多 20 条、最旧优先。`pending`/`restoring`/`restored`/`purging` 与"从未提交"的残留条目**永不自动清**(那是 bug 现场,留给设置页的「恢复」/「discard」出口)。`trashRetentionDays: 0` 关闭清扫,条目一直留到你自己删。回收站列表里会写明"删除 N 天后自动清理",每条还显示剩余天数。
|
|
153
|
+
|
|
152
154
|
`manifest.status` 只允许 `pending → trashed → restoring/restored / purging`。`GET /trash` 把**所有**条目都返回:`trashed` 进 `entries`(回收站 UI,可恢复/彻底删除),其余状态进 `incomplete`(设置页单独列出,提供「恢复」与「discard」两个出口,不再出现"失败了却没有任何清理入口"的死条目)。恢复顺序是删除的逆序:日志 → catalog → 记忆。
|
|
153
155
|
|
|
154
156
|
---
|
package/client.js
CHANGED
|
@@ -112,6 +112,8 @@ window.__ModuleLoader__.load({
|
|
|
112
112
|
'trash.title': 'Recycle bin',
|
|
113
113
|
'trash.empty': 'The recycle bin is empty.',
|
|
114
114
|
'trash.deletedAt': 'Deleted {time}',
|
|
115
|
+
'trash.retention': 'Entries are purged automatically {days} days after deletion. Set trashRetentionDays to 0 to keep them until you purge them yourself.',
|
|
116
|
+
'trash.expiresIn': 'auto-purged in {days} d',
|
|
115
117
|
'trash.modeSoft': 'soft',
|
|
116
118
|
'trash.modeHard': 'permanent',
|
|
117
119
|
'trash.items': '{sessions} conversation(s) · {memories} memory entries',
|
|
@@ -268,6 +270,8 @@ window.__ModuleLoader__.load({
|
|
|
268
270
|
'trash.title': '回收站',
|
|
269
271
|
'trash.empty': '回收站为空。',
|
|
270
272
|
'trash.deletedAt': '删除于 {time}',
|
|
273
|
+
'trash.retention': '条目在删除 {days} 天后自动清理。把 trashRetentionDays 设为 0 可改为一直保留、由你自己彻底删除。',
|
|
274
|
+
'trash.expiresIn': '{days} 天后自动清理',
|
|
271
275
|
'trash.modeSoft': '软删除',
|
|
272
276
|
'trash.modeHard': '彻底',
|
|
273
277
|
'trash.items': '{sessions} 个对话 · {memories} 条记忆',
|
|
@@ -1804,6 +1808,15 @@ window.__ModuleLoader__.load({
|
|
|
1804
1808
|
? state.limits.effectiveBatch
|
|
1805
1809
|
: (state?.limits?.maxBatchSessions ?? 0)
|
|
1806
1810
|
const overLimit = effectiveBatch > 0 && selectedIds.length > effectiveBatch
|
|
1811
|
+
// The Host purges entries on its own after `trashRetentionDays`; the row
|
|
1812
|
+
// hint has to be computed from the same number or it would promise a
|
|
1813
|
+
// window the Host does not keep.
|
|
1814
|
+
const retentionDays = Number(state?.trashRetentionDays) > 0 ? Number(state.trashRetentionDays) : 0
|
|
1815
|
+
const daysLeft = (entry) => {
|
|
1816
|
+
if (retentionDays <= 0 || !Number.isFinite(entry?.deletedAt) || entry.deletedAt <= 0) return null
|
|
1817
|
+
const left = entry.deletedAt + retentionDays * 86_400_000 - Date.now()
|
|
1818
|
+
return left <= 0 ? 0 : Math.ceil(left / 86_400_000)
|
|
1819
|
+
}
|
|
1807
1820
|
|
|
1808
1821
|
const openFeedback = () => dialogStore.open({
|
|
1809
1822
|
kind: 'feedback',
|
|
@@ -1868,12 +1881,13 @@ window.__ModuleLoader__.load({
|
|
|
1868
1881
|
: null,
|
|
1869
1882
|
|
|
1870
1883
|
tab === 'trash' ? h('div', { id: 'dc-panel-trash', role: 'tabpanel', 'aria-labelledby': 'dc-tab-trash', className: 'dc-panel' },
|
|
1884
|
+
retentionDays > 0 ? h('div', { className: 'dc-hint dc-hint-block' }, t('trash.retention', { days: retentionDays })) : null,
|
|
1871
1885
|
trash.length === 0 ? h('div', { className: 'dc-empty' }, t('trash.empty')) : null,
|
|
1872
1886
|
...trash.map((entry) => h('div', { key: entry.trashId, className: 'dc-row' },
|
|
1873
1887
|
h('div', { className: 'dc-row-main' },
|
|
1874
1888
|
h('div', { className: 'dc-row-title' }, entry.title || entry.sessionId),
|
|
1875
1889
|
h('div', { className: 'dc-hint' },
|
|
1876
|
-
`${t('trash.deletedAt', { time: formatTime(entry.deletedAt) })} · ${entry.mode === 'hard' ? t('trash.modeHard') : t('trash.modeSoft')} · ${t('trash.items', { sessions: 1, memories: entry.memory ?? 0 })}`)),
|
|
1890
|
+
`${t('trash.deletedAt', { time: formatTime(entry.deletedAt) })} · ${entry.mode === 'hard' ? t('trash.modeHard') : t('trash.modeSoft')} · ${t('trash.items', { sessions: 1, memories: entry.memory ?? 0 })}${daysLeft(entry) === null ? '' : ` · ${t('trash.expiresIn', { days: daysLeft(entry) })}`}`)),
|
|
1877
1891
|
h('div', { className: 'dc-row-actions' },
|
|
1878
1892
|
h(Button, { onClick: () => { void restore(entry.trashId) } }, t('action.restore')),
|
|
1879
1893
|
purgeTarget === entry.trashId
|
package/config.schema.json
CHANGED
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"description": "When non-empty, a session may only be deleted when its workspace path is inside one of these roots."
|
|
29
29
|
},
|
|
30
30
|
"defaultMode": { "enum": ["soft", "hard"], "default": "soft", "description": "Initial mode selected in the confirmation dialog." },
|
|
31
|
-
"trashRetentionDays": { "type": "number", "minimum": 0, "maximum": 3650, "default": 30, "description": "
|
|
31
|
+
"trashRetentionDays": { "type": "number", "minimum": 0, "maximum": 3650, "default": 30, "description": "Days a committed recycle-bin entry is kept before the Host purges it automatically (checked every six hours and whenever /trash is listed). 0 disables the sweep, leaving entries until the user purges them." },
|
|
32
32
|
"maxBatchSessions": { "type": "integer", "minimum": 1, "maximum": 1000, "default": 50, "description": "Upper bound on one batch. The effective limit is min(this, remaining deletions in the current minute), so a batch can never exceed the rate budget." },
|
|
33
33
|
"maxDeletesPerMinute": { "type": "integer", "minimum": 1, "maximum": 1000, "default": 20, "description": "Sliding-window limit for conversation deletions (single and batch items count individually)." },
|
|
34
34
|
"confirmTokenTtlMs": { "type": "integer", "minimum": 5000, "maximum": 3600000, "default": 120000 },
|
package/lib/core/config.js
CHANGED
|
@@ -40,6 +40,9 @@ export const DEFAULTS = deepFreeze({
|
|
|
40
40
|
protectedSessionIds: [],
|
|
41
41
|
allowedWorkspaceRoots: [],
|
|
42
42
|
defaultMode: 'soft',
|
|
43
|
+
// Days a committed entry may stay in the recycle bin before the Host purges
|
|
44
|
+
// it on its own (see `core/retention.js`). 0 disables the sweep: entries then
|
|
45
|
+
// stay until the user purges or discards them.
|
|
43
46
|
trashRetentionDays: 30,
|
|
44
47
|
maxBatchSessions: 50,
|
|
45
48
|
maxDeletesPerMinute: 20,
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recycle-bin retention policy.
|
|
3
|
+
*
|
|
4
|
+
* `trashRetentionDays` used to be informational only: it was validated, shown
|
|
5
|
+
* in `state()` and then never read again, so an entry stayed on disk forever
|
|
6
|
+
* and the documented "30 days" never happened. This module is the single
|
|
7
|
+
* predicate behind the automatic sweep, kept pure so the rule can be tested
|
|
8
|
+
* without a filesystem.
|
|
9
|
+
*
|
|
10
|
+
* Two deliberate refusals:
|
|
11
|
+
* - only `trashed` entries expire. `pending` / `failed` / `restoring` /
|
|
12
|
+
* `purging` are mid-transaction or unreadable states: their age says
|
|
13
|
+
* nothing about whether the transaction committed, so they are surfaced to
|
|
14
|
+
* the user for an explicit discard instead of being swept.
|
|
15
|
+
* - an entry whose `deletedAt` is missing, zero or not a finite number is
|
|
16
|
+
* never treated as "old". Guessing an age would silently destroy data that
|
|
17
|
+
* a corrupt or hand-edited manifest could not date.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** Milliseconds in one day; exported so the Client-facing math can reuse it. */
|
|
21
|
+
export const DAY_MS = 86_400_000
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Entries that are past the retention window, oldest first.
|
|
25
|
+
*
|
|
26
|
+
* @param {object[]} entries manifests from `trash.scanAll()`
|
|
27
|
+
* @param {object} options
|
|
28
|
+
* @param {number} options.retentionDays 0 (or negative/NaN) disables the sweep
|
|
29
|
+
* @param {() => number|number} [options.now]
|
|
30
|
+
* @param {number} [options.limit] cap per run so a huge bin cannot stall one sweep
|
|
31
|
+
* @returns {object[]}
|
|
32
|
+
*/
|
|
33
|
+
export function selectExpired(entries, { retentionDays, now = Date.now, limit = 20 } = {}) {
|
|
34
|
+
const days = Number(retentionDays)
|
|
35
|
+
if (!Number.isFinite(days) || days <= 0) return []
|
|
36
|
+
const at = typeof now === 'function' ? now() : Number(now)
|
|
37
|
+
if (!Number.isFinite(at)) return []
|
|
38
|
+
const cutoff = at - days * DAY_MS
|
|
39
|
+
const expired = []
|
|
40
|
+
for (const entry of Array.isArray(entries) ? entries : []) {
|
|
41
|
+
if (entry === null || typeof entry !== 'object') continue
|
|
42
|
+
if (entry.status !== 'trashed') continue
|
|
43
|
+
if (typeof entry.trashId !== 'string' || entry.trashId === '') continue
|
|
44
|
+
const deletedAt = entry.deletedAt
|
|
45
|
+
if (!Number.isFinite(deletedAt) || deletedAt <= 0) continue
|
|
46
|
+
if (deletedAt > cutoff) continue
|
|
47
|
+
expired.push(entry)
|
|
48
|
+
}
|
|
49
|
+
expired.sort((left, right) => left.deletedAt - right.deletedAt)
|
|
50
|
+
const max = Number.isFinite(limit) ? Math.max(0, Math.trunc(limit)) : 20
|
|
51
|
+
return expired.slice(0, max)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Whole days left before an entry expires, for the Client's per-row hint.
|
|
56
|
+
* `null` when retention is off or the entry cannot be dated.
|
|
57
|
+
*
|
|
58
|
+
* @param {object} entry
|
|
59
|
+
* @param {object} options
|
|
60
|
+
* @param {number} options.retentionDays
|
|
61
|
+
* @param {() => number|number} [options.now]
|
|
62
|
+
* @returns {number|null}
|
|
63
|
+
*/
|
|
64
|
+
export function daysUntilExpiry(entry, { retentionDays, now = Date.now } = {}) {
|
|
65
|
+
const days = Number(retentionDays)
|
|
66
|
+
if (!Number.isFinite(days) || days <= 0) return null
|
|
67
|
+
const deletedAt = entry?.deletedAt
|
|
68
|
+
if (!Number.isFinite(deletedAt) || deletedAt <= 0) return null
|
|
69
|
+
const at = typeof now === 'function' ? now() : Number(now)
|
|
70
|
+
if (!Number.isFinite(at)) return null
|
|
71
|
+
const left = deletedAt + days * DAY_MS - at
|
|
72
|
+
if (left <= 0) return 0
|
|
73
|
+
return Math.ceil(left / DAY_MS)
|
|
74
|
+
}
|
package/lib/host/api.js
CHANGED
|
@@ -44,6 +44,7 @@ import { join } from 'node:path'
|
|
|
44
44
|
import { DeleteError, ERROR_STATUS, asDeleteError, toWireError } from '../core/errors.js'
|
|
45
45
|
import { redactDetails } from '../core/redact.js'
|
|
46
46
|
import { createAuditLog } from '../core/audit.js'
|
|
47
|
+
import { selectExpired } from '../core/retention.js'
|
|
47
48
|
import { createTrashStore, isTrashId } from '../core/trash.js'
|
|
48
49
|
import { createMemoryService } from '../core/memory/registry.js'
|
|
49
50
|
import { performSessionDelete, performPurge, performRestore, performDiscard, entryCapabilities } from '../core/cascade.js'
|
|
@@ -410,6 +411,14 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
|
|
|
410
411
|
// the settings page responsive while every mutation invalidates it, so the
|
|
411
412
|
// user never sees a stale list after their own action.
|
|
412
413
|
const TRASH_CACHE_TTL_MS = 1_500
|
|
414
|
+
// Retention is a background chore, not a request: at most one sweep every
|
|
415
|
+
// five minutes (a `/trash` poll must not rescan the whole bin each time), and
|
|
416
|
+
// at most this many entries per sweep so one huge bin cannot hold the
|
|
417
|
+
// mutation lock for minutes.
|
|
418
|
+
const RETENTION_SWEEP_THROTTLE_MS = 300_000
|
|
419
|
+
const RETENTION_MAX_PER_RUN = 20
|
|
420
|
+
let lastRetentionSweepAt = 0
|
|
421
|
+
let retentionSweepInFlight = null
|
|
413
422
|
let trashCache = null
|
|
414
423
|
let trashCachedAt = 0
|
|
415
424
|
function invalidateTrashCache() {
|
|
@@ -503,11 +512,17 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
|
|
|
503
512
|
* delete a conversation afterwards.
|
|
504
513
|
*/
|
|
505
514
|
function clientGone(req, res) {
|
|
515
|
+
// Only signals that survive a normal, fully-read request count here.
|
|
516
|
+
// `req.destroyed` is NOT one of them: Node's IncomingMessage auto-destroys
|
|
517
|
+
// its readable side once the body is consumed, so it is `true` on every
|
|
518
|
+
// healthy POST after the body has been read (measured on Node 24:
|
|
519
|
+
// `{req.destroyed:true, req.aborted:false, req.complete:true,
|
|
520
|
+
// res.destroyed:false, res.socket.destroyed:false}`). Using it rejected
|
|
521
|
+
// every deletion with E_ABANDONED in 0.3.8.
|
|
506
522
|
if (res?.destroyed === true) return true
|
|
507
523
|
if (res?.writableEnded === true) return true
|
|
508
|
-
|
|
509
|
-
if (
|
|
510
|
-
if (req?.aborted === true) return true
|
|
524
|
+
const socket = res?.socket ?? req?.socket
|
|
525
|
+
if (socket !== null && socket !== undefined && socket.destroyed === true) return true
|
|
511
526
|
return false
|
|
512
527
|
}
|
|
513
528
|
|
|
@@ -950,6 +965,10 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
|
|
|
950
965
|
}
|
|
951
966
|
|
|
952
967
|
async function trashList() {
|
|
968
|
+
// Sweep before reading so the rows the user sees are the ones that survived
|
|
969
|
+
// the retention window, not entries this very listing is about to purge.
|
|
970
|
+
// The call is throttled and returns immediately when retention is off.
|
|
971
|
+
await sweepExpiredTrash()
|
|
953
972
|
if (trashCache !== null && now() - trashCachedAt < TRASH_CACHE_TTL_MS) return trashCache
|
|
954
973
|
// Every readable manifest is returned: `trashed` entries are the usable
|
|
955
974
|
// recycle bin, while failed/pending/restoring/purging entries used to be
|
|
@@ -985,6 +1004,70 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
|
|
|
985
1004
|
return result
|
|
986
1005
|
}
|
|
987
1006
|
|
|
1007
|
+
/**
|
|
1008
|
+
* Automatic retention: purge entries that have been in the recycle bin for
|
|
1009
|
+
* more than `trashRetentionDays` days.
|
|
1010
|
+
*
|
|
1011
|
+
* The sweep runs under the same `serialize` lock as a user mutation, so it
|
|
1012
|
+
* can never interleave with a restore/purge of the same entry, and it re-reads
|
|
1013
|
+
* each manifest under that lock (the scan that selected the candidates ran
|
|
1014
|
+
* outside it). `force` exists for tests and for a Host that just started;
|
|
1015
|
+
* production callers use the throttle so a `/trash` poll cannot turn into a
|
|
1016
|
+
* scan-and-purge loop.
|
|
1017
|
+
*
|
|
1018
|
+
* Failure policy: one un-purgeable entry never aborts the sweep. The cascade
|
|
1019
|
+
* already wrote its own `purge:failed` audit record, and the entry stays in
|
|
1020
|
+
* the bin for the next run or for the user.
|
|
1021
|
+
*/
|
|
1022
|
+
async function sweepExpiredTrash({ force = false } = {}) {
|
|
1023
|
+
const retentionDays = config.trashRetentionDays
|
|
1024
|
+
if (!(Number(retentionDays) > 0)) return { enabled: false, purged: [], failed: [], retentionDays: 0 }
|
|
1025
|
+
if (retentionSweepInFlight !== null) return retentionSweepInFlight
|
|
1026
|
+
if (force !== true && now() - lastRetentionSweepAt < RETENTION_SWEEP_THROTTLE_MS) {
|
|
1027
|
+
return { skipped: true, purged: [], failed: [], retentionDays }
|
|
1028
|
+
}
|
|
1029
|
+
const run = (async () => {
|
|
1030
|
+
const scannedAt = now()
|
|
1031
|
+
const { entries } = await trash.scanAll()
|
|
1032
|
+
const expired = selectExpired(entries, { retentionDays, now: scannedAt, limit: RETENTION_MAX_PER_RUN })
|
|
1033
|
+
if (expired.length === 0) return { purged: [], failed: [], retentionDays }
|
|
1034
|
+
return serialize(async () => {
|
|
1035
|
+
const purged = []
|
|
1036
|
+
const failed = []
|
|
1037
|
+
for (const candidate of expired) {
|
|
1038
|
+
const entry = await trash.read(candidate.trashId)
|
|
1039
|
+
if (entry === null || !entryCapabilities(entry).canPurge) continue
|
|
1040
|
+
try {
|
|
1041
|
+
await performPurge({ entry, ops, trash, audit, now, actor: 'local-user', source: 'retention', requestId: requestId() })
|
|
1042
|
+
purged.push(entry.trashId)
|
|
1043
|
+
} catch (error) {
|
|
1044
|
+
const normalized = asDeleteError(error)
|
|
1045
|
+
failed.push({ trashId: candidate.trashId, code: normalized.code })
|
|
1046
|
+
logger.warn?.(`[conversation-recycle-bin] retention: ${candidate.trashId} could not be purged (${normalized.code})`)
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
if (purged.length > 0) {
|
|
1050
|
+
gateway.invalidate()
|
|
1051
|
+
invalidateTrashCache()
|
|
1052
|
+
}
|
|
1053
|
+
return { purged, failed, retentionDays }
|
|
1054
|
+
}, { lockTimeoutMs: TRASH_LOCK_BUDGET_MS })
|
|
1055
|
+
})()
|
|
1056
|
+
.catch((error) => {
|
|
1057
|
+
// A busy lock or a crashed scan is not an error the user can act on:
|
|
1058
|
+
// record it and let the next sweep try again.
|
|
1059
|
+
const normalized = asDeleteError(error)
|
|
1060
|
+
logger.warn?.(`[conversation-recycle-bin] retention sweep skipped (${normalized.code})`)
|
|
1061
|
+
return { purged: [], failed: [{ trashId: null, code: normalized.code }], retentionDays }
|
|
1062
|
+
})
|
|
1063
|
+
.finally(() => {
|
|
1064
|
+
retentionSweepInFlight = null
|
|
1065
|
+
lastRetentionSweepAt = now()
|
|
1066
|
+
})
|
|
1067
|
+
retentionSweepInFlight = run
|
|
1068
|
+
return run
|
|
1069
|
+
}
|
|
1070
|
+
|
|
988
1071
|
async function auditTail(limit) {
|
|
989
1072
|
const parsed = Number.parseInt(String(limit ?? '50'), 10)
|
|
990
1073
|
const safe = Number.isFinite(parsed) ? Math.min(Math.max(parsed, 1), 200) : 50
|
|
@@ -1208,8 +1291,14 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
|
|
|
1208
1291
|
return {
|
|
1209
1292
|
handler,
|
|
1210
1293
|
routePrefix: ROUTE_PREFIX,
|
|
1294
|
+
/**
|
|
1295
|
+
* Run one retention sweep. Called by the Host timer in `index.js` and by
|
|
1296
|
+
* `/trash` (throttled); exposed so the entry point does not need to know
|
|
1297
|
+
* how the sweep selects its candidates.
|
|
1298
|
+
*/
|
|
1299
|
+
sweepExpiredTrash,
|
|
1211
1300
|
handler_services: { gateway, memoryService, trash, audit, limiter, feedbackService },
|
|
1212
1301
|
// exported for tests
|
|
1213
|
-
__test: { preview, deletePlan, restoreEntry, purgeEntry, discardEntry, state, sessionsForUi, trashList, auditTail, issueConfirmation, validateConfirmation, markConfirmationUsed, expectedPhrase, effectiveBatchLimit, feedbackService },
|
|
1302
|
+
__test: { preview, deletePlan, restoreEntry, purgeEntry, discardEntry, state, sessionsForUi, trashList, auditTail, sweepExpiredTrash, issueConfirmation, validateConfirmation, markConfirmationUsed, expectedPhrase, effectiveBatchLimit, feedbackService },
|
|
1214
1303
|
}
|
|
1215
1304
|
}
|
package/lib/index.js
CHANGED
|
@@ -5,8 +5,11 @@
|
|
|
5
5
|
* - it depends on nothing but `webServer` (the only service needed to reach
|
|
6
6
|
* the UI); every data service is probed per request so a slimmer profile
|
|
7
7
|
* degrades to a readable error instead of a failed plugin fiber;
|
|
8
|
-
* -
|
|
9
|
-
*
|
|
8
|
+
* - `apply` itself deletes nothing. The only autonomous work it schedules is
|
|
9
|
+
* the recycle-bin retention sweep (`trashRetentionDays`, default 30 days),
|
|
10
|
+
* which is the documented promise of that setting and is switched off with
|
|
11
|
+
* `trashRetentionDays: 0`. Everything else lives behind the user-confirmed
|
|
12
|
+
* HTTP plan (see `host/api.js`);
|
|
10
13
|
* - it registers no agent tool: deleting conversations is a user-only
|
|
11
14
|
* authority and the model must not be able to execute or authorize it.
|
|
12
15
|
*
|
|
@@ -32,6 +35,14 @@ export { PLUGIN_VERSION }
|
|
|
32
35
|
|
|
33
36
|
export { ROUTE_PREFIX }
|
|
34
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Retention cadence. The first sweep waits a minute so a Host start does not
|
|
40
|
+
* compete with boot; afterwards six hours is frequent enough for a
|
|
41
|
+
* day-granularity window and cheap enough to ignore.
|
|
42
|
+
*/
|
|
43
|
+
const RETENTION_FIRST_SWEEP_MS = 60_000
|
|
44
|
+
const RETENTION_SWEEP_INTERVAL_MS = 6 * 60 * 60 * 1000
|
|
45
|
+
|
|
35
46
|
/**
|
|
36
47
|
* Cordis entry point.
|
|
37
48
|
* @param {import('@deepseek-ai/cordis').Context} ctx
|
|
@@ -44,7 +55,6 @@ export function apply(ctx, rawConfig) {
|
|
|
44
55
|
const dshHome = resolveDshHome()
|
|
45
56
|
const paths = resolveDataPaths(dshHome)
|
|
46
57
|
const api = createDeleteApi({ ctx, config, paths })
|
|
47
|
-
|
|
48
58
|
ctx.effect(() => {
|
|
49
59
|
const dispose = ctx.webServer.register({ kind: 'prefix', path: ROUTE_PREFIX, handler: api.handler })
|
|
50
60
|
console.info(
|
|
@@ -60,4 +70,24 @@ export function apply(ctx, rawConfig) {
|
|
|
60
70
|
}
|
|
61
71
|
}
|
|
62
72
|
})
|
|
73
|
+
|
|
74
|
+
// Recycle-bin retention. The sweep is idempotent and takes the same mutation
|
|
75
|
+
// lock as a user action, so a Host that restarts mid-sweep simply resumes on
|
|
76
|
+
// the next tick. Both timers are unref'd: a Host that is otherwise done must
|
|
77
|
+
// still be able to exit.
|
|
78
|
+
ctx.effect(() => {
|
|
79
|
+
const sweep = () => {
|
|
80
|
+
void api.sweepExpiredTrash().catch((error) => {
|
|
81
|
+
console.warn('[conversation-recycle-bin] retention sweep failed:', error)
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
const first = setTimeout(sweep, RETENTION_FIRST_SWEEP_MS)
|
|
85
|
+
const timer = setInterval(sweep, RETENTION_SWEEP_INTERVAL_MS)
|
|
86
|
+
first.unref?.()
|
|
87
|
+
timer.unref?.()
|
|
88
|
+
return () => {
|
|
89
|
+
clearTimeout(first)
|
|
90
|
+
clearInterval(timer)
|
|
91
|
+
}
|
|
92
|
+
})
|
|
63
93
|
}
|
package/lib/version.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-conversation-recycle-bin",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.10",
|
|
4
4
|
"description": "DSH conversation deletion with an optional, previewed cascade into conversation-associated memory: soft delete + recycle bin, audit trail, batch rate limits and per-session rollback. 删除对话(可选一并删除关联记忆):软删除与回收站、审计留痕、批量限流、失败回滚。",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
"description": "删除对话并可选择一并删除该对话的关联记忆;支持软删除、回收站、审计、批量限流与失败回滚。"
|
|
58
58
|
},
|
|
59
59
|
"scripts": {
|
|
60
|
-
"test": "node test/manifest.test.mjs && node test/client-module.test.mjs && node test/core.test.mjs && node test/cascade.test.mjs && node test/memory.test.mjs && node test/trash-audit.test.mjs && node test/feedback.test.mjs && node test/api.test.mjs",
|
|
60
|
+
"test": "node test/manifest.test.mjs && node test/client-module.test.mjs && node test/core.test.mjs && node test/retention.test.mjs && node test/cascade.test.mjs && node test/memory.test.mjs && node test/trash-audit.test.mjs && node test/feedback.test.mjs && node test/api.test.mjs",
|
|
61
61
|
"check": "node scripts/check-syntax.mjs && node test/manifest.test.mjs"
|
|
62
62
|
},
|
|
63
63
|
"dsh": {
|