diff --git a/src/infra/heartbeat-reason.test.ts b/src/infra/heartbeat-reason.test.ts new file mode 100644 index 0000000000..6c2fdc68f9 --- /dev/null +++ b/src/infra/heartbeat-reason.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { + isHeartbeatActionWakeReason, + isHeartbeatEventDrivenReason, + normalizeHeartbeatWakeReason, + resolveHeartbeatReasonKind, +} from "./heartbeat-reason.js"; + +describe("heartbeat-reason", () => { + it("normalizes wake reasons with trim + requested fallback", () => { + expect(normalizeHeartbeatWakeReason(" cron:job-1 ")).toBe("cron:job-1"); + expect(normalizeHeartbeatWakeReason(" ")).toBe("requested"); + expect(normalizeHeartbeatWakeReason(undefined)).toBe("requested"); + }); + + it("classifies known reason kinds", () => { + expect(resolveHeartbeatReasonKind("retry")).toBe("retry"); + expect(resolveHeartbeatReasonKind("interval")).toBe("interval"); + expect(resolveHeartbeatReasonKind("manual")).toBe("manual"); + expect(resolveHeartbeatReasonKind("exec-event")).toBe("exec-event"); + expect(resolveHeartbeatReasonKind("wake")).toBe("wake"); + expect(resolveHeartbeatReasonKind("cron:job-1")).toBe("cron"); + expect(resolveHeartbeatReasonKind("hook:wake")).toBe("hook"); + expect(resolveHeartbeatReasonKind(" hook:wake ")).toBe("hook"); + }); + + it("classifies unknown reasons as other", () => { + expect(resolveHeartbeatReasonKind("requested")).toBe("other"); + expect(resolveHeartbeatReasonKind("slow")).toBe("other"); + expect(resolveHeartbeatReasonKind("")).toBe("other"); + expect(resolveHeartbeatReasonKind(undefined)).toBe("other"); + }); + + it("matches event-driven behavior used by heartbeat preflight", () => { + expect(isHeartbeatEventDrivenReason("exec-event")).toBe(true); + expect(isHeartbeatEventDrivenReason("cron:job-1")).toBe(true); + expect(isHeartbeatEventDrivenReason("wake")).toBe(true); + expect(isHeartbeatEventDrivenReason("hook:gmail:sync")).toBe(true); + expect(isHeartbeatEventDrivenReason("interval")).toBe(false); + expect(isHeartbeatEventDrivenReason("manual")).toBe(false); + expect(isHeartbeatEventDrivenReason("other")).toBe(false); + }); + + it("matches action-priority wake behavior", () => { + expect(isHeartbeatActionWakeReason("manual")).toBe(true); + expect(isHeartbeatActionWakeReason("exec-event")).toBe(true); + expect(isHeartbeatActionWakeReason("hook:wake")).toBe(true); + expect(isHeartbeatActionWakeReason("interval")).toBe(false); + expect(isHeartbeatActionWakeReason("cron:job-1")).toBe(false); + expect(isHeartbeatActionWakeReason("retry")).toBe(false); + }); +}); diff --git a/src/infra/heartbeat-reason.ts b/src/infra/heartbeat-reason.ts new file mode 100644 index 0000000000..968b1e2406 --- /dev/null +++ b/src/infra/heartbeat-reason.ts @@ -0,0 +1,54 @@ +export type HeartbeatReasonKind = + | "retry" + | "interval" + | "manual" + | "exec-event" + | "wake" + | "cron" + | "hook" + | "other"; + +function trimReason(reason?: string): string { + return typeof reason === "string" ? reason.trim() : ""; +} + +export function normalizeHeartbeatWakeReason(reason?: string): string { + const trimmed = trimReason(reason); + return trimmed.length > 0 ? trimmed : "requested"; +} + +export function resolveHeartbeatReasonKind(reason?: string): HeartbeatReasonKind { + const trimmed = trimReason(reason); + if (trimmed === "retry") { + return "retry"; + } + if (trimmed === "interval") { + return "interval"; + } + if (trimmed === "manual") { + return "manual"; + } + if (trimmed === "exec-event") { + return "exec-event"; + } + if (trimmed === "wake") { + return "wake"; + } + if (trimmed.startsWith("cron:")) { + return "cron"; + } + if (trimmed.startsWith("hook:")) { + return "hook"; + } + return "other"; +} + +export function isHeartbeatEventDrivenReason(reason?: string): boolean { + const kind = resolveHeartbeatReasonKind(reason); + return kind === "exec-event" || kind === "cron" || kind === "wake" || kind === "hook"; +} + +export function isHeartbeatActionWakeReason(reason?: string): boolean { + const kind = resolveHeartbeatReasonKind(reason); + return kind === "manual" || kind === "exec-event" || kind === "hook"; +} diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index f325a3729a..70ee5e34e1 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -49,6 +49,7 @@ import { isExecCompletionEvent, } from "./heartbeat-events-filter.js"; import { emitHeartbeatEvent, resolveIndicatorType } from "./heartbeat-events.js"; +import { resolveHeartbeatReasonKind } from "./heartbeat-reason.js"; import { resolveHeartbeatVisibility } from "./heartbeat-visibility.js"; import { type HeartbeatRunResult, @@ -491,10 +492,11 @@ type HeartbeatPreflight = HeartbeatReasonFlags & { }; function resolveHeartbeatReasonFlags(reason?: string): HeartbeatReasonFlags { + const reasonKind = resolveHeartbeatReasonKind(reason); return { - isExecEventReason: reason === "exec-event", - isCronEventReason: Boolean(reason?.startsWith("cron:")), - isWakeReason: reason === "wake" || Boolean(reason?.startsWith("hook:")), + isExecEventReason: reasonKind === "exec-event", + isCronEventReason: reasonKind === "cron", + isWakeReason: reasonKind === "wake" || reasonKind === "hook", }; } diff --git a/src/infra/heartbeat-wake.ts b/src/infra/heartbeat-wake.ts index d1dcfb0395..bccfdfe982 100644 --- a/src/infra/heartbeat-wake.ts +++ b/src/infra/heartbeat-wake.ts @@ -1,3 +1,9 @@ +import { + isHeartbeatActionWakeReason, + normalizeHeartbeatWakeReason, + resolveHeartbeatReasonKind, +} from "./heartbeat-reason.js"; + export type HeartbeatRunResult = | { status: "ran"; durationMs: number } | { status: "skipped"; reason: string } @@ -29,7 +35,6 @@ let timerKind: WakeTimerKind | null = null; const DEFAULT_COALESCE_MS = 250; const DEFAULT_RETRY_MS = 1_000; -const HOOK_REASON_PREFIX = "hook:"; const REASON_PRIORITY = { RETRY: 0, INTERVAL: 1, @@ -37,29 +42,22 @@ const REASON_PRIORITY = { ACTION: 3, } as const; -function isActionWakeReason(reason: string): boolean { - return reason === "manual" || reason === "exec-event" || reason.startsWith(HOOK_REASON_PREFIX); -} - function resolveReasonPriority(reason: string): number { - if (reason === "retry") { + const kind = resolveHeartbeatReasonKind(reason); + if (kind === "retry") { return REASON_PRIORITY.RETRY; } - if (reason === "interval") { + if (kind === "interval") { return REASON_PRIORITY.INTERVAL; } - if (isActionWakeReason(reason)) { + if (isHeartbeatActionWakeReason(reason)) { return REASON_PRIORITY.ACTION; } return REASON_PRIORITY.DEFAULT; } function normalizeWakeReason(reason?: string): string { - if (typeof reason !== "string") { - return "requested"; - } - const trimmed = reason.trim(); - return trimmed.length > 0 ? trimmed : "requested"; + return normalizeHeartbeatWakeReason(reason); } function normalizeWakeTarget(value?: string): string | undefined {