@wangxiaocuo/dsh-session-trash 0.0.0-stage → 0.5.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/LICENSE +21 -0
- package/README.en.md +194 -0
- package/README.md +193 -2
- package/client.js +2173 -0
- package/cordis.patch.yml +3 -0
- package/icon.svg +10 -0
- package/index.js +129 -0
- package/lib/cleanup.js +342 -0
- package/lib/eligibility.js +212 -0
- package/lib/errors.js +91 -0
- package/lib/fsops.js +414 -0
- package/lib/http.js +346 -0
- package/lib/inventory.js +271 -0
- package/lib/paths.js +39 -0
- package/lib/state.js +147 -0
- package/lib/store.js +545 -0
- package/lib/trash-service.js +1294 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +68 -4
package/cordis.patch.yml
ADDED
package/icon.svg
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64" role="img" aria-label="Session trash">
|
|
2
|
+
<rect width="64" height="64" rx="14" fill="#2A6FF0"/>
|
|
3
|
+
<g transform="translate(14 14) scale(2.25)" fill="none" stroke="#FFFFFF" stroke-width="1">
|
|
4
|
+
<path d="M1.28149 3.88831H14.7187"/>
|
|
5
|
+
<path d="M5.41602 3.88833V2.47962C5.41602 2.29282 5.52492 2.11366 5.71876 1.98157C5.9126 1.84948 6.17551 1.77527 6.44964 1.77527H9.55053C9.82466 1.77527 10.0876 1.84948 10.2814 1.98157C10.4753 2.11366 10.5842 2.29282 10.5842 2.47962V3.88833"/>
|
|
6
|
+
<path d="M2.57349 3.88831L3.19366 13.2943C3.21937 13.5502 3.33952 13.7872 3.53065 13.9593C3.72178 14.1313 3.97016 14.2259 4.22729 14.2246H11.7728C12.0299 14.2259 12.2783 14.1313 12.4694 13.9593C12.6605 13.7872 12.7807 13.5502 12.8064 13.2943L13.4266 3.88831"/>
|
|
7
|
+
<path d="M6.44946 6.98926V11.1238"/>
|
|
8
|
+
<path d="M9.55054 6.98926V11.1238"/>
|
|
9
|
+
</g>
|
|
10
|
+
</svg>
|
package/index.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import z from '@deepseek-ai/schemastery'
|
|
2
|
+
import { resolveTrashPaths } from './lib/paths.js'
|
|
3
|
+
import { TrashStore } from './lib/store.js'
|
|
4
|
+
import { createEligibility } from './lib/eligibility.js'
|
|
5
|
+
import { createStateService } from './lib/state.js'
|
|
6
|
+
import { createTrashService } from './lib/trash-service.js'
|
|
7
|
+
import { createCleanupService } from './lib/cleanup.js'
|
|
8
|
+
import { registerApi } from './lib/http.js'
|
|
9
|
+
|
|
10
|
+
/** Host services this plugin cannot work without. */
|
|
11
|
+
export const inject = ['sessionPersistence', 'webServer', 'connection', 'sessions', 'agents']
|
|
12
|
+
|
|
13
|
+
/** Validated plugin configuration; ordinary fields restart the plugin when changed. */
|
|
14
|
+
export const Config = z.object({
|
|
15
|
+
retentionDays: z.natural().min(1).max(3650).default(30).description('Days a trashed session is kept before automatic deletion.'),
|
|
16
|
+
sweepIntervalHours: z
|
|
17
|
+
.natural()
|
|
18
|
+
.min(1)
|
|
19
|
+
.max(168)
|
|
20
|
+
.default(6)
|
|
21
|
+
.description('Hours between automatic cleanup sweeps.'),
|
|
22
|
+
enableAutoCleanup: z.boolean().default(true).description('Automatically delete sessions past the retention period.'),
|
|
23
|
+
debug: z.boolean().default(false).description('Emit detailed trash diagnostics.'),
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Build a plain diagnostics sink that never depends on the shape of the
|
|
28
|
+
* harness logger service.
|
|
29
|
+
*
|
|
30
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx plugin context.
|
|
31
|
+
* @returns {{ info: Function, warn: Function, error: Function, debug: Function }} a prefixed diagnostics sink.
|
|
32
|
+
*/
|
|
33
|
+
function createLogger(ctx) {
|
|
34
|
+
const prefix = 'dsh-session-trash'
|
|
35
|
+
let sink = null
|
|
36
|
+
try {
|
|
37
|
+
const factory = ctx.get('logger')
|
|
38
|
+
if (typeof factory === 'function') {
|
|
39
|
+
const instance = factory(prefix)
|
|
40
|
+
if (instance !== null && instance !== undefined && typeof instance.info === 'function') sink = instance
|
|
41
|
+
} else if (factory !== null && factory !== undefined && typeof factory.info === 'function') {
|
|
42
|
+
sink = factory
|
|
43
|
+
}
|
|
44
|
+
} catch {
|
|
45
|
+
sink = null
|
|
46
|
+
}
|
|
47
|
+
const emit = (level, args) => {
|
|
48
|
+
if (sink !== null && typeof sink[level] === 'function') {
|
|
49
|
+
sink[level](...args)
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
const method = level === 'debug' ? 'log' : level
|
|
53
|
+
console[method](`[${prefix}]`, ...args)
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
info: (...args) => emit('info', args),
|
|
57
|
+
warn: (...args) => emit('warn', args),
|
|
58
|
+
error: (...args) => emit('error', args),
|
|
59
|
+
debug: (...args) => emit('debug', args),
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Register the trash services, its state projection, the HTTP API, crash
|
|
65
|
+
* recovery, and the retention sweep.
|
|
66
|
+
*
|
|
67
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx plugin context.
|
|
68
|
+
* @param {object} config validated plugin configuration.
|
|
69
|
+
* @returns {void}
|
|
70
|
+
*/
|
|
71
|
+
export function apply(ctx, config) {
|
|
72
|
+
const logger = createLogger(ctx)
|
|
73
|
+
const scoped = config.debug ? logger : { ...logger, debug: () => {} }
|
|
74
|
+
const paths = resolveTrashPaths()
|
|
75
|
+
const store = new TrashStore({ root: paths.root, logger: scoped })
|
|
76
|
+
const eligibility = createEligibility({ ctx, store, logger: scoped })
|
|
77
|
+
const trashService = createTrashService({
|
|
78
|
+
ctx,
|
|
79
|
+
store,
|
|
80
|
+
eligibility,
|
|
81
|
+
config,
|
|
82
|
+
logger: scoped,
|
|
83
|
+
paths,
|
|
84
|
+
})
|
|
85
|
+
const cleanupService = createCleanupService({
|
|
86
|
+
ctx,
|
|
87
|
+
trashService,
|
|
88
|
+
config,
|
|
89
|
+
logger: scoped,
|
|
90
|
+
})
|
|
91
|
+
const stateService = createStateService({
|
|
92
|
+
store,
|
|
93
|
+
config,
|
|
94
|
+
trashRootDisplay: paths.display,
|
|
95
|
+
nextSweepAt: () => cleanupService.nextSweepAt(),
|
|
96
|
+
cleanup: () => cleanupService.diagnostics(),
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
ctx.effect(() => () => cleanupService.stop(), 'session-trash: cleanup disposal')
|
|
100
|
+
|
|
101
|
+
const ready = store
|
|
102
|
+
.init()
|
|
103
|
+
.then(async (summary) => {
|
|
104
|
+
logger.info(
|
|
105
|
+
`trash root ready at ${paths.display}; retention ${config.retentionDays}d, sweep every ${config.sweepIntervalHours}h; index ${summary.status}, ${store.entries().length} entries`,
|
|
106
|
+
)
|
|
107
|
+
try {
|
|
108
|
+
const recovery = await trashService.recover()
|
|
109
|
+
if (recovery.changed > 0) {
|
|
110
|
+
logger.info(
|
|
111
|
+
`startup recovery settled ${recovery.changed} entries (${recovery.completed} completed, ${recovery.rolledBack} rolled back, ${recovery.missing} missing)`,
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
} catch (error) {
|
|
115
|
+
logger.error(
|
|
116
|
+
`startup trash recovery failed: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`,
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
cleanupService.start()
|
|
120
|
+
return summary
|
|
121
|
+
})
|
|
122
|
+
.catch((error) => {
|
|
123
|
+
logger.error(`trash root initialization failed at ${paths.display}: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`)
|
|
124
|
+
throw error
|
|
125
|
+
})
|
|
126
|
+
ready.catch(() => {})
|
|
127
|
+
|
|
128
|
+
registerApi({ ctx, eligibility, stateService, trashService, cleanupService, ready, config, logger: scoped })
|
|
129
|
+
}
|
package/lib/cleanup.js
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
/** Delay before the first automatic sweep after activation. */
|
|
2
|
+
export const ACTIVATION_SWEEP_DELAY_MS = 10_000
|
|
3
|
+
|
|
4
|
+
/** How long a page-open sweep waits before triggering another one. */
|
|
5
|
+
export const PAGE_OPEN_THROTTLE_MS = 60 * 60 * 1000
|
|
6
|
+
|
|
7
|
+
/** First delay after a failed sweep; doubled on every further consecutive failure. */
|
|
8
|
+
export const CLEANUP_RETRY_BASE_MS = 60 * 1000
|
|
9
|
+
|
|
10
|
+
/** Upper bound for the automatic retry delay. */
|
|
11
|
+
export const CLEANUP_RETRY_MAX_MS = 60 * 60 * 1000
|
|
12
|
+
|
|
13
|
+
/** Result returned after the service was stopped. */
|
|
14
|
+
const STOPPED_RESULT = Object.freeze({ purged: 0, failed: 0, settled: 0 })
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {unknown} error arbitrary failure.
|
|
18
|
+
* @returns {string} a concise message.
|
|
19
|
+
*/
|
|
20
|
+
function messageOf(error) {
|
|
21
|
+
return error instanceof Error ? error.message : String(error)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {unknown} error arbitrary failure.
|
|
26
|
+
* @returns {string} a bounded diagnostic message for `/state`.
|
|
27
|
+
*/
|
|
28
|
+
function errorText(error) {
|
|
29
|
+
const message = messageOf(error)
|
|
30
|
+
return message.length > 500 ? `${message.slice(0, 497)}...` : message
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx plugin context.
|
|
35
|
+
* @returns {{ interval: Function, timeout: Function } | undefined} the timer service, when composed.
|
|
36
|
+
*/
|
|
37
|
+
function resolveTimer(ctx) {
|
|
38
|
+
let timer
|
|
39
|
+
try {
|
|
40
|
+
timer = typeof ctx.get === 'function' ? ctx.get('timer') : undefined
|
|
41
|
+
} catch {
|
|
42
|
+
timer = undefined
|
|
43
|
+
}
|
|
44
|
+
if (timer !== null && timer !== undefined && typeof timer.interval === 'function' && typeof timer.timeout === 'function') {
|
|
45
|
+
return timer
|
|
46
|
+
}
|
|
47
|
+
if (typeof ctx.interval === 'function' && typeof ctx.timeout === 'function') {
|
|
48
|
+
return { interval: ctx.interval.bind(ctx), timeout: ctx.timeout.bind(ctx) }
|
|
49
|
+
}
|
|
50
|
+
return undefined
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Schedule the retention sweep. Automatic cleanup runs once shortly after
|
|
55
|
+
* activation and then every `sweepIntervalHours`; when the timer service is
|
|
56
|
+
* absent it degrades to activation + trash-page open. A scheduled sweep that
|
|
57
|
+
* fails, or that reports failed purges, is retried with exponential backoff so
|
|
58
|
+
* a transient sharing violation does not wait for the next full interval.
|
|
59
|
+
* Manual cleanup is always available and never gated by `enableAutoCleanup`.
|
|
60
|
+
*
|
|
61
|
+
* @param {{
|
|
62
|
+
* ctx: import('@deepseek-ai/cordis').Context,
|
|
63
|
+
* trashService: { sweep: (options: { allowPurge: boolean, now: number, reason: string }) => Promise<object> },
|
|
64
|
+
* config: { sweepIntervalHours: number, enableAutoCleanup: boolean },
|
|
65
|
+
* logger: { info: Function, warn: Function, error: Function, debug: Function },
|
|
66
|
+
* }} deps host services and the configured cadence.
|
|
67
|
+
* @returns {{
|
|
68
|
+
* start: () => void,
|
|
69
|
+
* stop: () => void,
|
|
70
|
+
* runNow: (options?: { manual?: boolean, reason?: string }) => Promise<object>,
|
|
71
|
+
* maybeSweepOnPageOpen: () => void,
|
|
72
|
+
* nextSweepAt: () => number | null,
|
|
73
|
+
* lastSweepAt: () => number,
|
|
74
|
+
* diagnostics: () => object,
|
|
75
|
+
* }} the cleanup service.
|
|
76
|
+
*/
|
|
77
|
+
export function createCleanupService({ ctx, trashService, config, logger }) {
|
|
78
|
+
const intervalMs = config.sweepIntervalHours * 60 * 60 * 1000
|
|
79
|
+
const timer = resolveTimer(ctx)
|
|
80
|
+
let lastAttemptAt = 0
|
|
81
|
+
let lastSuccessAt = 0
|
|
82
|
+
let lastRun = null
|
|
83
|
+
let scheduledAt = null
|
|
84
|
+
let retryAt = null
|
|
85
|
+
let consecutiveFailures = 0
|
|
86
|
+
let inFlight = null
|
|
87
|
+
let started = false
|
|
88
|
+
let stopped = false
|
|
89
|
+
let disposeActivation = null
|
|
90
|
+
let disposeInterval = null
|
|
91
|
+
let disposeRetry = null
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* @param {unknown} error sweep failure.
|
|
95
|
+
* @returns {void}
|
|
96
|
+
*/
|
|
97
|
+
function report(error) {
|
|
98
|
+
logger.warn(`automatic trash cleanup failed: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* @returns {void}
|
|
103
|
+
*/
|
|
104
|
+
function clearRetry() {
|
|
105
|
+
const dispose = disposeRetry
|
|
106
|
+
disposeRetry = null
|
|
107
|
+
retryAt = null
|
|
108
|
+
if (typeof dispose !== 'function') return
|
|
109
|
+
try {
|
|
110
|
+
dispose()
|
|
111
|
+
} catch (error) {
|
|
112
|
+
logger.debug(`cannot dispose the trash cleanup retry timer: ${messageOf(error)}`)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Schedule one backoff retry after a failed automatic sweep.
|
|
118
|
+
*
|
|
119
|
+
* @param {string} reason failure trigger for diagnostics.
|
|
120
|
+
* @returns {void}
|
|
121
|
+
*/
|
|
122
|
+
function scheduleRetry(reason) {
|
|
123
|
+
if (stopped || !config.enableAutoCleanup) return
|
|
124
|
+
clearRetry()
|
|
125
|
+
const exponent = Math.min(Math.max(consecutiveFailures - 1, 0), 8)
|
|
126
|
+
const delay = Math.min(CLEANUP_RETRY_BASE_MS * 2 ** exponent, CLEANUP_RETRY_MAX_MS)
|
|
127
|
+
retryAt = Date.now() + delay
|
|
128
|
+
const fire = () => {
|
|
129
|
+
retryAt = null
|
|
130
|
+
disposeRetry = null
|
|
131
|
+
void runNow({ reason: 'retry' }).catch(report)
|
|
132
|
+
}
|
|
133
|
+
if (timer !== undefined) {
|
|
134
|
+
disposeRetry = ctx.effect(() => timer.timeout(fire, delay), 'session-trash: cleanup retry')
|
|
135
|
+
} else {
|
|
136
|
+
const handle = setTimeout(fire, delay)
|
|
137
|
+
handle.unref?.()
|
|
138
|
+
disposeRetry = () => clearTimeout(handle)
|
|
139
|
+
}
|
|
140
|
+
logger.warn(
|
|
141
|
+
`trash cleanup (${reason}) will retry in ${Math.round(delay / 1000)}s after ${consecutiveFailures} consecutive failure(s)`,
|
|
142
|
+
)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Run one sweep, joining an in-flight sweep instead of queuing another. A
|
|
147
|
+
* manual request waits for the in-flight sweep and then runs its own purge,
|
|
148
|
+
* because an automatic sweep may have been started with `allowPurge: false`.
|
|
149
|
+
*
|
|
150
|
+
* @param {{ manual?: boolean, reason?: string }} [options] manual trigger and diagnostic label.
|
|
151
|
+
* @returns {Promise<object>} the sweep summary.
|
|
152
|
+
*/
|
|
153
|
+
async function runNow(options = {}) {
|
|
154
|
+
if (stopped) return { ...STOPPED_RESULT }
|
|
155
|
+
const manual = options.manual === true
|
|
156
|
+
while (inFlight !== null) {
|
|
157
|
+
if (!manual) return inFlight
|
|
158
|
+
await inFlight.catch(() => {})
|
|
159
|
+
}
|
|
160
|
+
const reason = options.reason ?? (manual ? 'manual' : 'scheduled')
|
|
161
|
+
const startedAt = Date.now()
|
|
162
|
+
inFlight = (async () => {
|
|
163
|
+
const allowPurge = manual || config.enableAutoCleanup
|
|
164
|
+
try {
|
|
165
|
+
let result
|
|
166
|
+
try {
|
|
167
|
+
result = await trashService.sweep({ allowPurge, now: Date.now(), reason })
|
|
168
|
+
} catch (error) {
|
|
169
|
+
lastAttemptAt = Date.now()
|
|
170
|
+
lastRun = {
|
|
171
|
+
at: lastAttemptAt,
|
|
172
|
+
reason,
|
|
173
|
+
purged: 0,
|
|
174
|
+
failed: 0,
|
|
175
|
+
settled: 0,
|
|
176
|
+
allowPurge,
|
|
177
|
+
durationMs: lastAttemptAt - startedAt,
|
|
178
|
+
error: errorText(error),
|
|
179
|
+
}
|
|
180
|
+
consecutiveFailures += 1
|
|
181
|
+
logger.error(`trash cleanup (${reason}) failed after ${lastRun.durationMs}ms: ${messageOf(error)}`)
|
|
182
|
+
if (!manual) scheduleRetry(reason)
|
|
183
|
+
throw error
|
|
184
|
+
}
|
|
185
|
+
lastAttemptAt = Date.now()
|
|
186
|
+
lastSuccessAt = lastAttemptAt
|
|
187
|
+
lastRun = {
|
|
188
|
+
at: lastAttemptAt,
|
|
189
|
+
reason,
|
|
190
|
+
purged: typeof result.purged === 'number' ? result.purged : 0,
|
|
191
|
+
failed: typeof result.failed === 'number' ? result.failed : 0,
|
|
192
|
+
settled: typeof result.changed === 'number' ? result.changed : 0,
|
|
193
|
+
allowPurge,
|
|
194
|
+
durationMs: lastAttemptAt - startedAt,
|
|
195
|
+
error: null,
|
|
196
|
+
}
|
|
197
|
+
if (lastRun.failed > 0) {
|
|
198
|
+
consecutiveFailures += 1
|
|
199
|
+
logger.warn(
|
|
200
|
+
`trash cleanup (${reason}) finished with ${lastRun.failed} failed purge(s) after ${lastRun.durationMs}ms`,
|
|
201
|
+
)
|
|
202
|
+
if (!manual) scheduleRetry(reason)
|
|
203
|
+
} else {
|
|
204
|
+
consecutiveFailures = 0
|
|
205
|
+
clearRetry()
|
|
206
|
+
if (lastRun.purged > 0 || lastRun.settled > 0) {
|
|
207
|
+
logger.info(
|
|
208
|
+
`trash cleanup (${reason}) purged ${lastRun.purged} expired sessions, settled ${lastRun.settled} entries in ${lastRun.durationMs}ms`,
|
|
209
|
+
)
|
|
210
|
+
} else {
|
|
211
|
+
logger.debug(`trash cleanup (${reason}) found nothing to do in ${lastRun.durationMs}ms`)
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return result
|
|
215
|
+
} finally {
|
|
216
|
+
inFlight = null
|
|
217
|
+
}
|
|
218
|
+
})()
|
|
219
|
+
return inFlight
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* @returns {void}
|
|
224
|
+
*/
|
|
225
|
+
function scheduleInterval() {
|
|
226
|
+
if (stopped || timer === undefined || !config.enableAutoCleanup) return
|
|
227
|
+
if (disposeInterval !== null) return
|
|
228
|
+
disposeInterval = ctx.effect(
|
|
229
|
+
() =>
|
|
230
|
+
timer.interval(() => {
|
|
231
|
+
scheduledAt = Date.now() + intervalMs
|
|
232
|
+
void runNow({ reason: 'interval' }).catch(report)
|
|
233
|
+
}, intervalMs),
|
|
234
|
+
'session-trash: retention sweep',
|
|
235
|
+
)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* @returns {number | null} the soonest scheduled automatic sweep, preferring a retry.
|
|
240
|
+
*/
|
|
241
|
+
function computeNextSweepAt() {
|
|
242
|
+
if (!config.enableAutoCleanup) return null
|
|
243
|
+
if (retryAt !== null) return retryAt
|
|
244
|
+
return timer === undefined ? null : scheduledAt
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
/**
|
|
249
|
+
* Schedule the activation sweep. Automatic cleanup is skipped when disabled;
|
|
250
|
+
* crash recovery has already run by this point either way.
|
|
251
|
+
*
|
|
252
|
+
* @returns {void}
|
|
253
|
+
*/
|
|
254
|
+
start() {
|
|
255
|
+
if (started || stopped) return
|
|
256
|
+
started = true
|
|
257
|
+
if (!config.enableAutoCleanup) {
|
|
258
|
+
scheduledAt = null
|
|
259
|
+
return
|
|
260
|
+
}
|
|
261
|
+
scheduledAt = timer === undefined ? null : Date.now() + ACTIVATION_SWEEP_DELAY_MS
|
|
262
|
+
const fire = () => {
|
|
263
|
+
void runNow({ reason: 'activation' })
|
|
264
|
+
.catch(report)
|
|
265
|
+
.finally(() => {
|
|
266
|
+
if (stopped || !config.enableAutoCleanup) return
|
|
267
|
+
scheduledAt = timer === undefined ? null : Date.now() + intervalMs
|
|
268
|
+
scheduleInterval()
|
|
269
|
+
})
|
|
270
|
+
}
|
|
271
|
+
if (timer !== undefined) {
|
|
272
|
+
disposeActivation = ctx.effect(() => timer.timeout(fire, ACTIVATION_SWEEP_DELAY_MS), 'session-trash: activation sweep')
|
|
273
|
+
} else {
|
|
274
|
+
const handle = setTimeout(fire, ACTIVATION_SWEEP_DELAY_MS)
|
|
275
|
+
handle.unref?.()
|
|
276
|
+
disposeActivation = () => clearTimeout(handle)
|
|
277
|
+
}
|
|
278
|
+
},
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Stop every scheduled sweep and retry, and ignore later triggers.
|
|
282
|
+
*
|
|
283
|
+
* @returns {void}
|
|
284
|
+
*/
|
|
285
|
+
stop() {
|
|
286
|
+
stopped = true
|
|
287
|
+
if (typeof disposeActivation === 'function') disposeActivation()
|
|
288
|
+
if (typeof disposeInterval === 'function') disposeInterval()
|
|
289
|
+
clearRetry()
|
|
290
|
+
disposeActivation = null
|
|
291
|
+
disposeInterval = null
|
|
292
|
+
},
|
|
293
|
+
|
|
294
|
+
runNow,
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Trigger a throttled background sweep when the trash page opens. The
|
|
298
|
+
* timer-backed composition uses a one-hour throttle; without a timer the
|
|
299
|
+
* configured sweep interval throttles instead.
|
|
300
|
+
*
|
|
301
|
+
* @returns {void}
|
|
302
|
+
*/
|
|
303
|
+
maybeSweepOnPageOpen() {
|
|
304
|
+
if (stopped) return
|
|
305
|
+
const throttle = timer === undefined || !config.enableAutoCleanup ? intervalMs : PAGE_OPEN_THROTTLE_MS
|
|
306
|
+
if (lastAttemptAt !== 0 && Date.now() - lastAttemptAt <= throttle) return
|
|
307
|
+
void runNow({ reason: 'page-open' }).catch(report)
|
|
308
|
+
},
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* @returns {number | null} the next scheduled automatic sweep, when one exists.
|
|
312
|
+
*/
|
|
313
|
+
nextSweepAt() {
|
|
314
|
+
return computeNextSweepAt()
|
|
315
|
+
},
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* @returns {number} the last succeeded sweep timestamp, or zero.
|
|
319
|
+
*/
|
|
320
|
+
lastSweepAt() {
|
|
321
|
+
return lastSuccessAt
|
|
322
|
+
},
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Read-only cleanup diagnostics for `/state` and the settings page.
|
|
326
|
+
*
|
|
327
|
+
* @returns {object} a JSON-safe diagnostic snapshot.
|
|
328
|
+
*/
|
|
329
|
+
diagnostics() {
|
|
330
|
+
return {
|
|
331
|
+
enabled: config.enableAutoCleanup,
|
|
332
|
+
timerBacked: timer !== undefined,
|
|
333
|
+
lastAttemptAt: lastAttemptAt === 0 ? null : lastAttemptAt,
|
|
334
|
+
lastSuccessAt: lastSuccessAt === 0 ? null : lastSuccessAt,
|
|
335
|
+
nextSweepAt: computeNextSweepAt(),
|
|
336
|
+
retryAt,
|
|
337
|
+
consecutiveFailures,
|
|
338
|
+
lastRun: lastRun === null ? null : { ...lastRun },
|
|
339
|
+
}
|
|
340
|
+
},
|
|
341
|
+
}
|
|
342
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import fs from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { isSafeDirectoryPath } from './fsops.js'
|
|
4
|
+
|
|
5
|
+
/** How long a successful preflight answer is reused by the menu. */
|
|
6
|
+
export const PREFLIGHT_TTL_MS = 2000
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @typedef {{ eligible: true } | { eligible: false, code: string, message: string, details?: object }} PreflightResult
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param {string} code refusal code.
|
|
14
|
+
* @param {string} message owner-facing explanation.
|
|
15
|
+
* @param {object} [details] optional structured context.
|
|
16
|
+
* @returns {{ eligible: false, code: string, message: string, details?: object }} a refusal.
|
|
17
|
+
*/
|
|
18
|
+
function refuse(code, message, details) {
|
|
19
|
+
return details === undefined ? { eligible: false, code, message } : { eligible: false, code, message, details }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Collect the ids of every stored session whose lineage descends from `sessionId`.
|
|
24
|
+
*
|
|
25
|
+
* @param {import('@deepseek-ai/dsh-session-persistence').SessionPersistence} persistence persistence service.
|
|
26
|
+
* @param {string} sessionId root session.
|
|
27
|
+
* @returns {Promise<string[]>} descendant ids.
|
|
28
|
+
*/
|
|
29
|
+
async function descendantsFromListing(persistence, sessionId) {
|
|
30
|
+
const snapshots = await persistence.list()
|
|
31
|
+
const childrenByParent = new Map()
|
|
32
|
+
for (const snapshot of snapshots) {
|
|
33
|
+
const parent = snapshot?.header?.parentSession
|
|
34
|
+
if (typeof parent !== 'string') continue
|
|
35
|
+
const bucket = childrenByParent.get(parent)
|
|
36
|
+
if (bucket === undefined) childrenByParent.set(parent, [snapshot.header.id])
|
|
37
|
+
else bucket.push(snapshot.header.id)
|
|
38
|
+
}
|
|
39
|
+
const found = []
|
|
40
|
+
const queue = [sessionId]
|
|
41
|
+
const visited = new Set([sessionId])
|
|
42
|
+
while (queue.length > 0) {
|
|
43
|
+
const current = queue.shift()
|
|
44
|
+
for (const child of childrenByParent.get(current) ?? []) {
|
|
45
|
+
if (visited.has(child)) continue
|
|
46
|
+
visited.add(child)
|
|
47
|
+
found.push(child)
|
|
48
|
+
queue.push(child)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return found
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Flatten the nested descendant tree returned by `sessionQuery.traceSession`.
|
|
56
|
+
* A grandchild whose direct parent is no longer stored still proves that the
|
|
57
|
+
* root session owns a lineage the trash must not split.
|
|
58
|
+
*
|
|
59
|
+
* @param {readonly object[]} nodes descendant nodes from the trace.
|
|
60
|
+
* @returns {string[]} every descendant session id.
|
|
61
|
+
*/
|
|
62
|
+
function collectTraceDescendants(nodes) {
|
|
63
|
+
const ids = []
|
|
64
|
+
const visit = (list) => {
|
|
65
|
+
for (const node of list) {
|
|
66
|
+
const id = node?.session?.header?.id ?? node?.session?.id
|
|
67
|
+
if (typeof id === 'string' && id !== '') ids.push(id)
|
|
68
|
+
if (Array.isArray(node?.descendants)) visit(node.descendants)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
visit(Array.isArray(nodes) ? nodes : [])
|
|
72
|
+
return ids
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Decide whether one session may move into the trash. The same predicate backs
|
|
77
|
+
* both the menu preflight and (from Phase 2) the write path.
|
|
78
|
+
*
|
|
79
|
+
* @param {{ ctx: import('@deepseek-ai/cordis').Context, store: import('./store.js').TrashStore, logger: object }} deps host services.
|
|
80
|
+
* @returns {{ evaluate: (sessionId: string) => Promise<PreflightResult>, invalidate: (sessionId?: string) => void }} the cached evaluator.
|
|
81
|
+
*/
|
|
82
|
+
export function createEligibility({ ctx, store, logger }) {
|
|
83
|
+
/** @type {Map<string, { expires: number, value: PreflightResult }>} */
|
|
84
|
+
const cache = new Map()
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* @param {string} sessionId session to inspect.
|
|
88
|
+
* @returns {Promise<PreflightResult>} the uncached verdict.
|
|
89
|
+
*/
|
|
90
|
+
async function evaluateUncached(sessionId) {
|
|
91
|
+
let snapshot
|
|
92
|
+
try {
|
|
93
|
+
snapshot = await ctx.sessionPersistence.stat(sessionId)
|
|
94
|
+
} catch (error) {
|
|
95
|
+
logger.warn(`preflight stat failed for ${sessionId}: ${error instanceof Error ? error.message : String(error)}`)
|
|
96
|
+
return refuse('IO_ERROR', 'The session storage could not be read.')
|
|
97
|
+
}
|
|
98
|
+
if (snapshot === undefined || snapshot === null) {
|
|
99
|
+
return refuse('SESSION_NOT_FOUND', 'This session is not in session storage.')
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const existing = store.findEntry(sessionId)
|
|
103
|
+
if (
|
|
104
|
+
existing !== undefined &&
|
|
105
|
+
(existing.state === 'trashed' ||
|
|
106
|
+
existing.state === 'trashing' ||
|
|
107
|
+
existing.state === 'restoring' ||
|
|
108
|
+
existing.state === 'purging')
|
|
109
|
+
) {
|
|
110
|
+
return refuse('ALREADY_TRASHED', 'This session is already recorded in the trash.')
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const location = snapshot.location
|
|
114
|
+
if (location === undefined || location === null || typeof location.path !== 'string') {
|
|
115
|
+
return refuse('SESSION_NOT_MATERIALIZED', 'This session has no stored log yet, so it has nothing to remove.')
|
|
116
|
+
}
|
|
117
|
+
if (location.kind !== 'jsonl') {
|
|
118
|
+
return refuse('STORAGE_UNSUPPORTED', `The "${String(location.kind)}" session storage backend is not supported.`)
|
|
119
|
+
}
|
|
120
|
+
if (!path.isAbsolute(location.path)) {
|
|
121
|
+
return refuse('STORAGE_UNSUPPORTED', 'The session log path is not absolute.')
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (snapshot.header?.origin === 'subagent') {
|
|
125
|
+
return refuse('SESSION_SUBAGENT', 'Subagent sessions cannot be moved to the trash on their own.')
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
let descendants
|
|
129
|
+
const sessionQuery = ctx.get('sessionQuery')
|
|
130
|
+
if (sessionQuery !== undefined && typeof sessionQuery.traceSession === 'function') {
|
|
131
|
+
try {
|
|
132
|
+
const trace = await sessionQuery.traceSession(sessionId)
|
|
133
|
+
descendants = collectTraceDescendants(trace?.descendants ?? [])
|
|
134
|
+
} catch (error) {
|
|
135
|
+
logger.warn(`preflight lineage failed for ${sessionId}: ${error instanceof Error ? error.message : String(error)}`)
|
|
136
|
+
return refuse('SESSION_LINEAGE_UNRESOLVED', 'The session lineage could not be resolved, so the session is left untouched.')
|
|
137
|
+
}
|
|
138
|
+
} else {
|
|
139
|
+
try {
|
|
140
|
+
descendants = await descendantsFromListing(ctx.sessionPersistence, sessionId)
|
|
141
|
+
} catch (error) {
|
|
142
|
+
logger.warn(`preflight lineage listing failed for ${sessionId}: ${error instanceof Error ? error.message : String(error)}`)
|
|
143
|
+
return refuse('SESSION_LINEAGE_UNRESOLVED', 'The session lineage could not be resolved, so the session is left untouched.')
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const childCount = descendants.filter((id) => typeof id === 'string' && id !== '').length
|
|
147
|
+
if (childCount > 0) {
|
|
148
|
+
return refuse('SESSION_HAS_DESCENDANTS', 'This session still has child sessions; archive the tree instead.', {
|
|
149
|
+
descendants: childCount,
|
|
150
|
+
})
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const liveSession = typeof ctx.sessions?.get === 'function' ? ctx.sessions.get(sessionId) : undefined
|
|
154
|
+
const liveAgent = typeof ctx.agents?.get === 'function' ? ctx.agents.get(sessionId) : undefined
|
|
155
|
+
if (liveSession !== undefined || liveAgent !== undefined) {
|
|
156
|
+
return refuse('SESSION_LIVE', 'This session is open right now. Close it before moving it to the trash.')
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
let activity
|
|
160
|
+
try {
|
|
161
|
+
activity = await ctx.waterfall('workspace/session-activity', { sessionId }, () => Promise.resolve([]))
|
|
162
|
+
} catch (error) {
|
|
163
|
+
logger.warn(`preflight activity failed for ${sessionId}: ${error instanceof Error ? error.message : String(error)}`)
|
|
164
|
+
return refuse('SESSION_ACTIVE', 'Session activity could not be checked, so the session is left untouched.')
|
|
165
|
+
}
|
|
166
|
+
if (Array.isArray(activity) && activity.length > 0) {
|
|
167
|
+
return refuse('SESSION_ACTIVE', 'This session still has running work.', {
|
|
168
|
+
activity: activity.map((item) => item?.kind ?? 'unknown'),
|
|
169
|
+
})
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const sessionDir = path.dirname(location.path)
|
|
173
|
+
let stats
|
|
174
|
+
try {
|
|
175
|
+
stats = await fs.lstat(sessionDir)
|
|
176
|
+
} catch (error) {
|
|
177
|
+
logger.warn(`preflight cannot stat ${sessionDir}: ${error instanceof Error ? error.message : String(error)}`)
|
|
178
|
+
return refuse('IO_ERROR', 'The session directory could not be read.')
|
|
179
|
+
}
|
|
180
|
+
if (!stats.isDirectory() || !isSafeDirectoryPath(sessionDir)) {
|
|
181
|
+
return refuse('UNSAFE_SESSION_DIR', 'The session directory is not a plain directory this plugin can move.')
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return { eligible: true }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
/**
|
|
189
|
+
* @param {string} sessionId session to inspect.
|
|
190
|
+
* @returns {Promise<PreflightResult>} the cached verdict.
|
|
191
|
+
*/
|
|
192
|
+
async evaluate(sessionId) {
|
|
193
|
+
if (store.isProtected()) {
|
|
194
|
+
return refuse('INDEX_CORRUPT', 'The trash index is unreadable, so protection mode refuses new entries.')
|
|
195
|
+
}
|
|
196
|
+
const now = Date.now()
|
|
197
|
+
const hit = cache.get(sessionId)
|
|
198
|
+
if (hit !== undefined && hit.expires > now) return hit.value
|
|
199
|
+
const value = await evaluateUncached(sessionId)
|
|
200
|
+
cache.set(sessionId, { expires: now + PREFLIGHT_TTL_MS, value })
|
|
201
|
+
return value
|
|
202
|
+
},
|
|
203
|
+
/**
|
|
204
|
+
* @param {string} [sessionId] session whose cached verdict is stale, or every session.
|
|
205
|
+
* @returns {void}
|
|
206
|
+
*/
|
|
207
|
+
invalidate(sessionId) {
|
|
208
|
+
if (sessionId === undefined) cache.clear()
|
|
209
|
+
else cache.delete(sessionId)
|
|
210
|
+
},
|
|
211
|
+
}
|
|
212
|
+
}
|