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(); 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, }; }