mirror of
https://github.com/block/buzz.git
synced 2026-11-01 23:11:58 +00:00
Adds Builderlab remote agents to mobile: a member signed in to Builderlab can list, create and delete their own hosted agents from the channels header, and active remote agents show as Online. Everything is gated by `HostedEnvironment.enabled` (the build-time `BUZZ_AUTH_DOMAINS` define). App Store and internal release builds do not set it, so they render and fetch nothing new. ### Behavior - **Agents** button in the channels header (native iOS bar and Material header) for hosted communities only. It opens a sheet listing the community's agents; tapping one pushes its profile inside the same sheet. - **Create**: for the member's own agents (the NIP-OA owner is the member, not the community). Registers the agent with Beekeeper, attests it to the community, saves optional private instructions, subscribes it to the relay, then publishes the kind 30177 registration with a NIP-OA owner proof. A failed step resumes with the same agent identity on retry; instructions stay in secure storage, never in preferences. - **Delete**: removes the relay registration, confirms it is gone, then deletes the Beekeeper agent. A partial failure keeps a retry row in the list. - **Presence**: an ACTIVE agent with a live kind 30177 registration on the active relay shows Online in profiles, channel rows, DMs and mentions, since remote agents wake on messages. Local presence data is unchanged. - Sign-in reuses the shared hosted flow. The account's saved Buzz key must match the server binding; nothing is adopted or rewritten. - `BuilderlabAccountClient` gains an allowlist for the six Beekeeper routes desktop already uses. Not included from the prototype: the separate entrypoint and second sign-in, test-community creation, staging-only hosts, QR-code agent links, and the USB proxy. ### Validation - Added tests: build-time gate, account/key binding before listing, setup resume with private instructions, deletion contract and retry, owner-proof verification, status parsing, presented presence requiring a live registration in the active community and following registration publish and removal (mutation-checked), account-change clearing, load retry, recovery of a pending setup whose instructions were lost, a lost registration response that can only resume with the same idempotency key (from any community), an HTTP-rejected registration that can be discarded, deletion of other agents while a setup is unfinished, rejection of owner proofs whose signed conditions or profile signature fail, registration publish and removal over a loopback WebSocket relay for an `http://`-saved community, and widget tests for in-sheet detail navigation, creation overlay with failure then resume, and delete confirmation. - The new tests also run in the CI lane with `BUZZ_AUTH_DOMAINS` configured, so the enabled path is covered. - Full mobile format, analyze and test suite pass locally. - Independent agent review: no gate leak or key-handling issue found; its robustness findings (discarding a setup that never got a server identity, clearing registry state on account change, retrying a skipped load, guarded notifications) are fixed; discard and account change have added tests. - Simulator: a `BUZZ_AUTH_DOMAINS` debug build launches to Builderlab sign-in. The signed-in flow was not re-run on this branch. - Codex Security Review: an earlier head received a published review, and its two findings were fixed. On745ad85andbe2e31bthe review hit its 45-minute timeout and was cancelled without a result, so the final head has no published security review. - Human app test: not applicable under the mobile testing exception. The end-to-end create, mention and delete flow was demonstrated on the prototype branch against Builderlab; this PR keeps the same Beekeeper and relay contracts. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow <tomb@block.xyz> Co-authored-by: george <973be878edd6d4f79692a3827f20a6f7fe0ed0362f269ff6b6ba8867033d7d8e@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
254 lines
8.5 KiB
Dart
254 lines
8.5 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/widgets.dart';
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
|
|
import '../../shared/agents/remote_agents.dart';
|
|
import '../../shared/relay/relay.dart';
|
|
|
|
/// Community-scoped presence: absent means unknown. Polls recover expired leases.
|
|
///
|
|
/// Presence is a best-effort estimate between foreground polls. Per-key
|
|
/// revisions fence stale query completions. Snapshot/live ordering is not
|
|
/// causal: snapshots carry relay time and live events carry signer time. A
|
|
/// live event at/below the relay snapshot second is confirmed by a fresh
|
|
/// snapshot (persistence precedes fan-out). An empty snapshot has no relay
|
|
/// second, so a delayed older live event can briefly supersede its Offline;
|
|
/// the next 60-second poll corrects it.
|
|
class PresenceCacheNotifier extends Notifier<Map<String, String>> {
|
|
/// Bound deduplication memory; redelivery must not re-arm confirmation.
|
|
static const _seenIdLimit = 256;
|
|
|
|
final Set<String> _tracked = {};
|
|
final Set<String> _pending = {};
|
|
final Map<String, int> _revisions = {};
|
|
final Map<String, int> _timestamps = {};
|
|
final Map<String, int> _snapshotAt = {};
|
|
final List<String> _seenIds = [];
|
|
void Function()? _presenceUnsub;
|
|
Timer? _poll;
|
|
Timer? _queued;
|
|
int _generation = 0;
|
|
int _epoch = 0;
|
|
bool _querying = false;
|
|
bool _opening = false;
|
|
bool _retrying = false;
|
|
(String, String?)? _scope;
|
|
|
|
@override
|
|
Map<String, String> build() {
|
|
final sessionState = ref.watch(relaySessionProvider);
|
|
final lifecycle = ref.watch(appLifecycleProvider);
|
|
final scope = ref.watch(
|
|
relayConfigProvider.select((config) => (config.baseUrl, config.nsec)),
|
|
);
|
|
if (_scope != scope) _tracked.clear();
|
|
_scope = scope;
|
|
final generation = ++_generation;
|
|
_epoch++;
|
|
_querying = _opening = _retrying = false;
|
|
_revisions.clear();
|
|
_timestamps.clear();
|
|
_snapshotAt.clear();
|
|
_seenIds.clear();
|
|
_pending
|
|
..clear()
|
|
..addAll(_tracked);
|
|
ref.onDispose(() {
|
|
_generation++;
|
|
_presenceUnsub?.call();
|
|
_presenceUnsub = null;
|
|
_poll?.cancel();
|
|
_queued?.cancel();
|
|
_queued = null;
|
|
});
|
|
if (sessionState.status == SessionStatus.connected &&
|
|
lifecycle == AppLifecycleState.resumed) {
|
|
_poll = Timer.periodic(const Duration(seconds: 60), (_) {
|
|
if (_presenceUnsub == null) {
|
|
unawaited(_subscribe(generation));
|
|
} else if (!_querying) {
|
|
_pending.addAll(_tracked);
|
|
_schedule(generation);
|
|
}
|
|
});
|
|
// A transport may report ready synchronously; initialize state first.
|
|
Future.microtask(() => _subscribe(generation));
|
|
}
|
|
return {};
|
|
}
|
|
|
|
/// Request initial presence for exact keys. Repeated render calls coalesce.
|
|
void track(List<String> pubkeys) {
|
|
for (final key in pubkeys.map((key) => key.trim().toLowerCase())) {
|
|
if (key.isNotEmpty && _tracked.add(key)) _pending.add(key);
|
|
}
|
|
_schedule(_generation);
|
|
}
|
|
|
|
void _schedule(int generation) {
|
|
_queued ??= Timer(Duration.zero, () {
|
|
_queued = null;
|
|
if (generation == _generation && _presenceUnsub != null && !_retrying) {
|
|
unawaited(_refresh(generation));
|
|
}
|
|
});
|
|
}
|
|
|
|
void _invalidateSnapshot() {
|
|
_epoch++;
|
|
_querying = false;
|
|
_pending.addAll(_tracked);
|
|
if (state.isNotEmpty) state = {};
|
|
}
|
|
|
|
Future<void> _subscribe(int generation) async {
|
|
if (_opening || generation != _generation) return;
|
|
_opening = true;
|
|
_retrying = false;
|
|
var closed = false;
|
|
try {
|
|
final unsub = await ref
|
|
.read(relaySessionProvider.notifier)
|
|
.subscribeWithStatus(
|
|
const NostrFilter(kinds: [EventKind.presenceUpdate], limit: 0),
|
|
(event) {
|
|
if (generation == _generation && !closed && !_retrying) {
|
|
_handlePresenceEvent(event);
|
|
}
|
|
},
|
|
onStatusChanged: (status) {
|
|
if (generation != _generation || closed) return;
|
|
_retrying = status == RelaySubscriptionStatus.retrying;
|
|
_invalidateSnapshot();
|
|
if (!_retrying) _schedule(generation);
|
|
},
|
|
onClosed: (_) {
|
|
if (generation != _generation || closed) return;
|
|
closed = true;
|
|
_presenceUnsub?.call();
|
|
_presenceUnsub = null;
|
|
_invalidateSnapshot();
|
|
},
|
|
);
|
|
if (generation != _generation || closed) {
|
|
unsub();
|
|
return;
|
|
}
|
|
_presenceUnsub = unsub;
|
|
_schedule(generation);
|
|
} catch (error) {
|
|
if (generation == _generation) {
|
|
_invalidateSnapshot();
|
|
debugPrint('[PresenceCacheNotifier] subscription failed: $error');
|
|
}
|
|
} finally {
|
|
if (generation == _generation) _opening = false;
|
|
}
|
|
}
|
|
|
|
void _handlePresenceEvent(NostrEvent event) {
|
|
final pubkey = event.pubkey.toLowerCase();
|
|
final status = event.content.trim();
|
|
if (event.kind != EventKind.presenceUpdate ||
|
|
!_tracked.contains(pubkey) ||
|
|
!_validStatus(status) ||
|
|
event.createdAt < (_timestamps[pubkey] ?? 0) ||
|
|
_seenRecently(event.id)) {
|
|
return;
|
|
}
|
|
// Accepted evidence fences in-flight queries, even if it needs confirmation.
|
|
_revisions[pubkey] = (_revisions[pubkey] ?? 0) + 1;
|
|
// Equality cannot order events within the relay's observation second.
|
|
if (event.createdAt <= (_snapshotAt[pubkey] ?? 0)) {
|
|
_pending.add(pubkey);
|
|
_schedule(_generation);
|
|
return;
|
|
}
|
|
_timestamps[pubkey] = event.createdAt;
|
|
if (state[pubkey] != status) state = {...state, pubkey: status};
|
|
}
|
|
|
|
Future<void> _refresh(int generation) async {
|
|
if (_querying || _pending.isEmpty) return;
|
|
_querying = true;
|
|
final epoch = _epoch;
|
|
final session = ref.read(relaySessionProvider.notifier);
|
|
while (_pending.isNotEmpty) {
|
|
final keys = _pending.take(100).toList();
|
|
_pending.removeAll(keys);
|
|
final revisions = {for (final key in keys) key: _revisions[key] ?? 0};
|
|
List<NostrEvent>? events;
|
|
try {
|
|
events = await session.queryRelay([
|
|
NostrFilter(
|
|
kinds: [EventKind.presenceUpdate],
|
|
authors: keys,
|
|
limit: keys.length,
|
|
),
|
|
]);
|
|
} catch (error) {
|
|
debugPrint('[PresenceCacheNotifier] snapshot failed: $error');
|
|
}
|
|
if (generation != _generation || epoch != _epoch) return;
|
|
final latest = <String, NostrEvent>{};
|
|
var observedAt = 0;
|
|
for (final event in events ?? <NostrEvent>[]) {
|
|
final key = (event.getTagValue('p') ?? event.pubkey).toLowerCase();
|
|
if (!revisions.containsKey(key) ||
|
|
event.kind != EventKind.presenceUpdate ||
|
|
!_validStatus(event.content.trim())) {
|
|
continue;
|
|
}
|
|
if (!latest.containsKey(key) ||
|
|
event.createdAt > latest[key]!.createdAt) {
|
|
latest[key] = event;
|
|
}
|
|
if (event.createdAt > observedAt) observedAt = event.createdAt;
|
|
}
|
|
final updated = {...state};
|
|
for (final key in keys) {
|
|
if ((_revisions[key] ?? 0) != revisions[key]) continue;
|
|
if (events == null) {
|
|
updated.remove(key); // failure is unknown; next poll retries
|
|
} else {
|
|
// Shared synthesis clock fences the batch; empty answers retain it.
|
|
if (observedAt > (_snapshotAt[key] ?? 0)) {
|
|
_snapshotAt[key] = observedAt;
|
|
}
|
|
updated[key] = latest[key]?.content.trim() ?? 'offline';
|
|
}
|
|
}
|
|
if (!mapEquals(state, updated)) state = updated;
|
|
}
|
|
_querying = false;
|
|
}
|
|
|
|
bool _seenRecently(String id) {
|
|
if (id.isEmpty) return false;
|
|
if (_seenIds.contains(id)) return true;
|
|
_seenIds.add(id);
|
|
if (_seenIds.length > _seenIdLimit) _seenIds.removeAt(0);
|
|
return false;
|
|
}
|
|
|
|
bool _validStatus(String status) =>
|
|
status == 'online' || status == 'away' || status == 'offline';
|
|
}
|
|
|
|
final presenceCacheProvider =
|
|
NotifierProvider<PresenceCacheNotifier, Map<String, String>>(
|
|
PresenceCacheNotifier.new,
|
|
);
|
|
|
|
/// Presentation shared by profiles, DMs and mentions. Remote agents wake on
|
|
/// messages, so an ACTIVE registration shows Online whatever their socket
|
|
/// presence. Local presence is unchanged.
|
|
final presentedPresenceProvider = Provider<Map<String, String>>((ref) {
|
|
final presence = ref.watch(presenceCacheProvider);
|
|
final remote = ref.watch(availableRemoteAgentsProvider).value ?? const {};
|
|
if (remote.isEmpty) return presence;
|
|
return {...presence, for (final pubkey in remote) pubkey: 'online'};
|
|
});
|