Files
grimoire/src/lib/req-state-machine.ts
Claude 46979e11e9 Fix EOSE tracking to prevent state regression after live events
The bug: When events arrived after EOSE was received (live streaming),
the subscription state was incorrectly reset from "eose" back to
"receiving", causing:
- False "LOADING" overall state
- Relay UI showing "RECEIVING" spinner when EOSE was already received
- Incorrect eoseCount in state machine

The fix: Add a new "live" subscription state that represents
"EOSE received AND actively receiving live events". State transitions:
- waiting → receiving (on first historical event)
- receiving → eose (on EOSE signal)
- eose → live (on live event after EOSE)

Changes:
- Add "live" to RelaySubscriptionState type with proper documentation
- Add liveCount to ReqOverallState for accurate counting
- Update event handler to set "live" state when eoseAt exists
- Update EOSE completion check to include "live" state
- Update getRelayStateBadge: "live" shows green, "receiving" shows yellow
- Update ReqViewer: show pulsing radio icon for live relays
- Update tests for new state and color changes

https://claude.ai/code/session_01DVTWqKNY4UHVSDDxckjkAh
2026-02-02 13:06:32 +00:00

279 lines
8.4 KiB
TypeScript

import type {
ReqRelayState,
ReqOverallState,
ReqOverallStatus,
} from "@/types/req-state";
/**
* Derive overall query status from individual relay states
*
* This function implements the core state machine logic that determines
* the overall status of a REQ subscription based on the states of individual
* relays. It handles edge cases like all-relays-disconnected, partial failures,
* and distinguishes between CLOSED and OFFLINE states.
*
* @param relayStates - Map of relay URLs to their current states
* @param overallEoseReceived - Whether the group subscription emitted EOSE
* @param isStreaming - Whether this is a streaming subscription (stream=true)
* @param queryStartedAt - Timestamp when the query started
* @returns Aggregated state for the entire query
*/
export function deriveOverallState(
relayStates: Map<string, ReqRelayState>,
overallEoseReceived: boolean,
isStreaming: boolean,
queryStartedAt: number,
): ReqOverallState {
const states = Array.from(relayStates.values());
// Count relay states
const totalRelays = states.length;
const connectedCount = states.filter(
(s) => s.connectionState === "connected",
).length;
const receivingCount = states.filter(
(s) => s.subscriptionState === "receiving",
).length;
const eoseCount = states.filter((s) => s.subscriptionState === "eose").length;
const liveCount = states.filter((s) => s.subscriptionState === "live").length;
const errorCount = states.filter((s) => s.connectionState === "error").length;
const disconnectedCount = states.filter(
(s) => s.connectionState === "disconnected",
).length;
// Calculate flags
const hasReceivedEvents = states.some((s) => s.eventCount > 0);
const hasActiveRelays = connectedCount > 0;
const allRelaysFailed = totalRelays > 0 && errorCount === totalRelays;
const allDisconnected =
totalRelays > 0 && disconnectedCount + errorCount === totalRelays;
// Timing
const firstEventAt = states
.map((s) => s.firstEventAt)
.filter((t): t is number => t !== undefined)
.sort((a, b) => a - b)[0];
const allEoseAt = overallEoseReceived ? Date.now() : undefined;
// Check if all relays are in terminal states (won't make further progress on initial load)
// "live" means EOSE received + streaming, so it's also a terminal state for initial load
const allRelaysTerminal = states.every(
(s) =>
s.subscriptionState === "eose" ||
s.subscriptionState === "live" ||
s.connectionState === "error" ||
s.connectionState === "disconnected",
);
// Derive status based on relay states and flags
const status: ReqOverallStatus = (() => {
// No relays selected yet (NIP-65 discovery in progress)
if (totalRelays === 0) {
return "discovering";
}
// All relays failed to connect, no events received
if (allRelaysFailed && !hasReceivedEvents) {
return "failed";
}
// All relays are in terminal states (done trying)
// This handles the case where relays disconnect before EOSE
if (allRelaysTerminal && !overallEoseReceived) {
if (!hasReceivedEvents) {
// All relays gave up before sending events
return "failed";
}
if (!hasActiveRelays) {
// Received events but all relays disconnected before EOSE
if (isStreaming) {
return "offline"; // Was trying to stream, now offline
} else {
return "closed"; // Non-streaming query, relays closed
}
}
// Some relays still active but all others terminated
// This is a partial success scenario
return "partial";
}
// No relays connected and no events received yet
if (!hasActiveRelays && !hasReceivedEvents) {
return "connecting";
}
// Had events and EOSE, but all relays disconnected now
if (allDisconnected && hasReceivedEvents && overallEoseReceived) {
if (isStreaming) {
return "offline"; // Was live, now offline
} else {
return "closed"; // Completed and closed (expected)
}
}
// EOSE not received yet, still loading initial data
if (!overallEoseReceived) {
return "loading";
}
// EOSE received, but some relays have issues (check this before "live")
if (overallEoseReceived && (errorCount > 0 || disconnectedCount > 0)) {
if (hasActiveRelays) {
return "partial"; // Some working, some not
} else {
return "offline"; // All disconnected after EOSE
}
}
// EOSE received, streaming mode, all relays healthy and connected
if (overallEoseReceived && isStreaming && hasActiveRelays) {
return "live";
}
// EOSE received, not streaming, all done
if (overallEoseReceived && !isStreaming) {
return "closed";
}
// Default fallback (should rarely hit this)
return "loading";
})();
return {
status,
totalRelays,
connectedCount,
receivingCount,
eoseCount,
liveCount,
errorCount,
disconnectedCount,
hasReceivedEvents,
hasActiveRelays,
allRelaysFailed,
queryStartedAt,
firstEventAt,
allEoseAt,
};
}
/**
* Get user-friendly status text for display
*/
export function getStatusText(state: ReqOverallState): string {
switch (state.status) {
case "discovering":
return "DISCOVERING";
case "connecting":
return "CONNECTING";
case "loading":
return "LOADING";
case "live":
return "LIVE";
case "partial":
return "PARTIAL";
case "offline":
return "OFFLINE";
case "closed":
return "CLOSED";
case "failed":
return "FAILED";
}
}
/**
* Get detailed status description for tooltips
*/
export function getStatusTooltip(state: ReqOverallState): string {
const { status, connectedCount, totalRelays, hasReceivedEvents } = state;
switch (status) {
case "discovering":
return "Selecting optimal relays using NIP-65";
case "connecting":
return `Connecting to ${totalRelays} relay${totalRelays !== 1 ? "s" : ""}...`;
case "loading":
return hasReceivedEvents
? `Loading events from ${connectedCount}/${totalRelays} relays`
: `Waiting for events from ${connectedCount}/${totalRelays} relays`;
case "live":
return `Streaming live events from ${connectedCount}/${totalRelays} relays`;
case "partial":
return `${connectedCount}/${totalRelays} relays active, some failed or disconnected`;
case "offline":
return "All relays disconnected. Showing cached results.";
case "closed":
return "Query completed, all relays closed";
case "failed":
return `Failed to connect to any of ${totalRelays} relays`;
}
}
/**
* Get status indicator color class
*/
export function getStatusColor(status: ReqOverallStatus): string {
switch (status) {
case "discovering":
case "connecting":
case "loading":
return "text-warning";
case "live":
return "text-success";
case "partial":
return "text-warning";
case "closed":
return "text-muted-foreground";
case "offline":
case "failed":
return "text-destructive";
}
}
/**
* Should the status indicator pulse/animate?
*/
export function shouldAnimate(status: ReqOverallStatus): boolean {
return ["discovering", "connecting", "loading", "live"].includes(status);
}
/**
* Get relay subscription state badge text
*/
export function getRelayStateBadge(
relay: ReqRelayState,
): { text: string; color: string } | null {
const { subscriptionState, connectionState } = relay;
// Prioritize subscription state (order matters for display priority)
if (subscriptionState === "live") {
// EOSE received, actively receiving live events
return { text: "LIVE", color: "text-success" };
}
if (subscriptionState === "receiving") {
// Receiving historical events (before EOSE)
return { text: "RECEIVING", color: "text-warning" };
}
if (subscriptionState === "eose") {
// EOSE received, idle (no live events yet)
return { text: "EOSE", color: "text-info" };
}
if (subscriptionState === "error") {
return { text: "ERROR", color: "text-destructive" };
}
// Show connection state if not connected
if (connectionState === "connecting") {
return { text: "CONNECTING", color: "text-warning" };
}
if (connectionState === "error") {
return { text: "ERROR", color: "text-destructive" };
}
if (connectionState === "disconnected") {
return { text: "OFFLINE", color: "text-muted-foreground" };
}
return null;
}