Skip to content

Commit a9d6f5d

Browse files
feat(orb): add fleet-wide instance health aggregation
The existing readiness check (/health, /ready) is per-instance only and never leaves the instance -- there was no way for an operator centrally managing many self-hosted instances to see which ones are actually up. Threads readiness() (the exact same check /ready already answers with) into the hourly Orb telemetry export, riding the same request even in a tick with nothing new to export -- no second, parallel health-check mechanism. The central ingest handler accepts this as an optional health field, relaxing the "empty events" rejection only when a health signal accompanies it, and persists it to two new orb_instances columns (healthy, health_reported_at) via a COALESCE-based upsert: an outcome-only export from a build that hasn't upgraded yet never overwrites a previously-reported status with null, and never looks healthy just because the instance is otherwise active. A new getFleetHealthSummary() aggregates healthy/unhealthy/unknown counts across registered instances, with a staleness window (unresponsive reads as unknown, not stuck on its last-known state) -- surfaced on the operator dashboard as "Instance status", named distinctly from the existing "Fleet health" card (gate-calibration quality, an unrelated concept that happens to share the word "health"). Closes #4933
1 parent 2b15f9c commit a9d6f5d

11 files changed

Lines changed: 363 additions & 22 deletions

File tree

apps/loopover-ui/src/routes/app.operator.test.tsx

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,3 +170,61 @@ describe("OperatorDashboard storage by tenant (#4890)", () => {
170170
expect(screen.getByText("2 rows")).toBeTruthy();
171171
});
172172
});
173+
174+
describe("OperatorDashboard instance status (#4933)", () => {
175+
function mockDashboard(fleetHealth?: {
176+
healthyCount: number;
177+
unhealthyCount: number;
178+
unknownCount: number;
179+
totalCount: number;
180+
}) {
181+
useApiResource.mockImplementation((path: string) => {
182+
if (path === "/v1/app/operator-dashboard") {
183+
return {
184+
status: "ready",
185+
data: {
186+
metrics: [{ label: "Installs", value: "12", delta: "+2" }],
187+
noiseReduction: [],
188+
weeklyReport: [],
189+
fleetHealth,
190+
},
191+
error: null,
192+
loadedAt: "2026-07-17T00:00:00.000Z",
193+
reload: () => {},
194+
};
195+
}
196+
return {
197+
status: "error",
198+
data: null,
199+
error: "unavailable in this test",
200+
errorKind: "unknown",
201+
loadedAt: null,
202+
reload: () => {},
203+
};
204+
});
205+
}
206+
207+
it("renders no section at all when fleetHealth is absent (self-host, the common case)", () => {
208+
mockDashboard(undefined);
209+
render(<OperatorDashboard />);
210+
expect(screen.queryByText("Instance status")).toBeNull();
211+
});
212+
213+
it("renders no section when fleetHealth.totalCount is 0", () => {
214+
mockDashboard({ healthyCount: 0, unhealthyCount: 0, unknownCount: 0, totalCount: 0 });
215+
render(<OperatorDashboard />);
216+
expect(screen.queryByText("Instance status")).toBeNull();
217+
});
218+
219+
it("renders the healthy/unhealthy/unknown counts, distinct from the gate-calibration Fleet health card", () => {
220+
mockDashboard({ healthyCount: 3, unhealthyCount: 1, unknownCount: 2, totalCount: 6 });
221+
render(<OperatorDashboard />);
222+
expect(screen.getByText("Instance status")).toBeTruthy();
223+
expect(screen.getByText("Healthy")).toBeTruthy();
224+
expect(screen.getByText("3")).toBeTruthy();
225+
expect(screen.getByText("Unhealthy")).toBeTruthy();
226+
expect(screen.getByText("1")).toBeTruthy();
227+
expect(screen.getByText("Unknown")).toBeTruthy();
228+
expect(screen.getByText("2")).toBeTruthy();
229+
});
230+
});

apps/loopover-ui/src/routes/app.operator.tsx

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,12 @@ type OperatorDashboardResponse = {
3636
upstreamDrift?: { status?: string } | null;
3737
aiCostByTenant?: Array<{ installationId: string; totalCostUsd: number }>;
3838
storageRowCountByTenant?: Array<{ installationId: string; rowCount: number }>;
39+
fleetHealth?: {
40+
healthyCount: number;
41+
unhealthyCount: number;
42+
unknownCount: number;
43+
totalCount: number;
44+
};
3945
};
4046

