mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-11 08:47:33 +00:00
feat(desktop,cli): route WoT kind-3 fetch through OutboxDispatcher (NIP-65)
Phase 3 of the outbox refactor (PR #3483, per Vitor's directive). The WoT service's kind-3 seeding on Desktop and the `amy wot sync` verb now go through OutboxDispatcher — index relays discover each author's kind-10002 write relays, then per-outbox-relay REQs fetch kind-3. Changes: Desktop: - DesktopRelaySubscriptionsCoordinator gains an inner OutboxCacheGateway that bridges DesktopLocalCache (cachedAdvertisedRelayList / consume) to OutboxDispatcher. - New suspend loadKind3ViaOutbox(pubkeys) method returns the dispatcher's Result for observability. - Main.kt WoT-seed effect now: 1. gates on wotService.isDisabled to preserve MAX_FOLLOWS guardrail (fix 2 from Phase 1) 2. calls loadKind3ViaOutbox instead of the direct loadKind3Batched on index relays 3. keeps the 2s markReady safety net for cold-start UX - clear() now also clears outboxDispatcher's dedup markers. amy: - WotCommand.sync rewritten to construct an OutboxDispatcher, buffer events in the gateway, and persist to ctx.store after fetch returns (store.insert is suspending; can't call from non-suspend gateway callbacks). - --json output additively gains kind10002_received, outbox_covered_authors, fallback_authors, persisted keys. - --timeout N still supported; now maps to overallTimeoutMs. Not in this commit (deferred to a follow-up on same PR if reviewers want it): - Routing stranger-avatar kind-0 fetch through the outbox path (MetadataPreloader wiring is more invasive; keeps this diff focused on the primary WoT concern). Plan: commons/plans/2026-07-06-fix-wot-outbox-model-and-review-fixes-plan.md
This commit is contained in:
@@ -24,14 +24,18 @@ import com.vitorpamplona.amethyst.cli.Args
|
||||
import com.vitorpamplona.amethyst.cli.Context
|
||||
import com.vitorpamplona.amethyst.cli.DataDir
|
||||
import com.vitorpamplona.amethyst.cli.Output
|
||||
import com.vitorpamplona.amethyst.commons.wot.OutboxCacheGateway
|
||||
import com.vitorpamplona.amethyst.commons.wot.OutboxDispatcher
|
||||
import com.vitorpamplona.amethyst.commons.wot.WoTService
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import java.util.Collections
|
||||
|
||||
/**
|
||||
* `amy wot <get|list|sync>` — Web-of-Trust score queries.
|
||||
@@ -113,41 +117,88 @@ object WotCommand {
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val timeoutMs = args.flag("timeout")?.toLongOrNull()?.times(1000) ?: 5_000L
|
||||
// Overall timeout; per-relay budget is set by OutboxDispatcher's
|
||||
// default (4s). `--timeout N` overrides the overall cap.
|
||||
val overallTimeoutMs = args.flag("timeout")?.toLongOrNull()?.times(1000) ?: 8_000L
|
||||
Context.open(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val self = ctx.identity.pubKeyHex
|
||||
val myKind3 = ctx.contactsOf(self)
|
||||
val follows =
|
||||
myKind3?.verifiedFollowKeySet()?.toList()
|
||||
myKind3?.verifiedFollowKeySet()?.toSet()
|
||||
?: return Output.error("no_follows", "no kind-3 in local store; run `amy follow` first")
|
||||
if (follows.isEmpty()) {
|
||||
Output.emit(mapOf("synced" to 0, "detail" to "empty follow set"))
|
||||
return 0
|
||||
}
|
||||
// Index relays — shared with the Desktop app via
|
||||
// `java.util.prefs`. Falls back to
|
||||
// `PreferencesIndexRelays.DEFAULT_INDEX_RELAYS` when the
|
||||
// user hasn't configured anything, so this is never empty
|
||||
// in practice.
|
||||
val relays = ctx.indexRelays()
|
||||
if (relays.isEmpty()) return Output.error("no_relays", "no index relays configured")
|
||||
|
||||
// Chunk authors into ≤100 per Filter for relays with per-filter caps.
|
||||
val filters =
|
||||
follows.chunked(100).map { chunk ->
|
||||
Filter(
|
||||
kinds = listOf(ContactListEvent.KIND),
|
||||
authors = chunk,
|
||||
limit = chunk.size,
|
||||
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
try {
|
||||
// Buffer discovered events; persist synchronously after
|
||||
// the fetch. `store.insert` is suspending so we can't call
|
||||
// it from the non-suspending gateway callbacks. This also
|
||||
// keeps `insert` errors surfaceable in a single log line
|
||||
// rather than swallowed into a race.
|
||||
val buffered = Collections.synchronizedList(mutableListOf<Event>())
|
||||
val gateway =
|
||||
object : OutboxCacheGateway {
|
||||
override fun cachedOutbox(pubkey: HexKey): AdvertisedRelayListEvent? =
|
||||
// Amy's store lookup is suspending; can't do
|
||||
// it here. The dispatcher then falls through
|
||||
// to Phase 1 discovery for every author, which
|
||||
// matches the old `amy wot sync` behaviour of
|
||||
// always re-asking. A future optimisation
|
||||
// could pre-populate a `Map<HexKey,
|
||||
// AdvertisedRelayListEvent>` before dispatch.
|
||||
null
|
||||
|
||||
override fun onOutboxDiscovered(
|
||||
event: AdvertisedRelayListEvent,
|
||||
relay: NormalizedRelayUrl,
|
||||
) {
|
||||
buffered.add(event)
|
||||
}
|
||||
|
||||
override fun onDiscoveredEvent(
|
||||
event: Event,
|
||||
relay: NormalizedRelayUrl,
|
||||
) {
|
||||
buffered.add(event)
|
||||
}
|
||||
}
|
||||
|
||||
val dispatcher =
|
||||
OutboxDispatcher(
|
||||
client = ctx.client,
|
||||
scope = scope,
|
||||
indexRelays = { relays },
|
||||
gateway = gateway,
|
||||
overallTimeoutMs = overallTimeoutMs,
|
||||
)
|
||||
}
|
||||
val received = ctx.drain(relays.associateWith { filters }, timeoutMs)
|
||||
val kind3Events = received.mapNotNull { it.second as? ContactListEvent }
|
||||
// Persist to store so future `get` / `list` see them.
|
||||
kind3Events.forEach { runCatching { ctx.store.insert(it) } }
|
||||
Output.emit(mapOf("received" to kind3Events.size, "followers" to follows.size))
|
||||
return 0
|
||||
|
||||
val result = dispatcher.fetchKind3Only(follows)
|
||||
|
||||
// Persist to store so future `get` / `list` see them.
|
||||
val eventsToPersist = synchronized(buffered) { buffered.toList() }
|
||||
eventsToPersist.forEach { runCatching { ctx.store.insert(it) } }
|
||||
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"followers" to follows.size,
|
||||
"authors_requested" to result.authorsRequested,
|
||||
"kind10002_received" to result.kind10002Received,
|
||||
"kind3_received" to result.kind3Received,
|
||||
"outbox_covered_authors" to result.outboxCoveredAuthors,
|
||||
"fallback_authors" to result.fallbackAuthors,
|
||||
"persisted" to eventsToPersist.size,
|
||||
),
|
||||
)
|
||||
return 0
|
||||
} finally {
|
||||
scope.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user