Files
lam-agent-vicinae/src/lib/retention.ts
T

181 lines
7.3 KiB
TypeScript

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<string>,
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<SessionSummary[]>;
/** Deletes conversations by id and returns every image path they referred to. */
deleteSessions: (ids: string[]) => Promise<string[]>;
/**
* 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<string[] | undefined>;
listSnipFiles: () => Promise<SnipFile[]>;
/** Returns how many files it acted on. The sweep ignores the count; the setup screen reports it. */
removeSnipFiles: (paths: string[]) => Promise<number>;
}
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<SweepOutcome> {
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 };
}