/** * append-only-registry.ts, the single owner of every append-only store the * platform writes. * * An append-only file that no one prunes grows without bound (the observed * 22.8 MB activity.md, unbounded agent journals). The fix is a registry: every * append-only store the platform writes registers here with an owner and a * retention policy, and a start-time janitor (runAppendOnlyRetentionSweep) owns * every registered path in one pass. A registry-membership check * (assertAppendOnlyStoreRegistered) fails LOUDLY on an unregistered id, the * same fail-closed discipline as the feature-gate-id and model-source checks, * so a new append-only store cannot ship unowned and grow forever in silence. * * The retention engine reused here is enforceFileRetention / * enforceJournalDirectoryRetention (age + total-size caps over append-only * files), the honest fit for line-appended logs, distinct from the * checkpoint-record RetentionPolicy engine that owns the snapshot subsystems. */ import { type AtRestPolicy } from '../at-rest-persistence.js'; /** Every append-only store the platform writes. Extend this when adding one. */ export type AppendOnlyStoreId = 'session-journals' | 'activity-log' | 'telemetry-local-ledger' | 'session-recovery-snapshots' | 'session-conversations' | 'surface-crash-log' | 'legacy-event-store'; /** * The roots a sweep resolves store paths from. A store whose required root is * absent is skipped this sweep (but stays registered, so the membership check * still enforces its ownership). */ export interface AppendOnlyRetentionRoots { readonly workingDirectory?: string | undefined; readonly surfaceRoot?: string | undefined; /** * The user home root. Consumed by `surface-crash-log`, which is deliberately * home-anchored rather than workingDirectory-anchored: a process that dies on * an uncaught fault may not have a usable working directory, and a crash is a * property of the surface install, not of the project it happened in. (The * recovery-snapshot store went the other way, see `session-recovery-snapshots` * below, because a recovery snapshot IS project state.) */ readonly homeDirectory?: string | undefined; /** Directory holding the shared activity.md log, when the caller configured one. */ readonly logDir?: string | undefined; /** Directory holding local telemetry ledger jsonl files, when configured. */ readonly telemetryDir?: string | undefined; } /** The concrete on-disk targets a store resolves to for a given set of roots. */ export interface AppendOnlyStoreTargets { /** Directories swept for every *.jsonl file within. */ readonly journalDirs: readonly string[]; /** Individual files swept directly. */ readonly files: readonly string[]; } /** One registered append-only store: its owner, retention policy, and path resolver. */ export interface AppendOnlyStoreDescriptor { readonly id: AppendOnlyStoreId; /** The subsystem that writes this store (for diagnostics/attribution). */ readonly owner: string; readonly description: string; /** The retention policy enforced over this store's files. */ readonly policy: AtRestPolicy; /** Resolve the store's concrete targets from the roots (empty when a root is absent). */ resolve(roots: AppendOnlyRetentionRoots): AppendOnlyStoreTargets; } /** The canonical registry. Adding an append-only writer means adding an entry here. */ export declare const APPEND_ONLY_STORES: readonly AppendOnlyStoreDescriptor[]; /** True when `id` is a registered append-only store. */ export declare function isAppendOnlyStoreRegistered(id: string): boolean; /** * Fail-closed membership check: throw when `id` is not a registered append-only * store. Mirrors assertFeatureGateIdRegistered, an unregistered append-only * path is a defect (it would grow unowned), so it fails loudly. */ export declare function assertAppendOnlyStoreRegistered(id: string, context: string): void; /** The outcome of one start-time retention sweep. */ export interface AppendOnlySweepOutcome { readonly sweptStores: readonly AppendOnlyStoreId[]; readonly skippedStores: readonly AppendOnlyStoreId[]; readonly deletedFiles: number; readonly reclaimedBytes: number; /** How many of `deletedFiles` were reclaimed by the per-store count cap rather than by age/size. */ readonly countCappedFiles: number; } /** * The start-time janitor: enforce every registered store's retention policy in * one pass over the paths its resolver yields for the given roots. A store * whose roots are absent is skipped (reported), not an error. Best-effort, * a failure on one store never aborts the others. */ export declare function runAppendOnlyRetentionSweep(roots: AppendOnlyRetentionRoots, options?: { readonly policyOverride?: AtRestPolicy | undefined; /** Override the per-store file-count bound (default MAX_FILES_PER_APPEND_ONLY_STORE). */ readonly maxFilesOverride?: number | undefined; }): AppendOnlySweepOutcome; /** * Convenience start-time entry point wired at runtime construction: resolve the * at-rest policy from a config getter and run the sweep, swallowing any failure * so a retention problem never takes runtime startup down. * * Takes the FULL roots object: a caller that omits logDir/telemetryDir/ * homeDirectory silently skips the activity-log, telemetry-ledger, and * recovery-snapshot stores every sweep, registered entries that never run. * The composition root passes every root it knows. */ export declare function runStartupAppendOnlySweep(roots: AppendOnlyRetentionRoots, configGet?: (key: string) => unknown): AppendOnlySweepOutcome | null; /** * How often the registry re-sweeps after startup. * * Six hours, not minutes and not days. A sweep is a stat pass over a few dozen * paths, so the cost is negligible at any cadence; what sets the number is * overshoot. The caps it enforces are a 30-day age horizon, a 512 MB size * budget, and a 512-file count bound, and a store can only exceed a cap for as * long as it takes the next sweep to arrive. Six hours bounds that overshoot to * a quarter of a day of append volume, small against a 512 MB budget even for * the fastest writer observed (the 22.8 MB activity.md accumulated over weeks), * and it reclaims a file within hours of its 30-day TTL rather than up to a full * day later. Anything in minutes would be pure wakeups for a store whose * shortest cap is measured in days. */ export declare const APPEND_ONLY_SWEEP_INTERVAL_MS: number; /** Construction seams for {@link AppendOnlyRetentionScheduler}; tests drive the timer directly. */ export interface AppendOnlyRetentionSchedulerOptions { /** The roots every sweep resolves store paths from (the composition root's full set). */ readonly roots: AppendOnlyRetentionRoots; /** Config getter for the at-rest policy, read fresh on every sweep so a live config edit applies. */ readonly configGet?: ((key: string) => unknown) | undefined; /** Sweep cadence; defaults to {@link APPEND_ONLY_SWEEP_INTERVAL_MS}. */ readonly intervalMs?: number | undefined; readonly setTimer?: ((fn: () => void, ms: number) => ReturnType) | undefined; readonly clearTimer?: ((timer: ReturnType) => void) | undefined; /** Observation seam for hosts/tests; never used for disclosure (the sweep logs its own reclaims). */ readonly onSweep?: ((outcome: AppendOnlySweepOutcome | null) => void) | undefined; } /** * The append-only janitor's daemon-lifetime half. * * A start-time-only sweep is a janitor that clocks in once: a daemon that stays * up for weeks never prunes any of the six registered stores again after boot, * which is exactly the window in which they grow. This scheduler re-runs the * same sweep on an unref'd timer (it can never be the reason a process stays * alive), stops cleanly, and is safe to start twice, a second start() is a * no-op rather than a second timer. Same lifecycle posture as * StoreSnapshotScheduler: the host that constructs it stops it on teardown. * * A sweep that reclaims nothing writes no log line, so a quiet daemon does not * accumulate an entry every interval, the disclosure requirement is about * deletions, and there are none to disclose. */ export declare class AppendOnlyRetentionScheduler { private readonly options; private timer; private running; constructor(options: AppendOnlyRetentionSchedulerOptions); private get intervalMs(); /** True while a sweep is scheduled. */ get isRunning(): boolean; /** Begin periodic sweeps. Idempotent: calling it again while running does nothing. */ start(): void; /** Stop periodic sweeps and release the timer. Idempotent. */ stop(): void; /** * Run one sweep now, then re-arm (when running). Never throws, the * underlying entry point already swallows and reports its own failures. */ tick(): AppendOnlySweepOutcome | null; private scheduleNext; } //# sourceMappingURL=append-only-registry.d.ts.map