@wangxiaocuo/dsh-session-trash 0.0.0-stage → 0.6.0

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.
@@ -0,0 +1,3 @@
1
+ - insert:
2
+ - id: session-trash
3
+ name: '@wangxiaocuo/dsh-session-trash'
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
+ }