@reddoorla/maintenance 0.83.0 → 0.85.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/airtable-HRKSA7V4.js +9 -0
- package/dist/{announce-4H64L2Z6.js → announce-GIAYN67S.js} +4 -4
- package/dist/{bump-deps-2OL6PGTM.js → bump-deps-SWJB6H5V.js} +2 -2
- package/dist/{chunk-XDCRVYFL.js → chunk-3YXDSCM6.js} +3 -3
- package/dist/{chunk-CLWNAMUX.js → chunk-577HCGZO.js} +3 -2
- package/dist/chunk-577HCGZO.js.map +1 -0
- package/dist/{chunk-MQUOUVQP.js → chunk-7J53SUT6.js} +2 -2
- package/dist/{chunk-PBCGXXG6.js → chunk-COZPL62X.js} +2 -2
- package/dist/{chunk-KV7DIRRO.js → chunk-GZNBCHAZ.js} +3 -3
- package/dist/{chunk-5P2HVNLZ.js → chunk-LTB6KVJW.js} +3 -3
- package/dist/{chunk-JVWJJ36K.js → chunk-MGR2H7F4.js} +2 -2
- package/dist/{chunk-2WN4SNBQ.js → chunk-NWB4O5EY.js} +2 -2
- package/dist/{chunk-OQFOMKBP.js → chunk-PBWDDDQH.js} +2 -2
- package/dist/{chunk-FSRJAF2Q.js → chunk-TP5M2NQB.js} +10 -3
- package/dist/{chunk-FSRJAF2Q.js.map → chunk-TP5M2NQB.js.map} +1 -1
- package/dist/cli/bin.js +23 -23
- package/dist/cli/commands/audit.js +5 -5
- package/dist/{convert-to-pnpm-KCXOYOHW.js → convert-to-pnpm-AT52NWZB.js} +2 -2
- package/dist/{digest-WGEPAWMP.js → digest-SQ5U6SN2.js} +5 -5
- package/dist/{ensure-site-HYAIVZY2.js → ensure-site-S25MDDHW.js} +2 -2
- package/dist/{forms-notify-target-HSL4ZMT5.js → forms-notify-target-SMKDKNT2.js} +2 -2
- package/dist/{github-signals-I7XACMCE.js → github-signals-G4ILZAJ6.js} +4 -4
- package/dist/{header-image-ADYPTGVY.js → header-image-T7XD37JL.js} +2 -2
- package/dist/{health-endpoint-JNCUILHO.js → health-endpoint-242WG2OJ.js} +2 -2
- package/dist/index.d.ts +20 -0
- package/dist/index.js +6 -6
- package/dist/index.js.map +1 -1
- package/dist/{init-FLGLJCMJ.js → init-TFVAN5OY.js} +2 -2
- package/dist/{launch-AVKH2JVF.js → launch-DWY2MF54.js} +5 -5
- package/dist/{lighthouse-airtable-CHANWVO6.js → lighthouse-airtable-BIPM26SZ.js} +3 -3
- package/dist/{onboard-HF4FTJDE.js → onboard-53CITIM7.js} +2 -2
- package/dist/{orchestrate-5FMNYJEA.js → orchestrate-KDNXZQX7.js} +3 -3
- package/dist/{preflight-HTWIX3MN.js → preflight-3XPDUHFJ.js} +4 -4
- package/dist/{prismic-ci-BH225Q4L.js → prismic-ci-UTQ6LRR3.js} +90 -6
- package/dist/prismic-ci-UTQ6LRR3.js.map +1 -0
- package/dist/{prismic-models-HXCAM4ZL.js → prismic-models-PVES6R5V.js} +5 -5
- package/dist/{renovate-dispatch-HBZG2UEU.js → renovate-dispatch-ICJGSFCE.js} +2 -2
- package/dist/{report-LV7NY5W3.js → report-WG6RHA2X.js} +5 -5
- package/dist/{self-updating-4Q65OVXY.js → self-updating-PPQVV6UA.js} +2 -2
- package/dist/{selftest-K5XZKVTE.js → selftest-BVCLJRNI.js} +5 -5
- package/dist/{smoke-suite-J3LL3JZJ.js → smoke-suite-OBWNPALQ.js} +2 -2
- package/dist/{submissions-GMBUP4LV.js → submissions-GJ6ITBRH.js} +2 -2
- package/dist/{svelte-codemods-JTMX4BSX.js → svelte-codemods-ZOBDHIES.js} +2 -2
- package/dist/{sync-configs-5DXSL64Q.js → sync-configs-CJXUX6MD.js} +2 -2
- package/dist/{upgrade-HCR25P25.js → upgrade-WLX27UPC.js} +2 -2
- package/dist/{websites-VITQ3WXN.js → websites-SADZR4XQ.js} +2 -2
- package/dist/{write-audits-to-airtable-3SOXLKJK.js → write-audits-to-airtable-7PH75YWY.js} +4 -4
- package/package.json +1 -1
- package/dist/airtable-5ZPU3MT4.js +0 -9
- package/dist/chunk-CLWNAMUX.js.map +0 -1
- package/dist/prismic-ci-BH225Q4L.js.map +0 -1
- /package/dist/{airtable-5ZPU3MT4.js.map → airtable-HRKSA7V4.js.map} +0 -0
- /package/dist/{announce-4H64L2Z6.js.map → announce-GIAYN67S.js.map} +0 -0
- /package/dist/{bump-deps-2OL6PGTM.js.map → bump-deps-SWJB6H5V.js.map} +0 -0
- /package/dist/{chunk-XDCRVYFL.js.map → chunk-3YXDSCM6.js.map} +0 -0
- /package/dist/{chunk-MQUOUVQP.js.map → chunk-7J53SUT6.js.map} +0 -0
- /package/dist/{chunk-PBCGXXG6.js.map → chunk-COZPL62X.js.map} +0 -0
- /package/dist/{chunk-KV7DIRRO.js.map → chunk-GZNBCHAZ.js.map} +0 -0
- /package/dist/{chunk-5P2HVNLZ.js.map → chunk-LTB6KVJW.js.map} +0 -0
- /package/dist/{chunk-JVWJJ36K.js.map → chunk-MGR2H7F4.js.map} +0 -0
- /package/dist/{chunk-2WN4SNBQ.js.map → chunk-NWB4O5EY.js.map} +0 -0
- /package/dist/{chunk-OQFOMKBP.js.map → chunk-PBWDDDQH.js.map} +0 -0
- /package/dist/{convert-to-pnpm-KCXOYOHW.js.map → convert-to-pnpm-AT52NWZB.js.map} +0 -0
- /package/dist/{digest-WGEPAWMP.js.map → digest-SQ5U6SN2.js.map} +0 -0
- /package/dist/{ensure-site-HYAIVZY2.js.map → ensure-site-S25MDDHW.js.map} +0 -0
- /package/dist/{forms-notify-target-HSL4ZMT5.js.map → forms-notify-target-SMKDKNT2.js.map} +0 -0
- /package/dist/{github-signals-I7XACMCE.js.map → github-signals-G4ILZAJ6.js.map} +0 -0
- /package/dist/{header-image-ADYPTGVY.js.map → header-image-T7XD37JL.js.map} +0 -0
- /package/dist/{health-endpoint-JNCUILHO.js.map → health-endpoint-242WG2OJ.js.map} +0 -0
- /package/dist/{init-FLGLJCMJ.js.map → init-TFVAN5OY.js.map} +0 -0
- /package/dist/{launch-AVKH2JVF.js.map → launch-DWY2MF54.js.map} +0 -0
- /package/dist/{lighthouse-airtable-CHANWVO6.js.map → lighthouse-airtable-BIPM26SZ.js.map} +0 -0
- /package/dist/{onboard-HF4FTJDE.js.map → onboard-53CITIM7.js.map} +0 -0
- /package/dist/{orchestrate-5FMNYJEA.js.map → orchestrate-KDNXZQX7.js.map} +0 -0
- /package/dist/{preflight-HTWIX3MN.js.map → preflight-3XPDUHFJ.js.map} +0 -0
- /package/dist/{prismic-models-HXCAM4ZL.js.map → prismic-models-PVES6R5V.js.map} +0 -0
- /package/dist/{renovate-dispatch-HBZG2UEU.js.map → renovate-dispatch-ICJGSFCE.js.map} +0 -0
- /package/dist/{report-LV7NY5W3.js.map → report-WG6RHA2X.js.map} +0 -0
- /package/dist/{self-updating-4Q65OVXY.js.map → self-updating-PPQVV6UA.js.map} +0 -0
- /package/dist/{selftest-K5XZKVTE.js.map → selftest-BVCLJRNI.js.map} +0 -0
- /package/dist/{smoke-suite-J3LL3JZJ.js.map → smoke-suite-OBWNPALQ.js.map} +0 -0
- /package/dist/{submissions-GMBUP4LV.js.map → submissions-GJ6ITBRH.js.map} +0 -0
- /package/dist/{svelte-codemods-JTMX4BSX.js.map → svelte-codemods-ZOBDHIES.js.map} +0 -0
- /package/dist/{sync-configs-5DXSL64Q.js.map → sync-configs-CJXUX6MD.js.map} +0 -0
- /package/dist/{upgrade-HCR25P25.js.map → upgrade-WLX27UPC.js.map} +0 -0
- /package/dist/{websites-VITQ3WXN.js.map → websites-SADZR4XQ.js.map} +0 -0
- /package/dist/{write-audits-to-airtable-3SOXLKJK.js.map → write-audits-to-airtable-7PH75YWY.js.map} +0 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/alerts/digest-collectors.ts","../src/alerts/digest-state.ts"],"sourcesContent":["// src/alerts/digest-collectors.ts\nimport type { AttentionItem } from \"./attention.js\";\nimport {\n allActionableVulnsTransitive,\n siteSlug,\n ACTIVE_STATUSES,\n isPreLaunch,\n type WebsiteRow,\n} from \"../reports/airtable/websites.js\";\nimport type { ReportRow } from \"../reports/airtable/reports.js\";\nimport { approveBlockers } from \"../reports/preflight.js\";\n\n/** Build the same `/s/<slug>` dashboard link the M3 ready-section uses, trailing-slash-safe.\n * An empty Name slugs to \"\" and `/s/` is a dead link — fall back to the fleet homepage,\n * exactly like the ready-section does. */\nfunction dashboardUrl(baseUrl: string, siteName: string): string {\n const root = baseUrl.replace(/\\/$/, \"\");\n const slug = siteSlug(siteName);\n return slug ? `${root}/s/${slug}` : root;\n}\n\n/**\n * A GitHub-signals sweep older than this (or never run) is no longer trustworthy:\n * a repo whose nightly probe THREW stops being re-swept, so its persisted\n * `Default Branch CI` / `Renovate Failing CIs` freeze at their last value forever\n * — a phantom 🔴 that can never clear. 3 days ≈ 3× the daily sweep interval, so a\n * single missed/flaky run doesn't drop a real signal. The CI/Renovate collectors\n * (only) skip a site whose `githubSignalsAt` is staler than this. Vuln/Lighthouse/\n * delivery signals come from other sweeps and are unaffected.\n */\nconst GITHUB_SIGNALS_STALE_DAYS = 3;\nconst MS_PER_DAY = 24 * 60 * 60 * 1000;\n\n/** True when a site's GitHub-signals sweep is too old (or never ran) to trust the\n * persisted CI/Renovate fields. A null `githubSignalsAt` (never swept) is stale —\n * there's no sweep to vouch for the values. A future timestamp (now - swept < 0)\n * is fresh. `now` is injected so the gate is testable. */\nfunction gitHubSignalsStale(swept: string | null, now: Date): boolean {\n if (swept === null) return true;\n const ageMs = now.getTime() - Date.parse(swept);\n if (!Number.isFinite(ageMs)) return true; // unparseable timestamp → don't trust it\n return ageMs > GITHUB_SIGNALS_STALE_DAYS * MS_PER_DAY;\n}\n\n/** Renovate auto-fix dispatches for one vuln episode before it's \"exhausted\" (manual fix needed). */\nconst AUTO_FIX_EXHAUSTED_CYCLES = 3;\n\n/**\n * One attention item per site carrying current critical+high vulns (medium/low omitted\n * per the locked threshold). PURE: takes already-fetched Websites rows. `metric` is the\n * critical+high count (so a rising count diffs as WORSE); `severity` is `critical` when\n * any critical exists, else `warning`. Null counts (never audited) read as 0 → skipped.\n * Once `securityAutoFixAttempts` reaches AUTO_FIX_EXHAUSTED_CYCLES the item is flagged\n * `autoFixExhausted` (forced-critical, escalated title) — Renovate tried and couldn't fix it.\n * EXCEPT when the persisted advisories prove every critical/high vuln is TRANSITIVE: then\n * nightly dispatches were green no-ops (no direct dep to bump — the fix rides the weekly\n * lockfile window), \"auto-fix failed\" would be a lie, and a stale pre-fix counter must not\n * escalate (Sonder said \"auto-fix failed (5×)\" on exactly this, 2026-08-10). Those sites get\n * an honest transitive-only title, keep count-based severity, and stay out of the digest\n * email (amber cockpit Watch still shows them). Unknown relationship data never mutes.\n *\n * Emission is UNCONDITIONAL — every surface applies its own policy on top:\n * - digest email: includes a vuln only when `autoFixExhausted` (the operator hears\n * about a vuln only after Renovate tried and failed; before that the fleet is\n * still self-patching) — runDigest filters\n * - cockpit: non-exhausted vuln → amber Watch; exhausted → hard break (assignTier),\n * and only an exhausted vuln pierces the pre-launch mute (piercesPreLaunchMute)\n */\nexport function collectVulnAlerts(sites: WebsiteRow[], baseUrl: string): AttentionItem[] {\n const items: AttentionItem[] = [];\n for (const s of sites) {\n const critical = s.securityVulnsCritical ?? 0;\n const high = s.securityVulnsHigh ?? 0;\n const metric = critical + high;\n if (metric <= 0) continue;\n const attempts = s.securityAutoFixAttempts ?? 0;\n const transitiveOnly = allActionableVulnsTransitive(s.securityAdvisories);\n const exhausted = !transitiveOnly && attempts >= AUTO_FIX_EXHAUSTED_CYCLES;\n const noun = metric === 1 ? \"vuln\" : \"vulns\";\n items.push({\n key: `vuln:${s.id}`,\n kind: \"vuln\",\n siteName: s.name,\n title: exhausted\n ? `${metric} critical/high ${noun} — auto-fix failed (${attempts}×)`\n : transitiveOnly\n ? `${metric} critical/high ${noun} — transitive-only, fix rides the weekly lockfile window`\n : `${metric} critical/high ${noun}`,\n url: dashboardUrl(baseUrl, s.name),\n severity: exhausted || critical > 0 ? \"critical\" : \"warning\",\n metric,\n ...(exhausted ? { autoFixExhausted: true } : {}),\n });\n }\n return items;\n}\n\n/** Absolute floor below which a Lighthouse category is \"Needs attention\" (Tucker's call). */\nconst LIGHTHOUSE_FLOOR = 75;\n\n/** The four Lighthouse categories, each mapped to its WebsiteRow score field, URL slug,\n * and the human label rendered in the digest title. Order is the operator's reading order. */\nconst LIGHTHOUSE_CATEGORIES: ReadonlyArray<{\n field: \"pScore\" | \"rScore\" | \"bpScore\" | \"seoScore\";\n slug: string;\n label: string;\n}> = [\n { field: \"pScore\", slug: \"performance\", label: \"Performance\" },\n { field: \"rScore\", slug: \"accessibility\", label: \"Accessibility\" },\n { field: \"bpScore\", slug: \"best-practices\", label: \"Best Practices\" },\n { field: \"seoScore\", slug: \"seo\", label: \"SEO\" },\n];\n\n/**\n * One attention item per Lighthouse category below the absolute floor (75) for each site.\n * PURE: takes already-fetched Websites rows. Categories are Performance/Accessibility/\n * Best-Practices/SEO. A null score (never audited) or a score >= 75 is skipped. The\n * `metric` is the DEFICIT (`100 - score`): a lower score → higher metric, so a category\n * that drops further diffs as WORSE and one that first crosses below 75 diffs as NEW —\n * which is how `diffAttention`'s \"WORSE on increase\" rule reads an inverted score. `key`\n * is `lighthouse:<siteId>:<categorySlug>`, so the four categories stay distinct per site.\n */\nexport function collectLighthouseAlerts(sites: WebsiteRow[], baseUrl: string): AttentionItem[] {\n const items: AttentionItem[] = [];\n for (const s of sites) {\n for (const cat of LIGHTHOUSE_CATEGORIES) {\n const score = s[cat.field];\n if (score === null || score >= LIGHTHOUSE_FLOOR) continue;\n items.push({\n key: `lighthouse:${s.id}:${cat.slug}`,\n kind: \"lighthouse\",\n siteName: s.name,\n title: `Lighthouse ${cat.label} ${score} (below ${LIGHTHOUSE_FLOOR})`,\n url: dashboardUrl(baseUrl, s.name),\n severity: \"warning\",\n metric: 100 - score,\n });\n }\n }\n return items;\n}\n\n/**\n * One attention item per report whose `deliveryStatus` is a failure (`bounced` or\n * `complained` — `delivered`/`pending` are ignored). PURE: takes already-fetched\n * Reports rows + a record-id→site map. A complaint ranks above a bounce (locked\n * threshold), so `severity` is `critical` for complained / `warning` for bounced.\n * `metric` is 1 (a binary event). Orphans (siteId not in the map) are skipped, as\n * the M3 ready-section does, so the digest never renders a broken link. The diff\n * key is the report RECORD id, so two failures on one site stay distinct.\n */\n/**\n * One attention item per unsent draft whose send is ALREADY known to fail\n * (approveBlockers: recipients / header image / report scores). An APPROVED one\n * is critical — the next 09:23 UTC run will go red on it; a pending one is a\n * warning — approving it just schedules that failure. Keyed `preflight:<reportId>`.\n * PURE; same predicate the approve gate and the dashboard chip use.\n */\nexport function collectPreflightBlocked(\n reports: ReportRow[],\n sitesById: Map<string, WebsiteRow>,\n baseUrl: string,\n): AttentionItem[] {\n const items: AttentionItem[] = [];\n for (const r of reports) {\n if (!r.draftReady || r.sentAt !== null) continue;\n // The approved state rides the KEY so a pending→approved escalation with\n // unchanged blockers re-news as a fresh critical instead of diffing\n // \"standing\" against its old warning self.\n const state = r.approvedToSend ? \"approved\" : \"pending\";\n const site = sitesById.get(r.siteId);\n if (!site) {\n // A dangling/empty Site link is itself a send blocker (sendApprovedReports\n // fails with \"Site row not found\") — surface it; the empty-slug\n // dashboardUrl fallback yields the fleet root, not a broken /s/ link.\n items.push({\n key: `preflight:${r.id}:${state}`,\n kind: \"preflight\",\n siteName: \"(unlinked site)\",\n title: r.approvedToSend\n ? `Approved ${r.reportType} will fail at send — site-not-found`\n : `${r.reportType} draft can't be approved — site-not-found`,\n url: dashboardUrl(baseUrl, \"\"),\n severity: r.approvedToSend ? \"critical\" : \"warning\",\n metric: 1,\n });\n continue;\n }\n const fails = approveBlockers(site, r).filter((f) => f.level === \"fail\");\n if (fails.length === 0) continue;\n const first = fails[0]!;\n const more = fails.length > 1 ? ` (+${fails.length - 1} more)` : \"\";\n items.push({\n key: `preflight:${r.id}:${state}`,\n kind: \"preflight\",\n siteName: site.name,\n title: r.approvedToSend\n ? `Approved ${r.reportType} will fail at send — ${first.check}${more}`\n : `${r.reportType} draft can't be approved — ${first.check}${more}`,\n url: dashboardUrl(baseUrl, site.name),\n severity: r.approvedToSend ? \"critical\" : \"warning\",\n metric: fails.length,\n });\n }\n return items;\n}\n\nexport function collectDeliveryFailures(\n reports: ReportRow[],\n sitesById: Map<string, WebsiteRow>,\n baseUrl: string,\n): AttentionItem[] {\n const items: AttentionItem[] = [];\n for (const r of reports) {\n if (r.deliveryStatus !== \"bounced\" && r.deliveryStatus !== \"complained\") continue;\n const site = sitesById.get(r.siteId);\n if (!site) continue; // orphan → skip rather than render a broken link\n const complained = r.deliveryStatus === \"complained\";\n items.push({\n key: `delivery:${r.id}`,\n kind: \"delivery\",\n siteName: site.name,\n title: complained ? \"Spam complaint on a sent report\" : \"A sent report bounced\",\n url: dashboardUrl(baseUrl, site.name),\n severity: complained ? \"critical\" : \"warning\",\n metric: 1,\n });\n }\n return items;\n}\n\n/**\n * One attention item per site carrying failing Renovate PRs, read from the\n * slice-2a-persisted `renovateFailingCis` field (the nightly github-signals sweep\n * populates it). PURE. Keyed `renovate:<siteId>` so the digest and the cockpit\n * share one diff key. `metric` is the count (a rising count diffs WORSE); severity\n * `warning`. Null/0 → skipped. A site whose `githubSignalsAt` is >3 days stale (or\n * null) is ALSO skipped — a repo that stopped being swept must not show a phantom\n * count forever (`now` injected, defaults to wall-clock).\n */\nexport function collectRenovateAlerts(\n sites: WebsiteRow[],\n baseUrl: string,\n now: Date = new Date(),\n): AttentionItem[] {\n const items: AttentionItem[] = [];\n for (const s of sites) {\n if (gitHubSignalsStale(s.githubSignalsAt, now)) continue;\n const n = s.renovateFailingCis ?? 0;\n if (n <= 0) continue;\n items.push({\n key: `renovate:${s.id}`,\n kind: \"renovate\",\n siteName: s.name,\n title: `${n} Renovate ${n === 1 ? \"PR\" : \"PRs\"} failing CI`,\n url: dashboardUrl(baseUrl, s.name),\n severity: \"warning\",\n metric: n,\n });\n }\n return items;\n}\n\n/**\n * One attention item per site whose persisted default-branch CI rollup is\n * `failing` (slice 2a). PURE. `metric` 1 (binary); severity `warning`. Any other\n * state (passing/pending/none) or null is skipped. A site whose `githubSignalsAt`\n * is >3 days stale (or null) is ALSO skipped — a repo that stopped being swept must\n * not show a phantom 🔴 forever (`now` injected, defaults to wall-clock).\n */\nexport function collectCiAlerts(\n sites: WebsiteRow[],\n baseUrl: string,\n now: Date = new Date(),\n): AttentionItem[] {\n const items: AttentionItem[] = [];\n for (const s of sites) {\n if (gitHubSignalsStale(s.githubSignalsAt, now)) continue;\n if (s.defaultBranchCi !== \"failing\") continue;\n items.push({\n key: `ci:${s.id}`,\n kind: \"ci\",\n siteName: s.name,\n title: \"Default-branch CI failing\",\n url: dashboardUrl(baseUrl, s.name),\n severity: \"warning\",\n metric: 1,\n });\n }\n return items;\n}\n\n/**\n * A soft-fail older than this is no longer a trustworthy CURRENT signal. Drafting\n * self-clears `analyticsSoftFailAt` on the next clean enrichment, so a non-null\n * value normally means \"errored on the most recent draft and hasn't recovered\" —\n * but a site that stopped being drafted (freq→None, deprecated) would otherwise\n * show a phantom forever. 45 days covers a monthly cadence + margin; older drops.\n */\nconst ANALYTICS_SOFT_FAIL_STALE_DAYS = 45;\n\n/**\n * One attention item per site whose last draft's GA/Search enrichment ERRORED, read\n * from the `analyticsSoftFailAt` timestamp (drafting sets it on a soft-fail, clears\n * it on a clean enrichment). PURE. Keyed `analytics:<siteId>` so the digest and the\n * cockpit share one diff key; `metric` 1 (binary), severity `warning`. A null\n * timestamp (clean, or the operator-added `Analytics soft-fail at` column absent) is\n * skipped, as is one staler than {@link ANALYTICS_SOFT_FAIL_STALE_DAYS}. On a\n * FLEET-WIDE subject outage many sites surface this at once — that breadth IS the\n * signal here; the report cron additionally emails a single concise fleet-wide alert\n * (see `assessAnalyticsAlert`). `now` injected, defaults to wall-clock.\n */\n/** A function-health sweep older than this can't confirm the CURRENT widget state.\n * The sweep is nightly, so 3 days (mirrors GITHUB_SIGNALS_STALE_DAYS) tolerates a\n * weekend of runner flakes without letting a months-old verdict drive an alarm. */\nconst TURNSTILE_WIDGET_STALE_DAYS = 3;\n\n/**\n * One CRITICAL attention item per site with `Require Turnstile` ON whose deployed\n * `/health` reports the Turnstile widget NOT configured (`turnstileWidget === \"fail\"`,\n * fresh per {@link TURNSTILE_WIDGET_STALE_DAYS}). That combination silently buckets\n * 100% of the site's real leads (token-less submissions escalate to spam_auto with\n * notify skipped) and the form-e2e probe cannot see it — testMode bypasses the gate —\n * so the operator's inbox just goes quiet. PURE. Keyed `turnstile:<siteId>`. Because\n * this is an AttentionItem it rides assignTier's items short-circuit, which sits ABOVE\n * the accepted-watch mute loop — an accept key can never silence it. A null verdict or\n * a stale sweep is NOT alarmed here; assignTier raises those as an acceptable\n * \"can't verify\" watch instead. `now` injected, defaults to wall-clock.\n */\nexport function collectTurnstileGuardrailAlerts(\n sites: WebsiteRow[],\n baseUrl: string,\n now: Date = new Date(),\n): AttentionItem[] {\n const items: AttentionItem[] = [];\n for (const s of sites) {\n if (!s.requireTurnstile || s.turnstileWidget !== \"fail\") continue;\n const at = s.functionHealthCheckedAt;\n if (at !== null) {\n const ageMs = now.getTime() - Date.parse(at);\n // Parseable and beyond the window → stale, downgraded to assignTier's watch.\n // Unparseable (NaN) keeps the item — never silently drop a real failure.\n if (Number.isFinite(ageMs) && ageMs > TURNSTILE_WIDGET_STALE_DAYS * MS_PER_DAY) continue;\n }\n items.push({\n key: `turnstile:${s.id}`,\n kind: \"turnstile\",\n siteName: s.name,\n title:\n \"Require Turnstile is ON but /health reports no widget — real leads are being auto-bucketed\",\n url: dashboardUrl(baseUrl, s.name),\n severity: \"critical\",\n metric: 1,\n });\n }\n return items;\n}\n\n/** Bounced lead notifications inside the window before a site alarms. One bounce can\n * be a transient greylist/full-mailbox blip; a repeat says the point-of-contact\n * address itself is bad (the Espada mode: apm@ bounced 4 of 8 with nothing alarming). */\nexport const NOTIFY_BOUNCE_THRESHOLD = 2;\n\n/** Window (days) the bounce count covers. Two weeks: long enough that a low-volume\n * site's second bounce still lands in the same window as its first, short enough\n * that a long-fixed address doesn't alarm forever (bounced rows never self-clear —\n * the alarm ages out as bounced submissions leave the window). */\nexport const NOTIFY_BOUNCE_WINDOW_DAYS = 14;\n\n/**\n * One CRITICAL attention item per site with >= {@link NOTIFY_BOUNCE_THRESHOLD} bounced\n * lead notifications in the last {@link NOTIFY_BOUNCE_WINDOW_DAYS} days — leads are\n * being captured but silently never reach the client, which is worse than no form at\n * all (notifyStatus \"sent\" only means Resend accepted the email; the resend-webhook\n * flips it to \"bounced\" when Resend later reports the failure, 2026-07-16). PURE:\n * takes the pre-fetched per-site counts (`countNotifyBouncedBySite`, keyed by the\n * Websites record id). Keyed `notify-bounce:<siteId>` so the digest and the cockpit\n * share one diff key; `metric` is the bounce count (a rising count diffs WORSE).\n * A site absent from the map (or below threshold) is skipped, as is a count for a\n * site id not in `sites` (orphan → never render a broken link).\n */\nexport function collectNotifyBounceAlerts(\n sites: WebsiteRow[],\n bouncedBySite: ReadonlyMap<string, number>,\n baseUrl: string,\n): AttentionItem[] {\n const items: AttentionItem[] = [];\n for (const s of sites) {\n const n = bouncedBySite.get(s.id) ?? 0;\n if (n < NOTIFY_BOUNCE_THRESHOLD) continue;\n items.push({\n key: `notify-bounce:${s.id}`,\n kind: \"notify-bounce\",\n siteName: s.name,\n title: `${n} lead notifications bounced (${NOTIFY_BOUNCE_WINDOW_DAYS}d) — check the point-of-contact address`,\n url: dashboardUrl(baseUrl, s.name),\n severity: \"critical\",\n metric: n,\n });\n }\n return items;\n}\n\nexport function collectAnalyticsFailures(\n sites: WebsiteRow[],\n baseUrl: string,\n now: Date = new Date(),\n): AttentionItem[] {\n const items: AttentionItem[] = [];\n for (const s of sites) {\n const at = s.analyticsSoftFailAt;\n if (at === null) continue;\n const ageMs = now.getTime() - Date.parse(at);\n // Unparseable timestamp (NaN) → keep the item (don't silently drop a real failure\n // on a parse glitch); only a parseable, beyond-window age is skipped as stale.\n if (Number.isFinite(ageMs) && ageMs > ANALYTICS_SOFT_FAIL_STALE_DAYS * MS_PER_DAY) continue;\n items.push({\n key: `analytics:${s.id}`,\n kind: \"analytics\",\n siteName: s.name,\n title: \"GA/Search enrichment failing (analytics blank)\",\n url: dashboardUrl(baseUrl, s.name),\n severity: \"warning\",\n metric: 1,\n });\n }\n return items;\n}\n\n/** How current a `fail` / `unknown` must be to still CLAIM the fleet's present\n * state. The sweep is nightly, so 3 days (mirrors GITHUB_SIGNALS_STALE_DAYS)\n * tolerates a weekend of runner flakes without letting a months-old finding drive\n * a \"models diverge right now\" alarm. Past it the finding does not vanish — it\n * converts to the staleness item below, which claims nothing about the models. */\nconst PRISMIC_DRIFT_STALE_DAYS = 3;\n\n/** How long a `pass` may go unrefreshed before it is treated as unverified.\n *\n * Deliberately LONGER than the currency window, and the asymmetry is the point.\n * An old `fail` stops being reported as CURRENT drift at 3 days because it may\n * already be fixed. An old `pass` is a green claim nobody has re-established, and\n * escalating that at 3 days would light up the WHOLE fleet after a long weekend of\n * runner flakes — the noise that gets a real alarm muted. A week is the interval\n * over which a silently-dead nightly actually matters. */\nconst PRISMIC_STALE_PASS_DAYS = 7;\n\n/**\n * True when the nightly `prismic-models --fleet airtable` sweep is EXPECTED to\n * cover this site. Mirrors the Airtable inventory's own filter (src/inventory/\n * airtable.ts): live `maintenance` sites (active, not pre-launch) that carry a\n * `url` AND a Name that yields a slug. Deliberately duplicated rather than\n * imported — the inventory builds `Site` objects and needs a workdir; this is a\n * pure predicate over a row — but the two must stay in step: widen the inventory\n * and widen this. That is no longer left to memory: every shape of row is put\n * through BOTH implementations by tests/alerts/prismic-sweep-scope.test.ts, which\n * goes red the moment they diverge.\n *\n * THE SLUG CLAUSE IS NOT COSMETIC, and it was the first drift this pair produced.\n * The inventory drops an empty-slug row (`siteSlug(name) === \"\"`) with a warning,\n * because such a row can neither form a checkout path nor be matched back to its\n * Websites row on write-back — and `writeSweepToAirtable` joins by that same slug,\n * so even a verdict computed for it could never land. Without this clause a live\n * site named in a script with no `[a-z0-9]` (or with an empty Name) was covered\n * here and swept nowhere: the permanent, un-ackable morning email this predicate\n * exists to prevent, produced by the predicate itself.\n *\n * Only the STALENESS escalation consults it. A `fail`/`unknown` is a verdict some\n * run actually established and is reported wherever it came from; the staleness\n * item is an alarm invented FROM AN ABSENCE, and an absence is only wrong where a\n * sweep was owed. A deprecated site that left the inventory would otherwise carry\n * a frozen `pass` into the digest every morning forever — un-ackable (attention\n * items sit above the accepted-watch mute) and unfixable except by hand-clearing\n * an Airtable cell.\n */\nfunction prismicSweepCovers(s: WebsiteRow): boolean {\n return (\n s.status !== null &&\n ACTIVE_STATUSES.has(s.status) &&\n !isPreLaunch(s.status) &&\n s.url.length > 0 &&\n siteSlug(s.name).length > 0\n );\n}\n\n/**\n * One item per site whose nightly Prismic model verdict needs a human, from the\n * `prismic-models --fleet --write-airtable` sweep. PURE (`now` injected).\n *\n * The verdict is THREE-valued plus blank, and each state gets its own key so the\n * digest's snapshot diff (keyed on `key`) can never let one condition stand in for\n * another — a site sliding from drift to \"couldn't check\" re-news as NEW rather\n * than diffing \"standing\" against the drift it replaced (both carry `metric` 1):\n *\n * - `fail`, fresh → `prismic-drift:<siteId>` — repo and Prismic diverge.\n * - `unknown`, fresh → `prismic-unknown:<siteId>` — the check RAN AND COULD NOT\n * ANSWER (unreadable checkout, dead write token, unreachable Prismic). Its own\n * wording, because reporting a dead token as \"models diverge\" sends the\n * operator to fix a model when the job is to fix a secret.\n * - any verdict nobody has re-established → `prismic-stale:<siteId>` — a `pass`\n * older than {@link PRISMIC_STALE_PASS_DAYS} (or undateable), or a `fail` /\n * `unknown` older than {@link PRISMIC_DRIFT_STALE_DAYS}. A stale verdict is\n * never silence: dropping it would make \"nobody could evaluate this\" read\n * exactly like \"this is fine\", which is the failure this whole column exists\n * to close. Gated by {@link prismicSweepCovers}.\n * - blank (never swept, or not a Prismic site) → nothing, at any age. There is\n * no claim to un-verify.\n *\n * A `pass` inside its window is the ONLY silent verdict. At most one item per site\n * — the Airtable cell holds one state at a time.\n *\n * `warning`, not `critical`: on the cockpit ANY item already tiers the site 🔴, so\n * severity buys only (a) piercing the pre-launch mute and (b) sorting first in the\n * needs-you feed. `critical` is reserved for the guardrails that are actively\n * losing leads or breaking a live page (Turnstile, notify-bounce, delivery); a\n * model that drifted — or a check that could not run — is neither, and piercing\n * the pre-launch mute would alarm on exactly the sites the sweep excludes by\n * design.\n */\nexport function collectPrismicDriftAlerts(\n sites: WebsiteRow[],\n baseUrl: string,\n now: Date = new Date(),\n): AttentionItem[] {\n const items: AttentionItem[] = [];\n for (const s of sites) {\n const verdict = s.prismicModels;\n if (verdict === null) continue;\n\n const at = s.prismicModelsCheckedAt;\n // +Infinity when there is no timestamp, NaN when it won't parse — both mean\n // \"the age of this answer is unknowable\", which the two verdict families read\n // in OPPOSITE directions on purpose (see `unverified`).\n const ageMs = at === null ? Number.POSITIVE_INFINITY : now.getTime() - Date.parse(at);\n const ageKnown = Number.isFinite(ageMs);\n const windowDays = verdict === \"pass\" ? PRISMIC_STALE_PASS_DAYS : PRISMIC_DRIFT_STALE_DAYS;\n // An undateable `pass` is unverified — a green claim nobody can date is not a\n // green claim. An undateable `fail`/`unknown` is NOT downgraded: never silently\n // drop a real finding over a parse glitch.\n const unverified = ageKnown ? ageMs > windowDays * MS_PER_DAY : verdict === \"pass\";\n\n if (unverified) {\n if (!prismicSweepCovers(s)) continue;\n items.push({\n key: `prismic-stale:${s.id}`,\n kind: \"prismic-drift\",\n siteName: s.name,\n title: `Prismic model check has not run recently — the last verdict (\"${verdict}\") is unverified`,\n url: dashboardUrl(baseUrl, s.name),\n severity: \"warning\",\n metric: 1,\n });\n continue;\n }\n\n if (verdict === \"pass\") continue;\n\n const first = s.prismicModelsDrift\n ?.split(\"\\n\")\n .find((l) => l.trim() !== \"\")\n ?.trim();\n if (verdict === \"unknown\") {\n items.push({\n key: `prismic-unknown:${s.id}`,\n kind: \"prismic-drift\",\n siteName: s.name,\n title: first\n ? `Prismic model check could not run — ${first}`\n : \"Prismic model check could not run\",\n url: dashboardUrl(baseUrl, s.name),\n severity: \"warning\",\n metric: 1,\n });\n continue;\n }\n\n items.push({\n key: `prismic-drift:${s.id}`,\n kind: \"prismic-drift\",\n siteName: s.name,\n title: first\n ? `Prismic models diverge from the repo — ${first}`\n : \"Prismic models diverge from the repo\",\n url: dashboardUrl(baseUrl, s.name),\n severity: \"warning\",\n metric: 1,\n });\n }\n return items;\n}\n","// src/alerts/digest-state.ts\nimport type { FieldSet } from \"airtable\";\nimport type { AirtableBase } from \"../reports/airtable/client.js\";\nimport type { AttentionItem } from \"./attention.js\";\n\n/**\n * The persisted prior-run snapshot: stable item `key` → its last metric + the\n * date it was FIRST flagged. Lives as JSON in the single \"Digest State\" Airtable\n * row (the IO that loads/stores it — readDigestState/writeDigestState — is added\n * in component 2). `next` from diffAttention is what gets written back.\n * `exhausted` is carried only for vuln keys whose Renovate auto-fix has been\n * exhausted (absent = not exhausted; older snapshots without the field read the\n * same way).\n */\nexport type DigestSnapshot = Record<\n string,\n { metric: number; firstFlaggedAt: string; exhausted?: boolean }\n>;\n\n/**\n * PURE diff — the testable core of the hybrid \"snapshot now, mark what's new\".\n * For each current item vs the prior snapshot:\n * - key absent from prior → NEW (firstFlaggedAt = today)\n * - present and metric > prior.metric → WORSE (keep the original firstFlaggedAt)\n * - present and autoFixExhausted flipped on → WORSE (the count didn't move, but\n * \"Renovate gave up\" is an escalation — without this the digest's first mention\n * of a vuln, which is gated on exhaustion, would arrive unbadged)\n * - otherwise (equal or dropped) → STANDING (keep the original firstFlaggedAt)\n * `next` contains EXACTLY the current items' keys: resolved keys drop out, so a\n * fixed-then-recurring problem re-news correctly. Neither input is mutated.\n */\nexport function diffAttention(\n items: AttentionItem[],\n prior: DigestSnapshot,\n today: string,\n): { tagged: AttentionItem[]; next: DigestSnapshot } {\n const tagged: AttentionItem[] = [];\n const next: DigestSnapshot = {};\n for (const it of items) {\n const was = prior[it.key];\n const exhaustedFlip = it.autoFixExhausted === true && was?.exhausted !== true;\n let status: AttentionItem[\"status\"];\n let firstFlaggedAt: string;\n if (!was) {\n status = \"new\";\n firstFlaggedAt = today;\n } else if (it.metric > was.metric || exhaustedFlip) {\n status = \"worse\";\n firstFlaggedAt = was.firstFlaggedAt;\n } else {\n status = \"standing\";\n firstFlaggedAt = was.firstFlaggedAt;\n }\n tagged.push({ ...it, status });\n next[it.key] = {\n metric: it.metric,\n firstFlaggedAt,\n ...(it.autoFixExhausted === true ? { exhausted: true } : {}),\n };\n }\n return { tagged, next };\n}\n\n/** The single-row Airtable table that persists the prior digest snapshot. */\nexport const DIGEST_STATE_TABLE = \"Digest State\";\n\n/**\n * Read the persisted prior snapshot from the \"Digest State\" singleton.\n *\n * Reads the FIRST row of an unfiltered select (the table holds exactly one row;\n * the test fake does not evaluate filterByFormula, so we never rely on one). A\n * read miss (no row) OR a parse error (malformed Snapshot JSON) collapses to `{}`\n * — every key then reads as NEW once, which is safe degradation (never crashes\n * the digest).\n */\nexport async function readDigestState(base: AirtableBase): Promise<DigestSnapshot> {\n const rows: { id: string; fields: Record<string, unknown> }[] = [];\n await base(DIGEST_STATE_TABLE)\n .select({ maxRecords: 1, pageSize: 1 })\n .eachPage((records, fetchNextPage) => {\n for (const rec of records) rows.push({ id: rec.id, fields: rec.fields });\n fetchNextPage();\n });\n const first = rows[0];\n if (!first) return {};\n const raw = first.fields[\"Snapshot\"];\n if (typeof raw !== \"string\") return {};\n try {\n return JSON.parse(raw) as DigestSnapshot;\n } catch {\n return {};\n }\n}\n\n/**\n * Persist the next snapshot to the \"Digest State\" singleton: get-or-create the\n * one row. If a row exists, UPDATE it (keyed by its record id); otherwise CREATE\n * one. `Snapshot` = JSON.stringify(snap); `Updated At` = the injected ISO\n * timestamp (or now). A caller that catches+logs a write failure keeps the\n * already-sent digest unaffected (next run re-news at worst).\n */\nexport async function writeDigestState(\n base: AirtableBase,\n snap: DigestSnapshot,\n updatedAt: string = new Date().toISOString(),\n): Promise<void> {\n const rows: { id: string }[] = [];\n await base(DIGEST_STATE_TABLE)\n .select({ maxRecords: 1, pageSize: 1 })\n .eachPage((records, fetchNextPage) => {\n for (const rec of records) rows.push({ id: rec.id });\n fetchNextPage();\n });\n const fields: FieldSet = {\n Snapshot: JSON.stringify(snap),\n \"Updated At\": updatedAt,\n };\n const existing = rows[0];\n if (existing) {\n await base(DIGEST_STATE_TABLE).update([{ id: existing.id, fields }]);\n } else {\n await base(DIGEST_STATE_TABLE).create([{ fields }]);\n }\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/alerts/digest-collectors.ts","../src/alerts/digest-state.ts"],"sourcesContent":["// src/alerts/digest-collectors.ts\nimport type { AttentionItem } from \"./attention.js\";\nimport {\n allActionableVulnsTransitive,\n siteSlug,\n ACTIVE_STATUSES,\n isPreLaunch,\n type WebsiteRow,\n} from \"../reports/airtable/websites.js\";\nimport type { ReportRow } from \"../reports/airtable/reports.js\";\nimport { approveBlockers } from \"../reports/preflight.js\";\n\n/** Build the same `/s/<slug>` dashboard link the M3 ready-section uses, trailing-slash-safe.\n * An empty Name slugs to \"\" and `/s/` is a dead link — fall back to the fleet homepage,\n * exactly like the ready-section does. */\nfunction dashboardUrl(baseUrl: string, siteName: string): string {\n const root = baseUrl.replace(/\\/$/, \"\");\n const slug = siteSlug(siteName);\n return slug ? `${root}/s/${slug}` : root;\n}\n\n/**\n * A GitHub-signals sweep older than this (or never run) is no longer trustworthy:\n * a repo whose nightly probe THREW stops being re-swept, so its persisted\n * `Default Branch CI` / `Renovate Failing CIs` freeze at their last value forever\n * — a phantom 🔴 that can never clear. 3 days ≈ 3× the daily sweep interval, so a\n * single missed/flaky run doesn't drop a real signal. The CI/Renovate collectors\n * (only) skip a site whose `githubSignalsAt` is staler than this. Vuln/Lighthouse/\n * delivery signals come from other sweeps and are unaffected.\n */\nconst GITHUB_SIGNALS_STALE_DAYS = 3;\nconst MS_PER_DAY = 24 * 60 * 60 * 1000;\n\n/** True when a site's GitHub-signals sweep is too old (or never ran) to trust the\n * persisted CI/Renovate fields. A null `githubSignalsAt` (never swept) is stale —\n * there's no sweep to vouch for the values. A future timestamp (now - swept < 0)\n * is fresh. `now` is injected so the gate is testable. */\nfunction gitHubSignalsStale(swept: string | null, now: Date): boolean {\n if (swept === null) return true;\n const ageMs = now.getTime() - Date.parse(swept);\n if (!Number.isFinite(ageMs)) return true; // unparseable timestamp → don't trust it\n return ageMs > GITHUB_SIGNALS_STALE_DAYS * MS_PER_DAY;\n}\n\n/** Renovate auto-fix dispatches for one vuln episode before it's \"exhausted\" (manual fix needed). */\nconst AUTO_FIX_EXHAUSTED_CYCLES = 3;\n\n/**\n * One attention item per site carrying current critical+high vulns (medium/low omitted\n * per the locked threshold). PURE: takes already-fetched Websites rows. `metric` is the\n * critical+high count (so a rising count diffs as WORSE); `severity` is `critical` when\n * any critical exists, else `warning`. Null counts (never audited) read as 0 → skipped.\n * Once `securityAutoFixAttempts` reaches AUTO_FIX_EXHAUSTED_CYCLES the item is flagged\n * `autoFixExhausted` (forced-critical, escalated title) — Renovate tried and couldn't fix it.\n * EXCEPT when the persisted advisories prove every critical/high vuln is TRANSITIVE: then\n * nightly dispatches were green no-ops (no direct dep to bump — the fix rides the weekly\n * lockfile window), \"auto-fix failed\" would be a lie, and a stale pre-fix counter must not\n * escalate (Sonder said \"auto-fix failed (5×)\" on exactly this, 2026-08-10). Those sites get\n * an honest transitive-only title, keep count-based severity, and stay out of the digest\n * email (amber cockpit Watch still shows them). Unknown relationship data never mutes.\n *\n * Emission is UNCONDITIONAL — every surface applies its own policy on top:\n * - digest email: includes a vuln only when `autoFixExhausted` (the operator hears\n * about a vuln only after Renovate tried and failed; before that the fleet is\n * still self-patching) — runDigest filters\n * - cockpit: non-exhausted vuln → amber Watch; exhausted → hard break (assignTier),\n * and only an exhausted vuln pierces the pre-launch mute (piercesPreLaunchMute)\n */\nexport function collectVulnAlerts(sites: WebsiteRow[], baseUrl: string): AttentionItem[] {\n const items: AttentionItem[] = [];\n for (const s of sites) {\n const critical = s.securityVulnsCritical ?? 0;\n const high = s.securityVulnsHigh ?? 0;\n const metric = critical + high;\n if (metric <= 0) continue;\n const attempts = s.securityAutoFixAttempts ?? 0;\n const transitiveOnly = allActionableVulnsTransitive(s.securityAdvisories);\n const exhausted = !transitiveOnly && attempts >= AUTO_FIX_EXHAUSTED_CYCLES;\n const noun = metric === 1 ? \"vuln\" : \"vulns\";\n items.push({\n key: `vuln:${s.id}`,\n kind: \"vuln\",\n siteName: s.name,\n title: exhausted\n ? `${metric} critical/high ${noun} — auto-fix failed (${attempts}×)`\n : transitiveOnly\n ? `${metric} critical/high ${noun} — transitive-only, fix rides the weekly lockfile window`\n : `${metric} critical/high ${noun}`,\n url: dashboardUrl(baseUrl, s.name),\n severity: exhausted || critical > 0 ? \"critical\" : \"warning\",\n metric,\n ...(exhausted ? { autoFixExhausted: true } : {}),\n });\n }\n return items;\n}\n\n/** Absolute floor below which a Lighthouse category is \"Needs attention\" (Tucker's call). */\nconst LIGHTHOUSE_FLOOR = 75;\n\n/** The four Lighthouse categories, each mapped to its WebsiteRow score field, URL slug,\n * and the human label rendered in the digest title. Order is the operator's reading order. */\nconst LIGHTHOUSE_CATEGORIES: ReadonlyArray<{\n field: \"pScore\" | \"rScore\" | \"bpScore\" | \"seoScore\";\n slug: string;\n label: string;\n}> = [\n { field: \"pScore\", slug: \"performance\", label: \"Performance\" },\n { field: \"rScore\", slug: \"accessibility\", label: \"Accessibility\" },\n { field: \"bpScore\", slug: \"best-practices\", label: \"Best Practices\" },\n { field: \"seoScore\", slug: \"seo\", label: \"SEO\" },\n];\n\n/**\n * One attention item per Lighthouse category below the absolute floor (75) for each site.\n * PURE: takes already-fetched Websites rows. Categories are Performance/Accessibility/\n * Best-Practices/SEO. A null score (never audited) or a score >= 75 is skipped. The\n * `metric` is the DEFICIT (`100 - score`): a lower score → higher metric, so a category\n * that drops further diffs as WORSE and one that first crosses below 75 diffs as NEW —\n * which is how `diffAttention`'s \"WORSE on increase\" rule reads an inverted score. `key`\n * is `lighthouse:<siteId>:<categorySlug>`, so the four categories stay distinct per site.\n */\nexport function collectLighthouseAlerts(sites: WebsiteRow[], baseUrl: string): AttentionItem[] {\n const items: AttentionItem[] = [];\n for (const s of sites) {\n for (const cat of LIGHTHOUSE_CATEGORIES) {\n const score = s[cat.field];\n if (score === null || score >= LIGHTHOUSE_FLOOR) continue;\n items.push({\n key: `lighthouse:${s.id}:${cat.slug}`,\n kind: \"lighthouse\",\n siteName: s.name,\n title: `Lighthouse ${cat.label} ${score} (below ${LIGHTHOUSE_FLOOR})`,\n url: dashboardUrl(baseUrl, s.name),\n severity: \"warning\",\n metric: 100 - score,\n });\n }\n }\n return items;\n}\n\n/**\n * One attention item per report whose `deliveryStatus` is a failure (`bounced` or\n * `complained` — `delivered`/`pending` are ignored). PURE: takes already-fetched\n * Reports rows + a record-id→site map. A complaint ranks above a bounce (locked\n * threshold), so `severity` is `critical` for complained / `warning` for bounced.\n * `metric` is 1 (a binary event). Orphans (siteId not in the map) are skipped, as\n * the M3 ready-section does, so the digest never renders a broken link. The diff\n * key is the report RECORD id, so two failures on one site stay distinct.\n */\n/**\n * One attention item per unsent draft whose send is ALREADY known to fail\n * (approveBlockers: recipients / header image / report scores). An APPROVED one\n * is critical — the next 09:23 UTC run will go red on it; a pending one is a\n * warning — approving it just schedules that failure. Keyed `preflight:<reportId>`.\n * PURE; same predicate the approve gate and the dashboard chip use.\n */\nexport function collectPreflightBlocked(\n reports: ReportRow[],\n sitesById: Map<string, WebsiteRow>,\n baseUrl: string,\n): AttentionItem[] {\n const items: AttentionItem[] = [];\n for (const r of reports) {\n if (!r.draftReady || r.sentAt !== null) continue;\n // The approved state rides the KEY so a pending→approved escalation with\n // unchanged blockers re-news as a fresh critical instead of diffing\n // \"standing\" against its old warning self.\n const state = r.approvedToSend ? \"approved\" : \"pending\";\n const site = sitesById.get(r.siteId);\n if (!site) {\n // A dangling/empty Site link is itself a send blocker (sendApprovedReports\n // fails with \"Site row not found\") — surface it; the empty-slug\n // dashboardUrl fallback yields the fleet root, not a broken /s/ link.\n items.push({\n key: `preflight:${r.id}:${state}`,\n kind: \"preflight\",\n siteName: \"(unlinked site)\",\n title: r.approvedToSend\n ? `Approved ${r.reportType} will fail at send — site-not-found`\n : `${r.reportType} draft can't be approved — site-not-found`,\n url: dashboardUrl(baseUrl, \"\"),\n severity: r.approvedToSend ? \"critical\" : \"warning\",\n metric: 1,\n });\n continue;\n }\n const fails = approveBlockers(site, r).filter((f) => f.level === \"fail\");\n if (fails.length === 0) continue;\n const first = fails[0]!;\n const more = fails.length > 1 ? ` (+${fails.length - 1} more)` : \"\";\n items.push({\n key: `preflight:${r.id}:${state}`,\n kind: \"preflight\",\n siteName: site.name,\n title: r.approvedToSend\n ? `Approved ${r.reportType} will fail at send — ${first.check}${more}`\n : `${r.reportType} draft can't be approved — ${first.check}${more}`,\n url: dashboardUrl(baseUrl, site.name),\n severity: r.approvedToSend ? \"critical\" : \"warning\",\n metric: fails.length,\n });\n }\n return items;\n}\n\nexport function collectDeliveryFailures(\n reports: ReportRow[],\n sitesById: Map<string, WebsiteRow>,\n baseUrl: string,\n): AttentionItem[] {\n const items: AttentionItem[] = [];\n for (const r of reports) {\n if (r.deliveryStatus !== \"bounced\" && r.deliveryStatus !== \"complained\") continue;\n const site = sitesById.get(r.siteId);\n if (!site) continue; // orphan → skip rather than render a broken link\n const complained = r.deliveryStatus === \"complained\";\n items.push({\n key: `delivery:${r.id}`,\n kind: \"delivery\",\n siteName: site.name,\n title: complained ? \"Spam complaint on a sent report\" : \"A sent report bounced\",\n url: dashboardUrl(baseUrl, site.name),\n severity: complained ? \"critical\" : \"warning\",\n metric: 1,\n });\n }\n return items;\n}\n\n/**\n * One attention item per site carrying failing Renovate PRs, read from the\n * slice-2a-persisted `renovateFailingCis` field (the nightly github-signals sweep\n * populates it). PURE. Keyed `renovate:<siteId>` so the digest and the cockpit\n * share one diff key. `metric` is the count (a rising count diffs WORSE); severity\n * `warning`. Null/0 → skipped. A site whose `githubSignalsAt` is >3 days stale (or\n * null) is ALSO skipped — a repo that stopped being swept must not show a phantom\n * count forever (`now` injected, defaults to wall-clock).\n */\nexport function collectRenovateAlerts(\n sites: WebsiteRow[],\n baseUrl: string,\n now: Date = new Date(),\n): AttentionItem[] {\n const items: AttentionItem[] = [];\n for (const s of sites) {\n if (gitHubSignalsStale(s.githubSignalsAt, now)) continue;\n const n = s.renovateFailingCis ?? 0;\n if (n <= 0) continue;\n items.push({\n key: `renovate:${s.id}`,\n kind: \"renovate\",\n siteName: s.name,\n title: `${n} Renovate ${n === 1 ? \"PR\" : \"PRs\"} failing CI`,\n url: dashboardUrl(baseUrl, s.name),\n severity: \"warning\",\n metric: n,\n });\n }\n return items;\n}\n\n/**\n * One attention item per site whose persisted default-branch CI rollup is\n * `failing` (slice 2a). PURE. `metric` 1 (binary); severity `warning`. Any other\n * state (passing/pending/none) or null is skipped. A site whose `githubSignalsAt`\n * is >3 days stale (or null) is ALSO skipped — a repo that stopped being swept must\n * not show a phantom 🔴 forever (`now` injected, defaults to wall-clock).\n */\nexport function collectCiAlerts(\n sites: WebsiteRow[],\n baseUrl: string,\n now: Date = new Date(),\n): AttentionItem[] {\n const items: AttentionItem[] = [];\n for (const s of sites) {\n if (gitHubSignalsStale(s.githubSignalsAt, now)) continue;\n if (s.defaultBranchCi !== \"failing\") continue;\n items.push({\n key: `ci:${s.id}`,\n kind: \"ci\",\n siteName: s.name,\n title: \"Default-branch CI failing\",\n url: dashboardUrl(baseUrl, s.name),\n severity: \"warning\",\n metric: 1,\n });\n }\n return items;\n}\n\n/**\n * A soft-fail older than this is no longer a trustworthy CURRENT signal. Drafting\n * self-clears `analyticsSoftFailAt` on the next clean enrichment, so a non-null\n * value normally means \"errored on the most recent draft and hasn't recovered\" —\n * but a site that stopped being drafted (freq→None, deprecated) would otherwise\n * show a phantom forever. 45 days covers a monthly cadence + margin; older drops.\n */\nconst ANALYTICS_SOFT_FAIL_STALE_DAYS = 45;\n\n/**\n * One attention item per site whose last draft's GA/Search enrichment ERRORED, read\n * from the `analyticsSoftFailAt` timestamp (drafting sets it on a soft-fail, clears\n * it on a clean enrichment). PURE. Keyed `analytics:<siteId>` so the digest and the\n * cockpit share one diff key; `metric` 1 (binary), severity `warning`. A null\n * timestamp (clean, or the operator-added `Analytics soft-fail at` column absent) is\n * skipped, as is one staler than {@link ANALYTICS_SOFT_FAIL_STALE_DAYS}. On a\n * FLEET-WIDE subject outage many sites surface this at once — that breadth IS the\n * signal here; the report cron additionally emails a single concise fleet-wide alert\n * (see `assessAnalyticsAlert`). `now` injected, defaults to wall-clock.\n */\n/** A function-health sweep older than this can't confirm the CURRENT widget state.\n * The sweep is nightly, so 3 days (mirrors GITHUB_SIGNALS_STALE_DAYS) tolerates a\n * weekend of runner flakes without letting a months-old verdict drive an alarm. */\nconst TURNSTILE_WIDGET_STALE_DAYS = 3;\n\n/**\n * One CRITICAL attention item per site with `Require Turnstile` ON whose deployed\n * `/health` reports the Turnstile widget NOT configured (`turnstileWidget === \"fail\"`,\n * fresh per {@link TURNSTILE_WIDGET_STALE_DAYS}). That combination silently buckets\n * 100% of the site's real leads (token-less submissions escalate to spam_auto with\n * notify skipped) and the form-e2e probe cannot see it — testMode bypasses the gate —\n * so the operator's inbox just goes quiet. PURE. Keyed `turnstile:<siteId>`. Because\n * this is an AttentionItem it rides assignTier's items short-circuit, which sits ABOVE\n * the accepted-watch mute loop — an accept key can never silence it. A null verdict or\n * a stale sweep is NOT alarmed here; assignTier raises those as an acceptable\n * \"can't verify\" watch instead. `now` injected, defaults to wall-clock.\n */\nexport function collectTurnstileGuardrailAlerts(\n sites: WebsiteRow[],\n baseUrl: string,\n now: Date = new Date(),\n): AttentionItem[] {\n const items: AttentionItem[] = [];\n for (const s of sites) {\n if (!s.requireTurnstile || s.turnstileWidget !== \"fail\") continue;\n const at = s.functionHealthCheckedAt;\n if (at !== null) {\n const ageMs = now.getTime() - Date.parse(at);\n // Parseable and beyond the window → stale, downgraded to assignTier's watch.\n // Unparseable (NaN) keeps the item — never silently drop a real failure.\n if (Number.isFinite(ageMs) && ageMs > TURNSTILE_WIDGET_STALE_DAYS * MS_PER_DAY) continue;\n }\n items.push({\n key: `turnstile:${s.id}`,\n kind: \"turnstile\",\n siteName: s.name,\n title:\n \"Require Turnstile is ON but /health reports no widget — real leads are being auto-bucketed\",\n url: dashboardUrl(baseUrl, s.name),\n severity: \"critical\",\n metric: 1,\n });\n }\n return items;\n}\n\n/** Bounced lead notifications inside the window before a site alarms. One bounce can\n * be a transient greylist/full-mailbox blip; a repeat says the point-of-contact\n * address itself is bad (the Espada mode: apm@ bounced 4 of 8 with nothing alarming). */\nexport const NOTIFY_BOUNCE_THRESHOLD = 2;\n\n/** Window (days) the bounce count covers. Two weeks: long enough that a low-volume\n * site's second bounce still lands in the same window as its first, short enough\n * that a long-fixed address doesn't alarm forever (bounced rows never self-clear —\n * the alarm ages out as bounced submissions leave the window). */\nexport const NOTIFY_BOUNCE_WINDOW_DAYS = 14;\n\n/**\n * One CRITICAL attention item per site with >= {@link NOTIFY_BOUNCE_THRESHOLD} bounced\n * lead notifications in the last {@link NOTIFY_BOUNCE_WINDOW_DAYS} days — leads are\n * being captured but silently never reach the client, which is worse than no form at\n * all (notifyStatus \"sent\" only means Resend accepted the email; the resend-webhook\n * flips it to \"bounced\" when Resend later reports the failure, 2026-07-16). PURE:\n * takes the pre-fetched per-site counts (`countNotifyBouncedBySite`, keyed by the\n * Websites record id). Keyed `notify-bounce:<siteId>` so the digest and the cockpit\n * share one diff key; `metric` is the bounce count (a rising count diffs WORSE).\n * A site absent from the map (or below threshold) is skipped, as is a count for a\n * site id not in `sites` (orphan → never render a broken link).\n */\nexport function collectNotifyBounceAlerts(\n sites: WebsiteRow[],\n bouncedBySite: ReadonlyMap<string, number>,\n baseUrl: string,\n): AttentionItem[] {\n const items: AttentionItem[] = [];\n for (const s of sites) {\n const n = bouncedBySite.get(s.id) ?? 0;\n if (n < NOTIFY_BOUNCE_THRESHOLD) continue;\n items.push({\n key: `notify-bounce:${s.id}`,\n kind: \"notify-bounce\",\n siteName: s.name,\n title: `${n} lead notifications bounced (${NOTIFY_BOUNCE_WINDOW_DAYS}d) — check the point-of-contact address`,\n url: dashboardUrl(baseUrl, s.name),\n severity: \"critical\",\n metric: n,\n });\n }\n return items;\n}\n\nexport function collectAnalyticsFailures(\n sites: WebsiteRow[],\n baseUrl: string,\n now: Date = new Date(),\n): AttentionItem[] {\n const items: AttentionItem[] = [];\n for (const s of sites) {\n const at = s.analyticsSoftFailAt;\n if (at === null) continue;\n const ageMs = now.getTime() - Date.parse(at);\n // Unparseable timestamp (NaN) → keep the item (don't silently drop a real failure\n // on a parse glitch); only a parseable, beyond-window age is skipped as stale.\n if (Number.isFinite(ageMs) && ageMs > ANALYTICS_SOFT_FAIL_STALE_DAYS * MS_PER_DAY) continue;\n items.push({\n key: `analytics:${s.id}`,\n kind: \"analytics\",\n siteName: s.name,\n title: \"GA/Search enrichment failing (analytics blank)\",\n url: dashboardUrl(baseUrl, s.name),\n severity: \"warning\",\n metric: 1,\n });\n }\n return items;\n}\n\n/** How current a `fail` / `unknown` must be to still CLAIM the fleet's present\n * state. The sweep is nightly, so 3 days (mirrors GITHUB_SIGNALS_STALE_DAYS)\n * tolerates a weekend of runner flakes without letting a months-old finding drive\n * a \"models diverge right now\" alarm. Past it the finding does not vanish — it\n * converts to the staleness item below, which claims nothing about the models. */\nconst PRISMIC_DRIFT_STALE_DAYS = 3;\n\n/** How long a `pass` may go unrefreshed before it is treated as unverified.\n *\n * Deliberately LONGER than the currency window, and the asymmetry is the point.\n * An old `fail` stops being reported as CURRENT drift at 3 days because it may\n * already be fixed. An old `pass` is a green claim nobody has re-established, and\n * escalating that at 3 days would light up the WHOLE fleet after a long weekend of\n * runner flakes — the noise that gets a real alarm muted. A week is the interval\n * over which a silently-dead nightly actually matters. */\nconst PRISMIC_STALE_PASS_DAYS = 7;\n\n/**\n * True when the nightly `prismic-models --fleet airtable` sweep is EXPECTED to\n * cover this site. Mirrors the Airtable inventory's own filter (src/inventory/\n * airtable.ts): live `maintenance` sites (active, not pre-launch) that carry a\n * `url` AND a Name that yields a slug. Deliberately duplicated rather than\n * imported — the inventory builds `Site` objects and needs a workdir; this is a\n * pure predicate over a row — but the two must stay in step: widen the inventory\n * and widen this. That is no longer left to memory: every shape of row is put\n * through BOTH implementations by tests/alerts/prismic-sweep-scope.test.ts, which\n * goes red the moment they diverge.\n *\n * THE SLUG CLAUSE IS NOT COSMETIC, and it was the first drift this pair produced.\n * The inventory drops an empty-slug row (`siteSlug(name) === \"\"`) with a warning,\n * because such a row can neither form a checkout path nor be matched back to its\n * Websites row on write-back — and `writeSweepToAirtable` joins by that same slug,\n * so even a verdict computed for it could never land. Without this clause a live\n * site named in a script with no `[a-z0-9]` (or with an empty Name) was covered\n * here and swept nowhere: the permanent, un-ackable morning email this predicate\n * exists to prevent, produced by the predicate itself.\n *\n * Only the STALENESS escalation consults it. A `fail`/`unknown` is a verdict some\n * run actually established and is reported wherever it came from; the staleness\n * item is an alarm invented FROM AN ABSENCE, and an absence is only wrong where a\n * sweep was owed. A deprecated site that left the inventory would otherwise carry\n * a frozen `pass` into the digest every morning forever — un-ackable (attention\n * items sit above the accepted-watch mute) and unfixable except by hand-clearing\n * an Airtable cell.\n */\nfunction prismicSweepCovers(s: WebsiteRow): boolean {\n return (\n s.status !== null &&\n ACTIVE_STATUSES.has(s.status) &&\n !isPreLaunch(s.status) &&\n s.url.length > 0 &&\n siteSlug(s.name).length > 0\n );\n}\n\n/**\n * One item per site whose nightly Prismic model verdict needs a human, from the\n * `prismic-models --fleet --write-airtable` sweep. PURE (`now` injected).\n *\n * The verdict is THREE-valued plus blank, and each state gets its own key so the\n * digest's snapshot diff (keyed on `key`) can never let one condition stand in for\n * another — a site sliding from drift to \"couldn't check\" re-news as NEW rather\n * than diffing \"standing\" against the drift it replaced (both carry `metric` 1):\n *\n * - `fail`, fresh → `prismic-drift:<siteId>` — repo and Prismic diverge.\n * - `unknown`, fresh → `prismic-unknown:<siteId>` — the check RAN AND COULD NOT\n * ANSWER (unreadable checkout, dead write token, unreachable Prismic). Its own\n * wording, because reporting a dead token as \"models diverge\" sends the\n * operator to fix a model when the job is to fix a secret.\n * - any verdict nobody has re-established → `prismic-stale:<siteId>` — a `pass`\n * older than {@link PRISMIC_STALE_PASS_DAYS} (or undateable), or a `fail` /\n * `unknown` older than {@link PRISMIC_DRIFT_STALE_DAYS}. A stale verdict is\n * never silence: dropping it would make \"nobody could evaluate this\" read\n * exactly like \"this is fine\", which is the failure this whole column exists\n * to close. Gated by {@link prismicSweepCovers}.\n * - blank (never swept, or not a Prismic site) → nothing, at any age. There is\n * no claim to un-verify.\n *\n * A `pass` inside its window is the ONLY silent verdict. At most one item per site\n * — the Airtable cell holds one state at a time.\n *\n * `warning`, not `critical`: on the cockpit ANY item already tiers the site 🔴, so\n * severity buys only (a) piercing the pre-launch mute and (b) sorting first in the\n * needs-you feed. `critical` is reserved for the guardrails that are actively\n * losing leads or breaking a live page (Turnstile, notify-bounce, delivery); a\n * model that drifted — or a check that could not run — is neither, and piercing\n * the pre-launch mute would alarm on exactly the sites the sweep excludes by\n * design.\n */\n/**\n * Is an operator's acceptance of a known divergence still in force?\n *\n * Every uncertain answer is `false` — an absent, blank or unparseable cell leaves\n * the alarm ringing. The asymmetry is the point: a mistyped date costing one noisy\n * item is recoverable, and a mistyped date silently muting a live finding is the\n * exact failure this column was built to close. The boundary belongs to the alarm\n * too: an ack \"until 09:00\" has nothing left to say at 09:00.\n */\nexport function prismicAckIsLive(ackUntil: string | null, now: Date): boolean {\n if (ackUntil === null || ackUntil.trim() === \"\") return false;\n const until = Date.parse(ackUntil);\n if (!Number.isFinite(until)) return false;\n return until > now.getTime();\n}\n\nexport function collectPrismicDriftAlerts(\n sites: WebsiteRow[],\n baseUrl: string,\n now: Date = new Date(),\n): AttentionItem[] {\n const items: AttentionItem[] = [];\n for (const s of sites) {\n const verdict = s.prismicModels;\n if (verdict === null) continue;\n\n const at = s.prismicModelsCheckedAt;\n // +Infinity when there is no timestamp, NaN when it won't parse — both mean\n // \"the age of this answer is unknowable\", which the two verdict families read\n // in OPPOSITE directions on purpose (see `unverified`).\n const ageMs = at === null ? Number.POSITIVE_INFINITY : now.getTime() - Date.parse(at);\n const ageKnown = Number.isFinite(ageMs);\n const windowDays = verdict === \"pass\" ? PRISMIC_STALE_PASS_DAYS : PRISMIC_DRIFT_STALE_DAYS;\n // An undateable `pass` is unverified — a green claim nobody can date is not a\n // green claim. An undateable `fail`/`unknown` is NOT downgraded: never silently\n // drop a real finding over a parse glitch.\n const unverified = ageKnown ? ageMs > windowDays * MS_PER_DAY : verdict === \"pass\";\n\n if (unverified) {\n if (!prismicSweepCovers(s)) continue;\n items.push({\n key: `prismic-stale:${s.id}`,\n kind: \"prismic-drift\",\n siteName: s.name,\n title: `Prismic model check has not run recently — the last verdict (\"${verdict}\") is unverified`,\n url: dashboardUrl(baseUrl, s.name),\n severity: \"warning\",\n metric: 1,\n });\n continue;\n }\n\n if (verdict === \"pass\") continue;\n\n const first = s.prismicModelsDrift\n ?.split(\"\\n\")\n .find((l) => l.trim() !== \"\")\n ?.trim();\n if (verdict === \"unknown\") {\n items.push({\n key: `prismic-unknown:${s.id}`,\n kind: \"prismic-drift\",\n siteName: s.name,\n title: first\n ? `Prismic model check could not run — ${first}`\n : \"Prismic model check could not run\",\n url: dashboardUrl(baseUrl, s.name),\n severity: \"warning\",\n metric: 1,\n });\n continue;\n }\n\n // The operator has reviewed THIS divergence and accepted it until a date they\n // set — see `WebsiteRow.prismicAckUntil`. Reached only on `fail`: the `unknown`\n // and staleness branches above have already returned, so an ack can never mute\n // \"the check could not run\" or \"nobody has re-established this\".\n if (prismicAckIsLive(s.prismicAckUntil, now)) continue;\n\n items.push({\n key: `prismic-drift:${s.id}`,\n kind: \"prismic-drift\",\n siteName: s.name,\n title: first\n ? `Prismic models diverge from the repo — ${first}`\n : \"Prismic models diverge from the repo\",\n url: dashboardUrl(baseUrl, s.name),\n severity: \"warning\",\n metric: 1,\n });\n }\n return items;\n}\n","// src/alerts/digest-state.ts\nimport type { FieldSet } from \"airtable\";\nimport type { AirtableBase } from \"../reports/airtable/client.js\";\nimport type { AttentionItem } from \"./attention.js\";\n\n/**\n * The persisted prior-run snapshot: stable item `key` → its last metric + the\n * date it was FIRST flagged. Lives as JSON in the single \"Digest State\" Airtable\n * row (the IO that loads/stores it — readDigestState/writeDigestState — is added\n * in component 2). `next` from diffAttention is what gets written back.\n * `exhausted` is carried only for vuln keys whose Renovate auto-fix has been\n * exhausted (absent = not exhausted; older snapshots without the field read the\n * same way).\n */\nexport type DigestSnapshot = Record<\n string,\n { metric: number; firstFlaggedAt: string; exhausted?: boolean }\n>;\n\n/**\n * PURE diff — the testable core of the hybrid \"snapshot now, mark what's new\".\n * For each current item vs the prior snapshot:\n * - key absent from prior → NEW (firstFlaggedAt = today)\n * - present and metric > prior.metric → WORSE (keep the original firstFlaggedAt)\n * - present and autoFixExhausted flipped on → WORSE (the count didn't move, but\n * \"Renovate gave up\" is an escalation — without this the digest's first mention\n * of a vuln, which is gated on exhaustion, would arrive unbadged)\n * - otherwise (equal or dropped) → STANDING (keep the original firstFlaggedAt)\n * `next` contains EXACTLY the current items' keys: resolved keys drop out, so a\n * fixed-then-recurring problem re-news correctly. Neither input is mutated.\n */\nexport function diffAttention(\n items: AttentionItem[],\n prior: DigestSnapshot,\n today: string,\n): { tagged: AttentionItem[]; next: DigestSnapshot } {\n const tagged: AttentionItem[] = [];\n const next: DigestSnapshot = {};\n for (const it of items) {\n const was = prior[it.key];\n const exhaustedFlip = it.autoFixExhausted === true && was?.exhausted !== true;\n let status: AttentionItem[\"status\"];\n let firstFlaggedAt: string;\n if (!was) {\n status = \"new\";\n firstFlaggedAt = today;\n } else if (it.metric > was.metric || exhaustedFlip) {\n status = \"worse\";\n firstFlaggedAt = was.firstFlaggedAt;\n } else {\n status = \"standing\";\n firstFlaggedAt = was.firstFlaggedAt;\n }\n tagged.push({ ...it, status });\n next[it.key] = {\n metric: it.metric,\n firstFlaggedAt,\n ...(it.autoFixExhausted === true ? { exhausted: true } : {}),\n };\n }\n return { tagged, next };\n}\n\n/** The single-row Airtable table that persists the prior digest snapshot. */\nexport const DIGEST_STATE_TABLE = \"Digest State\";\n\n/**\n * Read the persisted prior snapshot from the \"Digest State\" singleton.\n *\n * Reads the FIRST row of an unfiltered select (the table holds exactly one row;\n * the test fake does not evaluate filterByFormula, so we never rely on one). A\n * read miss (no row) OR a parse error (malformed Snapshot JSON) collapses to `{}`\n * — every key then reads as NEW once, which is safe degradation (never crashes\n * the digest).\n */\nexport async function readDigestState(base: AirtableBase): Promise<DigestSnapshot> {\n const rows: { id: string; fields: Record<string, unknown> }[] = [];\n await base(DIGEST_STATE_TABLE)\n .select({ maxRecords: 1, pageSize: 1 })\n .eachPage((records, fetchNextPage) => {\n for (const rec of records) rows.push({ id: rec.id, fields: rec.fields });\n fetchNextPage();\n });\n const first = rows[0];\n if (!first) return {};\n const raw = first.fields[\"Snapshot\"];\n if (typeof raw !== \"string\") return {};\n try {\n return JSON.parse(raw) as DigestSnapshot;\n } catch {\n return {};\n }\n}\n\n/**\n * Persist the next snapshot to the \"Digest State\" singleton: get-or-create the\n * one row. If a row exists, UPDATE it (keyed by its record id); otherwise CREATE\n * one. `Snapshot` = JSON.stringify(snap); `Updated At` = the injected ISO\n * timestamp (or now). A caller that catches+logs a write failure keeps the\n * already-sent digest unaffected (next run re-news at worst).\n */\nexport async function writeDigestState(\n base: AirtableBase,\n snap: DigestSnapshot,\n updatedAt: string = new Date().toISOString(),\n): Promise<void> {\n const rows: { id: string }[] = [];\n await base(DIGEST_STATE_TABLE)\n .select({ maxRecords: 1, pageSize: 1 })\n .eachPage((records, fetchNextPage) => {\n for (const rec of records) rows.push({ id: rec.id });\n fetchNextPage();\n });\n const fields: FieldSet = {\n Snapshot: JSON.stringify(snap),\n \"Updated At\": updatedAt,\n };\n const existing = rows[0];\n if (existing) {\n await base(DIGEST_STATE_TABLE).update([{ id: existing.id, fields }]);\n } else {\n await base(DIGEST_STATE_TABLE).create([{ fields }]);\n }\n}\n"],"mappings":"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","names":[]}
|
package/dist/cli/bin.js
CHANGED
|
@@ -111,7 +111,7 @@ cli.command("sync-configs [site]", "Sync canonical configs into a site.").option
|
|
|
111
111
|
'Inventory file (.json or .mjs/.js), or "airtable" to read from Websites table'
|
|
112
112
|
).option("--workdir <path>", "Clone target for fleet mode (default ~/.reddoor-maint/sites)").action(
|
|
113
113
|
async (site, opts) => runOrExit(
|
|
114
|
-
async () => (await import("../sync-configs-
|
|
114
|
+
async () => (await import("../sync-configs-CJXUX6MD.js")).runSyncConfigsCommand(site, opts),
|
|
115
115
|
opts
|
|
116
116
|
)
|
|
117
117
|
);
|
|
@@ -120,7 +120,7 @@ cli.command("bump-deps [site]", "Bump dependencies.").option("--group <group>",
|
|
|
120
120
|
'Inventory file (.json or .mjs/.js), or "airtable" to read from Websites table'
|
|
121
121
|
).option("--workdir <path>", "Clone target for fleet mode (default ~/.reddoor-maint/sites)").action(
|
|
122
122
|
async (site, opts) => runOrExit(
|
|
123
|
-
async () => (await import("../bump-deps-
|
|
123
|
+
async () => (await import("../bump-deps-SWJB6H5V.js")).runBumpDepsCommand(site, opts),
|
|
124
124
|
opts
|
|
125
125
|
)
|
|
126
126
|
);
|
|
@@ -129,7 +129,7 @@ cli.command(
|
|
|
129
129
|
"Bootstrap a repo to keep itself updated (Renovate + protection; platform auto-merge off; ci.yml is the starter's, not managed here)."
|
|
130
130
|
).option("--dry", "List what would be enabled without writing or opening PRs").option("--fleet <inventory>", 'Inventory file (.json or .mjs/.js), or "airtable"').option("--workdir <path>", "Clone target for fleet mode (default ~/.reddoor-maint/sites)").action(
|
|
131
131
|
async (site, opts) => runOrExit(
|
|
132
|
-
async () => (await import("../self-updating-
|
|
132
|
+
async () => (await import("../self-updating-PPQVV6UA.js")).runSelfUpdatingCommand(site, opts),
|
|
133
133
|
opts
|
|
134
134
|
)
|
|
135
135
|
);
|
|
@@ -138,7 +138,7 @@ cli.command(
|
|
|
138
138
|
"Add the Prismic model delivery workflow to a site via PR (one PR per repo; never pushes to main)."
|
|
139
139
|
).option("--dry", "List the sites that would be offered the workflow, without opening any PR").option("--fleet <inventory>", 'Inventory file (.json or .mjs/.js), or "airtable"').option("--workdir <path>", "Clone target for fleet mode (default ~/.reddoor-maint/sites)").action(
|
|
140
140
|
async (site, opts) => runOrExit(
|
|
141
|
-
async () => (await import("../prismic-ci-
|
|
141
|
+
async () => (await import("../prismic-ci-UTQ6LRR3.js")).runPrismicCiCommand(site, opts),
|
|
142
142
|
opts
|
|
143
143
|
)
|
|
144
144
|
);
|
|
@@ -153,7 +153,7 @@ cli.command(
|
|
|
153
153
|
"Write the report to this file (the CI workflow posts it as a PR comment)"
|
|
154
154
|
).action(
|
|
155
155
|
async (site, opts) => runOrExit(
|
|
156
|
-
async () => (await import("../prismic-models-
|
|
156
|
+
async () => (await import("../prismic-models-PVES6R5V.js")).runPrismicModelsCommand(site, opts),
|
|
157
157
|
opts
|
|
158
158
|
)
|
|
159
159
|
);
|
|
@@ -162,7 +162,7 @@ cli.command("upgrade <upgrade> [site]", "Run a named upgrade recipe (svelte-4-to
|
|
|
162
162
|
'Inventory file (.json or .mjs/.js), or "airtable" to read from Websites table'
|
|
163
163
|
).option("--workdir <path>", "Clone target for fleet mode (default ~/.reddoor-maint/sites)").action(
|
|
164
164
|
async (upgrade, site, opts) => runOrExit(
|
|
165
|
-
async () => (await import("../upgrade-
|
|
165
|
+
async () => (await import("../upgrade-WLX27UPC.js")).runUpgradeCommand(upgrade, site, opts),
|
|
166
166
|
opts
|
|
167
167
|
)
|
|
168
168
|
);
|
|
@@ -174,7 +174,7 @@ cli.command(
|
|
|
174
174
|
'Inventory file (.json or .mjs/.js), or "airtable" to read from Websites table'
|
|
175
175
|
).option("--workdir <path>", "Clone target for fleet mode (default ~/.reddoor-maint/sites)").action(
|
|
176
176
|
async (site, opts) => runOrExit(
|
|
177
|
-
async () => (await import("../convert-to-pnpm-
|
|
177
|
+
async () => (await import("../convert-to-pnpm-AT52NWZB.js")).runConvertToPnpmCommand(site, opts),
|
|
178
178
|
opts
|
|
179
179
|
)
|
|
180
180
|
);
|
|
@@ -183,7 +183,7 @@ cli.command("svelte-codemods [site]", "Apply Svelte 5 gotcha codemods to an alre
|
|
|
183
183
|
'Inventory file (.json or .mjs/.js), or "airtable" to read from Websites table'
|
|
184
184
|
).option("--workdir <path>", "Clone target for fleet mode (default ~/.reddoor-maint/sites)").action(
|
|
185
185
|
async (site, opts) => runOrExit(
|
|
186
|
-
async () => (await import("../svelte-codemods-
|
|
186
|
+
async () => (await import("../svelte-codemods-ZOBDHIES.js")).runSvelteCodemodsCommand(site, opts),
|
|
187
187
|
opts
|
|
188
188
|
)
|
|
189
189
|
);
|
|
@@ -195,7 +195,7 @@ cli.command(
|
|
|
195
195
|
'Inventory file (.json or .mjs/.js), or "airtable" to read from Websites table'
|
|
196
196
|
).option("--workdir <path>", "Clone target for fleet mode (default ~/.reddoor-maint/sites)").action(
|
|
197
197
|
async (site, opts) => runOrExit(
|
|
198
|
-
async () => (await import("../health-endpoint-
|
|
198
|
+
async () => (await import("../health-endpoint-242WG2OJ.js")).runHealthEndpointCommand(site, opts),
|
|
199
199
|
opts
|
|
200
200
|
)
|
|
201
201
|
);
|
|
@@ -207,7 +207,7 @@ cli.command(
|
|
|
207
207
|
'Inventory file (.json or .mjs/.js), or "airtable" to read from Websites table'
|
|
208
208
|
).option("--workdir <path>", "Clone target for fleet mode (default ~/.reddoor-maint/sites)").action(
|
|
209
209
|
async (site, opts) => runOrExit(
|
|
210
|
-
async () => (await import("../smoke-suite-
|
|
210
|
+
async () => (await import("../smoke-suite-OBWNPALQ.js")).runSmokeSuiteCommand(site, opts),
|
|
211
211
|
opts
|
|
212
212
|
)
|
|
213
213
|
);
|
|
@@ -219,7 +219,7 @@ cli.command(
|
|
|
219
219
|
'Inventory file (.json or .mjs/.js), or "airtable" to read from Websites table'
|
|
220
220
|
).option("--workdir <path>", "Clone target for fleet mode (default ~/.reddoor-maint/sites)").action(
|
|
221
221
|
async (site, opts) => runOrExit(
|
|
222
|
-
async () => (await import("../onboard-
|
|
222
|
+
async () => (await import("../onboard-53CITIM7.js")).runOnboardCommand(site, opts),
|
|
223
223
|
opts
|
|
224
224
|
)
|
|
225
225
|
);
|
|
@@ -230,20 +230,20 @@ cli.command(
|
|
|
230
230
|
"--fleet <inventory>",
|
|
231
231
|
'Inventory file (.json or .mjs/.js), or "airtable" to read from Websites table'
|
|
232
232
|
).option("--workdir <path>", "Clone target for fleet mode (default ~/.reddoor-maint/sites)").action(
|
|
233
|
-
async (site, opts) => runOrExit(async () => (await import("../init-
|
|
233
|
+
async (site, opts) => runOrExit(async () => (await import("../init-TFVAN5OY.js")).runInitCommand(site, opts), opts)
|
|
234
234
|
);
|
|
235
235
|
cli.command(
|
|
236
236
|
"launch <site>",
|
|
237
237
|
"Bootstrap + first-audit a site, then draft its launch email for approval."
|
|
238
238
|
).action(
|
|
239
239
|
async (site, opts) => runOrExit(
|
|
240
|
-
async () => (await import("../launch-
|
|
240
|
+
async () => (await import("../launch-DWY2MF54.js")).runLaunchCommand(site, opts),
|
|
241
241
|
opts
|
|
242
242
|
)
|
|
243
243
|
);
|
|
244
244
|
cli.command("header-image [site]", "Generate a site's report header image from its live homepage.").option("--all", "Every live site with no Header image yet (backfill)").option("--force", "With --all, regenerate sites that already have one").option("--write-airtable", "Upload to the Websites row instead of writing a local file").option("--out-dir <path>", "Directory for local output (default: reports/)").option("--settle-ms <ms>", "Override the post-load settle delay for slow/animated homepages").action(
|
|
245
245
|
async (site, opts) => runOrExit(
|
|
246
|
-
async () => (await import("../header-image-
|
|
246
|
+
async () => (await import("../header-image-T7XD37JL.js")).runHeaderImageCommand(site, opts),
|
|
247
247
|
opts
|
|
248
248
|
)
|
|
249
249
|
);
|
|
@@ -252,7 +252,7 @@ cli.command(
|
|
|
252
252
|
"Draft the monthly-report announcement email for maintenance sites (all, or one) for approval."
|
|
253
253
|
).action(
|
|
254
254
|
async (site, opts) => runOrExit(
|
|
255
|
-
async () => (await import("../announce-
|
|
255
|
+
async () => (await import("../announce-GIAYN67S.js")).runAnnounceCommand(site, opts),
|
|
256
256
|
opts
|
|
257
257
|
)
|
|
258
258
|
);
|
|
@@ -261,7 +261,7 @@ cli.command(
|
|
|
261
261
|
"Show who a form submission would email, and optionally flip the pre-launch guard (read-back confirmed)."
|
|
262
262
|
).option("--set <on|off>", "Flip the guard: on = route to operator, off = restore.").option("--restore <status>", "Status to restore with --set off. Required; never inferred.").action(
|
|
263
263
|
async (site, opts) => runOrExit(
|
|
264
|
-
async () => (await import("../forms-notify-target-
|
|
264
|
+
async () => (await import("../forms-notify-target-SMKDKNT2.js")).runFormsNotifyTargetCommand(
|
|
265
265
|
site,
|
|
266
266
|
opts
|
|
267
267
|
),
|
|
@@ -273,7 +273,7 @@ cli.command(
|
|
|
273
273
|
"Operator self-tests. kind=email: preview a report email for a site (or --all) to yourself."
|
|
274
274
|
).option("--type <type>", "Report type: announcement (default) | maintenance | testing | launch").option("--to <addr>", "Recipient(s), comma-separated. Default: OPERATOR_EMAIL.").option("--all", "Send a preview for every maintenance site (to --to/operator).").option("--dry-run", "Render only; write reports/<slug>/selftest-<type>.html; do not send.").action(
|
|
275
275
|
async (kind, site, opts) => runOrExit(
|
|
276
|
-
async () => (await import("../selftest-
|
|
276
|
+
async () => (await import("../selftest-BVCLJRNI.js")).runSelftestCommand(kind, site, opts),
|
|
277
277
|
opts
|
|
278
278
|
)
|
|
279
279
|
);
|
|
@@ -285,13 +285,13 @@ cli.command(
|
|
|
285
285
|
"Fleet mode: announcement\u2192maintenance sites; maintenance/testing\u2192all schedulable sites."
|
|
286
286
|
).action(
|
|
287
287
|
async (site, opts) => runOrExit(
|
|
288
|
-
async () => (await import("../preflight-
|
|
288
|
+
async () => (await import("../preflight-3XPDUHFJ.js")).runPreflightCommand(site, opts),
|
|
289
289
|
opts
|
|
290
290
|
)
|
|
291
291
|
);
|
|
292
292
|
cli.command("ensure-site <slug>", "Create/verify the Airtable Websites row for a new site.").option("--name <display name>", "Human Name for the row (client-facing copy uses it verbatim).").option("--url <url>", "Deployed URL (e.g. the Netlify site URL).").option("--contact <email>", "point of contact \u2014 the client address reports resolve to.").option("--git-repo <owner/repo>", "GitHub identity. Default on create: reddoorla/<slug>.").action(
|
|
293
293
|
async (slug, opts) => runOrExit(
|
|
294
|
-
async () => (await import("../ensure-site-
|
|
294
|
+
async () => (await import("../ensure-site-S25MDDHW.js")).runEnsureSiteCommand(slug, opts),
|
|
295
295
|
opts
|
|
296
296
|
)
|
|
297
297
|
);
|
|
@@ -309,7 +309,7 @@ cli.command("report [site]", "Draft or send maintenance/testing reports.").optio
|
|
|
309
309
|
"Email the operator one daily digest of reports ready for approval (skips when empty)."
|
|
310
310
|
).action(
|
|
311
311
|
async (site, opts) => runOrExit(
|
|
312
|
-
async () => (await import("../report-
|
|
312
|
+
async () => (await import("../report-WG6RHA2X.js")).runReportCommand(site, opts),
|
|
313
313
|
opts
|
|
314
314
|
)
|
|
315
315
|
);
|
|
@@ -318,7 +318,7 @@ cli.command(
|
|
|
318
318
|
"Sweep the fleet for GitHub signals (Renovate-failing/CI/last-commit) and write Airtable."
|
|
319
319
|
).option("--fleet", "Run across every site in the Airtable inventory.").option("--write-airtable", "Write each site's signals back to its Websites row.").action(
|
|
320
320
|
async (opts) => runOrExit(
|
|
321
|
-
async () => (await import("../github-signals-
|
|
321
|
+
async () => (await import("../github-signals-G4ILZAJ6.js")).runGitHubSignalsCommand({
|
|
322
322
|
fleet: opts.fleet,
|
|
323
323
|
writeAirtable: opts.writeAirtable
|
|
324
324
|
}),
|
|
@@ -350,7 +350,7 @@ cli.command(
|
|
|
350
350
|
"Write the re-buckets: status \u2192 spam_auto, spam_score/spam_reason replaced with the new verdict + 'retro-rescore' marker."
|
|
351
351
|
).action(
|
|
352
352
|
async (action, opts) => runOrExit(
|
|
353
|
-
async () => (await import("../submissions-
|
|
353
|
+
async () => (await import("../submissions-GJ6ITBRH.js")).runSubmissionsCommand(action, opts),
|
|
354
354
|
opts
|
|
355
355
|
)
|
|
356
356
|
);
|
|
@@ -359,7 +359,7 @@ cli.command(
|
|
|
359
359
|
"Trigger Renovate on fleet sites the security sweep flagged with critical/high vulns."
|
|
360
360
|
).option("--fleet", "Run across every active, repo-backed site in the Airtable inventory.").action(
|
|
361
361
|
async (opts) => runOrExit(
|
|
362
|
-
async () => (await import("../renovate-dispatch-
|
|
362
|
+
async () => (await import("../renovate-dispatch-ICJGSFCE.js")).runRenovateDispatchCommand({
|
|
363
363
|
fleet: opts.fleet
|
|
364
364
|
}),
|
|
365
365
|
opts
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
} from "../../chunk-ZMIPG6TR.js";
|
|
9
9
|
import {
|
|
10
10
|
resolveSites
|
|
11
|
-
} from "../../chunk-
|
|
11
|
+
} from "../../chunk-7J53SUT6.js";
|
|
12
12
|
import {
|
|
13
13
|
ALL_AUDIT_NAMES,
|
|
14
14
|
runOneAudit
|
|
@@ -235,9 +235,9 @@ ${skipNotice}`;
|
|
|
235
235
|
let writeBackFailed = false;
|
|
236
236
|
if (opts.writeAirtable !== void 0) {
|
|
237
237
|
const { openBase, readAirtableConfig } = await import("../../client-EPVO3FEH.js");
|
|
238
|
-
const { listWebsites } = await import("../../websites-
|
|
238
|
+
const { listWebsites } = await import("../../websites-SADZR4XQ.js");
|
|
239
239
|
if (opts.fleet !== void 0) {
|
|
240
|
-
const { writeFleetAuditsToAirtable, formatFleetWriteSummary } = await import("../../write-audits-to-airtable-
|
|
240
|
+
const { writeFleetAuditsToAirtable, formatFleetWriteSummary } = await import("../../write-audits-to-airtable-7PH75YWY.js");
|
|
241
241
|
const base = openBase(readAirtableConfig());
|
|
242
242
|
const websites = await listWebsites(base);
|
|
243
243
|
const fleetWrite = await writeFleetAuditsToAirtable({ base, websites, results });
|
|
@@ -253,8 +253,8 @@ ${skipNotice}`;
|
|
|
253
253
|
|
|
254
254
|
${formatFleetWriteSummary(fleetWrite)}`;
|
|
255
255
|
} else {
|
|
256
|
-
const { resolveSlugFromCwd } = await import("../../lighthouse-airtable-
|
|
257
|
-
const { writeAuditsToAirtable } = await import("../../write-audits-to-airtable-
|
|
256
|
+
const { resolveSlugFromCwd } = await import("../../lighthouse-airtable-BIPM26SZ.js");
|
|
257
|
+
const { writeAuditsToAirtable } = await import("../../write-audits-to-airtable-7PH75YWY.js");
|
|
258
258
|
const slug = typeof opts.writeAirtable === "string" && opts.writeAirtable.length > 0 ? opts.writeAirtable : await resolveSlugFromCwd(cwd);
|
|
259
259
|
let writeSummary = null;
|
|
260
260
|
await new Listr(
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
} from "./chunk-ZMIPG6TR.js";
|
|
9
9
|
import {
|
|
10
10
|
resolveSites
|
|
11
|
-
} from "./chunk-
|
|
11
|
+
} from "./chunk-7J53SUT6.js";
|
|
12
12
|
import {
|
|
13
13
|
convertToPnpm
|
|
14
14
|
} from "./chunk-RJB3QUSP.js";
|
|
@@ -51,4 +51,4 @@ async function runConvertToPnpmCommand(site, opts) {
|
|
|
51
51
|
export {
|
|
52
52
|
runConvertToPnpmCommand
|
|
53
53
|
};
|
|
54
|
-
//# sourceMappingURL=convert-to-pnpm-
|
|
54
|
+
//# sourceMappingURL=convert-to-pnpm-AT52NWZB.js.map
|
|
@@ -13,12 +13,12 @@ import {
|
|
|
13
13
|
diffAttention,
|
|
14
14
|
readDigestState,
|
|
15
15
|
writeDigestState
|
|
16
|
-
} from "./chunk-
|
|
17
|
-
import "./chunk-
|
|
16
|
+
} from "./chunk-TP5M2NQB.js";
|
|
17
|
+
import "./chunk-LTB6KVJW.js";
|
|
18
18
|
import "./chunk-D4FVAIEJ.js";
|
|
19
19
|
import {
|
|
20
20
|
isIdempotencyConflict
|
|
21
|
-
} from "./chunk-
|
|
21
|
+
} from "./chunk-COZPL62X.js";
|
|
22
22
|
import "./chunk-GECESMVS.js";
|
|
23
23
|
import {
|
|
24
24
|
isPendingApproval,
|
|
@@ -38,7 +38,7 @@ import "./chunk-U5SZ4FOP.js";
|
|
|
38
38
|
import {
|
|
39
39
|
listWebsites,
|
|
40
40
|
siteSlug
|
|
41
|
-
} from "./chunk-
|
|
41
|
+
} from "./chunk-577HCGZO.js";
|
|
42
42
|
import {
|
|
43
43
|
openBase,
|
|
44
44
|
readAirtableConfig
|
|
@@ -337,4 +337,4 @@ export {
|
|
|
337
337
|
renderDigestHtml,
|
|
338
338
|
runDigest
|
|
339
339
|
};
|
|
340
|
-
//# sourceMappingURL=digest-
|
|
340
|
+
//# sourceMappingURL=digest-SQ5U6SN2.js.map
|
|
@@ -2,7 +2,7 @@ import {
|
|
|
2
2
|
WEBSITES_TABLE,
|
|
3
3
|
listWebsites,
|
|
4
4
|
siteSlug
|
|
5
|
-
} from "./chunk-
|
|
5
|
+
} from "./chunk-577HCGZO.js";
|
|
6
6
|
import {
|
|
7
7
|
openBase,
|
|
8
8
|
readAirtableConfig
|
|
@@ -84,4 +84,4 @@ async function runEnsureSiteCommand(slug, opts) {
|
|
|
84
84
|
export {
|
|
85
85
|
runEnsureSiteCommand
|
|
86
86
|
};
|
|
87
|
-
//# sourceMappingURL=ensure-site-
|
|
87
|
+
//# sourceMappingURL=ensure-site-S25MDDHW.js.map
|
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
listWebsites,
|
|
6
6
|
siteSlug,
|
|
7
7
|
updateSiteField
|
|
8
|
-
} from "./chunk-
|
|
8
|
+
} from "./chunk-577HCGZO.js";
|
|
9
9
|
import {
|
|
10
10
|
openBase,
|
|
11
11
|
readAirtableConfig
|
|
@@ -255,4 +255,4 @@ export {
|
|
|
255
255
|
formatNotifyTarget,
|
|
256
256
|
runFormsNotifyTargetCommand
|
|
257
257
|
};
|
|
258
|
-
//# sourceMappingURL=forms-notify-target-
|
|
258
|
+
//# sourceMappingURL=forms-notify-target-SMKDKNT2.js.map
|
|
@@ -3,8 +3,8 @@ import {
|
|
|
3
3
|
} from "./chunk-PZQ3WED2.js";
|
|
4
4
|
import {
|
|
5
5
|
formatFleetWriteSummary
|
|
6
|
-
} from "./chunk-
|
|
7
|
-
import "./chunk-
|
|
6
|
+
} from "./chunk-3YXDSCM6.js";
|
|
7
|
+
import "./chunk-PBWDDDQH.js";
|
|
8
8
|
import {
|
|
9
9
|
detectSignalEvents,
|
|
10
10
|
fleetSweptEvent
|
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
listWebsites,
|
|
23
23
|
siteSlug,
|
|
24
24
|
updateGitHubSignals
|
|
25
|
-
} from "./chunk-
|
|
25
|
+
} from "./chunk-577HCGZO.js";
|
|
26
26
|
import {
|
|
27
27
|
openBase,
|
|
28
28
|
readAirtableConfig
|
|
@@ -134,4 +134,4 @@ export {
|
|
|
134
134
|
githubSignalsExitCode,
|
|
135
135
|
runGitHubSignalsCommand
|
|
136
136
|
};
|
|
137
|
-
//# sourceMappingURL=github-signals-
|
|
137
|
+
//# sourceMappingURL=github-signals-G4ILZAJ6.js.map
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
ACTIVE_STATUSES,
|
|
9
9
|
listWebsites,
|
|
10
10
|
siteSlug
|
|
11
|
-
} from "./chunk-
|
|
11
|
+
} from "./chunk-577HCGZO.js";
|
|
12
12
|
import {
|
|
13
13
|
openBase,
|
|
14
14
|
readAirtableConfig
|
|
@@ -92,4 +92,4 @@ export {
|
|
|
92
92
|
resolveTargets,
|
|
93
93
|
runHeaderImageCommand
|
|
94
94
|
};
|
|
95
|
-
//# sourceMappingURL=header-image-
|
|
95
|
+
//# sourceMappingURL=header-image-T7XD37JL.js.map
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
} from "./chunk-ZMIPG6TR.js";
|
|
9
9
|
import {
|
|
10
10
|
resolveSites
|
|
11
|
-
} from "./chunk-
|
|
11
|
+
} from "./chunk-7J53SUT6.js";
|
|
12
12
|
import {
|
|
13
13
|
healthEndpoint
|
|
14
14
|
} from "./chunk-R6FPFMOS.js";
|
|
@@ -51,4 +51,4 @@ async function runHealthEndpointCommand(site, opts) {
|
|
|
51
51
|
export {
|
|
52
52
|
runHealthEndpointCommand
|
|
53
53
|
};
|
|
54
|
-
//# sourceMappingURL=health-endpoint-
|
|
54
|
+
//# sourceMappingURL=health-endpoint-242WG2OJ.js.map
|
package/dist/index.d.ts
CHANGED
|
@@ -649,6 +649,26 @@ type WebsiteRow = {
|
|
|
649
649
|
* nothing could be established for an `unknown`, the skip reason for a blank,
|
|
650
650
|
* and null for a `pass` (which clears yesterday's finding). */
|
|
651
651
|
prismicModelsDrift: string | null;
|
|
652
|
+
/**
|
|
653
|
+
* An expiry the operator sets to accept a KNOWN, EXPECTED `fail` — most often
|
|
654
|
+
* "I am modelling in Prismic on a branch that has not landed, so Prismic is
|
|
655
|
+
* legitimately ahead of `main`". While this timestamp is in the future the
|
|
656
|
+
* drift item is not raised.
|
|
657
|
+
*
|
|
658
|
+
* It expires on purpose, and nothing renews it automatically. A permanent ack
|
|
659
|
+
* would reproduce this column's own failure mode one step later: once the
|
|
660
|
+
* branch lands, the same acked cell would swallow real drift and "nobody is
|
|
661
|
+
* looking at this" would render as "this is fine". An expiry means the worst
|
|
662
|
+
* case is a silence that ends by itself.
|
|
663
|
+
*
|
|
664
|
+
* Narrow by construction — it mutes only `fail`, the finding the operator
|
|
665
|
+
* actually reviewed. `unknown` (the check could not run) and a verdict too old
|
|
666
|
+
* to be current are never muted by it; see `collectPrismicDriftAlerts`.
|
|
667
|
+
*
|
|
668
|
+
* OPERATOR PRECONDITION: `Prismic Ack Until` is a dateTime column. Until it
|
|
669
|
+
* exists this reads null and nothing is acked, which is the safe direction.
|
|
670
|
+
*/
|
|
671
|
+
prismicAckUntil: string | null;
|
|
652
672
|
notifyRouting: NotifyRouting | null;
|
|
653
673
|
};
|
|
654
674
|
/** The `Prismic Models` cell — see {@link WebsiteRow.prismicModels} for why it
|