diff --git a/actions/setup/js/check_daily_aic_workflow_guardrail.cjs b/actions/setup/js/check_daily_aic_workflow_guardrail.cjs index cc9bbe2b8a8..c16bde0d6ef 100644 --- a/actions/setup/js/check_daily_aic_workflow_guardrail.cjs +++ b/actions/setup/js/check_daily_aic_workflow_guardrail.cjs @@ -13,6 +13,8 @@ const { createRateLimitAwareGithub, fetchAndLogRateLimit } = require("./github_r const PRIMARY_GUARDRAIL_ARTIFACT_NAMES = ["usage"]; const DAILY_WORKFLOW_WINDOW_MS = 24 * 60 * 60 * 1000; +/** Cache entries older than this threshold (in ms) are skipped when loading. */ +const CACHE_RETENTION_MS = 48 * 60 * 60 * 1000; const MAX_WORKFLOW_RUN_PAGES = 10; const RATE_LIMIT_RESERVE = 100; const REQUEST_OVERHEAD_BUDGET = MAX_WORKFLOW_RUN_PAGES + 4; @@ -94,7 +96,11 @@ function shouldSkipDailyAICGuardrail() { /** * Loads the per-workflow usage cache from the JSONL file restored by the activation job's - * cache-restore step. Each line is a JSON object `{ run_id: number, aic: number }`. + * cache-restore step. Each line is a JSON object `{ run_id: number, aic: number, timestamp?: string }`. + * + * Entries with a `timestamp` older than {@link CACHE_RETENTION_MS} (48 h) are skipped so that + * stale data cannot inflate the daily-AIC total. Entries without a `timestamp` (written by an + * older version of the write script) are kept for backward compatibility. * * Returns a `Map` so that callers can check whether a prior run's AIC is already * known without downloading the run's artifact from the GitHub API. @@ -112,7 +118,10 @@ function loadAICUsageCache(filePath) { return cache; } const content = fs.readFileSync(cachePath, "utf8"); + const now = Date.now(); + const cutoff = now - CACHE_RETENTION_MS; let loaded = 0; + let skippedStale = 0; for (const rawLine of content.split("\n")) { const line = rawLine.trim(); if (!line || !line.startsWith("{")) { @@ -120,6 +129,14 @@ function loadAICUsageCache(filePath) { } try { const entry = JSON.parse(line); + // Skip entries that have a timestamp and are older than the retention window. + if (typeof entry?.timestamp === "string") { + const ts = Date.parse(entry.timestamp); + if (Number.isFinite(ts) && ts < cutoff) { + skippedStale++; + continue; + } + } const runId = Number(entry?.run_id); const rawAic = entry?.aic; const aic = typeof rawAic === "number" ? rawAic : NaN; @@ -131,7 +148,7 @@ function loadAICUsageCache(filePath) { // Ignore malformed lines. } } - logDailyGuardrail("Loaded usage cache", { path: cachePath, entriesLoaded: loaded }); + logDailyGuardrail("Loaded usage cache", { path: cachePath, entriesLoaded: loaded, skippedStale }); } catch (err) { logDailyGuardrail("Failed to load usage cache; proceeding without it", { path: cachePath, diff --git a/actions/setup/js/check_daily_aic_workflow_guardrail.test.cjs b/actions/setup/js/check_daily_aic_workflow_guardrail.test.cjs index 12d3fb73f54..61ab16c94ee 100644 --- a/actions/setup/js/check_daily_aic_workflow_guardrail.test.cjs +++ b/actions/setup/js/check_daily_aic_workflow_guardrail.test.cjs @@ -612,5 +612,30 @@ describe("check_daily_aic_workflow_guardrail", () => { expect(cache.has(603)).toBe(false); expect(cache.get(604)).toBe(4.2); }); + + it("loads entries that have a recent timestamp (within 48 h)", () => { + const recentTimestamp = new Date(Date.now() - 60 * 60 * 1000).toISOString(); // 1 hour ago + fs.writeFileSync(cacheFile, JSON.stringify({ run_id: 701, aic: 8.0, timestamp: recentTimestamp }) + "\n", "utf8"); + const cache = exports.loadAICUsageCache(cacheFile); + expect(cache.has(701)).toBe(true); + expect(cache.get(701)).toBe(8.0); + }); + + it("skips entries whose timestamp is older than 48 h", () => { + const staleTimestamp = new Date(Date.now() - 49 * 60 * 60 * 1000).toISOString(); // 49 hours ago + const recentTimestamp = new Date(Date.now() - 30 * 60 * 1000).toISOString(); // 30 minutes ago + fs.writeFileSync(cacheFile, [JSON.stringify({ run_id: 801, aic: 3.0, timestamp: staleTimestamp }), JSON.stringify({ run_id: 802, aic: 5.0, timestamp: recentTimestamp })].join("\n") + "\n", "utf8"); + const cache = exports.loadAICUsageCache(cacheFile); + expect(cache.has(801)).toBe(false); + expect(cache.has(802)).toBe(true); + expect(cache.get(802)).toBe(5.0); + }); + + it("keeps entries without a timestamp (backward compatibility)", () => { + fs.writeFileSync(cacheFile, JSON.stringify({ run_id: 901, aic: 2.5 }) + "\n", "utf8"); + const cache = exports.loadAICUsageCache(cacheFile); + expect(cache.has(901)).toBe(true); + expect(cache.get(901)).toBe(2.5); + }); }); }); diff --git a/actions/setup/js/write_daily_aic_usage_cache.cjs b/actions/setup/js/write_daily_aic_usage_cache.cjs index a33aeb56a19..bb9f074b8fc 100644 --- a/actions/setup/js/write_daily_aic_usage_cache.cjs +++ b/actions/setup/js/write_daily_aic_usage_cache.cjs @@ -20,6 +20,9 @@ const { getErrorMessage } = require("./error_helpers.cjs"); /** Path where the restored (and updated) usage cache lives on the runner. */ const CACHE_FILE_PATH = "/tmp/gh-aw/agentic-workflow-usage-cache.jsonl"; +/** Entries older than this threshold (in ms) are pruned when rewriting the cache. */ +const CACHE_RETENTION_MS = 48 * 60 * 60 * 1000; + /** * Directory prepared by the "Collect usage artifact files" step in the conclusion job. * Contains agent_usage.jsonl and agent/token_usage.jsonl which mirror the contents of @@ -47,12 +50,18 @@ function logCache(message, details) { } /** - * Appends a `{run_id, aic}` JSONL entry to the cache file, preserving any existing entries - * that were restored from the previous cache snapshot. + * Appends a `{run_id, aic, timestamp}` JSONL entry to the cache file, preserving any existing + * entries that were restored from the previous cache snapshot and are within the 48-hour + * retention window. Entries older than {@link CACHE_RETENTION_MS} are pruned to keep the + * cache file bounded. * + * @param {string} [cacheFilePath] Override the cache file path (defaults to {@link CACHE_FILE_PATH}; useful in tests). + * @param {string} [usageDir] Override the usage directory (defaults to {@link USAGE_DIR}; useful in tests). * @returns {Promise} */ -async function main() { +async function mainWithPaths(cacheFilePath, usageDir) { + const cachePath = cacheFilePath || CACHE_FILE_PATH; + const usageDirPath = usageDir || USAGE_DIR; try { const runId = Number(process.env.GITHUB_RUN_ID || 0); if (!runId) { @@ -61,8 +70,8 @@ async function main() { } // Compute AIC from the usage JSONL files prepared by buildUsageArtifactUploadSteps. - const usageFiles = findJSONLFiles(USAGE_DIR); - logCache("Scanning usage JSONL files", { dir: USAGE_DIR, count: usageFiles.length, files: usageFiles }); + const usageFiles = findJSONLFiles(usageDirPath); + logCache("Scanning usage JSONL files", { dir: usageDirPath, count: usageFiles.length, files: usageFiles }); const aic = sumAICFromUsageJSONLFiles(usageFiles); logCache("Computed AIC for current run", { runId, aic }); @@ -76,32 +85,67 @@ async function main() { } // Read existing cache content (restored from the previous run's cache snapshot, if any). - let existingLines = ""; + // Entries with a `timestamp` older than CACHE_RETENTION_MS are pruned to keep the file + // bounded. Entries without a `timestamp` (written by an older version of this script) + // are preserved for backward compatibility. + /** @type {string[]} */ + let keptLines = []; try { - if (fs.existsSync(CACHE_FILE_PATH)) { - existingLines = fs.readFileSync(CACHE_FILE_PATH, "utf8").trimEnd(); - const lineCount = existingLines ? existingLines.split("\n").length : 0; - logCache("Loaded existing cache entries", { path: CACHE_FILE_PATH, lineCount }); + if (fs.existsSync(cachePath)) { + const raw = fs.readFileSync(cachePath, "utf8").trimEnd(); + const now = Date.now(); + const cutoff = now - CACHE_RETENTION_MS; + let total = 0; + let pruned = 0; + for (const rawLine of raw.split("\n")) { + const line = rawLine.trim(); + if (!line) continue; + total++; + try { + const entry = JSON.parse(line); + if (typeof entry?.timestamp === "string") { + const ts = Date.parse(entry.timestamp); + if (Number.isFinite(ts) && ts < cutoff) { + pruned++; + continue; + } + } + keptLines.push(line); + } catch { + // Preserve lines that cannot be parsed (defensive: avoids data loss). + keptLines.push(line); + } + } + logCache("Loaded existing cache entries", { path: cachePath, total, kept: keptLines.length, pruned }); } else { - logCache("No existing cache file found; starting fresh", { path: CACHE_FILE_PATH }); + logCache("No existing cache file found; starting fresh", { path: cachePath }); } } catch (readErr) { core.warning(`[daily-aic-cache] Could not read existing cache file: ${getErrorMessage(readErr)}`); } // Build the updated JSONL content. - const newEntry = JSON.stringify({ run_id: runId, aic }); - const updatedContent = existingLines ? `${existingLines}\n${newEntry}\n` : `${newEntry}\n`; + const newEntry = JSON.stringify({ run_id: runId, aic, timestamp: new Date().toISOString() }); + const updatedContent = keptLines.length > 0 ? `${keptLines.join("\n")}\n${newEntry}\n` : `${newEntry}\n`; // Ensure the directory exists and write the updated file. - const dir = path.dirname(CACHE_FILE_PATH); + const dir = path.dirname(cachePath); fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync(CACHE_FILE_PATH, updatedContent, "utf8"); - logCache("Wrote cache entry", { runId, aic, path: CACHE_FILE_PATH }); + fs.writeFileSync(cachePath, updatedContent, "utf8"); + logCache("Wrote cache entry", { runId, aic, path: cachePath }); } catch (error) { // Non-fatal: a cache write failure should never block the conclusion job. core.warning(`[daily-aic-cache] Failed to write usage cache: ${getErrorMessage(error)}`); } } -module.exports = { main }; +/** + * Entry point called from the GitHub Actions step. + * + * @returns {Promise} + */ +async function main() { + return mainWithPaths(); +} + +module.exports = { main, mainWithPaths }; diff --git a/actions/setup/js/write_daily_aic_usage_cache.test.cjs b/actions/setup/js/write_daily_aic_usage_cache.test.cjs new file mode 100644 index 00000000000..2f3f85ffc60 --- /dev/null +++ b/actions/setup/js/write_daily_aic_usage_cache.test.cjs @@ -0,0 +1,123 @@ +// @ts-check +import fs from "fs"; +import os from "os"; +import path from "path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +let exports; + +describe("write_daily_aic_usage_cache", () => { + let tmpDir; + let cacheFile; + let usageDir; + + beforeEach(async () => { + vi.resetModules(); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "write-aic-cache-test-")); + cacheFile = path.join(tmpDir, "agentic-workflow-usage-cache.jsonl"); + usageDir = path.join(tmpDir, "usage"); + fs.mkdirSync(usageDir, { recursive: true }); + + global.core = { info: vi.fn(), warning: vi.fn(), error: vi.fn(), setFailed: vi.fn() }; + process.env.GITHUB_RUN_ID = "12345"; + + const mod = await import("./write_daily_aic_usage_cache.cjs"); + exports = mod.default || mod; + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + delete global.core; + delete process.env.GITHUB_RUN_ID; + }); + + /** + * Writes a usage JSONL file in the temp usage directory so that sumAICFromUsageJSONLFiles + * can pick it up. The entry uses the `ai_credits` field for simplicity. + * + * @param {number} aiCredits + */ + function writeUsageFile(aiCredits) { + fs.writeFileSync(path.join(usageDir, "agent_usage.jsonl"), JSON.stringify({ ai_credits: aiCredits }) + "\n", "utf8"); + } + + /** + * Invoke the main() function after patching the module-level paths to point to the + * temp directory. We call the function directly on the in-process module instance. + * + * @returns {Promise} + */ + async function runMain() { + // Patch the module-level path constants by calling main() on the already-imported + // module; to make the paths configurable we call the exported helper that accepts + // explicit path arguments (defined below), falling back to swapping env vars. + await exports.mainWithPaths(cacheFile, usageDir); + } + + it("writes a new entry with run_id, aic, and a timestamp when no cache file exists", async () => { + writeUsageFile(7.5); + await runMain(); + + const content = fs.readFileSync(cacheFile, "utf8").trim(); + const entry = JSON.parse(content); + expect(entry.run_id).toBe(12345); + expect(entry.aic).toBe(7.5); + expect(typeof entry.timestamp).toBe("string"); + // Timestamp should be a valid ISO 8601 date within the last minute. + const ts = Date.parse(entry.timestamp); + expect(Number.isFinite(ts)).toBe(true); + expect(ts).toBeLessThanOrEqual(Date.now()); + expect(ts).toBeGreaterThan(Date.now() - 60_000); + }); + + it("appends to an existing cache file and preserves entries within 48 h", async () => { + const recentTs = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(); // 2 hours ago + fs.writeFileSync(cacheFile, JSON.stringify({ run_id: 9999, aic: 3.0, timestamp: recentTs }) + "\n", "utf8"); + + writeUsageFile(5.0); + await runMain(); + + const lines = fs.readFileSync(cacheFile, "utf8").trim().split("\n"); + expect(lines).toHaveLength(2); + const first = JSON.parse(lines[0]); + expect(first.run_id).toBe(9999); + const second = JSON.parse(lines[1]); + expect(second.run_id).toBe(12345); + expect(second.aic).toBe(5.0); + }); + + it("prunes existing entries whose timestamp is older than 48 h", async () => { + const staleTs = new Date(Date.now() - 50 * 60 * 60 * 1000).toISOString(); // 50 hours ago + const recentTs = new Date(Date.now() - 1 * 60 * 60 * 1000).toISOString(); // 1 hour ago + fs.writeFileSync(cacheFile, [JSON.stringify({ run_id: 1001, aic: 1.0, timestamp: staleTs }), JSON.stringify({ run_id: 1002, aic: 2.0, timestamp: recentTs })].join("\n") + "\n", "utf8"); + + writeUsageFile(4.0); + await runMain(); + + const lines = fs.readFileSync(cacheFile, "utf8").trim().split("\n"); + const runIds = lines.map(line => JSON.parse(line).run_id); + // Stale entry 1001 must be pruned; recent 1002 and new 12345 kept. + expect(runIds).not.toContain(1001); + expect(runIds).toContain(1002); + expect(runIds).toContain(12345); + }); + + it("preserves entries without a timestamp (backward compatibility)", async () => { + fs.writeFileSync(cacheFile, JSON.stringify({ run_id: 2001, aic: 8.0 }) + "\n", "utf8"); + + writeUsageFile(1.0); + await runMain(); + + const lines = fs.readFileSync(cacheFile, "utf8").trim().split("\n"); + const runIds = lines.map(line => JSON.parse(line).run_id); + expect(runIds).toContain(2001); + expect(runIds).toContain(12345); + }); + + it("skips writing when GITHUB_RUN_ID is not set", async () => { + delete process.env.GITHUB_RUN_ID; + writeUsageFile(5.0); + await runMain(); + expect(fs.existsSync(cacheFile)).toBe(false); + }); +});