// Pure decision logic for the uptime probe; no I/O so it tests without a network.
export const DOWN_THRESHOLD = 2;
export const BROKEN_THRESHOLD = 2;
export const STARTING_GRACE_MS = 3 * 60 * 1000;
export function initialState() {
return {
downStreak: 0,
downSince: null,
brokenStreak: 0,
brokenSince: null,
incident: null, // null | "down" | "broken"
version: null,
node: null,
versionChangedAt: null,
lastStatus: null,
lastCheckAt: null,
};
}
export function isHealthy(health) {
return !!health && health.status === 200 && health.ready === true;
}
// The timeline is only worth fetching once the version endpoint is healthy.
export function needsTimeline(health) {
return isHealthy(health);
}
// A 503 right after a version change is the new process still starting.
export function timelineBroken(timelineStatus, versionChangedAt, now) {
if (timelineStatus === null || timelineStatus === undefined) return false;
if (timelineStatus < 500) return false;
if (timelineStatus === 503 && versionChangedAt !== null && now - versionChangedAt < STARTING_GRACE_MS) {
return false;
}
return true;
}
// obs: {health: {status, ready, version, node, error}, timelineStatus: number|null}
// Returns {state, actions}; actions are {kind: "down"|"broken"|"recovered", since, outageMs?}.
export function step(prev, obs, now) {
const s = { ...prev };
const actions = [];
const h = obs.health || {};
s.lastCheckAt = now;
s.lastStatus = h.status ?? null;
if (!isHealthy(h)) {
s.downStreak = prev.downStreak + 1;
s.brokenStreak = 0;
if (s.downStreak === 1) s.downSince = now;
if (s.downStreak >= DOWN_THRESHOLD && prev.incident !== "down") {
s.incident = "down";
actions.push({ kind: "down", since: s.downSince });
}
return { state: s, actions };
}
if (h.version !== prev.version) {
s.versionChangedAt = prev.version === null ? null : now;
s.version = h.version ?? null;
}
s.node = h.node ?? prev.node;
const wasDown = prev.incident === "down";
const downSince = prev.downSince;
s.downStreak = 0;
s.downSince = null;
if (timelineBroken(obs.timelineStatus, s.versionChangedAt, now)) {
s.brokenStreak = prev.brokenStreak + 1;
if (s.brokenStreak === 1) s.brokenSince = now;
if (wasDown) {
// the host answers again but the forge is still broken: the outage is not over
s.incident = "broken";
s.brokenSince = now;
actions.push({ kind: "recovered", since: downSince, outageMs: now - downSince, from: "down" });
s.brokenStreak = 1;
} else if (s.brokenStreak >= BROKEN_THRESHOLD && prev.incident !== "broken") {
s.incident = "broken";
actions.push({ kind: "broken", since: s.brokenSince });
}
return { state: s, actions };
}
s.brokenStreak = 0;
if (wasDown) {
actions.push({ kind: "recovered", since: downSince, outageMs: now - downSince, from: "down" });
} else if (prev.incident === "broken") {
actions.push({ kind: "recovered", since: prev.brokenSince, outageMs: now - prev.brokenSince, from: "broken" });
}
if (wasDown || prev.incident === "broken") s.incident = null;
s.brokenSince = null;
return { state: s, actions };
}
export function formatDuration(ms) {
const total = Math.max(0, Math.round(ms / 1000));
const h = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
const sec = total % 60;
if (h > 0) return `${h}h ${m}m`;
if (m > 0) return `${m}m ${sec}s`;
return `${sec}s`;
}
export function composeMessage(action, state, forgeUrl) {
const since = new Date(action.since).toISOString();
let subject;
if (action.kind === "down") subject = `quarry forge DOWN since ${since}`;
else if (action.kind === "broken") subject = `quarry forge up but BROKEN since ${since}`;
else subject = `quarry forge recovered after ${formatDuration(action.outageMs)} (since ${since})`;
const body = [
subject,
"",
`last status: ${state.lastStatus === null ? "no response" : state.lastStatus}`,
`version: ${state.version ?? "unknown"}`,
`node: ${state.node ?? "unknown"}`,
`forge: ${forgeUrl}`,
"",
].join("\r\n");
return { subject, body };
}