import { SNIP_ORPHAN_GRACE_MS } from "./constants"; import type { SessionSummary } from "./types"; /** * What gets deleted, and when. * * Two separate jobs live here, because they are the same job from the user's * point of view ("Lam keeps my history tidy") but nothing like each other in code: * * - Conversations unused for longer than the retention window. * - Screenshot files nothing refers to any more. A capture is written to disk * before the chat view opens, so a snip the user never sent belongs to no * conversation at all and no amount of session deletion would ever collect it. * * Both are pure decisions here, with every side effect injected, following * `startup.ts`: the alternative is discovering from a screenshot that a sweep * deleted a conversation it should not have. Imports nothing from Vicinae, so it * unit tests with no runtime. * * ## The bias is always towards deleting nothing * * This is the only code in the extension that destroys data, so every ambiguity * resolves towards leaving it alone. An unrecognised retention value disables the * sweep rather than guessing at a window; a snip is only collected once it is * both unreferenced *and* older than a grace period; and when the referenced set * cannot be established at all, the orphan pass is skipped rather than run * against a set it knows to be incomplete. */ /** One file in the snip directory, with the age the sweep judges it by. */ export interface SnipFile { path: string; /** Last modification time, which for a capture is when it was taken. */ mtimeMs: number; } /** * Conversations unused for at least `maxAgeMs`, oldest first. * * Age is measured from `updatedAt`, not `createdAt`, so a conversation started * last month but used this morning is not old. The open conversation is excluded * outright: it is about to be rendered, and deleting the thing the user is * looking at is the one outcome that would be indistinguishable from a bug. * * `undefined` means the sweep is off, which is the default. Every value that * cannot be resolved to a window arrives here as `undefined`. */ export function expiredSessions( summaries: SessionSummary[], maxAgeMs: number | undefined, now: number, keepId?: string, ): SessionSummary[] { if (maxAgeMs === undefined) return []; return summaries.filter((summary) => summary.id !== keepId && now - summary.updatedAt >= maxAgeMs); } /** * Snip files that nothing refers to and that have waited out the grace period. * * The wait is the whole safety story. A snip is on disk seconds after it is * taken and stays unreferenced until the user sends it, so without a grace * period the sweep would delete the file out from under a question the user is * still typing. A day is far longer than any real pending snip and far shorter * than the point at which the file matters. */ export function orphanedSnips( files: SnipFile[], referenced: Iterable, now: number, graceMs: number = SNIP_ORPHAN_GRACE_MS, ): SnipFile[] { const kept = new Set(referenced); return files.filter((file) => !kept.has(file.path) && now - file.mtimeMs >= graceMs); } export interface SweepInput { /** Age past which an unused conversation is deleted. `undefined` disables everything. */ maxAgeMs?: number; now: number; /** The conversation currently open, which is never deleted. */ keepId?: string; /** How long an unreferenced snip must sit before it is collected. */ snipGraceMs?: number; } /** The side effects, injected so nothing here touches storage or the file system directly. */ export interface SweepPorts { listSessions: () => Promise; /** Deletes conversations by id and returns every image path they referred to. */ deleteSessions: (ids: string[]) => Promise; /** * Image paths still referred to by conversations that remain, or `undefined` * when that cannot be established. * * `undefined` is not the same as an empty array and must not be treated as * one: it means the referenced set is unknown, and a set that is merely * incomplete looks exactly like a pile of orphans. */ referencedAttachments: () => Promise; listSnipFiles: () => Promise; /** Returns how many files it acted on. The sweep ignores the count; the setup screen reports it. */ removeSnipFiles: (paths: string[]) => Promise; } export interface SweepOutcome { /** Conversations deleted. */ sessions: number; /** Image files deleted, from deleted conversations and unreferenced orphans alike. */ files: number; } const NOTHING_DONE: SweepOutcome = { sessions: 0, files: 0 }; /** * What to tell the user was removed, or `undefined` when nothing was. * * The count names the thing rather than saying "items", because "2 conversations * and 4 screenshots" is the sentence a person wants to read before deciding * whether the tidy-up did what they expected. The background sweep treats * `undefined` as "stay silent"; an action the user pressed has to answer even * when the answer is nothing, so those callers spell that case out themselves. */ export function describeRemoval(counts: SweepOutcome): string | undefined { const parts: string[] = []; if (counts.sessions > 0) { parts.push(`${counts.sessions} conversation${counts.sessions === 1 ? "" : "s"}`); } if (counts.files > 0) { parts.push(`${counts.files} screenshot${counts.files === 1 ? "" : "s"}`); } return parts.length > 0 ? parts.join(" and ") : undefined; } /** * Runs both passes. * * Disabled means disabled. When `maxAgeMs` is undefined nothing is read and * nothing is written, which also keeps the common case off the hot path: this * runs on every command open and after every answer. */ export async function sweepExpired(input: SweepInput, ports: SweepPorts): Promise { if (input.maxAgeMs === undefined) return NOTHING_DONE; const expired = expiredSessions(await ports.listSessions(), input.maxAgeMs, input.now, input.keepId); const fromSessions = expired.length > 0 ? await ports.deleteSessions(expired.map((summary) => summary.id)) : []; const referenced = await ports.referencedAttachments(); // An incomplete referenced set would make every screenshot it does not happen // to mention look like an orphan, so an unknown set means neither pass runs. const kept = referenced === undefined ? undefined : new Set(referenced); // A deleted conversation's files are removed explicitly rather than by the // grace-period rule, because a screenshot taken five minutes before its // conversation is deleted should go with it rather than sit on disk for a day. // A shared path is the exception: two conversations can refer to the same // capture, and the survivor still needs it. const fromDeleted = kept === undefined ? [] : fromSessions.filter((path) => !kept.has(path)); const orphans = kept === undefined ? [] : orphanedSnips( await ports.listSnipFiles(), kept, input.now, input.snipGraceMs ?? SNIP_ORPHAN_GRACE_MS, ); // Deduplicated because a file belonging to a deleted conversation can also // satisfy the orphan rule once that conversation has left the index. const paths = [...new Set([...fromDeleted, ...orphans.map((file) => file.path)])]; if (paths.length > 0) await ports.removeSnipFiles(paths); return { sessions: expired.length, files: paths.length }; }