diff --git a/fleetd/src/main/java/dev/ltms/fleet/mcp/FleetMcp.java b/fleetd/src/main/java/dev/ltms/fleet/mcp/FleetMcp.java
index d567411..c25083d 100644
--- a/fleetd/src/main/java/dev/ltms/fleet/mcp/FleetMcp.java
+++ b/fleetd/src/main/java/dev/ltms/fleet/mcp/FleetMcp.java
@@ -1015,6 +1015,25 @@ public final class FleetMcp {
* both maps at once when it is both exhaustion-quarantined AND cooling off.
*/
static McpSchema.CallToolResult profiles(PeerLauncher workers, QuarantineSource quarantine, OutageSource outage) {
+ return text(json(profilesView(workers, quarantine, outage)));
+ }
+
+ /**
+ * The body both front doors answer {@code profiles} with: the configured profile names, the
+ * default, and the two independent outage states — {@code quarantined} (the backend reported it
+ * out of capacity) and {@code coolingOff} (the credential threw repeated non-exhaustion backend
+ * errors). Each map is present only when at least one profile is in that state, and a profile
+ * can appear in both at once, because the two checks are separate.
+ *
+ *
fleetd #297: extracted so {@code fleet_profiles} and {@code GET /profiles} render from ONE
+ * body builder rather than two copies. Passing both doors the same {@link QuarantineSource} and
+ * {@link OutageSource} instances is necessary but not sufficient: with the loop written out
+ * twice, a later edit to the row shape — a renamed key, an added field — lands on one door and
+ * not the other, and the two then disagree about a live outage. That is exactly what fleetd
+ * #284 was, where one rule computed in two places was widened in only one and a single response
+ * contradicted itself. Shared inputs do not make duplicated computation safe.
+ */
+ public static Map profilesView(PeerLauncher workers, QuarantineSource quarantine, OutageSource outage) {
Map result = new LinkedHashMap<>();
result.put("profiles", workers.profiles());
result.put("default", workers.defaultProfile() == null ? "" : workers.defaultProfile());
@@ -1046,7 +1065,7 @@ public final class FleetMcp {
if (!coolingOff.isEmpty()) {
result.put("coolingOff", coolingOff);
}
- return text(json(result));
+ return result;
}
/**
diff --git a/fleetd/src/main/java/dev/ltms/fleet/rest/FleetApp.java b/fleetd/src/main/java/dev/ltms/fleet/rest/FleetApp.java
index 94c9420..56a36fc 100644
--- a/fleetd/src/main/java/dev/ltms/fleet/rest/FleetApp.java
+++ b/fleetd/src/main/java/dev/ltms/fleet/rest/FleetApp.java
@@ -427,38 +427,10 @@ public final class FleetApp {
if (!allow(ctx, routeAction("GET /profiles"), null)) {
return;
}
- Map body = new LinkedHashMap<>();
- body.put("profiles", workers.profiles());
- body.put("default", workers.defaultProfile() == null ? "" : workers.defaultProfile());
- Map quarantined = new LinkedHashMap<>();
- Map coolingOff = new LinkedHashMap<>();
- for (String profile : workers.profiles()) {
- String credentialId = quarantine.credentialIdFor().apply(profile);
- if (credentialId != null) {
- quarantine.quarantine().remainingSeconds(credentialId).ifPresent(remaining -> {
- Map row = new LinkedHashMap<>();
- row.put("credentialId", credentialId);
- row.put("quarantinedForSeconds", remaining);
- quarantined.put(profile, row);
- });
- }
- String outageCredentialId = outage.credentialIdFor().apply(profile);
- if (outageCredentialId != null) {
- outage.outagePolicy().remainingCoolOffSeconds(outageCredentialId).ifPresent(remaining -> {
- Map row = new LinkedHashMap<>();
- row.put("credentialId", outageCredentialId);
- row.put("coolingOffForSeconds", remaining);
- coolingOff.put(profile, row);
- });
- }
- }
- if (!quarantined.isEmpty()) {
- body.put("quarantined", quarantined);
- }
- if (!coolingOff.isEmpty()) {
- body.put("coolingOff", coolingOff);
- }
- ctx.status(200).json(body);
+ // fleetd #297: ONE body builder, shared with fleet_profiles. Handing both doors the same
+ // QuarantineSource/OutageSource instances stops them reading different facts; rendering
+ // through the same method stops them reporting those facts differently. Both are needed.
+ ctx.status(200).json(FleetMcp.profilesView(workers, quarantine, outage));
}
/**