diff --git a/scripts/build-portal-runtime.mjs b/scripts/build-portal-runtime.mjs index e779328..4eba621 100755 --- a/scripts/build-portal-runtime.mjs +++ b/scripts/build-portal-runtime.mjs @@ -367,6 +367,7 @@ async function writeMetadata() { ' bash scripts/install-runtime-metrics-agent.sh', ' node scripts/runtime-slo-report.mjs', ' node scripts/runtime-slo-report.mjs --write-report', + ' node scripts/runtime-slo-report.mjs --write-report --prune --retention-days 30', ' bash scripts/install-runtime-slo-report-agent.sh', ' node scripts/runtime-worker-drain.mjs drain goosed-3', ' node scripts/runtime-worker-drain.mjs undrain goosed-3', diff --git a/scripts/install-runtime-slo-report-agent.sh b/scripts/install-runtime-slo-report-agent.sh index 336f84c..058b9b2 100644 --- a/scripts/install-runtime-slo-report-agent.sh +++ b/scripts/install-runtime-slo-report-agent.sh @@ -11,6 +11,7 @@ GUI="gui/$(id -u)" HOUR="${MEMIND_RUNTIME_SLO_HOUR:-23}" MINUTE="${MEMIND_RUNTIME_SLO_MINUTE:-55}" REPORT_DIR="${MEMIND_RUNTIME_REPORT_DIR:-$ROOT/reports/runtime-slo}" +RETENTION_DAYS="${MEMIND_RUNTIME_SLO_RETENTION_DAYS:-30}" mkdir -p "$HOME/Library/LaunchAgents" "$HOME/Library/Logs" "$REPORT_DIR" @@ -34,6 +35,9 @@ cat > "$PLIST" <$NODE_BIN $SCRIPT --write-report + --prune + --retention-days + $RETENTION_DAYS --report-dir $REPORT_DIR @@ -69,4 +73,5 @@ echo "installed $PLIST" echo "script: $SCRIPT" echo "schedule: daily ${HOUR}:${MINUTE}" echo "report_dir: $REPORT_DIR" +echo "retention_days: $RETENTION_DAYS" echo "log: $LOG" diff --git a/scripts/runtime-slo-report.mjs b/scripts/runtime-slo-report.mjs index 77309cf..da34e2d 100755 --- a/scripts/runtime-slo-report.mjs +++ b/scripts/runtime-slo-report.mjs @@ -90,27 +90,85 @@ function uniquePaths(paths) { function parseArgs(argv) { const args = { writeReport: false, + prune: false, + retentionDays: Number(process.env.MEMIND_RUNTIME_SLO_RETENTION_DAYS || 30), reportDir: process.env.MEMIND_RUNTIME_REPORT_DIR || '', }; for (let i = 0; i < argv.length; i += 1) { const item = argv[i]; if (item === '--write-report') args.writeReport = true; + else if (item === '--prune') args.prune = true; + else if (item === '--retention-days') args.retentionDays = Number(argv[++i] ?? args.retentionDays); else if (item === '--report-dir') args.reportDir = String(argv[++i] ?? args.reportDir); else if (item === '--help' || item === '-h') args.help = true; } + if (!Number.isFinite(args.retentionDays) || args.retentionDays < 1) { + throw new Error(`Invalid --retention-days: ${args.retentionDays}`); + } return args; } function printHelp() { console.log([ 'Usage:', - ' node scripts/runtime-slo-report.mjs [--write-report] [--report-dir ]', + ' node scripts/runtime-slo-report.mjs [--write-report] [--prune] [--retention-days ] [--report-dir ]', '', 'Default behavior is read-only and writes nothing.', '--write-report writes JSON and Markdown snapshots to an operations report directory.', + '--prune deletes old .json/.md snapshots in the report directory only.', ].join('\n')); } +function pruneReportDir(reportDir, retentionDays) { + const resolvedDir = path.resolve(reportDir); + const cutoffMs = Date.now() - retentionDays * 24 * 60 * 60 * 1000; + const result = { + enabled: true, + reportDir: resolvedDir, + retentionDays, + deleted: [], + kept: 0, + skipped: 0, + errors: [], + }; + let entries = []; + try { + entries = fs.readdirSync(resolvedDir, { withFileTypes: true }); + } catch (err) { + result.errors.push(err instanceof Error ? err.message : String(err)); + return result; + } + for (const entry of entries) { + if (!entry.isFile()) { + result.skipped += 1; + continue; + } + if (!entry.name.endsWith('.json') && !entry.name.endsWith('.md')) { + result.skipped += 1; + continue; + } + const full = path.join(resolvedDir, entry.name); + let stat = null; + try { + stat = fs.statSync(full); + } catch (err) { + result.errors.push(`${entry.name}: ${err instanceof Error ? err.message : String(err)}`); + continue; + } + if (stat.mtimeMs >= cutoffMs) { + result.kept += 1; + continue; + } + try { + fs.unlinkSync(full); + result.deleted.push(entry.name); + } catch (err) { + result.errors.push(`${entry.name}: ${err instanceof Error ? err.message : String(err)}`); + } + } + return result; +} + function tableCountQueries() { return [ ['h5_users', 'users'], @@ -257,6 +315,17 @@ if (runtimeSummary?.toolQueue?.inFlight > runtimeSummary?.toolQueue?.maxConcurre failures.push('tool_queue_concurrency_exceeded'); } +const reportDir = path.resolve(args.reportDir || path.join(appRoot, 'reports', 'runtime-slo')); +if (args.writeReport || args.prune) { + fs.mkdirSync(reportDir, { recursive: true }); +} +const reportPrune = args.prune + ? pruneReportDir(reportDir, args.retentionDays) + : { enabled: false, reportDir, retentionDays: args.retentionDays }; +for (const error of reportPrune.errors ?? []) { + failures.push(`report_prune_error:${error}`); +} + const report = { ok: failures.length === 0, checkedAt: new Date().toISOString(), @@ -274,7 +343,9 @@ const report = { mindSpace: false, redis: false, report: Boolean(args.writeReport), + reportPrune: Boolean(args.prune), }, + reportPrune, }; function markdownReport(payload) { @@ -319,13 +390,17 @@ function markdownReport(payload) { JSON.stringify(payload.writes, null, 2), '```', '', + '## Report Prune', + '', + '```json', + JSON.stringify(payload.reportPrune ?? null, null, 2), + '```', + '', ); return lines.join('\n'); } if (args.writeReport) { - const reportDir = path.resolve(args.reportDir || path.join(appRoot, 'reports', 'runtime-slo')); - fs.mkdirSync(reportDir, { recursive: true }); const stamp = new Date().toISOString().replace(/[:.]/g, '-'); report.reportFiles = { json: path.join(reportDir, `${stamp}.json`),