Files
flynn/src/audit/phase0BaselineArtifactRetention.ts
T
William Valentin 06998ac65d fix(audit): require integer rolling retention keep limits
Validate keepPerFamily/--keep-per-family as non-negative integers, remove silent flooring, add regression coverage, and sync runbook/docs wording.
2026-02-27 13:11:31 -08:00

220 lines
6.3 KiB
TypeScript

export type Phase0RollingArtifactFamily =
| 'channel'
| 'gateway'
| 'backend_pi_embedded'
| 'backend_native'
| 'backend_drift'
| 'prune';
export interface Phase0RollingArtifactFile {
file_name: string;
family: Phase0RollingArtifactFamily;
tag: string;
tag_timestamp_ms: number;
}
export interface Phase0RollingArtifactRetentionPlan {
keep: Phase0RollingArtifactFile[];
remove: Phase0RollingArtifactFile[];
families: Array<{
family: Phase0RollingArtifactFamily;
total_tags: number;
keep_tags: number;
remove_tags: number;
}>;
}
const ROLLING_TAG_PATTERN = /^(\d{4})-(\d{2})-(\d{2})-(\d{6})$/;
const FAMILY_PATTERNS: Array<{ family: Phase0RollingArtifactFamily; pattern: RegExp }> = [
{
family: 'channel',
pattern: /^phase0_baseline_live_(\d{4}-\d{2}-\d{2}-\d{6})\.(json|jsonl|md)$/,
},
{
family: 'gateway',
pattern: /^phase0_baseline_live_gateway_(\d{4}-\d{2}-\d{2}-\d{6})\.(json|jsonl|md)$/,
},
{
family: 'backend_pi_embedded',
pattern: /^phase0_baseline_live_backend_pi_embedded_(\d{4}-\d{2}-\d{2}-\d{6})\.(json|jsonl|md)$/,
},
{
family: 'backend_native',
pattern: /^phase0_baseline_live_backend_native_(\d{4}-\d{2}-\d{2}-\d{6})\.(json|jsonl|md)$/,
},
{
family: 'backend_drift',
pattern: /^phase0_baseline_live_backend_drift_(\d{4}-\d{2}-\d{2}-\d{6})\.(json|md)$/,
},
{
family: 'prune',
pattern: /^phase0_baseline_live_prune_(\d{4}-\d{2}-\d{2}-\d{6})\.(json|md)$/,
},
];
function parseRollingTagTimestampMs(tag: string): number | undefined {
const match = ROLLING_TAG_PATTERN.exec(tag);
if (!match) {
return undefined;
}
const year = Number(match[1]);
const month = Number(match[2]);
const day = Number(match[3]);
const hhmmss = match[4] ?? '';
if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day) || hhmmss.length !== 6) {
return undefined;
}
const hour = Number(hhmmss.slice(0, 2));
const minute = Number(hhmmss.slice(2, 4));
const second = Number(hhmmss.slice(4, 6));
if (!Number.isFinite(hour) || !Number.isFinite(minute) || !Number.isFinite(second)) {
return undefined;
}
if (month < 1 || month > 12 || day < 1 || day > 31 || hour < 0 || hour > 23 || minute < 0 || minute > 59 || second < 0 || second > 59) {
return undefined;
}
const timestampMs = Date.UTC(year, month - 1, day, hour, minute, second);
if (!Number.isFinite(timestampMs)) {
return undefined;
}
const utc = new Date(timestampMs);
if (
utc.getUTCFullYear() !== year
|| utc.getUTCMonth() !== month - 1
|| utc.getUTCDate() !== day
|| utc.getUTCHours() !== hour
|| utc.getUTCMinutes() !== minute
|| utc.getUTCSeconds() !== second
) {
return undefined;
}
return timestampMs;
}
function parseRollingArtifactFile(fileName: string): Phase0RollingArtifactFile | undefined {
for (const entry of FAMILY_PATTERNS) {
const match = entry.pattern.exec(fileName);
if (!match) {
continue;
}
const tag = match[1] ?? '';
const timestampMs = parseRollingTagTimestampMs(tag);
if (typeof timestampMs !== 'number') {
continue;
}
return {
file_name: fileName,
family: entry.family,
tag,
tag_timestamp_ms: timestampMs,
};
}
return undefined;
}
function sortByTagTimeDesc(a: { tag_timestamp_ms: number; tag: string }, b: { tag_timestamp_ms: number; tag: string }): number {
const delta = b.tag_timestamp_ms - a.tag_timestamp_ms;
if (delta !== 0) {
return delta;
}
return b.tag.localeCompare(a.tag);
}
export function collectRollingPhase0ArtifactFiles(fileNames: string[]): Phase0RollingArtifactFile[] {
const parsed: Phase0RollingArtifactFile[] = [];
for (const fileName of fileNames) {
const row = parseRollingArtifactFile(fileName);
if (row) {
parsed.push(row);
}
}
return parsed;
}
export function planRollingPhase0ArtifactRetention(
fileNames: string[],
keepPerFamily: number,
): Phase0RollingArtifactRetentionPlan {
if (!Number.isFinite(keepPerFamily)) {
throw new Error('keepPerFamily must be a finite integer greater than or equal to 0.');
}
if (!Number.isInteger(keepPerFamily)) {
throw new Error('keepPerFamily must be an integer greater than or equal to 0.');
}
if (keepPerFamily < 0) {
throw new Error('keepPerFamily must be greater than or equal to 0.');
}
const keepLimit = keepPerFamily;
const parsed = collectRollingPhase0ArtifactFiles(fileNames);
const keep: Phase0RollingArtifactFile[] = [];
const remove: Phase0RollingArtifactFile[] = [];
const familyRows: Phase0RollingArtifactRetentionPlan['families'] = [];
for (const familyPattern of FAMILY_PATTERNS) {
const family = familyPattern.family;
const familyFiles = parsed.filter((row) => row.family === family);
const byTag = new Map<string, { tag_timestamp_ms: number; files: Phase0RollingArtifactFile[] }>();
for (const row of familyFiles) {
const existing = byTag.get(row.tag);
if (existing) {
existing.files.push(row);
existing.tag_timestamp_ms = Math.max(existing.tag_timestamp_ms, row.tag_timestamp_ms);
} else {
byTag.set(row.tag, {
tag_timestamp_ms: row.tag_timestamp_ms,
files: [row],
});
}
}
const sortedTags = [...byTag.entries()]
.map(([tag, row]) => ({ tag, tag_timestamp_ms: row.tag_timestamp_ms, files: row.files }))
.sort(sortByTagTimeDesc);
const keepTags = new Set(sortedTags.slice(0, keepLimit).map((row) => row.tag));
for (const row of familyFiles) {
if (keepTags.has(row.tag)) {
keep.push(row);
} else {
remove.push(row);
}
}
familyRows.push({
family,
total_tags: sortedTags.length,
keep_tags: Math.min(sortedTags.length, keepLimit),
remove_tags: Math.max(0, sortedTags.length - keepLimit),
});
}
const sortFilesAsc = (a: Phase0RollingArtifactFile, b: Phase0RollingArtifactFile): number => {
const familyDelta = a.family.localeCompare(b.family);
if (familyDelta !== 0) {
return familyDelta;
}
const tagDelta = sortByTagTimeDesc(a, b);
if (tagDelta !== 0) {
return tagDelta;
}
return a.file_name.localeCompare(b.file_name);
};
return {
keep: [...keep].sort(sortFilesAsc),
remove: [...remove].sort(sortFilesAsc),
families: familyRows,
};
}