dsh-conversation-recycle-bin 0.3.13 → 0.3.14
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 +24 -0
- package/README.md +3 -2
- package/lib/core/cascade.js +72 -8
- package/lib/core/errors.js +3 -0
- package/lib/host/api.js +43 -9
- package/lib/version.js +1 -1
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,30 @@
|
|
|
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.14] — 2026-10-30
|
|
7
|
+
|
|
8
|
+
第二轮独立审计(架构 / 安全 / 验收各跑 186 条、并做了变异测试与真实探针)之后的修复:一条会**删掉唯一副本**的读盘失败被当成「空目录」,一条会把用户**刚恢复的对话**再删一次,还有一类条目**三条出路全拒**却仍在 UI 上提供「恢复」按钮。
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- **`hasFiles()` 不再把「读不到」当成「没有文件」**(`lib/host/api.js`):`catch { return false }` 让 Windows 的 EPERM/EBUSY 读失败等价于「空目录」,`canDiscard` 因此为 true,丢弃该条目会连同唯一的 `memory/` bundle 一起删除。现在只有 `ENOENT`/`ENOTDIR` 才算空,其它错误抛出;`entryDisk()` 捕获后按 **「可能有文件」** 上报,`performDiscard()` 的 `hasFiles` 失败同样按「有文件」拒绝。
|
|
13
|
+
- **保留期清扫不再删除用户刚恢复的会话**(`lib/core/cascade.js`、`lib/host/api.js`):`performPurge` 的 soft 分支只看 `session.live`,不看 `archived`;用户 unarchive 之后,1.5s 的 gateway 缓存窗口内执行清扫会 quiesce 并硬删这个**正在使用的**会话。现在 purge 先判 `archived === false` 并抛 `E_SESSION_ALIVE`(409,新增码),清扫在拿锁后先 `gateway.invalidate()` 再 inspect,被跳过的条目按 6 小时退避,不再每轮刷一次失败。
|
|
14
|
+
- **purge 的状态写入挪到判定之后**:`inspect()` 曾放在 `trash.update(status:'purging')` 之后且不在 try 内,一次 inspect 异常就把条目永久留在 `purging`(既不可恢复也不再被保留期清扫)。
|
|
15
|
+
- **头不可读的日志条目现在有出路**:日志在盘上但读不出 `cwd`(截断、编码不对、缺少 header)时,条目永远到不了终态,而「恢复」会答"没有可恢复的内容"、"丢弃"又说"还持有日志"、purge 因状态不可达 —— 三条路全拒,磁盘空间占着。现在:`entryCapabilities()` 接受 `payloadRecoverable: false`,`canRestore` 为 false、`canDiscard` 为 true;`performRestore` 明确报「日志读不出,请丢弃」;`performDiscard` 在重建路径失败时允许删除(仍要求输入确认短语)。
|
|
16
|
+
- **`ops.hasFiles` 的 `readdir` 结果只在目录存在时解释**,未捕获的非 ENOENT 错误不再被静默吞掉。
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- **`engines.node` 从 `>=20` 提高到 `>=22.15`**:代码用到 22.x 的 `readdir(..., { recursive: true })` 与 `AbortSignal` 行为,而 `>=20` 让 DSH 在 Node 20 上安装本插件后必然崩。
|
|
21
|
+
- 测试夹具 `makeTempHome()` 先 `mkdir test/.tmp`:干净检出上 `mkdtemp` 会 ENOENT,之前能过只是因为某个套件恰好先跑并顺手建了目录(干净树实测 160 pass / 26 fail)。
|
|
22
|
+
|
|
23
|
+
### Tests
|
|
24
|
+
|
|
25
|
+
- 新增 `session log stage: the mapping is persisted before the directory moves`(真实 `moveToTrash` + 真实文件系统,断言 `beforeMove` 时源目录仍在、目标尚不存在)。把 `catalog.js` 里 `moveDirectory` 与 `beforeMove` 调换后该用例失败——此前这处变异**全套 186 条仍绿**。
|
|
26
|
+
- 新增 `unrecoverable log: not restorable, and discard is the way out`(真实目录 + 头不可读 → `canRestore:false`、`canDiscard:true`、restore 报错文案、discard 成功)。
|
|
27
|
+
- `trash: a corrupt manifest is surfaced as unreadable and discardable once empty` 更新为符合新语义,并补一条「manifest 损坏但 `memory/` 有文件 → 仍拒绝丢弃」。
|
|
28
|
+
- 测试 186 → **188**;干净树(先删 `test/.tmp`)`node --test test/*.test.mjs` 全绿,不再依赖套件执行顺序。
|
|
29
|
+
|
|
6
30
|
## [0.3.13] — 2026-10-29
|
|
7
31
|
|
|
8
32
|
2026-10-29 桌面端 / 网页端 bug 复核报告(`AUDIT-2026-10-29-desktop-web.md`)中的 4 条功能缺陷与 1 条文档偏差,本版全部修掉。
|
package/README.md
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
给 DeepSeek Harness (DSH) Web 增加「删除对话」:会话列表的「…」菜单与会话详情页头部各有一个删除入口;确认框内可勾选「同时删除关联记忆」(默认不勾选),并实时显示将被影响的记忆条数。删除默认进入插件回收站,可恢复;每次操作写入审计日志;批量删除有上限与限流,批量软删除需输入数量,彻底删除一键执行;事务任一步失败即回滚。
|
|
4
4
|
|
|
5
|
-
> 状态:`0.3.
|
|
5
|
+
> 状态:`0.3.14`,Host + Web Client 双半,零构建(plain JS ESM),只依赖宿主已发布的公开扩展点。
|
|
6
|
+
> 运行要求:**Node.js ≥ 22.15**(Host 侧用到 `readdir(..., { recursive: true })`、`AbortSignal` 等 22.x 行为),DSH ≥ `0.2.1-rc.2`(`package.json` 的 `engines` 已如实声明,DSH 安装校验会据此拒绝更低版本)。
|
|
6
7
|
> 包名:`dsh-conversation-recycle-bin`(原 `dsh-delete-conversation` 因 npm 被他人占用而改名,见 CHANGELOG 0.2.0)。
|
|
7
8
|
> 本 README 同时是设计说明与验收依据;安装/回滚步骤见 [INSTALL.md](INSTALL.md);变更见 [CHANGELOG.md](CHANGELOG.md)。
|
|
8
9
|
|
|
@@ -206,7 +207,7 @@ dsh-conversation-recycle-bin/
|
|
|
206
207
|
│ ├─ memory.js # 适配器装配
|
|
207
208
|
│ ├─ feedback.js # 反馈校验/渲染/投递(http 或 smtp,凭证只读环境变量)
|
|
208
209
|
│ └─ api.js # 同源 HTTP API、确认 token、限流
|
|
209
|
-
└─ test/ #
|
|
210
|
+
└─ test/ # 188 个用例,覆盖用户要求的 5 类场景 + live 删除 + 记忆锁 + 原地刷新 + 部分失败/跨进程/未完成条目 + 反馈通道 + Client 半渲染冒烟
|
|
210
211
|
```
|
|
211
212
|
|
|
212
213
|
---
|
package/lib/core/cascade.js
CHANGED
|
@@ -288,15 +288,21 @@ const PURGEABLE_STATUSES = new Set(['trashed', 'restored'])
|
|
|
288
288
|
* the caller, which is the only side that can stat the entry directory.
|
|
289
289
|
*
|
|
290
290
|
* @param {{ status?: unknown }} entry
|
|
291
|
-
* @param {{ hasLogFiles?: boolean|null, hasMemoryFiles?: boolean|null, requirePayload?: boolean }} [disk]
|
|
291
|
+
* @param {{ hasLogFiles?: boolean|null, hasMemoryFiles?: boolean|null, payloadRecoverable?: boolean|null, requirePayload?: boolean }} [disk]
|
|
292
292
|
* `requirePayload` makes `canRestore` false for entry states that have no
|
|
293
293
|
* payload at all (nothing in the manifest and nothing on disk), which is the
|
|
294
294
|
* answer the list endpoint publishes to the Client.
|
|
295
|
+
* `payloadRecoverable === false` means the log copy is on disk but its header
|
|
296
|
+
* cannot be read: restore has no path to put it back to, so the only action
|
|
297
|
+
* that can reclaim the space is discard.
|
|
295
298
|
*/
|
|
296
299
|
export function entryCapabilities(entry, disk = {}) {
|
|
297
300
|
const status = typeof entry?.status === 'string' && entry.status !== '' ? entry.status : 'unknown'
|
|
298
301
|
const hasLogFiles = disk.hasLogFiles ?? null
|
|
299
302
|
const hasMemoryFiles = disk.hasMemoryFiles ?? null
|
|
303
|
+
// Unreadable log ≠ restorable log. Offering restore here produced a button
|
|
304
|
+
// that always failed with "discard it", while discard itself refused.
|
|
305
|
+
const unrecoverable = disk.payloadRecoverable === false
|
|
300
306
|
const hasSessionLog = entry?.sessionLog !== null && entry?.sessionLog !== undefined
|
|
301
307
|
const hasCatalog = entry?.catalog !== null && entry?.catalog !== undefined
|
|
302
308
|
const memoryTotal = typeof entry?.memory?.total === 'number' ? entry.memory.total : 0
|
|
@@ -307,12 +313,14 @@ export function entryCapabilities(entry, disk = {}) {
|
|
|
307
313
|
const hasPayload = hasSessionLog || hasCatalog || memoryTotal > 0 || hasLogFiles === true || hasMemoryFiles === true
|
|
308
314
|
return {
|
|
309
315
|
status,
|
|
310
|
-
canRestore: RESTORABLE_STATUSES.has(status) && (disk.requirePayload !== true || hasPayload),
|
|
316
|
+
canRestore: RESTORABLE_STATUSES.has(status) && !unrecoverable && (disk.requirePayload !== true || hasPayload),
|
|
311
317
|
canPurge: PURGEABLE_STATUSES.has(status),
|
|
312
318
|
// Discard is the escape hatch for entries that never committed: it is
|
|
313
319
|
// refused while a payload copy still exists, because deleting the folder
|
|
314
|
-
// would destroy the only copy of a log or a memory bundle.
|
|
315
|
-
|
|
320
|
+
// would destroy the only copy of a log or a memory bundle. The one
|
|
321
|
+
// exception is a log whose header cannot be read — nothing can use it any
|
|
322
|
+
// more, so discarding is the only way to get the disk space back.
|
|
323
|
+
canDiscard: status !== 'trashed' && hasMemoryFiles !== true && (unrecoverable || hasLogFiles !== true),
|
|
316
324
|
}
|
|
317
325
|
}
|
|
318
326
|
|
|
@@ -349,6 +357,19 @@ export async function performRestore({ entry, ops, trash, audit, now = Date.now,
|
|
|
349
357
|
if (sessionLog === null && typeof ops.recoverSessionLog === 'function') {
|
|
350
358
|
sessionLog = await ops.recoverSessionLog(entry).catch(() => null)
|
|
351
359
|
}
|
|
360
|
+
// If the log copy is on disk but its header cannot be read (truncated, wrong
|
|
361
|
+
// codec, missing `cwd`), there is no path to put it back to and no state it
|
|
362
|
+
// can ever reach. Say that plainly instead of "nothing left to restore",
|
|
363
|
+
// which sent the user to a discard that then refused to run.
|
|
364
|
+
if (sessionLog === null && typeof ops.pathExists === 'function' && typeof trash?.entryDir === 'function') {
|
|
365
|
+
const dir = trash.entryDir(entry.trashId)
|
|
366
|
+
if (typeof dir === 'string' && await ops.pathExists(join(dir, 'session')).catch(() => false)) {
|
|
367
|
+
throw new DeleteError('E_TRASH_CONFLICT', 'The conversation log in this recycle-bin entry cannot be read, so it cannot be restored. Discard the entry to reclaim the space.', {
|
|
368
|
+
trashId: entry.trashId,
|
|
369
|
+
reason: 'log-unreadable',
|
|
370
|
+
})
|
|
371
|
+
}
|
|
372
|
+
}
|
|
352
373
|
const catalog = entry.catalog !== null && entry.catalog !== undefined && typeof entry.catalog === 'object' ? entry.catalog : null
|
|
353
374
|
let memorySummary = entry.memory !== null && entry.memory !== undefined && typeof entry.memory === 'object' ? entry.memory : null
|
|
354
375
|
let hasMemory = entry.includeMemory === true && memorySummary !== null && (memorySummary.total ?? 0) > 0
|
|
@@ -519,11 +540,35 @@ export async function performPurge({ entry, ops, trash, audit, now = Date.now, a
|
|
|
519
540
|
throw new DeleteError('E_TRASH_CONFLICT', 'This recycle-bin entry cannot be purged in its current state.', { trashId: entry.trashId, status: entry.status })
|
|
520
541
|
}
|
|
521
542
|
const startedAt = now()
|
|
543
|
+
// Both checks happen BEFORE the status moves to `purging`: doing them after
|
|
544
|
+
// the write left a thrown refusal with the entry stuck in a state that
|
|
545
|
+
// neither restore nor the retention sweep will ever pick up again.
|
|
546
|
+
let softSession = null
|
|
547
|
+
if (entry.mode === 'soft' && typeof ops.session?.inspect === 'function') {
|
|
548
|
+
try {
|
|
549
|
+
softSession = await ops.session.inspect(entry.sessionId)
|
|
550
|
+
} catch (error) {
|
|
551
|
+
throw new DeleteError('E_INTERNAL', 'Could not tell whether the conversation is still visible, so the entry was not purged.', {
|
|
552
|
+
trashId: entry.trashId,
|
|
553
|
+
sessionId: entry.sessionId,
|
|
554
|
+
cause: error instanceof Error ? error.message : String(error),
|
|
555
|
+
}, { cause: error })
|
|
556
|
+
}
|
|
557
|
+
// A soft-deleted entry archived its session. If the user brought it back,
|
|
558
|
+
// the conversation is visible and usable again; purging would stop it and
|
|
559
|
+
// delete it a second time, which is the opposite of what restore promised.
|
|
560
|
+
if (softSession !== null && softSession.archived === false) {
|
|
561
|
+
throw new DeleteError('E_SESSION_ALIVE', 'This conversation is visible in the Host again, so it was not purged. Archive it first if you really want it gone.', {
|
|
562
|
+
trashId: entry.trashId,
|
|
563
|
+
sessionId: entry.sessionId,
|
|
564
|
+
})
|
|
565
|
+
}
|
|
566
|
+
}
|
|
522
567
|
await trash.update(entry.trashId, { status: 'purging' })
|
|
523
568
|
const steps = []
|
|
524
569
|
|
|
525
570
|
if (entry.mode === 'soft') {
|
|
526
|
-
const session =
|
|
571
|
+
const session = softSession
|
|
527
572
|
if (session !== null) {
|
|
528
573
|
if (session.live === true) {
|
|
529
574
|
steps.push({
|
|
@@ -644,16 +689,35 @@ export async function performDiscard({ entry, ops, trash, audit, now = Date.now,
|
|
|
644
689
|
const holdsLog = typeof ops.pathExists === 'function'
|
|
645
690
|
? await ops.pathExists(join(entryDir, 'session'))
|
|
646
691
|
: entry.sessionLog !== null && entry.sessionLog !== undefined
|
|
647
|
-
|
|
692
|
+
// A log with no recorded location is normally restorable (the header carries
|
|
693
|
+
// the cwd). When the header is unreadable there is no such path: the log can
|
|
694
|
+
// never go back, so discard is the only way to reclaim the folder — refusing
|
|
695
|
+
// it left the entry stuck in `pending` forever.
|
|
696
|
+
let logUnrecoverable = false
|
|
697
|
+
if (holdsLog === true && (entry.sessionLog === null || entry.sessionLog === undefined) && typeof ops.recoverSessionLog === 'function') {
|
|
698
|
+
logUnrecoverable = (await ops.recoverSessionLog(entry).catch(() => null)) === null
|
|
699
|
+
}
|
|
700
|
+
if (holdsLog === true && !logUnrecoverable) {
|
|
648
701
|
throw new DeleteError('E_TRASH_CONFLICT', 'This incomplete entry still holds the conversation log; restore or purge it instead of discarding it.', {
|
|
649
702
|
trashId: entry.trashId,
|
|
650
703
|
status: entry.status,
|
|
651
704
|
})
|
|
652
705
|
}
|
|
653
|
-
|
|
706
|
+
// An unreadable memory folder must count as "holds files": the old `false`
|
|
707
|
+
// default let discard delete the only copy of a bundle it never looked at.
|
|
708
|
+
let holdsMemory = false
|
|
709
|
+
if (typeof ops.hasFiles === 'function') {
|
|
710
|
+
holdsMemory = await ops.hasFiles(join(entryDir, 'memory')).catch(() => true)
|
|
711
|
+
} else {
|
|
712
|
+
holdsMemory = entry.includeMemory === true && (entry.memory?.total ?? 0) > 0
|
|
713
|
+
}
|
|
654
714
|
// The status half of the decision is shared with the UI; the two refusals
|
|
655
715
|
// below stay separate because their advice differs (purge vs restore).
|
|
656
|
-
const capabilities = entryCapabilities(entry, {
|
|
716
|
+
const capabilities = entryCapabilities(entry, {
|
|
717
|
+
hasLogFiles: holdsLog === true && !logUnrecoverable,
|
|
718
|
+
hasMemoryFiles: holdsMemory,
|
|
719
|
+
payloadRecoverable: logUnrecoverable ? false : null,
|
|
720
|
+
})
|
|
657
721
|
if (holdsMemory || !capabilities.canDiscard) {
|
|
658
722
|
const which = holdsMemory ? 'memory files' : 'payload'
|
|
659
723
|
throw new DeleteError('E_TRASH_CONFLICT', `This incomplete entry still holds ${which}; restore or purge it instead of discarding it.`, {
|
package/lib/core/errors.js
CHANGED
|
@@ -37,6 +37,9 @@ export const ERROR_STATUS = Object.freeze({
|
|
|
37
37
|
// know before it retries or refreshes.
|
|
38
38
|
E_QUEUE_TIMEOUT: 503,
|
|
39
39
|
E_ABANDONED: 409,
|
|
40
|
+
// A soft-deleted conversation the user brought back: purging it would delete
|
|
41
|
+
// a live conversation a second time.
|
|
42
|
+
E_SESSION_ALIVE: 409,
|
|
40
43
|
// Feedback channel: its own codes, so the Client can distinguish "this Host
|
|
41
44
|
// has no mail transport" from "the transport refused the message".
|
|
42
45
|
E_FEEDBACK_DISABLED: 503,
|
package/lib/host/api.js
CHANGED
|
@@ -405,12 +405,23 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
|
|
|
405
405
|
return false
|
|
406
406
|
}
|
|
407
407
|
},
|
|
408
|
+
/**
|
|
409
|
+
* Whether a directory holds anything at all. This used to swallow every
|
|
410
|
+
* error and answer `false`, which made "I cannot read it" indistinguishable
|
|
411
|
+
* from "it is empty": a Windows EPERM/EBUSY on the memory folder then read
|
|
412
|
+
* as "no memory here", and discarding that entry deleted the one remaining
|
|
413
|
+
* copy of the bundle. Callers now decide what an unreadable directory
|
|
414
|
+
* means; they all treat it as "may hold files".
|
|
415
|
+
*/
|
|
408
416
|
hasFiles: async (target) => {
|
|
409
417
|
try {
|
|
410
418
|
const entries = await readdir(target, { recursive: true })
|
|
411
419
|
return entries.length > 0
|
|
412
|
-
} catch {
|
|
413
|
-
|
|
420
|
+
} catch (error) {
|
|
421
|
+
// "Not there" really does mean "nothing to delete"; anything else means
|
|
422
|
+
// we could not look, and that must not be reported as empty.
|
|
423
|
+
if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') return false
|
|
424
|
+
throw error
|
|
414
425
|
}
|
|
415
426
|
},
|
|
416
427
|
/**
|
|
@@ -1031,18 +1042,34 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
|
|
|
1031
1042
|
}
|
|
1032
1043
|
|
|
1033
1044
|
/** Disk payload of one entry, for the capability flags the Client renders. */
|
|
1034
|
-
async function entryDisk(trashId) {
|
|
1035
|
-
if (!isTrashId(trashId)) return { hasLogFiles: false, hasMemoryFiles: false }
|
|
1045
|
+
async function entryDisk(trashId, entry = null) {
|
|
1046
|
+
if (!isTrashId(trashId)) return { hasLogFiles: false, hasMemoryFiles: false, payloadRecoverable: null }
|
|
1036
1047
|
let dir
|
|
1037
1048
|
try {
|
|
1038
1049
|
dir = trash.entryDir(trashId)
|
|
1039
1050
|
} catch {
|
|
1040
|
-
return { hasLogFiles: false, hasMemoryFiles: false }
|
|
1051
|
+
return { hasLogFiles: false, hasMemoryFiles: false, payloadRecoverable: null }
|
|
1041
1052
|
}
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1053
|
+
const hasLogFiles = await ops.pathExists(join(dir, 'session'))
|
|
1054
|
+
let hasMemoryFiles
|
|
1055
|
+
try {
|
|
1056
|
+
hasMemoryFiles = await ops.hasFiles(join(dir, 'memory'))
|
|
1057
|
+
} catch (error) {
|
|
1058
|
+
// Unreadable is not empty: report the folder as holding files so no
|
|
1059
|
+
// capability flag offers an action that would delete them unseen.
|
|
1060
|
+
logger.warn?.(`[conversation-recycle-bin] could not read the memory folder of ${trashId}: ${error?.message ?? error}`)
|
|
1061
|
+
hasMemoryFiles = true
|
|
1062
|
+
}
|
|
1063
|
+
// A log that is on disk but whose header cannot be read is a dead end: not
|
|
1064
|
+
// restorable (there is no path to put it back to) and not purgeable (the
|
|
1065
|
+
// status never commits). Only "discard" can reclaim that space, so the flag
|
|
1066
|
+
// must say so instead of advertising a restore that always fails.
|
|
1067
|
+
let payloadRecoverable = null
|
|
1068
|
+
if (hasLogFiles === true && entry !== null && (entry.sessionLog === null || entry.sessionLog === undefined)) {
|
|
1069
|
+
const rebuilt = await ops.recoverSessionLog(entry).catch(() => null)
|
|
1070
|
+
payloadRecoverable = rebuilt !== null
|
|
1045
1071
|
}
|
|
1072
|
+
return { hasLogFiles, hasMemoryFiles, payloadRecoverable }
|
|
1046
1073
|
}
|
|
1047
1074
|
|
|
1048
1075
|
async function serializeEntry(entry) {
|
|
@@ -1052,7 +1079,7 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
|
|
|
1052
1079
|
// countdown is therefore decided here, once, by the same predicate the sweep
|
|
1053
1080
|
// uses (`daysUntilExpiry`), and `expiresAt` carries the absolute deadline.
|
|
1054
1081
|
const daysLeft = daysUntilExpiry(entry, { retentionDays, now: now() })
|
|
1055
|
-
const disk = await entryDisk(entry.trashId)
|
|
1082
|
+
const disk = await entryDisk(entry.trashId, entry)
|
|
1056
1083
|
return {
|
|
1057
1084
|
trashId: entry.trashId,
|
|
1058
1085
|
sessionId: entry.sessionId,
|
|
@@ -1172,6 +1199,10 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
|
|
|
1172
1199
|
return serialize(async () => {
|
|
1173
1200
|
const purged = []
|
|
1174
1201
|
const failed = []
|
|
1202
|
+
// The janitor must decide from freshly read state: the gateway caches
|
|
1203
|
+
// `archived` for 1.5s, and a user who unarchives a conversation inside
|
|
1204
|
+
// that window would be judged "still archived" and have it purged.
|
|
1205
|
+
gateway.invalidate()
|
|
1175
1206
|
for (const candidate of expired) {
|
|
1176
1207
|
const entry = await trash.read(candidate.trashId)
|
|
1177
1208
|
if (entry === null || !entryCapabilities(entry).canPurge) continue
|
|
@@ -1198,6 +1229,9 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
|
|
|
1198
1229
|
const running = session.running === true || session.live === true || session.unreadable === true
|
|
1199
1230
|
if (revived || running || session.archived !== true) {
|
|
1200
1231
|
failed.push({ trashId: candidate.trashId, code: 'E_SESSION_ALIVE' })
|
|
1232
|
+
// Re-check in hours, not in the next five minutes: a revived
|
|
1233
|
+
// conversation is a human decision, not a race to win.
|
|
1234
|
+
retentionRetries.set(candidate.trashId, { failures: 1, nextAt: now() + RETENTION_RETRY_MAX_MS })
|
|
1201
1235
|
continue
|
|
1202
1236
|
}
|
|
1203
1237
|
}
|
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.14",
|
|
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",
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"LICENSE"
|
|
34
34
|
],
|
|
35
35
|
"engines": {
|
|
36
|
-
"node": ">=
|
|
36
|
+
"node": ">=22.15",
|
|
37
37
|
"dsh": ">=0.2.1-rc.2"
|
|
38
38
|
},
|
|
39
39
|
"optionalDependencies": {
|