diff --git a/pm-stack/aggregator/src/aa.ts b/pm-stack/aggregator/src/aa.ts index d255813f6..b60b5412b 100644 --- a/pm-stack/aggregator/src/aa.ts +++ b/pm-stack/aggregator/src/aa.ts @@ -607,6 +607,14 @@ export interface AaPlanFact { /** List-rate dollars of usage per $1 paid, when the plan meters so. */ multiplier: number | null; note: string; + /** Typed cancellation source data (#1174): the plan is cancelled and + * its paid period ends on this ISO date. The quota-action derivation + * (buildQuotaActions) reads THESE fields — never the note prose — + * so update them with the registry when a plan changes. */ + cancelled?: boolean; + periodEndsAt?: string; + /** Concrete next step while the cancelled period still has time left. */ + renewalAction?: string; } export const AA_PLAN_FACTS: Record = { @@ -614,11 +622,16 @@ export const AA_PLAN_FACTS: Record = { plan: "OpenCode Go — Personal", multiplier: 6, note: "$10/mo buys a $60/mo list-rate meter (6×); Flash promo cap $80 (≈8×). Cancelled — period ends 2026-11-02.", + cancelled: true, + periodEndsAt: "2026-11-02", + renewalAction: "Burn before Lab per rotation order", }, "opencode-go.lab": { plan: "OpenCode Go — Lab", multiplier: 6, note: "$10/mo buys a $60/mo list-rate meter (6×); Flash promo cap $80 (≈8×). Cancelled — period ends 2026-11-06.", + cancelled: true, + periodEndsAt: "2026-11-06", }, "opencode-go": { plan: "OpenCode Go", diff --git a/pm-stack/aggregator/src/derive.ts b/pm-stack/aggregator/src/derive.ts index 9ccb554d1..ebd29417c 100644 --- a/pm-stack/aggregator/src/derive.ts +++ b/pm-stack/aggregator/src/derive.ts @@ -823,7 +823,7 @@ export function deriveToday( // PAYG over $5 (unbilled accrual only — Q3 default), surfaces worst-first. // --------------------------------------------------------------------------- -import { countedEstimate, type AttentionFlag, type QuotaView, type SnapshotRecord, type UsageView } from "./model.ts"; +import { countedEstimate, type AttentionFlag, type Horizon, type QuotaAction, type QuotaActionKind, type QuotaView, type SnapshotRecord, type UsageView } from "./model.ts"; export const BUBBLE_PCT = 80; export const BUBBLE_USD = 5; @@ -896,6 +896,158 @@ export function buildAttentionFlags( return flags.sort((a, b) => (a.kind === b.kind ? b.severity - a.severity : a.kind === "over-limit" ? -1 : 1)); } +// --- Quota actions (#1174) -------------------------------------------------- +// One derivation for the expiring/cancelled, walled, and high-usage +// quota actions surfaced on /quota's strip, /today, and /attention's +// orchestrator tier. The cancellation source data is the plan-facts +// registry (AaPlanFact.cancelled/periodEndsAt, updated with the +// registry when a plan changes) — never scraped from display prose. +// Walled and high-usage state comes from the pools themselves. An +// entry exists only while its condition holds (deadline in the +// future and inside the window, pool still walled, window still hot +// with a distant reset), so entries expire and clear on their own and +// no surface can render a duplicate or stale copy. + +/** Structural plan-fact input; AA_PLAN_FACTS satisfies this. */ +export interface QuotaPlanFact { + cancelled?: boolean; + periodEndsAt?: string | null; + renewalAction?: string | null; +} + +/** A cancelled period counts as expiring inside this many days. */ +export const QUOTA_ACTION_WINDOW_DAYS = 14; +/** High-usage bar: strictly above this percent of limit. */ +export const QUOTA_HIGH_USAGE_PCT = 85; +/** High-usage counts only when the reset is further out than this. */ +export const QUOTA_HIGH_USAGE_RESET_MS = 24 * 3_600_000; + +function planFactKeyForPool(poolId: string): string { + return poolId.replace(":", "."); +} + +/** + * Every eligible quota action across the pools, worst first: expiring + * periods by nearest deadline, then walled pools, then high-usage + * windows by percent. Pure — same quota in, same actions out — so the + * three surfaces render the identical entries. + */ +export function buildQuotaActions( + quota: QuotaView, + plans: Readonly> = {}, + now = Date.now(), +): QuotaAction[] { + const actions: QuotaAction[] = []; + for (const pool of quota.pools) { + const key = planFactKeyForPool(pool.id); + const fact = Object.hasOwn(plans, key) ? plans[key] : undefined; // #3056 own-key guard + const periodEnd = fact?.cancelled && fact.periodEndsAt ? Date.parse(fact.periodEndsAt) : NaN; + if (Number.isFinite(periodEnd)) { + const left = (periodEnd as number) - now; + if (left >= 0 && left <= QUOTA_ACTION_WINDOW_DAYS * 86_400_000) { + const day = String(fact?.periodEndsAt).slice(0, 10); + actions.push({ + id: `${pool.id}:expiry`, + poolId: pool.id, + provider: pool.provider, + plan: pool.plan, + kind: "expiry", + title: `${pool.provider} — ${pool.plan} ends ${day}`, + detail: `Cancelled — period ends ${day}${fact?.renewalAction ? ` · ${fact.renewalAction}` : ""}`, + deadline: new Date(periodEnd as number).toISOString(), + href: "/quota", + }); + } + } + if (pool.state === "walled") { + actions.push({ + id: `${pool.id}:walled`, + poolId: pool.id, + provider: pool.provider, + plan: pool.plan, + kind: "walled", + title: `${pool.provider} — ${pool.plan} is walled`, + detail: pool.wallLiftsAt + ? `Walled — lifts ${pool.wallLiftsAt.slice(0, 10)} · hold new sessions until then` + : "Walled with no lift date recorded — check the pool before starting sessions", + deadline: pool.wallLiftsAt ?? null, + href: "/quota", + }); + } + let worst: QuotaWindow | null = null; + let worstPct = 0; + for (const w of pool.windows) { + const pct = windowPercent(w); + if (pct > worstPct) { + worstPct = pct; + worst = w; + } + } + if (worst && worstPct > QUOTA_HIGH_USAGE_PCT && worst.resetsAt) { + const reset = Date.parse(worst.resetsAt); + if (Number.isFinite(reset) && reset - now > QUOTA_HIGH_USAGE_RESET_MS) { + actions.push({ + id: `${pool.id}:high-usage`, + poolId: pool.id, + provider: pool.provider, + plan: pool.plan, + kind: "high-usage", + title: `${pool.provider} — ${pool.plan} at ${Math.round(worstPct)}%`, + detail: `${worst.name} window ${Math.round(worstPct)}% used · resets ${worst.resetsAt.slice(0, 10)} — burn it down or hold new sessions`, + deadline: worst.resetsAt, + href: "/quota", + }); + } + } + } + const rank: Record = { expiry: 0, walled: 1, "high-usage": 2 }; + return actions.sort( + (a, b) => + rank[a.kind] - rank[b.kind] || + (a.deadline ?? "").localeCompare(b.deadline ?? "") || + a.id.localeCompare(b.id), + ); +} + +/** The /quota "Needs attention" strip rows for already-derived actions. */ +export function quotaActionFlags(actions: QuotaAction[], now = Date.now()): AttentionFlag[] { + return actions.map((a) => ({ + kind: "quota-action" as const, + label: a.title, + detail: a.detail, + href: a.href, + severity: + a.kind === "expiry" && a.deadline + ? 1000 + (QUOTA_ACTION_WINDOW_DAYS - (Date.parse(a.deadline) - now) / 86_400_000) + : a.kind === "walled" + ? 500 + : 100, + })); +} + +/** The /attention orchestrator-tier items for already-derived actions. */ +export function quotaActionTierItems(actions: QuotaAction[], now = Date.now()): TierItem[] { + const at = new Date(now).toISOString(); + return actions.map((a) => { + const left = a.deadline ? Date.parse(a.deadline) - now : NaN; + return { + id: `quota-action:${a.id}`, + kind: "task" as const, + title: a.title, + // Aggregator-synthesized like the #1148 watchdog items: derived + // live, never published by the orchestrator. + source: "watchdog" as const, + reason: a.detail, + since: at, + lastSeenAt: at, + horizon: ( + Number.isFinite(left) ? (left <= 86_400_000 ? "today" : left <= 3 * 86_400_000 ? "next-3-days" : "longer") : "longer" + ) as Horizon, + recommendedAction: a.detail, + }; + }); +} + export interface Freshness { stale: boolean; /** Whole days since the freshest read; null when never measured. */ diff --git a/pm-stack/aggregator/src/model.ts b/pm-stack/aggregator/src/model.ts index 3af2e76f2..689a381fd 100644 --- a/pm-stack/aggregator/src/model.ts +++ b/pm-stack/aggregator/src/model.ts @@ -1274,9 +1274,12 @@ export interface ChargeRow { /** * One tripped bubble rule (§1.3): a metric over 80% of its limit, or * projected unbilled PAYG over $5 (unbilled accrual only, Q3 default). + * "quota-action" entries are the eligible quota actions from + * buildQuotaActions (derive.ts, #1174) — expiry/cancellation, walled, + * and high-usage — rendered on the same strip, never a second list. */ export interface AttentionFlag { - kind: "over-limit" | "payg-unbilled" | "unmatched-charge"; + kind: "over-limit" | "payg-unbilled" | "unmatched-charge" | "quota-action"; label: string; detail: string; href: string; @@ -1284,6 +1287,34 @@ export interface AttentionFlag { severity: number; } +/** + * One actionable quota entry (#1174): an expiring/cancelled plan + * period, a walled pool, or a high-usage window whose reset is far + * enough away to act on. Derived once by buildQuotaActions (derive.ts) + * from the pools' typed source data — never by scraping display + * prose — and rendered identically on /quota, /today, and /attention. + * An entry exists only while its source condition holds: past + * deadlines and cleared states produce no entry, so nothing goes + * stale and nothing renders twice. + */ +export type QuotaActionKind = "expiry" | "walled" | "high-usage"; + +export interface QuotaAction { + /** Stable per pool + kind, so surfaces dedupe by identity. */ + id: string; + poolId: string; + provider: string; + plan: string; + kind: QuotaActionKind; + /** Actionable one-liner, e.g. "OpenCode Go — Personal ends 2026-11-02". */ + title: string; + /** Reason + deadline + concrete next step, when the source names one. */ + detail: string; + /** ISO deadline when the source carries one (expiry date, wall lift, reset). */ + deadline: string | null; + href: string; +} + /** * The quota plane's latest-state contract (Stage 2, #1151): one entry * per provider credential the gatherer read this tick, each window diff --git a/pm-stack/aggregator/src/render.ts b/pm-stack/aggregator/src/render.ts index 8b3dd8f96..a866312fe 100644 --- a/pm-stack/aggregator/src/render.ts +++ b/pm-stack/aggregator/src/render.ts @@ -6,6 +6,7 @@ import { type RoadmapGrain, type TodayView, allRefs, + buildQuotaActions, commentStream, executionStrip, freshness, @@ -37,7 +38,7 @@ import { } from "./tier-items.ts"; import { commentKindCounts, githubCommentUrl, queuedComments, type CommentKindCounts } from "./comments.ts"; import { serviceHomeLinks, type ItemServiceLink } from "./service-links.ts"; -import { aaCaiPoints, aaPoints, summarizeAa } from "./aa.ts"; +import { AA_PLAN_FACTS, aaCaiPoints, aaPoints, summarizeAa } from "./aa.ts"; import { summarizeDns } from "./dns.ts"; import type { Incident, IncidentEvent, PushSubscriptionRow } from "./alerts.ts"; import { ageMsText, feedAgeText } from "./feed.ts"; @@ -3340,6 +3341,7 @@ export function renderTodayBody( quotaUnavailable = false, ): string { const walled = quota.pools.filter((p) => p.state === "walled"); + const quotaActions = buildQuotaActions(quota, AA_PLAN_FACTS); const alerts: string[] = []; const drewTotal = attention.drew.length + attention.drewExtra.length; if (drewTotal) { @@ -3347,9 +3349,17 @@ export function renderTodayBody( ``, ); } - if (walled.length) { + // #1174: the same eligible quota actions as /quota's strip and + // /attention's orchestrator tier, derived once above — never a + // second list. Walled pools without a typed action still banner. + for (const a of quotaActions) { alerts.push( - ``, + ``, + ); + } + for (const p of walled.filter((p) => !quotaActions.some((a) => a.poolId === p.id))) { + alerts.push( + ``, ); } // #1139: the derived view's banner names the section's TRUE state — @@ -3370,8 +3380,14 @@ export function renderTodayBody( const actions = today.actions?.length ? `

Pushed back into motion

${today.actions.map((a) => `
${esc(a.kind)} ${esc(a.target)}
${esc(a.summary)}
${esc(fmtDate(a.at))}
`).join("")}` : ""; - const objectives = today.objectives.length - ? today.objectives + const quotaObjectives = quotaActions.map((a) => ({ + title: a.title, + detail: a.detail, + chip: "quota · action", + })); + const allObjectives = [...today.objectives, ...quotaObjectives]; + const objectives = allObjectives.length + ? allObjectives .map( (o, i) => `
${i + 1}
${esc(o.title)} ${esc(o.chip)}${o.detail ? `
${esc(o.detail)}` : ""}${o.ref ? `
${esc(o.ref)}` : ""}
`, diff --git a/pm-stack/aggregator/src/server.ts b/pm-stack/aggregator/src/server.ts index 0fc34ef6f..a59e5fa41 100644 --- a/pm-stack/aggregator/src/server.ts +++ b/pm-stack/aggregator/src/server.ts @@ -25,6 +25,7 @@ import { import { loadConfig } from "./config.ts"; import { itemServiceLinks } from "./service-links.ts"; import { loadFeed } from "./feed.ts"; +import { AA_PLAN_FACTS } from "./aa.ts"; import { buildHealth, pageRenderHealth, @@ -35,7 +36,7 @@ import { } from "./health.ts"; import { manifestRouteFor } from "./pages.ts"; import { watchdogTierItems, WATCHDOG_PROBE_SERVICE_IDS } from "./watchdog.ts"; -import { allRefs, buildAttention, deriveToday, projectPace, type RoadmapGrain } from "./derive.ts"; +import { allRefs, buildAttention, buildQuotaActions, deriveToday, projectPace, quotaActionTierItems, type RoadmapGrain } from "./derive.ts"; import { reconcileRoadmap, roadmapDriftReport, @@ -434,8 +435,20 @@ async function attentionContext() { // recomputes it on every orchestration read (#1139). const diagnosis = source.diagnostics?.().orchestration; const watchdog = await deriveWatchdogItems(diagnosis); + const quota = await source.quota().catch(() => null); + const view = buildAttention(feed, summary, Date.now(), diagnosis, watchdog); + // #1174: the same eligible quota actions as /quota's strip and + // /today, as orchestrator-tier task items — derived once here from + // the same pure function, so entries clear on every surface the + // moment their source condition no longer holds. + if (quota) { + view.orchestratorExtra = [ + ...view.orchestratorExtra, + ...quotaActionTierItems(buildQuotaActions(quota, AA_PLAN_FACTS)), + ]; + } return { - view: buildAttention(feed, summary, Date.now(), diagnosis, watchdog), + view, coverage, orchestration: summary, diagnosis, diff --git a/pm-stack/aggregator/src/sources/fixtures.ts b/pm-stack/aggregator/src/sources/fixtures.ts index ffa20baa0..cb938b5ed 100644 --- a/pm-stack/aggregator/src/sources/fixtures.ts +++ b/pm-stack/aggregator/src/sources/fixtures.ts @@ -24,7 +24,7 @@ import { refsInText, refUrl, } from "../model.ts"; -import { buildAttentionFlags } from "../derive.ts"; +import { buildAttentionFlags, buildQuotaActions, quotaActionFlags } from "../derive.ts"; import { gristDocUrl } from "../service-links.ts"; import { recordFromSessionEntry } from "../registry.ts"; import type { Source } from "../source.ts"; @@ -1402,7 +1402,13 @@ export function fixtureSource(): Source { return [...byRef.values()]; }, quota: async () => FIXTURE_QUOTA, - usage: async () => ({ ...FIXTURE_USAGE, attention: buildAttentionFlags(FIXTURE_QUOTA, FIXTURE_USAGE) }), + usage: async () => ({ + ...FIXTURE_USAGE, + attention: [ + ...buildAttentionFlags(FIXTURE_QUOTA, FIXTURE_USAGE), + ...quotaActionFlags(buildQuotaActions(FIXTURE_QUOTA)), + ], + }), attention: async () => FIXTURE_ATTENTION, // Fixtures carry no orchestration section: "not measured" is the // honest fixture state for a job that only runs live (#899). diff --git a/pm-stack/aggregator/src/sources/live.ts b/pm-stack/aggregator/src/sources/live.ts index b85c6cfea..de823f395 100644 --- a/pm-stack/aggregator/src/sources/live.ts +++ b/pm-stack/aggregator/src/sources/live.ts @@ -7,7 +7,7 @@ import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; import { dirname } from "node:path"; import type { Config } from "../config.ts"; import { applyBoardDonePolicy, isRetiredDoneTableTitle } from "../board-policy.ts"; -import { isAaSection } from "../aa.ts"; +import { AA_PLAN_FACTS, isAaSection } from "../aa.ts"; import { isDnsSection } from "../dns.ts"; import { diagnoseOrchestrationSection, @@ -24,7 +24,7 @@ import { import type { RepoCensus } from "../coverage.ts"; import { isSessionRecord, mergeRecords, recordFromSessionEntry } from "../registry.ts"; import { isRecord } from "../validate.ts"; -import { buildAttentionFlags } from "../derive.ts"; +import { buildAttentionFlags, buildQuotaActions, quotaActionFlags } from "../derive.ts"; import { summarizeDns } from "../dns.ts"; import { applyCloudflareExport, @@ -1054,7 +1054,11 @@ export function liveSource(cfg: Config, opts?: { standingAuto?: () => Promise Promise = {}): QuotaPool { + return { + id: "opencode-go:personal", + provider: "OpenCode Go", + plan: "Personal", + state: "healthy", + windows: [], + ...over, + }; +} + +function quota(pools: QuotaPool[]): QuotaView { + return { pools, burn: [], choices: { hasHeadroom: [], burnBeforeReset: [], doNotStart: [] } }; +} + +const EMPTY_TODAY: TodayView = { objectives: [], momentum: [], fromOrchestrator: false }; +const EMPTY_ATTENTION: AttentionView = { + drew: [], + orchestrator: [], + watchdog: [], + drewExtra: [], + orchestratorExtra: [], + all: [], + expiredCount: 0, + tierSource: { present: false, state: "absent" }, +}; + +describe("buildQuotaActions (#1174)", () => { + test("cancelled period inside 14 days yields an expiry action with the renewal step", () => { + const actions = buildQuotaActions(quota([pool()]), PLANS, Date.parse("2026-10-26T12:00:00Z")); + expect(actions).toHaveLength(1); + const [a] = actions; + expect(a.kind).toBe("expiry"); + expect(a.id).toBe("opencode-go:personal:expiry"); + expect(a.title).toContain("2026-11-02"); + expect(a.detail).toContain("Cancelled"); + expect(a.detail).toContain("Burn before Lab per rotation order"); + expect(a.deadline).not.toBeNull(); + expect(a.href).toBe("/quota"); + }); + + test("expired period yields no entry", () => { + const actions = buildQuotaActions(quota([pool()]), PLANS, Date.parse("2026-11-03T12:00:00Z")); + expect(actions).toHaveLength(0); + }); + + test("period more than 14 days out yields no entry", () => { + const actions = buildQuotaActions(quota([pool()]), PLANS, Date.parse("2026-10-01T12:00:00Z")); + expect(actions).toHaveLength(0); + }); + + test("missing plan fact yields no expiry entry", () => { + const actions = buildQuotaActions(quota([pool()]), {}, Date.parse("2026-10-26T12:00:00Z")); + expect(actions).toHaveLength(0); + }); + + test("walled pool yields a walled action carrying the lift date", () => { + const actions = buildQuotaActions( + quota([pool({ state: "walled", wallLiftsAt: "2026-11-05T00:00:00Z" })]), + {}, + Date.parse("2026-10-26T12:00:00Z"), + ); + expect(actions).toHaveLength(1); + expect(actions[0].kind).toBe("walled"); + expect(actions[0].deadline).toBe("2026-11-05T00:00:00Z"); + expect(actions[0].detail).toContain("2026-11-05"); + }); + + test("healthy pool with no facts yields no entry (clearing)", () => { + const actions = buildQuotaActions( + quota([pool({ state: "healthy", windows: [] })]), + PLANS, + Date.parse("2026-11-10T12:00:00Z"), + ); + expect(actions).toHaveLength(0); + }); + + test("high usage with a distant reset yields a high-usage action", () => { + const now = Date.parse("2026-10-26T12:00:00Z"); + const actions = buildQuotaActions( + quota([ + pool({ + id: "openai:codex", + provider: "OpenAI", + plan: "Codex", + windows: [ + { name: "weekly", unit: "other", limit: 100, used: 92, resetsAt: "2026-10-30T12:00:00Z", percent: 92 }, + ], + }), + ]), + {}, + now, + ); + expect(actions).toHaveLength(1); + expect(actions[0].kind).toBe("high-usage"); + expect(actions[0].detail).toContain("92%"); + }); + + test("high usage with a near reset yields no entry", () => { + const now = Date.parse("2026-10-26T12:00:00Z"); + const actions = buildQuotaActions( + quota([ + pool({ + id: "openai:codex", + provider: "OpenAI", + plan: "Codex", + windows: [ + { name: "weekly", unit: "other", limit: 100, used: 92, resetsAt: "2026-10-26T20:00:00Z", percent: 92 }, + ], + }), + ]), + {}, + now, + ); + expect(actions).toHaveLength(0); + }); + + test("85% exactly is not high usage; entries order expiry, walled, high-usage", () => { + const now = Date.parse("2026-10-26T12:00:00Z"); + const actions = buildQuotaActions( + quota([ + pool({ + id: "openai:codex", + provider: "OpenAI", + plan: "Codex", + windows: [ + { name: "weekly", unit: "other", limit: 100, used: 85, resetsAt: "2026-10-30T12:00:00Z", percent: 85 }, + ], + }), + pool(), + pool({ id: "meta:high-usage", provider: "Meta", plan: "High usage", state: "walled" }), + ]), + PLANS, + now, + ); + expect(actions.map((a) => a.kind)).toEqual(["expiry", "walled"]); + }); +}); + +describe("quota action surfaces (#1174)", () => { + test("flags carry the strip contract", () => { + const actions = buildQuotaActions(quota([pool()]), PLANS, Date.parse("2026-10-26T12:00:00Z")); + const flags = quotaActionFlags(actions); + expect(flags).toHaveLength(1); + expect(flags[0].kind).toBe("quota-action"); + expect(flags[0].href).toBe("/quota"); + expect(flags[0].label).toBe(actions[0].title); + }); + + test("tier items are stable task items", () => { + const actions = buildQuotaActions(quota([pool()]), PLANS, Date.parse("2026-10-26T12:00:00Z")); + const items = quotaActionTierItems(actions, Date.parse("2026-10-26T12:00:00Z")); + expect(items).toHaveLength(1); + expect(items[0].id).toBe("quota-action:opencode-go:personal:expiry"); + expect(items[0].kind).toBe("task"); + expect(items[0].reason).toBe(actions[0].detail); + expect(items[0].horizon).toBe("longer"); + }); + + test("/today renders the action as alert and objective", () => { + const html = renderTodayBody( + EMPTY_TODAY, + EMPTY_ATTENTION, + quota([pool({ state: "walled", wallLiftsAt: "2026-11-05T00:00:00Z" })]), + [], + ); + expect(html).toContain("Quota action:"); + expect(html).toContain("is walled"); + expect(html).toContain("quota · action"); + }); + + test("/today with no eligible actions renders no quota alerts", () => { + const html = renderTodayBody( + EMPTY_TODAY, + EMPTY_ATTENTION, + quota([pool({ id: "example:pool", provider: "Example", plan: "Demo", state: "healthy", windows: [] })]), + [], + ); + expect(html).not.toContain("Quota action:"); + expect(html).toContain("No objectives lined up for today."); + }); +}); diff --git a/pm-stack/aggregator/test/quota-contract.test.ts b/pm-stack/aggregator/test/quota-contract.test.ts index 97fa0aac6..1adf23a50 100644 --- a/pm-stack/aggregator/test/quota-contract.test.ts +++ b/pm-stack/aggregator/test/quota-contract.test.ts @@ -21,7 +21,7 @@ const USAGE_UNITS = [ const SAAS_GROUPS = ["database", "object-storage", "compute", "dev-platform", "domains", "account-tooling"]; const SAAS_STATUSES = ["active", "candidate", "no-account", "unknown"]; const SOURCE_CLASSES = ["api", "console", "manual", "estimate", "invoice"]; -const ATTENTION_KINDS = ["over-limit", "payg-unbilled", "unmatched-charge"]; +const ATTENTION_KINDS = ["over-limit", "payg-unbilled", "unmatched-charge", "quota-action"]; const isNumOrNull = (v: unknown) => v === null || (typeof v === "number" && Number.isFinite(v)); const isStrOrNull = (v: unknown) => v === null || typeof v === "string";