feat(admin): pulse day-mean dry run, apply step and admin card

This commit is contained in:
Marc-André Bombeck committed 2026-10-16 00:58:48 +02:00
1 parent 877920f5f7
commit 9f17cef8e4
17 files changed
+915 -16

No files matched your search

@@ -127,6 +127,8 @@ const ALLOWED_SQL: Record<string, string> = {
"Canonical-source blood pressure and weight over 90 days: sparse types, a few rows a day.",
"lib/measurements/reconcile-external-measurement.ts::1":
"Row locks (`FOR UPDATE`) on an id list the caller already read; returns those ids only.",
"lib/jobs/pulse-day-mean.ts::1":
"Selects one row per account; `measurements` is read only inside the MIN / MAX scalar subqueries of each row.",
"lib/jobs/pr-detection.ts::1":
"Selects account ids; `measurements` is read inside `EXISTS`, one row per account at most.",
"lib/rollups/measurement-coverage.ts::1":
@@ -201,6 +201,11 @@ const ALLOWED: Readonly<Record<string, { count: number; reason: string }>> = {
reason:
"A size probe for an admin card: null shows no figure, and the status read must not fail over it.",
},
"lib/jobs/pulse-day-mean.ts::isPulseDayMeanRunActive": {
count: 1,
reason:
"No pgboss schema on a web-only deployment, so no run can be active; the route answers 503 there before it asks.",
},
"lib/jobs/job-observer.ts::emitJobLine": {
count: 1,
reason:
@@ -107,6 +107,11 @@ const PINNED_AFFORDANCES: Record<
"src/components/admin/measurement-maintenance-card.tsx": {
"toast.success": 1,
},
// Fires only on a 202: the dry run was queued, or the method was applied.
// The dry run's outcome itself is the status line.
"src/components/admin/pulse-day-mean-card.tsx": {
"toast.success": 2,
},
"src/components/admin/invite-tokens-section.tsx": {
"text-success": 2,
"toast.success": 3,
+3
View File
@@ -41,6 +41,7 @@ import { SupportReportCard } from "@/components/admin/support-report-card";
import { RevealWhenSettled } from "@/components/ui/reveal-when-settled";
import { Skeleton } from "@/components/ui/skeleton";
import { MeasurementMaintenanceCard } from "@/components/admin/measurement-maintenance-card";
import { PulseDayMeanCard } from "@/components/admin/pulse-day-mean-card";
import { InviteTokensSection } from "@/components/admin/invite-tokens-section";
import { UserManagementSection } from "@/components/admin/user-management-section";
import type { AdminSectionSlug } from "@/components/admin/section-slugs";
@@ -75,6 +76,8 @@ export function AdminSectionRenderer({
<SupportReportCard />
{/* v1.42 — the table maintenance after the tombstone purge. */}
<MeasurementMaintenanceCard />
{/* v1.43 — the pulse day-mean method, dry run before apply. */}
<PulseDayMeanCard />
</SectionFrame>
</RevealWhenSettled>
);
@@ -0,0 +1,275 @@
/**
* `GET/POST /api/admin/maintenance/pulse-day-mean` — the pulse day-mean
* method's dry run and apply step. Admin only; apply needs a finished dry run
* from the last seven days, and nothing starts while a run is active.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
$queryRaw: vi.fn(),
appSettings: { findUnique: vi.fn(), upsert: vi.fn() },
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/jobs/boss-instance", () => ({ getGlobalBoss: vi.fn() }));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET, POST } from "../route";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { getSession } from "@/lib/auth/session";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import {
activePulseDayMeanMethod,
setActivePulseDayMeanMethod,
} from "@/lib/measurements/day-statistic";
const ADMIN = {
session: { id: "s-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "admin-1", username: "admin", role: "ADMIN" as const },
};
const USER = {
session: { id: "s-2", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "bob", role: "USER" as const },
};
const URL = "http://localhost/api/admin/maintenance/pulse-day-mean";
const get = () =>
(GET as unknown as (r: NextRequest) => Promise<Response>)(
new NextRequest(URL),
);
const post = (body: unknown) =>
(POST as unknown as (r: NextRequest) => Promise<Response>)(
new NextRequest(URL, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
}),
);
const NOW = new Date("2026-10-16T12:00:00Z");
const send = vi.fn();
function settings(dryRun: unknown, method = "hour_mean_v1") {
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
pulseDayMeanMethod: method,
pulseDayMeanAppliedAt: null,
pulseDayMeanDryRunJson: dryRun,
} as never);
}
function activeJobs(n: number) {
vi.mocked(prisma.$queryRaw).mockResolvedValue([{ n }] as never);
}
const RESULT = {
accounts: 1,
daysCompared: 30,
daysChangedOver1Bpm: 2,
maxChangeBpm: 3.4,
distribution: [
{ bucket: "0-1", days: 28 },
{ bucket: "1-2", days: 1 },
{ bucket: "2-5", days: 1 },
{ bucket: "5+", days: 0 },
],
perAccount: [{ account: "#1", days: 30, changedOver1Bpm: 2 }],
};
function doneAt(iso: string) {
return {
status: "done",
startedAt: iso,
finishedAt: iso,
result: RESULT,
};
}
beforeEach(() => {
vi.resetAllMocks();
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(NOW);
setActivePulseDayMeanMethod("hour_mean_v1");
vi.mocked(getSession).mockResolvedValue(ADMIN as never);
vi.mocked(getGlobalBoss).mockReturnValue({ send } as never);
vi.mocked(prisma.appSettings.upsert).mockResolvedValue({} as never);
send.mockResolvedValue("job-1");
activeJobs(0);
});
afterEach(() => {
vi.useRealTimers();
setActivePulseDayMeanMethod("hour_mean_v1");
});
describe("admin only", () => {
it("refuses anyone but an admin, before reading anything", async () => {
vi.mocked(getSession).mockResolvedValue(USER as never);
expect((await get()).status).toBe(403);
expect((await post({ action: "dry_run" })).status).toBe(403);
expect(prisma.appSettings.findUnique).not.toHaveBeenCalled();
expect(send).not.toHaveBeenCalled();
});
});
describe("GET", () => {
it("answers the method and the last dry run", async () => {
settings(doneAt("2026-10-15T08:00:00.000Z"));
const body = await (await get()).json();
expect(body.data).toEqual({
method: "hour_mean_v1",
appliedAt: null,
lastDryRun: doneAt("2026-10-15T08:00:00.000Z"),
});
});
it("reads a run marked running with no live job behind it as failed", async () => {
settings({
status: "running",
startedAt: "2026-10-16T11:00:00.000Z",
finishedAt: null,
result: null,
});
activeJobs(0);
const body = await (await get()).json();
expect(body.data.lastDryRun.status).toBe("failed");
});
it("drops a stored value of the wrong shape instead of passing it on", async () => {
settings({ status: "done", userId: "u-1" });
const body = await (await get()).json();
expect(body.data.lastDryRun).toBeNull();
});
});
describe("POST dry_run", () => {
it("marks the dry run queued and enqueues it", async () => {
settings(null);
const res = await post({ action: "dry_run" });
expect(res.status).toBe(202);
expect((await res.json()).data).toEqual({ enqueued: true });
expect(send).toHaveBeenCalledWith(
"pulse-day-mean",
{ action: "dry_run" },
expect.objectContaining({ retryLimit: 0, singletonKey: "pulse-day-mean" }),
);
expect(prisma.appSettings.upsert).toHaveBeenCalledWith(
expect.objectContaining({
update: {
pulseDayMeanDryRunJson: expect.objectContaining({ status: "queued" }),
},
}),
);
});
it("answers 409 busy while a run is active, and starts nothing", async () => {
activeJobs(1);
const res = await post({ action: "dry_run" });
expect(res.status).toBe(409);
expect((await res.json()).meta.errorCode).toBe(
"maintenance.pulseDayMean.busy",
);
expect(send).not.toHaveBeenCalled();
expect(prisma.appSettings.upsert).not.toHaveBeenCalled();
});
it("puts the last dry run back when the send loses the singleton", async () => {
const previous = doneAt("2026-10-15T08:00:00.000Z");
settings(previous);
send.mockResolvedValue(null);
const res = await post({ action: "dry_run" });
expect(res.status).toBe(409);
expect(vi.mocked(prisma.appSettings.upsert).mock.lastCall?.[0]).toEqual(
expect.objectContaining({ update: { pulseDayMeanDryRunJson: previous } }),
);
});
});
describe("POST apply", () => {
it("is refused without a dry run", async () => {
settings(null);
const res = await post({ action: "apply" });
expect(res.status).toBe(409);
expect((await res.json()).meta.errorCode).toBe(
"maintenance.pulseDayMean.dryRunRequired",
);
expect(prisma.appSettings.upsert).not.toHaveBeenCalled();
expect(send).not.toHaveBeenCalled();
});
it("is refused with a dry run older than seven days", async () => {
settings(doneAt("2026-10-09T11:59:00.000Z"));
expect((await post({ action: "apply" })).status).toBe(409);
});
it("is refused with a dry run that failed", async () => {
settings({ ...doneAt("2026-10-15T08:00:00.000Z"), status: "failed" });
expect((await post({ action: "apply" })).status).toBe(409);
});
it("is refused 409 busy while a run is active", async () => {
settings(doneAt("2026-10-15T08:00:00.000Z"));
activeJobs(1);
const res = await post({ action: "apply" });
expect((await res.json()).meta.errorCode).toBe(
"maintenance.pulseDayMean.busy",
);
});
it("switches the method, enqueues the rebuild and audits it", async () => {
settings(doneAt("2026-10-15T08:00:00.000Z"));
const res = await post({ action: "apply" });
expect(res.status).toBe(202);
expect((await res.json()).data).toEqual({ enqueued: true });
expect(prisma.appSettings.upsert).toHaveBeenCalledWith(
expect.objectContaining({
update: {
pulseDayMeanMethod: "weighted_hour_mean_v2",
pulseDayMeanAppliedAt: NOW,
},
}),
);
expect(send).toHaveBeenCalledWith(
"pulse-day-mean",
{ action: "rebuild" },
expect.anything(),
);
expect(activePulseDayMeanMethod()).toBe("weighted_hour_mean_v2");
expect(auditLog).toHaveBeenCalledWith(
"admin.pulseDayMean.apply",
expect.objectContaining({
userId: "admin-1",
details: { method: "weighted_hour_mean_v2", rebuildEnqueued: true },
}),
);
});
});
describe("POST body", () => {
it("is strict", async () => {
expect((await post({ action: "apply", force: true })).status).toBe(422);
expect((await post({ action: "rebuild" })).status).toBe(422);
expect(send).not.toHaveBeenCalled();
});
it("answers 503 without a background worker", async () => {
vi.mocked(getGlobalBoss).mockReturnValue(null as never);
expect((await post({ action: "dry_run" })).status).toBe(503);
});
});
@@ -26,7 +26,6 @@ import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import {
hasFreshDryRun,
isPulseDayMeanRunActive,
PULSE_DAY_MEAN_QUEUE,
PULSE_DAY_MEAN_SEND_OPTIONS,
@@ -39,7 +38,10 @@ import {
setActivePulseDayMeanMethod,
type PulseDayMeanMethod,
} from "@/lib/measurements/day-statistic";
import { pulseDayMeanActionSchema } from "@/lib/validations/pulse-day-mean";
import {
hasFreshDryRun,
pulseDayMeanActionSchema,
} from "@/lib/validations/pulse-day-mean";
export const dynamic = "force-dynamic";
@@ -0,0 +1,306 @@
"use client";
/**
* `<PulseDayMeanCard>` — switch the pulse day mean to weigh sparse hours, with
* a dry run first (#1107 follow-up).
*
* The card shows the active method, starts a dry run, shows what it found
* (how many days would move by more than 1 bpm, the largest move, the spread,
* and the same per account under an ordinal), and applies the new method once
* a dry run from the last seven days has finished. It polls only while a dry
* run is queued or running. The route enforces every rule the buttons mirror.
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { FlaskConical, HeartPulse, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { StatTile } from "@/components/admin/_stat-tile";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { SettingsCard } from "@/components/settings/settings-card";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
import { showErrorToast } from "@/lib/errors/error-toast";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import {
hasFreshDryRun,
type PulseDayMeanActionInput,
type PulseDayMeanStatus,
} from "@/lib/validations/pulse-day-mean";
const ENDPOINT = "/api/admin/maintenance/pulse-day-mean";
/** How often the card asks while a dry run is waiting or working. */
const POLL_MS = 10_000;
export function PulseDayMeanCard() {
const { t } = useTranslations();
const fmt = useFormatters();
const queryClient = useQueryClient();
const status = useQuery({
queryKey: queryKeys.adminPulseDayMean(),
queryFn: () =>
apiGet<PulseDayMeanStatus>(ENDPOINT, { credentials: "include" }),
refetchInterval: (query) => {
const state = query.state.data?.lastDryRun?.status;
return state === "queued" || state === "running" ? POLL_MS : false;
},
});
const run = useMutation({
mutationFn: async (
action: PulseDayMeanActionInput["action"],
): Promise<{ action: typeof action; enqueued: boolean }> => {
const res = await apiFetchRaw(ENDPOINT, {
method: "POST",
credentials: "include",
headers: { "content-type": "application/json" },
body: JSON.stringify({ action }),
});
if (!res.ok) {
let code: string | undefined;
try {
const body = (await res.json()) as { meta?: { errorCode?: string } };
code = body.meta?.errorCode;
} catch {
// No envelope: the generic message below.
}
// The route's two refusals each have their own message.
throw new Error(
code === "maintenance.pulseDayMean.busy"
? t("admin.section.pulseDayMean.error.busy")
: code === "maintenance.pulseDayMean.dryRunRequired"
? t("admin.section.pulseDayMean.error.dryRunRequired")
: t("admin.section.pulseDayMean.error.generic"),
);
}
const { enqueued } = (
(await res.json()) as { data: { enqueued: boolean } }
).data;
return { action, enqueued };
},
onSuccess: ({ action, enqueued }) => {
if (action === "apply") {
toast.success(
enqueued
? t("admin.section.pulseDayMean.applied")
: t("admin.section.pulseDayMean.appliedNoRebuild"),
);
} else {
toast.success(t("admin.section.pulseDayMean.dryRunStarted"));
}
void queryClient.invalidateQueries({
queryKey: queryKeys.adminPulseDayMean(),
});
},
onError: (err: Error) => {
showErrorToast(err.message, { cause: err });
},
});
if (status.isError) {
return (
<QueryErrorCard
title={t("admin.section.pulseDayMean.loadError")}
onRetry={() => void status.refetch()}
/>
);
}
const s = status.data;
const dryRun = s?.lastDryRun ?? null;
const result = dryRun?.status === "done" ? dryRun.result : null;
const active = dryRun?.status === "queued" || dryRun?.status === "running";
const weighted = s?.method === "weighted_hour_mean_v2";
const canApply = !!s && !active && hasFreshDryRun(s);
return (
<SettingsCard data-testid="admin-pulse-day-mean">
<SettingsCardHeader
icon={HeartPulse}
title={t("admin.section.pulseDayMean.title")}
description={t("admin.section.pulseDayMean.description")}
status={
s ? (
<Badge variant="secondary" data-slot="pulse-day-mean-method">
{weighted
? t("admin.section.pulseDayMean.method.weighted")
: t("admin.section.pulseDayMean.method.hourly")}
</Badge>
) : null
}
/>
<p className="text-sm">{t("admin.section.pulseDayMean.body")}</p>
<div
className="text-sm"
role="status"
aria-live="polite"
data-slot="pulse-day-mean-state"
data-state={status.isLoading ? "loading" : (dryRun?.status ?? "idle")}
>
{status.isLoading ? (
<span className="text-muted-foreground flex items-center gap-2">
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden
/>
{t("admin.section.pulseDayMean.loading")}
</span>
) : active ? (
<span className="flex items-center gap-2">
<Loader2
className="text-muted-foreground size-4 animate-spin motion-reduce:animate-none"
aria-hidden
/>
{dryRun?.status === "queued"
? t("admin.section.pulseDayMean.state.queued")
: t("admin.section.pulseDayMean.state.running")}
</span>
) : dryRun?.status === "done" ? (
<span>
{t("admin.section.pulseDayMean.state.done", {
at: fmt.dateTime(dryRun.finishedAt ?? dryRun.startedAt),
})}
</span>
) : dryRun?.status === "failed" ? (
<span className="text-destructive">
{t("admin.section.pulseDayMean.state.failed")}
</span>
) : (
<span className="text-muted-foreground">
{t("admin.section.pulseDayMean.state.idle")}
</span>
)}
</div>
{result ? (
<div className="space-y-3" data-slot="pulse-day-mean-result">
<div className="grid gap-3 sm:grid-cols-3">
<StatTile
label={t("admin.section.pulseDayMean.result.days")}
value={fmt.number(result.daysCompared)}
/>
<StatTile
label={t("admin.section.pulseDayMean.result.changed")}
value={fmt.number(result.daysChangedOver1Bpm)}
/>
<StatTile
label={t("admin.section.pulseDayMean.result.max")}
value={t("admin.section.pulseDayMean.result.maxValue", {
value: fmt.number(result.maxChangeBpm, 1),
})}
/>
</div>
<dl className="grid grid-cols-2 gap-x-3 gap-y-2 text-sm sm:grid-cols-4">
{result.distribution.map((d) => (
<div key={d.bucket}>
<dt className="text-muted-foreground text-xs">
{t("admin.section.pulseDayMean.result.bucket", {
range: d.bucket,
})}
</dt>
<dd className="tabular-nums">{fmt.number(d.days)}</dd>
</div>
))}
</dl>
{result.perAccount.length > 0 ? (
<ul className="space-y-1 text-sm">
{result.perAccount.map((a) => (
<li
key={a.account}
className="flex items-center justify-between gap-3"
>
<span>
{t("admin.section.pulseDayMean.result.account", {
account: a.account,
})}
</span>
<span className="text-muted-foreground tabular-nums">
{t("admin.section.pulseDayMean.result.accountDays", {
changed: fmt.number(a.changedOver1Bpm),
days: fmt.number(a.days),
})}
</span>
</li>
))}
</ul>
) : null}
</div>
) : null}
{s?.appliedAt ? (
<p className="text-muted-foreground text-xs">
{t("admin.section.pulseDayMean.appliedAt", {
at: fmt.dateTime(s.appliedAt),
})}
</p>
) : null}
<SettingsCardActions>
<Button
size="sm"
variant="outline"
className="min-h-11 sm:min-h-9"
disabled={!s || active || run.isPending}
onClick={() => run.mutate("dry_run")}
data-testid="admin-pulse-day-mean-dry-run"
>
<FlaskConical className="size-3.5" aria-hidden />
{t("admin.section.pulseDayMean.dryRun")}
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
size="sm"
className="min-h-11 sm:min-h-9"
disabled={!canApply || run.isPending}
data-testid="admin-pulse-day-mean-apply"
>
{run.isPending ? (
<Loader2
className="size-3.5 animate-spin motion-reduce:animate-none"
aria-hidden
/>
) : null}
{t("admin.section.pulseDayMean.apply")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("admin.section.pulseDayMean.confirmTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("admin.section.pulseDayMean.confirmBody")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("admin.section.pulseDayMean.cancel")}
</AlertDialogCancel>
<AlertDialogAction onClick={() => run.mutate("apply")}>
{t("admin.section.pulseDayMean.confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</SettingsCardActions>
</SettingsCard>
);
}
-14
View File
@@ -63,9 +63,6 @@ export interface PulseDayMeanPayload {
action: "dry_run" | "rebuild";
}
/** How old a finished dry run may be for the apply step to accept it. */
export const DRY_RUN_FRESH_MS = 7 * 24 * 60 * 60 * 1000;
const DAY_MS = 24 * 60 * 60 * 1000;
/** Days of one account's pulse a dry-run statement covers. */
const DRY_RUN_WINDOW_DAYS = 90;
@@ -131,17 +128,6 @@ export async function readPulseDayMeanStatus(): Promise<PulseDayMeanStatus> {
};
}
/** A finished dry run from the last seven days, the apply step's condition. */
export function hasFreshDryRun(
status: PulseDayMeanStatus,
now: Date = new Date(),
): boolean {
const run = status.lastDryRun;
if (!run || run.status !== "done" || run.finishedAt === null) return false;
const finished = Date.parse(run.finishedAt);
return Number.isFinite(finished) && now.getTime() - finished <= DRY_RUN_FRESH_MS;
}
/** The only write a dry run makes. */
export async function writeDryRun(run: PulseDayMeanDryRun): Promise<void> {
await prisma.appSettings.upsert({
+2
View File
@@ -53,6 +53,8 @@ export const adminKeys = {
adminEncryptionStatus: () => ["admin", "encryption", "status"] as const,
adminMeasurementMaintenance: () =>
["admin", "maintenance", "measurements"] as const,
/** v1.43 — the pulse day-mean method, its dry run and apply step. */
adminPulseDayMean: () => ["admin", "maintenance", "pulse-day-mean"] as const,
/** v1.40.0 — the "Back up your encryption key" step (admin + dashboard banner). */
adminEncryptionKeyBackup: () =>
["admin", "encryption", "key-backup"] as const,
+19
View File
@@ -56,3 +56,22 @@ export interface PulseDayMeanStatus {
appliedAt: string | null;
lastDryRun: PulseDayMeanDryRun | null;
}
/** How old a finished dry run may be for the apply step to accept it. */
const DRY_RUN_FRESH_MS = 7 * 24 * 60 * 60 * 1000;
/**
* A finished dry run from the last seven days: the apply step's condition,
* enforced by the route and mirrored by the admin card's button.
*/
export function hasFreshDryRun(
status: Pick<PulseDayMeanStatus, "lastDryRun">,
now: Date = new Date(),
): boolean {
const run = status.lastDryRun;
if (!run || run.status !== "done" || run.finishedAt === null) return false;
const finished = Date.parse(run.finishedAt);
return (
Number.isFinite(finished) && now.getTime() - finished <= DRY_RUN_FRESH_MS
);
}