@wangxiaocuo/dsh-session-trash 0.6.1 → 0.6.3
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 +85 -0
- package/README.en.md +14 -6
- package/README.md +14 -6
- package/client.js +398 -112
- package/lib/errors.js +61 -1
- package/lib/fsops.js +8 -1
- package/lib/state.js +47 -4
- package/package.json +5 -4
package/lib/errors.js
CHANGED
|
@@ -62,16 +62,76 @@ export function statusOf(error) {
|
|
|
62
62
|
return ERROR_STATUS[code] ?? 500
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Detail keys the Host may return to the Client. Anything else - notably raw
|
|
67
|
+
* filesystem paths or nested error text - stays in the Host log, because the
|
|
68
|
+
* wire contract promises that the trash root's absolute path is never exposed.
|
|
69
|
+
*/
|
|
70
|
+
const SAFE_DETAIL_KEYS = new Set([
|
|
71
|
+
'sessionId',
|
|
72
|
+
'trashId',
|
|
73
|
+
'code',
|
|
74
|
+
'phase',
|
|
75
|
+
'copied',
|
|
76
|
+
'kind',
|
|
77
|
+
'targetKind',
|
|
78
|
+
'expected',
|
|
79
|
+
'measured',
|
|
80
|
+
'activity',
|
|
81
|
+
'descendants',
|
|
82
|
+
'sessions',
|
|
83
|
+
])
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Metrics snapshots are the only nested detail value the Host returns; keep
|
|
87
|
+
* only their numeric counters so no path can ride along.
|
|
88
|
+
*
|
|
89
|
+
* @param {unknown} value candidate metrics object.
|
|
90
|
+
* @returns {object | undefined} the numeric metrics.
|
|
91
|
+
*/
|
|
92
|
+
function sanitizeMetrics(value) {
|
|
93
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined
|
|
94
|
+
const safe = {}
|
|
95
|
+
for (const key of ['sizeBytes', 'fileCount', 'symlinkCount']) {
|
|
96
|
+
if (typeof value[key] === 'number') safe[key] = value[key]
|
|
97
|
+
}
|
|
98
|
+
return Object.keys(safe).length === 0 ? undefined : safe
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* @param {unknown} details arbitrary structured context.
|
|
103
|
+
* @returns {object | undefined} the same context restricted to whitelisted keys.
|
|
104
|
+
*/
|
|
105
|
+
export function sanitizeDetails(details) {
|
|
106
|
+
if (details === null || typeof details !== 'object' || Array.isArray(details)) return undefined
|
|
107
|
+
const safe = {}
|
|
108
|
+
for (const [key, value] of Object.entries(details)) {
|
|
109
|
+
if (!SAFE_DETAIL_KEYS.has(key)) continue
|
|
110
|
+
if (key === 'expected' || key === 'measured') {
|
|
111
|
+
const metrics = sanitizeMetrics(value)
|
|
112
|
+
if (metrics !== undefined) safe[key] = metrics
|
|
113
|
+
continue
|
|
114
|
+
}
|
|
115
|
+
if (Array.isArray(value)) {
|
|
116
|
+
safe[key] = value.filter((item) => typeof item === 'string' || typeof item === 'number')
|
|
117
|
+
continue
|
|
118
|
+
}
|
|
119
|
+
if (value === null || ['string', 'number', 'boolean'].includes(typeof value)) safe[key] = value
|
|
120
|
+
}
|
|
121
|
+
return Object.keys(safe).length === 0 ? undefined : safe
|
|
122
|
+
}
|
|
123
|
+
|
|
65
124
|
/**
|
|
66
125
|
* @param {unknown} error arbitrary thrown value.
|
|
67
126
|
* @returns {{ code: string, message: string, details?: object }} the wire error shape.
|
|
68
127
|
*/
|
|
69
128
|
export function toWireError(error) {
|
|
70
129
|
if (error instanceof TrashError) {
|
|
130
|
+
const details = sanitizeDetails(error.details)
|
|
71
131
|
return {
|
|
72
132
|
code: error.code,
|
|
73
133
|
message: error.message,
|
|
74
|
-
...(
|
|
134
|
+
...(details === undefined ? {} : { details }),
|
|
75
135
|
}
|
|
76
136
|
}
|
|
77
137
|
const message = error instanceof Error ? error.message : String(error)
|
package/lib/fsops.js
CHANGED
|
@@ -294,6 +294,11 @@ export function metricsMatch(entry, measured) {
|
|
|
294
294
|
if (entry === null || entry === undefined) return true
|
|
295
295
|
if (typeof entry.sizeBytes === 'number' && entry.sizeBytes !== measured.sizeBytes) return false
|
|
296
296
|
if (typeof entry.fileCount === 'number' && entry.fileCount !== measured.fileCount) return false
|
|
297
|
+
// A cross-device copy records the symlink count of the source; comparing it
|
|
298
|
+
// catches a symlink that the copy silently dropped.
|
|
299
|
+
if (typeof entry.symlinkCount === 'number' && typeof measured.symlinkCount === 'number' && entry.symlinkCount !== measured.symlinkCount) {
|
|
300
|
+
return false
|
|
301
|
+
}
|
|
297
302
|
return true
|
|
298
303
|
}
|
|
299
304
|
|
|
@@ -356,10 +361,12 @@ async function copyAcrossDevices(source, destination, expected, rename, copy) {
|
|
|
356
361
|
try {
|
|
357
362
|
await fs.rm(source, { recursive: true, force: false, maxRetries: 3, retryDelay: 100 })
|
|
358
363
|
} catch (error) {
|
|
364
|
+
// The paths stay in the Host log; the wire error only carries the error
|
|
365
|
+
// code and the fact that a verified copy already exists.
|
|
359
366
|
throw new TrashError(
|
|
360
367
|
'IO_ERROR',
|
|
361
368
|
'The session was copied to the destination, but the source could not be removed.',
|
|
362
|
-
{
|
|
369
|
+
{ code: errorCode(error) ?? null, copied: true },
|
|
363
370
|
)
|
|
364
371
|
}
|
|
365
372
|
}
|
package/lib/state.js
CHANGED
|
@@ -5,6 +5,34 @@ import { memberList } from './members.js'
|
|
|
5
5
|
/** Maximum concurrent filesystem probes while describing entries. */
|
|
6
6
|
const PROBE_CONCURRENCY = 3
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Known failure families recorded in `lastError`, in match order. The raw text
|
|
10
|
+
* stays an English diagnostic for logs and tooltips; the code is what the
|
|
11
|
+
* Client localizes.
|
|
12
|
+
*/
|
|
13
|
+
const LAST_ERROR_KINDS = [
|
|
14
|
+
[/^The trash copy of this session is missing\./, 'TRASH_COPY_MISSING'],
|
|
15
|
+
[/^The trash copy is missing these member sessions:/, 'TRASH_COPY_MISSING_MEMBERS'],
|
|
16
|
+
[/^The trash object directory is missing\./, 'TRASH_OBJECT_MISSING'],
|
|
17
|
+
[/^The session log could not be deleted:/, 'PURGE_FAILED'],
|
|
18
|
+
[/^The restored session (tree )?(is|was) not visible to session storage\./, 'RESTORE_INVISIBLE'],
|
|
19
|
+
[/^Session storage still reports the moved session tree/, 'MOVE_UNSETTLED'],
|
|
20
|
+
[/^An original session directory unexpectedly exists/, 'SOURCE_CONFLICT'],
|
|
21
|
+
[/^The trash index could not be committed/, 'INDEX_WRITE_FAILED'],
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {unknown} message raw `lastError` text.
|
|
26
|
+
* @returns {string | null} a stable failure family, or null when none was recorded.
|
|
27
|
+
*/
|
|
28
|
+
export function lastErrorCode(message) {
|
|
29
|
+
if (typeof message !== 'string' || message === '') return null
|
|
30
|
+
for (const [pattern, code] of LAST_ERROR_KINDS) {
|
|
31
|
+
if (pattern.test(message)) return code
|
|
32
|
+
}
|
|
33
|
+
return 'IO_ERROR'
|
|
34
|
+
}
|
|
35
|
+
|
|
8
36
|
/**
|
|
9
37
|
* @param {string | null | undefined} directory candidate workspace directory.
|
|
10
38
|
* @returns {Promise<boolean>} whether the directory exists right now.
|
|
@@ -31,7 +59,14 @@ async function directoryExists(directory) {
|
|
|
31
59
|
* }} deps state inputs.
|
|
32
60
|
* @returns {{ snapshot: () => Promise<object>, describe: (entry: object) => Promise<object> }} the state service.
|
|
33
61
|
*/
|
|
34
|
-
export function createStateService({
|
|
62
|
+
export function createStateService({
|
|
63
|
+
store,
|
|
64
|
+
config,
|
|
65
|
+
trashRootDisplay,
|
|
66
|
+
nextSweepAt = () => null,
|
|
67
|
+
cleanup,
|
|
68
|
+
directoryExists: probeDirectory = directoryExists,
|
|
69
|
+
}) {
|
|
35
70
|
/**
|
|
36
71
|
* @returns {object} cleanup diagnostics, or a neutral fallback.
|
|
37
72
|
*/
|
|
@@ -58,11 +93,17 @@ export function createStateService({ store, config, trashRootDisplay, nextSweepA
|
|
|
58
93
|
|
|
59
94
|
/**
|
|
60
95
|
* @param {object} entry committed index entry.
|
|
96
|
+
* @param {Map<string, Promise<boolean>>} [probeCache] workspace probes already
|
|
97
|
+
* started for this snapshot, so entries sharing a cwd probe it once.
|
|
61
98
|
* @returns {Promise<object>} the wire entry.
|
|
62
99
|
*/
|
|
63
|
-
async function describeEntry(entry) {
|
|
100
|
+
async function describeEntry(entry, probeCache = new Map()) {
|
|
64
101
|
const cwd = typeof entry.originalCwd === 'string' && entry.originalCwd !== '' ? entry.originalCwd : null
|
|
65
|
-
|
|
102
|
+
let workspaceNowExists = null
|
|
103
|
+
if (cwd !== null) {
|
|
104
|
+
if (!probeCache.has(cwd)) probeCache.set(cwd, probeDirectory(cwd))
|
|
105
|
+
workspaceNowExists = await probeCache.get(cwd)
|
|
106
|
+
}
|
|
66
107
|
const expiresAt =
|
|
67
108
|
typeof entry.trashedAt === 'number' && entry.trashedAt > 0 ? entry.trashedAt + config.retentionDays * 86_400_000 : null
|
|
68
109
|
const trashed = entry.state === 'trashed'
|
|
@@ -85,6 +126,7 @@ export function createStateService({ store, config, trashRootDisplay, nextSweepA
|
|
|
85
126
|
canForget: entry.state === 'missing',
|
|
86
127
|
requiresOrphanRestore: cwd !== null && workspaceNowExists === false,
|
|
87
128
|
lastError: entry.lastError ?? null,
|
|
129
|
+
lastErrorCode: lastErrorCode(entry.lastError),
|
|
88
130
|
subagentCount: Math.max(0, members.length - 1),
|
|
89
131
|
members: members.map((member) => ({
|
|
90
132
|
sessionId: member.sessionId,
|
|
@@ -104,7 +146,8 @@ export function createStateService({ store, config, trashRootDisplay, nextSweepA
|
|
|
104
146
|
* @returns {Promise<object>} the complete `/state` response body.
|
|
105
147
|
*/
|
|
106
148
|
async snapshot() {
|
|
107
|
-
const
|
|
149
|
+
const probeCache = new Map()
|
|
150
|
+
const entries = await mapLimit(store.entries(), PROBE_CONCURRENCY, (entry) => describeEntry(entry, probeCache))
|
|
108
151
|
let totalBytes = 0
|
|
109
152
|
let failedEntries = 0
|
|
110
153
|
for (const entry of entries) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wangxiaocuo/dsh-session-trash",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.3",
|
|
4
4
|
"description": "DeepSeek Harness plugin: move cold sessions into a recoverable trash, restore them within the retention window, or delete their logs permanently.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "wangxiaocuo",
|
|
@@ -40,11 +40,12 @@
|
|
|
40
40
|
"locale/*.json",
|
|
41
41
|
"icon.svg",
|
|
42
42
|
"README.md",
|
|
43
|
-
"README.en.md"
|
|
43
|
+
"README.en.md",
|
|
44
|
+
"CHANGELOG.md"
|
|
44
45
|
],
|
|
45
46
|
"scripts": {
|
|
46
|
-
"test": "node
|
|
47
|
-
"test:coverage": "node --experimental-test-coverage
|
|
47
|
+
"test": "node test/run-unit.mjs",
|
|
48
|
+
"test:coverage": "node test/run-unit.mjs --experimental-test-coverage",
|
|
48
49
|
"test:e2e": "node test/e2e-web.mjs",
|
|
49
50
|
"test:smoke": "node test/smoke-host.mjs"
|
|
50
51
|
},
|