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:
nrobi144
2026-07-07 13:31:41 +03:00
parent dddeae74b6
commit bb2a83c1fe
3 changed files with 153 additions and 28 deletions
@@ -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()
}
}
}