diff --git a/docs/superpowers/baselines/2026-04-17/BASELINE.md b/docs/superpowers/baselines/2026-04-17/BASELINE.md index 1d0eccfc..8cb4b8b9 100644 --- a/docs/superpowers/baselines/2026-04-17/BASELINE.md +++ b/docs/superpowers/baselines/2026-04-17/BASELINE.md @@ -237,6 +237,7 @@ Ordered by severity. Each item cites the raw artifact it was derived from. Follow-up split out below. - **`GraphHealthIndicator` reports `OUT_OF_SERVICE` (503) even when the graph is loaded.** Discovered during the pipeline smoke-test fix. `/actuator/health` body: `{"groups":["liveness","readiness"],"status":"OUT_OF_SERVICE"}`. The server is fully functional (`/api/stats` returns real data) but the health indicator makes `/actuator/health` unusable as a readiness probe for orchestrators (K8s, Compose, CI). Fix in `src/main/java/io/github/randomcodespace/iq/health/GraphHealthIndicator.java`. Low for baseline use; High when we start Dockerizing or targeting K8s. + - **RESOLVED (2026-04-17, branch `phase-a/fix-graph-health`)**: Root cause was *not* in `GraphHealthIndicator` (which correctly returns UP when nodes>0). It was in `ServeCommand`: the CLI blocks on `Thread.currentThread().join()` inside Spring Boot's `CommandLineRunner.run()`, which prevents `ApplicationReadyEvent` from ever firing. Without that event, Spring's default readiness publisher never flips `ReadinessState` from `REFUSING_TRAFFIC` (503 `OUT_OF_SERVICE`) to `ACCEPTING_TRAFFIC` (200 `UP`). Fix: `ServeCommand` now explicitly publishes `AvailabilityChangeEvent` for `LivenessState.CORRECT` + `ReadinessState.ACCEPTING_TRAFFIC` before blocking, via a new `markReady()` method (unit-tested). Verified end-to-end: `health_http` is now 200 on both seeds (petclinic ready 13s, express ready 14s; status "UP"). Follow-up filed: `GraphBootstrapper`'s `@EventListener(ApplicationReadyEvent.class)` is effectively dead code for the same reason — only noticed because enrich always runs before serve in our pipeline, so the bootstrap fallback never actually needs to fire. - **SpotBugs: 8 HIGH-priority findings (priority=1) + 1,484 at priority=2.** Total 1,492. HIGH findings must be triaged individually (read `raw/spotbugs.xml`). Noise-dominant rules (`NM_METHOD_NAMING_CONVENTION`=730, `SF_SWITCH_NO_DEFAULT`=448) should be filtered via a SpotBugs exclude file so real signal surfaces; real-concern patterns that deserve review now: `NP_NULL_ON_SOME_PATH_FROM_RETURN_VALUE` (26), `BC_UNCONFIRMED_CAST` (55), `UL_UNRELEASED_LOCK_EXCEPTION_PATH` (1), `WMI_WRONG_MAP_ITERATOR` (2), `ES_COMPARING_STRINGS_WITH_EQ` (2), `MT_CORRECTNESS` category (1). - Raw: `raw/spotbugs.xml`, `raw/spotbugs-summary.json`. diff --git a/src/main/java/io/github/randomcodespace/iq/cli/ServeCommand.java b/src/main/java/io/github/randomcodespace/iq/cli/ServeCommand.java index f64f35fc..39bf4526 100644 --- a/src/main/java/io/github/randomcodespace/iq/cli/ServeCommand.java +++ b/src/main/java/io/github/randomcodespace/iq/cli/ServeCommand.java @@ -5,6 +5,10 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.availability.AvailabilityChangeEvent; +import org.springframework.boot.availability.LivenessState; +import org.springframework.boot.availability.ReadinessState; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Component; import picocli.CommandLine.Command; import picocli.CommandLine.Option; @@ -56,6 +60,9 @@ public class ServeCommand implements Callable { @Autowired(required = false) private GraphStore graphStore; + @Autowired + private ApplicationEventPublisher events; + @Override public Integer call() { Path root = path.toAbsolutePath().normalize(); @@ -96,6 +103,16 @@ public Integer call() { System.out.println(); CliOutput.info("Press Ctrl+C to stop."); + // Publish availability transitions so /actuator/health reports UP (200). + // This Callable is invoked from a CommandLineRunner that blocks forever + // (the Thread.join below), so Spring's ApplicationReadyEvent — which + // normally drives ReadinessState to ACCEPTING_TRAFFIC — never fires. + // Without this, /actuator/health stays OUT_OF_SERVICE (503) even though + // the server is accepting and serving traffic. See also: known gap on + // GraphBootstrapper's @EventListener(ApplicationReadyEvent.class) which + // is dead for the same reason — out of scope for this fix. + markReady(); + try { Thread.currentThread().join(); } catch (InterruptedException e) { @@ -105,6 +122,15 @@ public Integer call() { return 0; } + /** + * Flip availability state to live + accepting traffic. Extracted for + * testability — callers can verify the right events are published. + */ + void markReady() { + AvailabilityChangeEvent.publish(events, this, LivenessState.CORRECT); + AvailabilityChangeEvent.publish(events, this, ReadinessState.ACCEPTING_TRAFFIC); + } + public Path getPath() { return path; } public int getPort() { return port; } public String getHost() { return host; } diff --git a/src/test/java/io/github/randomcodespace/iq/cli/ServeCommandTest.java b/src/test/java/io/github/randomcodespace/iq/cli/ServeCommandTest.java index d8d5f800..c4858adc 100644 --- a/src/test/java/io/github/randomcodespace/iq/cli/ServeCommandTest.java +++ b/src/test/java/io/github/randomcodespace/iq/cli/ServeCommandTest.java @@ -1,12 +1,21 @@ package io.github.randomcodespace.iq.cli; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; +import org.springframework.boot.availability.AvailabilityChangeEvent; +import org.springframework.boot.availability.LivenessState; +import org.springframework.boot.availability.ReadinessState; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.test.util.ReflectionTestUtils; import picocli.CommandLine; import java.nio.file.Path; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; class ServeCommandTest { @@ -76,4 +85,25 @@ void pathNotSwallowedWhenNoUiPrecedesPath() { assertEquals(true, cmd.isNoUi()); assertEquals(Path.of("/some/repo"), cmd.getPath()); } + + @Test + void markReadyPublishesLivenessThenReadiness() { + // Regression guard for /actuator/health returning 503 OUT_OF_SERVICE: + // serve's CommandLineRunner blocks forever, so Spring never fires + // ApplicationReadyEvent and readiness stays REFUSING_TRAFFIC. + // ServeCommand must publish LivenessState.CORRECT + ReadinessState.ACCEPTING_TRAFFIC + // before blocking so /actuator/health reports UP (200). + var cmd = new ServeCommand(); + var mockEvents = Mockito.mock(ApplicationEventPublisher.class); + ReflectionTestUtils.setField(cmd, "events", mockEvents); + + cmd.markReady(); + + var captor = ArgumentCaptor.forClass(AvailabilityChangeEvent.class); + verify(mockEvents, times(2)).publishEvent(captor.capture()); + var published = captor.getAllValues(); + // Order matters: liveness first (process is alive), then readiness (serving traffic). + assertEquals(LivenessState.CORRECT, published.get(0).getState()); + assertEquals(ReadinessState.ACCEPTING_TRAFFIC, published.get(1).getState()); + } }