mirror of
https://github.com/block/buzz.git
synced 2026-10-28 04:51:40 +00:00
fix(mobile): recover rejected and lost agent registrations anywhere
An HTTP rejection of register-agent is as definite as a rejected status, so it clears the registering mark and the setup can be discarded. A registration whose response was lost can be replayed from any community to learn its identity; finishing still needs the original community. Publishing or removing a registration now refreshes remote availability even when the registry's ACTIVE set is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tom Brow <tomb@block.xyz>
This commit is contained in:
@@ -50,8 +50,12 @@ final availableRemoteAgentsProvider = FutureProvider<Set<String>>((ref) async {
|
||||
ref.watch(relaySessionProvider.select((session) => session.status));
|
||||
final controller = ref.watch(remoteAgentControllerProvider);
|
||||
final active = controller.activeAgents;
|
||||
final revision = controller.registrationRevision;
|
||||
void changed() {
|
||||
if (!setEquals(controller.activeAgents, active)) ref.invalidateSelf();
|
||||
if (!setEquals(controller.activeAgents, active) ||
|
||||
controller.registrationRevision != revision) {
|
||||
ref.invalidateSelf();
|
||||
}
|
||||
}
|
||||
|
||||
controller.addListener(changed);
|
||||
@@ -217,6 +221,15 @@ class RemoteAgentController extends ChangeNotifier {
|
||||
if (!_disposed) notifyListeners();
|
||||
}
|
||||
|
||||
/// Bumped when this device publishes or removes a kind 30177 registration,
|
||||
/// which changes availability even when the registry's ACTIVE set does not.
|
||||
int registrationRevision = 0;
|
||||
|
||||
void _registrationsChanged() {
|
||||
registrationRevision++;
|
||||
_notify();
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
final session = account.account ?? await account.restore();
|
||||
if (session == null) {
|
||||
@@ -353,7 +366,13 @@ class RemoteAgentController extends ChangeNotifier {
|
||||
});
|
||||
}
|
||||
var setup = Map<String, dynamic>.from(pending!);
|
||||
if (setup['owner'] != owner || setup['community'] != community.relayUrl) {
|
||||
// A registration whose response was lost may be replayed from any
|
||||
// community, so the account learns its identity even if the original
|
||||
// community is unavailable. Everything after it needs that community.
|
||||
final elsewhere = setup['community'] != community.relayUrl;
|
||||
final unknownIdentity =
|
||||
setup['pubkey'] == null && setup['registering'] == true;
|
||||
if (setup['owner'] != owner || (elsewhere && !unknownIdentity)) {
|
||||
throw StateError('Finish the pending setup in its original community.');
|
||||
}
|
||||
if (setup['instructionsMissing'] == true) {
|
||||
@@ -371,16 +390,26 @@ class RemoteAgentController extends ChangeNotifier {
|
||||
// lost. Only Resume, with the same idempotency key, can recover it.
|
||||
setup = {...setup, 'registering': true};
|
||||
await _savePending(setup);
|
||||
final result = await account.post('/v3/beekeeper/register-agent', {
|
||||
'agent_name': setup['name'],
|
||||
'idempotency_key': setup['request'],
|
||||
});
|
||||
// An explicit rejection allocated nothing, so it can be discarded.
|
||||
Future<void> refused() async {
|
||||
setup = {...setup}..remove('registering');
|
||||
await _savePending(setup);
|
||||
}
|
||||
|
||||
final Map<String, dynamic> result;
|
||||
try {
|
||||
result = await account.post('/v3/beekeeper/register-agent', {
|
||||
'agent_name': setup['name'],
|
||||
'idempotency_key': setup['request'],
|
||||
});
|
||||
} on AccountFailure catch (failure) {
|
||||
if (failure == AccountFailure.rejected) await refused();
|
||||
rethrow;
|
||||
}
|
||||
if (!hasStatus(result, 'UNATTESTED', 1) &&
|
||||
result['status'] != null &&
|
||||
result['agent_pubkey'] == null) {
|
||||
// An explicit rejection allocated nothing, so it can be discarded.
|
||||
setup = {...setup}..remove('registering');
|
||||
await _savePending(setup);
|
||||
await refused();
|
||||
}
|
||||
requireStatus(result, 'UNATTESTED', 1);
|
||||
final pubkey = result['agent_pubkey'];
|
||||
@@ -392,6 +421,11 @@ class RemoteAgentController extends ChangeNotifier {
|
||||
setup = {...setup, 'pubkey': pubkey, 'id': result['agent_id']};
|
||||
await _savePending(setup);
|
||||
}
|
||||
if (elsewhere) {
|
||||
throw StateError(
|
||||
'Agent identity recovered. Finish the setup in its original community.',
|
||||
);
|
||||
}
|
||||
final pubkey = setup['pubkey'] as String;
|
||||
status = 'Authorizing and activating agent…';
|
||||
_notify();
|
||||
@@ -412,6 +446,7 @@ class RemoteAgentController extends ChangeNotifier {
|
||||
}
|
||||
await subscribe(setup['id'] as String, community.relayUrl);
|
||||
await publishPolicy(community.relayUrl, setup);
|
||||
_registrationsChanged();
|
||||
// Retain the completed registration if the listing refresh fails, so Retry
|
||||
// resumes the same identity instead of creating another agent.
|
||||
await refresh();
|
||||
@@ -577,6 +612,7 @@ class RemoteAgentController extends ChangeNotifier {
|
||||
}
|
||||
check();
|
||||
await removePolicy(community.relayUrl, pubkey);
|
||||
_registrationsChanged();
|
||||
check();
|
||||
final remaining = await fetch(
|
||||
NostrFilter(
|
||||
|
||||
@@ -42,6 +42,7 @@ class FakeRegistry extends BuilderlabAccountClient {
|
||||
bool failInstructions = false;
|
||||
bool rejectRegistration = false;
|
||||
bool loseRegistration = false;
|
||||
bool refuseRegistration = false;
|
||||
Object deleteStatus = 1;
|
||||
final calls = <String>[];
|
||||
final bodies = <String, Map<String, dynamic>>{};
|
||||
@@ -68,6 +69,7 @@ class FakeRegistry extends BuilderlabAccountClient {
|
||||
case '/v3/beekeeper/list-agents':
|
||||
return {'status': 1, 'agents': listed};
|
||||
case '/v3/beekeeper/register-agent':
|
||||
if (refuseRegistration) throw AccountFailure.rejected;
|
||||
if (rejectRegistration) {
|
||||
return {'status': 'REGISTER_AGENT_STATUS_NAME_TAKEN'};
|
||||
}
|
||||
|
||||
@@ -338,6 +338,13 @@ void main() {
|
||||
);
|
||||
expect(controller.status, contains('NAME_TAKEN'));
|
||||
expect(controller.canDiscardPending, isTrue);
|
||||
// An HTTP rejection is just as definite.
|
||||
registry
|
||||
..rejectRegistration = false
|
||||
..refuseRegistration = true;
|
||||
expect(await controller.create(community, 'Scout'), isFalse);
|
||||
expect(controller.status, contains('rejected'));
|
||||
expect(controller.canDiscardPending, isTrue);
|
||||
final request = controller.pending!['request'];
|
||||
expect(prefs.getString('$_scope:pending'), isNotNull);
|
||||
|
||||
@@ -351,7 +358,7 @@ void main() {
|
||||
|
||||
// A lost response may hide a committed identity, so it can only resume.
|
||||
registry
|
||||
..rejectRegistration = false
|
||||
..refuseRegistration = false
|
||||
..loseRegistration = true;
|
||||
expect(
|
||||
await controller.create(community, 'Scout', instructions: 'Private.'),
|
||||
@@ -385,16 +392,21 @@ void main() {
|
||||
expect(await remove(other), isFalse);
|
||||
expect(controller.status, contains('Resume the unfinished setup'));
|
||||
|
||||
// Resume reuses the idempotency key and recovers the same identity.
|
||||
// Resume from another community reuses the idempotency key and recovers
|
||||
// the same identity; finishing it still needs the original community.
|
||||
registry
|
||||
..loseRegistration = false
|
||||
..failInstructions = true
|
||||
..bodies.clear();
|
||||
expect(await controller.create(community, 'Scout'), isFalse);
|
||||
expect(await controller.create(elsewhere, 'Scout'), isFalse);
|
||||
expect(controller.status, contains('Agent identity recovered'));
|
||||
expect(
|
||||
registry.bodies['/v3/beekeeper/register-agent']?['idempotency_key'],
|
||||
lost,
|
||||
);
|
||||
registry.bodies.clear();
|
||||
expect(await controller.create(elsewhere, 'Scout'), isFalse);
|
||||
expect(controller.status, contains('original community'));
|
||||
expect(registry.bodies, isEmpty);
|
||||
expect(controller.pending?['pubkey'], fakeAgent);
|
||||
expect(await controller.discardPending(), isFalse);
|
||||
expect(controller.pending?['pubkey'], fakeAgent);
|
||||
@@ -519,6 +531,31 @@ void main() {
|
||||
fakeAgent,
|
||||
});
|
||||
expect(container.read(presentedPresenceProvider)[fakeAgent], 'online');
|
||||
|
||||
// Removing the registration hides it even when the runtime stays ACTIVE.
|
||||
registry.deleteStatus = 2;
|
||||
relay.live = false;
|
||||
expect(
|
||||
await controller.deleteAgent(
|
||||
pubkey: fakeAgent,
|
||||
community: _community(),
|
||||
isCurrent: () => true,
|
||||
fetch: (_) async => [],
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
expect(controller.activeAgents, {fakeAgent});
|
||||
expect(await container.read(availableRemoteAgentsProvider.future), isEmpty);
|
||||
expect(container.read(presentedPresenceProvider)[fakeAgent], 'offline');
|
||||
|
||||
// Publishing a registration for the still-ACTIVE agent shows it again.
|
||||
relay.live = true;
|
||||
expect(await controller.create(_community(), 'Scout'), isTrue);
|
||||
expect(controller.activeAgents, {fakeAgent});
|
||||
expect(await container.read(availableRemoteAgentsProvider.future), {
|
||||
fakeAgent,
|
||||
});
|
||||
expect(container.read(presentedPresenceProvider)[fakeAgent], 'online');
|
||||
}, skip: !HostedEnvironment.enabled);
|
||||
|
||||
test('owner proof verifies for only the provisioned agent key', () {
|
||||
|
||||
Reference in New Issue
Block a user