@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.
@@ -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
+ }
@@ -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
+ }