4147
type FleetMetrics = {
@@ -254,6 +260,36 @@ export function OperatorDashboard() {
254260
</section>
255261
) : null}
256262

263+
{data.fleetHealth && data.fleetHealth.totalCount > 0 ? (
264+
<section className="rounded-token border border-border bg-transparent p-5">
265+
<div className="flex items-center gap-2">
266+
<BarChart3 className="size-4 text-mint" />
267+
<h2 className="font-display text-token-lg font-semibold">Instance status</h2>
268+
</div>
269+
<p className="mt-1 max-w-2xl text-token-xs text-muted-foreground">
270+
Fleet instance readiness across {data.fleetHealth.totalCount} registered self-hosted
271+
instance(s) — separate from the gate-calibration numbers above.
272+
</p>
273+
<div className="mt-4 grid gap-3 sm:grid-cols-3">
274+
<Stat
275+
label="Healthy"
276+
value={String(data.fleetHealth.healthyCount)}
277+
hint="reported ready"
278+
/>
279+
<Stat
280+
label="Unhealthy"
281+
value={String(data.fleetHealth.unhealthyCount)}
282+
hint="reported not ready"
283+
/>
284+
<Stat
285+
label="Unknown"
286+
value={String(data.fleetHealth.unknownCount)}
287+
hint="no recent report"
288+
/>
289+
</div>
290+
</section>
291+
) : null}
292+
257293
{quality ? (
258294
<section className="rounded-token border border-border bg-transparent p-5">
259295
<div className="flex flex-wrap items-start justify-between gap-3">
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
-- #4933: fleet-wide instance health aggregation. Distinct from orb_instances.last_seen_at (bumped on EVERY
2+
-- ingest call, including outcome-only exports from an older self-host build that doesn't send a health
3+
-- signal at all): healthy/health_reported_at are only set when a payload actually carries one, so an
4+
-- instance that hasn't upgraded yet stays NULL (unknown) rather than silently reading as healthy.
5+
ALTER TABLE orb_instances ADD COLUMN healthy INTEGER;
6+
ALTER TABLE orb_instances ADD COLUMN health_reported_at TEXT;

src/orb/analytics.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,3 +248,41 @@ export async function computeFleetAnalytics(env: Env, opts: { windowDays?: numbe
248248
gamingPatternFlags,
249249
};
250250
}
251+
252+
/** #4933: fleet-wide instance READINESS, not gate-calibration quality -- deliberately separate from (and
253+
* named differently on the dashboard than) the "Fleet health" gate-precision card above, which this is
254+
* often confused with despite measuring something unrelated. */
255+
export interface FleetHealthSummary {
256+
healthyCount: number;
257+
unhealthyCount: number;
258+
// Never reported a health status, or its last report is older than HEALTH_STALE_HOURS -- an
259+
// unresponsive instance must read as "don't know," not silently keep counting as its last-known state.
260+
unknownCount: number;
261+
totalCount: number; // registered instances only, matching computeFleetAnalytics's own trust gate
262+
}
263+
264+
// A bit over 2x the hourly export cron (server.ts's runOrbExport), so one missed tick doesn't immediately
265+
// flip an instance to "unknown."
266+
export const HEALTH_STALE_HOURS = 3;
267+
268+
export async function getFleetHealthSummary(env: Env, now: Date = new Date()): Promise<FleetHealthSummary> {
269+
const staleBefore = new Date(now.getTime() - HEALTH_STALE_HOURS * 60 * 60 * 1000).toISOString();
270+
try {
271+
const row = await env.DB.prepare(
272+
`SELECT
273+
SUM(CASE WHEN healthy = 1 AND health_reported_at IS NOT NULL AND health_reported_at > ? THEN 1 ELSE 0 END) AS healthy_count,
274+
SUM(CASE WHEN healthy = 0 AND health_reported_at IS NOT NULL AND health_reported_at > ? THEN 1 ELSE 0 END) AS unhealthy_count,
275+
COUNT(*) AS total_count
276+
FROM orb_instances
277+
WHERE registered = 1`,
278+
)
279+
.bind(staleBefore, staleBefore)
280+
.first<{ healthy_count: number | null; unhealthy_count: number | null; total_count: number }>();
281+
const healthyCount = Number(row?.healthy_count ?? 0);
282+
const unhealthyCount = Number(row?.unhealthy_count ?? 0);
283+
const totalCount = Number(row?.total_count ?? 0);
284+
return { healthyCount, unhealthyCount, unknownCount: totalCount - healthyCount - unhealthyCount, totalCount };
285+
} catch {
286+
return { healthyCount: 0, unhealthyCount: 0, unknownCount: 0, totalCount: 0 };
287+
}
288+
}

src/orb/ingest.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,9 @@ interface OrbIngestEvent {
6363
interface OrbIngestPayload {
6464
instance_id: string;
6565
events: OrbIngestEvent[];
66+
// #4933: optional -- an older self-host build that hasn't upgraded yet simply omits this, and the
67+
// instance's stored health stays whatever it last was (or NULL/unknown on first contact).
68+
health?: { ok: boolean };
6669
}
6770

6871
export type OrbIngestResult = { accepted: number } | { error: string };
@@ -89,19 +92,35 @@ export async function handleOrbIngest(body: string, db: D1Database): Promise<Orb
8992
return { error: "invalid_payload" };
9093
}
9194

92-
const { instance_id, events } = payload as OrbIngestPayload;
93-
if (!instance_id || instance_id.length > MAX_INSTANCE_ID_CHARS || events.length === 0) {
95+
const { instance_id, events, health } = payload as OrbIngestPayload;
96+
// #4933: an empty batch is only valid when it's carrying a health-only ping (the hourly export still
97+
// has to report health even in a tick with nothing new to export) -- a truly empty, health-less payload
98+
// stays rejected exactly as before.
99+
const healthy = typeof health === "object" && health !== null && typeof health.ok === "boolean" ? (health.ok ? 1 : 0) : null;
100+
if (!instance_id || instance_id.length > MAX_INSTANCE_ID_CHARS || (events.length === 0 && healthy === null)) {
94101
return { error: "invalid_payload" };
95102
}
103+
const healthReportedAt = healthy === null ? null : new Date().toISOString();
96104

97105
// Record the instance on first contact (registered=0 by default) and bump last_seen. The registration
98106
// gate lives in computeFleetAnalytics: signals are stored for everyone, but only registered instances
99107
// count toward the fleet median — so open ingest can't be used to skew calibration (the das-github-mirror
100108
// model: every source is seen, trusted only once an operator opts it in).
109+
//
110+
// healthy/health_reported_at only move when THIS payload actually reported a health status (COALESCE
111+
// falls back to whatever was already stored) -- an outcome-only ingest from a self-host build that
112+
// hasn't upgraded to send health yet must never silently overwrite a real prior health reading with
113+
// NULL, and must never look "healthy" just because the instance is otherwise active.
101114
try {
102115
await db
103-
.prepare(`INSERT INTO orb_instances (instance_id) VALUES (?) ON CONFLICT(instance_id) DO UPDATE SET last_seen_at = CURRENT_TIMESTAMP`)
104-
.bind(instance_id)
116+
.prepare(
117+
`INSERT INTO orb_instances (instance_id, healthy, health_reported_at) VALUES (?, ?, ?)
118+
ON CONFLICT(instance_id) DO UPDATE SET
119+
last_seen_at = CURRENT_TIMESTAMP,
120+
healthy = COALESCE(excluded.healthy, orb_instances.healthy),
121+
health_reported_at = COALESCE(excluded.health_reported_at, orb_instances.health_reported_at)`,
122+
)
123+
.bind(instance_id, healthy, healthReportedAt)
105124
.run();
106125
} catch {
107126
// best-effort: never fail ingest because the instance bookkeeping hiccupped

src/selfhost/orb-collector.ts

Lines changed: 26 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ interface FleetEvent {
5151
interface OrbExportPayload {
5252
instance_id: string;
5353
events: FleetEvent[];
54+
health?: { ok: boolean };
5455
}
5556

5657
/** Stable instance identifier (hash of the Orb/App ID — no PII). A brokered instance holds no App id, so its
@@ -154,11 +155,14 @@ export function cycleTimeMs(decidedAt: string, outcomeAt: string): number | null
154155
}
155156

156157
/**
157-
* Export newly-resolved PR outcomes (since this instance's watermark) to the central collector. Reads from
158-
* review_audit (de-noised, reversal-aware), anonymizes, signs, POSTs, then advances the cursor.
159-
* Returns the number of events exported (0 if air-gapped, the App isn't configured, or nothing new).
158+
* Export newly-resolved PR outcomes (since this instance's watermark) to the central collector, and --
159+
* when `healthOk` is supplied (#4933, threaded from server.ts's own readiness() result) -- a health ping
160+
* riding the same request, even in a tick with nothing new to export. Reads from review_audit (de-noised,
161+
* reversal-aware), anonymizes, signs, POSTs, then advances the cursor.
162+
* Returns the number of events exported (0 if air-gapped, the App isn't configured, or nothing new to
163+
* export -- a health-only ping with zero events still returns 0, matching "nothing new" today).
160164
*/
161-
export async function exportOrbBatch(db: D1Database, batchSize = 200, fetchFn: typeof fetch = fetch): Promise<number> {
165+
export async function exportOrbBatch(db: D1Database, batchSize = 200, fetchFn: typeof fetch = fetch, healthOk?: boolean): Promise<number> {
162166
// Air-gapped/offline deployments explicitly suppress every outbound telemetry call, including brokered mode.
163167
if ((process.env.ORB_AIR_GAP ?? "").toLowerCase() === "true") return 0;
164168

@@ -185,11 +189,13 @@ export async function exportOrbBatch(db: D1Database, batchSize = 200, fetchFn: t
185189
const cursorTargetId = cursorRow?.last_exported_target_id ?? "";
186190

187191
const { results } = await db.prepare(FLEET_QUERY).bind(cursorAt, cursorAt, cursorTargetId, batchSize).all<FleetRow>();
188-
if (!results || results.length === 0) return 0;
192+
// A health-only ping (healthOk !== undefined) still has something to send even with zero new events;
193+
// otherwise, exactly as before, nothing new means nothing to do.
194+
if ((!results || results.length === 0) && healthOk === undefined) return 0;
189195

190196
const payload: OrbExportPayload = {
191197
instance_id: instance,
192-
events: results.map((r) => ({
198+
events: (results ?? []).map((r) => ({
193199
repo_hash: anonymize ? hmacAnonymize(r.project, secret) : r.project,
194200
pr_hash: anonymize ? hmacAnonymize(r.target_id, secret) : r.target_id,
195201
gate_verdict: r.verdict,
@@ -200,6 +206,7 @@ export async function exportOrbBatch(db: D1Database, batchSize = 200, fetchFn: t
200206
decision_timestamp: r.decided_at,
201207
outcome_timestamp: r.outcome_at,
202208
})),
209+
...(healthOk !== undefined ? { health: { ok: healthOk } } : {}),
203210
};
204211

205212
const body = JSON.stringify(payload);
@@ -235,14 +242,18 @@ export async function exportOrbBatch(db: D1Database, batchSize = 200, fetchFn: t
235242
}
236243

237244
// Advance the watermark to the newest event in this batch (rows are ordered by event_at, target_id).
238-
const lastRow = results[results.length - 1]!;
239-
await db
240-
.prepare(
241-
`INSERT OR REPLACE INTO orb_export_cursor (instance_hash, last_exported_at, last_exported_target_id, updated_at) VALUES (?, ?, ?, ?)`,
242-
)
243-
.bind(instance, lastRow.event_at, lastRow.target_id, new Date().toISOString())
244-
.run();
245+
// A health-only ping (no results) has no watermark to advance -- nothing new was read or sent as events.
246+
if (results && results.length > 0) {
247+
const lastRow = results[results.length - 1]!;
248+
await db
249+
.prepare(
250+
`INSERT OR REPLACE INTO orb_export_cursor (instance_hash, last_exported_at, last_exported_target_id, updated_at) VALUES (?, ?, ?, ?)`,
251+
)
252+
.bind(instance, lastRow.event_at, lastRow.target_id, new Date().toISOString())
253+
.run();
254+
}
245255

246-
incr("loopover_orb_events_exported_total", {}, results.length);
247-
return results.length;
256+
const exportedCount = results?.length ?? 0;
257+
if (exportedCount > 0) incr("loopover_orb_events_exported_total", {}, exportedCount);
258+
return exportedCount;
248259
}

src/server.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1115,9 +1115,17 @@ async function main(): Promise<void> {
11151115

11161116
// Orb fleet-telemetry export — ALWAYS ON (the fleet-calibration contract of self-hosting). Self-gates
11171117
// inside exportOrbBatch: a no-op until the GitHub App is configured, or when ORB_AIR_GAP=true.
1118+
//
1119+
// #4933: rides the SAME readiness() this instance's own /ready endpoint uses (readinessProbes, built
1120+
// above) rather than inventing a second, parallel health check -- so "healthy" reported to the fleet
1121+
// always means exactly what /ready already means locally. A readiness() failure here degrades to "no
1122+
// health signal this tick" (healthOk stays undefined) rather than reporting a wrong status.
11181123
/* v8 ignore start -- self-host entrypoint timers start a live server; monitor semantics are covered in selfhost tests. */
11191124
const runOrbExport = () =>
1120-
runOrbExportWithMonitor(() => exportOrbBatch(backend.db)).catch((error) =>
1125+
runOrbExportWithMonitor(async () => {
1126+
const health = await readiness(backend.db, readinessProbes).catch(() => null);
1127+
return exportOrbBatch(backend.db, undefined, undefined, health?.ok);
1128+
}).catch((error) =>
11211129
console.error(
11221130
JSON.stringify({
11231131
level: "error",

src/services/operator-dashboard.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ import type {
3030
ScoringModelSnapshotRecord,
3131
WeeklyValueReport,
3232
} from "../types";
33-
import { computeFleetAnalytics, type FleetAnalytics } from "../orb/analytics";
33+
import { computeFleetAnalytics, getFleetHealthSummary, type FleetAnalytics, type FleetHealthSummary } from "../orb/analytics";
3434
import { computeAgentHealth, computeCalibration, type AgentHealth, type Calibration } from "../review/ops";
3535
import { computeGateEval, type GateEvalReport } from "../review/parity";
3636
import { computeCycleTimeAggregate, computeFindingAcceptance, type CycleTimeAggregate } from "../review/stats";
@@ -102,6 +102,9 @@ export type OperatorDashboardPayload = {
102102
// D1 storage cap already has alerting (src/selfhost/d1-size-probe.ts); this is the per-installation dimension
103103
// that alerting doesn't have. Same self-host-always-empty caveat as aiCostByTenant above.
104104
storageRowCountByTenant: RowCountByTenant[];
105+
// #4933: fleet-wide instance READINESS (up/down/unknown), distinct from fleetMetrics above (gate-calibration
106+
// quality). Always all-zero for a self-host operator (no registered peer instances).
107+
fleetHealth: FleetHealthSummary;
105108
};
106109

107110
const USAGE_WINDOW_DAYS = 7;
@@ -140,6 +143,7 @@ export async function buildOperatorDashboardPayload(
140143
findingAcceptance,
141144
aiCostByTenant,
142145
storageRowCountByTenant,
146+
fleetHealth,
143147
] = await Promise.all([
144148
listRepositories(env),
145149
listInstallations(env),
@@ -171,6 +175,8 @@ export async function buildOperatorDashboardPayload(
171175
listAiCostByTenantSince(env, usageSince),
172176
// #4890 (re-scoped): per-tenant row-count breakdown, same window as the rest of the usage metrics above.
173177
listRowCountByTenantSince(env, usageSince),
178+
// #4933: fleet-wide instance readiness -- a point-in-time summary, no window needed.
179+
getFleetHealthSummary(env),
174180
]);
175181
const weeklyValueReport = buildWeeklyValueReport({
176182
generatedAt: nowIso(),
@@ -291,6 +297,7 @@ export async function buildOperatorDashboardPayload(
291297
acceptance,
292298
aiCostByTenant,
293299
storageRowCountByTenant,
300+
fleetHealth,
294301
};
295302
}
296303

0 commit comments

Comments
 (0)