110 lines
3.7 KiB
TypeScript
110 lines
3.7 KiB
TypeScript
|
|
/**
|
||
|
|
* Session Deleter — periodically drains pending session deletes.
|
||
|
|
*
|
||
|
|
* Reads from gc_pending_session_deletes table, executes deletion via
|
||
|
|
* Hermes CLI, tracks failures (max 3 attempts), and auto-drains on
|
||
|
|
* a timer + profile switch.
|
||
|
|
*/
|
||
|
|
import { getDb } from '../../db/index'
|
||
|
|
import { deleteSession as hermesDeleteSession } from './hermes-cli'
|
||
|
|
import { logger } from '../logger'
|
||
|
|
|
||
|
|
const MAX_ATTEMPTS = 3
|
||
|
|
const DRAIN_INTERVAL_MS = 300_000
|
||
|
|
|
||
|
|
export class SessionDeleter {
|
||
|
|
private static _instance: SessionDeleter | null = null
|
||
|
|
private timer: ReturnType<typeof setInterval> | null = null
|
||
|
|
private currentProfile: string = 'default'
|
||
|
|
|
||
|
|
static getInstance(): SessionDeleter {
|
||
|
|
if (!SessionDeleter._instance) {
|
||
|
|
SessionDeleter._instance = new SessionDeleter()
|
||
|
|
}
|
||
|
|
return SessionDeleter._instance
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Start periodic drain for the given profile */
|
||
|
|
start(profile: string): void {
|
||
|
|
this.currentProfile = profile
|
||
|
|
this.stop()
|
||
|
|
logger.info('[SessionDeleter] started, profile=%s, interval=%dms', profile, DRAIN_INTERVAL_MS)
|
||
|
|
// Drain immediately on start, then on interval
|
||
|
|
this.drain(profile).catch(() => {})
|
||
|
|
this.timer = setInterval(() => {
|
||
|
|
this.drain(profile).catch(() => {})
|
||
|
|
}, DRAIN_INTERVAL_MS)
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Switch to a new profile, stop old timer and start new one */
|
||
|
|
switchProfile(newProfile: string): void {
|
||
|
|
if (newProfile !== this.currentProfile) {
|
||
|
|
logger.info('[SessionDeleter] switching profile %s -> %s', this.currentProfile, newProfile)
|
||
|
|
this.start(newProfile)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Stop periodic drain */
|
||
|
|
stop(): void {
|
||
|
|
if (this.timer) {
|
||
|
|
clearInterval(this.timer)
|
||
|
|
this.timer = null
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Drain pending deletes for a specific profile (called on profile switch or manually) */
|
||
|
|
async drain(profile: string): Promise<{ deleted: string[]; skipped: string[]; failed: string[] }> {
|
||
|
|
const db = getDb()
|
||
|
|
if (!db) return { deleted: [], skipped: [], failed: [] }
|
||
|
|
|
||
|
|
const now = Date.now()
|
||
|
|
const rows = db.prepare(`
|
||
|
|
SELECT session_id, profile_name, status, attempt_count, last_error
|
||
|
|
FROM gc_pending_session_deletes
|
||
|
|
WHERE profile_name = ? AND status = 'pending' AND attempt_count < ? AND next_attempt_at <= ?
|
||
|
|
ORDER BY created_at ASC
|
||
|
|
LIMIT 50
|
||
|
|
`).all(profile, MAX_ATTEMPTS, now) as Array<{
|
||
|
|
session_id: string
|
||
|
|
profile_name: string
|
||
|
|
status: string
|
||
|
|
attempt_count: number
|
||
|
|
last_error: string | null
|
||
|
|
}>
|
||
|
|
|
||
|
|
if (rows.length === 0) return { deleted: [], skipped: [], failed: [] }
|
||
|
|
|
||
|
|
const deleted: string[] = []
|
||
|
|
const skipped: string[] = []
|
||
|
|
const failed: string[] = []
|
||
|
|
|
||
|
|
for (const row of rows) {
|
||
|
|
try {
|
||
|
|
const ok = await hermesDeleteSession(row.session_id)
|
||
|
|
if (ok) {
|
||
|
|
db.prepare('DELETE FROM gc_pending_session_deletes WHERE session_id = ?').run(row.session_id)
|
||
|
|
db.prepare('DELETE FROM gc_session_profiles WHERE session_id = ?').run(row.session_id)
|
||
|
|
deleted.push(row.session_id)
|
||
|
|
} else {
|
||
|
|
skipped.push(row.session_id)
|
||
|
|
}
|
||
|
|
} catch (err: any) {
|
||
|
|
const msg = err?.message || 'Unknown error'
|
||
|
|
db.prepare(
|
||
|
|
`UPDATE gc_pending_session_deletes
|
||
|
|
SET status = 'pending', attempt_count = attempt_count + 1, last_error = ?, updated_at = ?, next_attempt_at = ?
|
||
|
|
WHERE session_id = ?`,
|
||
|
|
).run(msg, now, now + 60_000, row.session_id)
|
||
|
|
failed.push(row.session_id)
|
||
|
|
logger.warn('[SessionDeleter] failed to delete %s (attempt %d): %s', row.session_id, row.attempt_count + 1, msg)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (deleted.length || failed.length) {
|
||
|
|
logger.info('[SessionDeleter] profile=%s: deleted=%d, failed=%d', profile, deleted.length, failed.length)
|
||
|
|
}
|
||
|
|
|
||
|
|
return { deleted, skipped, failed }
|
||
|
|
}
|
||
|
|
}
|