// HubService — singleton wrapper around fai_dart_sdk's HubClient. // // Centralises the gRPC connection so each page just imports // `HubService.instance` instead of constructing its own client. // Methods return UI-friendly types so pages stay free of // protobuf imports. import 'package:fai_dart_sdk/fai_dart_sdk.dart'; import 'package:shared_preferences/shared_preferences.dart'; class HubService { HubService._(); static final HubService instance = HubService._(); HubClient _client = HubClient(); /// The endpoint string ("http://host:port") the connection /// targets. Shown in the navigation rail. String get endpointLabel => _client.endpoint.toString(); HubEndpoint get currentEndpoint => _client.endpoint; static const _kHostKey = 'hub.host'; static const _kPortKey = 'hub.port'; static const _kSecureKey = 'hub.secure'; /// Read persisted endpoint and reconnect if it differs from /// the default. Called once at app start. Future loadPersistedEndpoint() async { final prefs = await SharedPreferences.getInstance(); final host = prefs.getString(_kHostKey); final port = prefs.getInt(_kPortKey); final secure = prefs.getBool(_kSecureKey); if (host == null) return; final endpoint = HubEndpoint( host: host, port: port ?? 50051, secure: secure ?? false, ); if (endpoint.toString() != _client.endpoint.toString()) { await reconnect(endpoint); } } /// Reconnect to a new endpoint and persist for next launch. Future reconnect(HubEndpoint endpoint) async { await _client.close(); _client = HubClient(endpoint: endpoint); final prefs = await SharedPreferences.getInstance(); await prefs.setString(_kHostKey, endpoint.host); await prefs.setInt(_kPortKey, endpoint.port); await prefs.setBool(_kSecureKey, endpoint.secure); } static const _kThemeKey = 'theme.mode'; /// Read the persisted theme mode (system / light / dark) for /// initial app startup. Defaults to system. Future loadThemeMode() async { final prefs = await SharedPreferences.getInstance(); final raw = prefs.getString(_kThemeKey); return ThemeModeValue.fromWire(raw) ?? ThemeModeValue.system; } /// Persist the theme mode the user chose. Future saveThemeMode(ThemeModeValue mode) async { final prefs = await SharedPreferences.getInstance(); await prefs.setString(_kThemeKey, mode.wire); } Future healthy() => _client.healthy(); Future> listModules() async { final caps = await _client.listCapabilities(); // Group capabilities by module so Studio's UI maps a card // to a module rather than a capability. final byModule = >{}; for (final c in caps) { byModule.putIfAbsent(c.moduleName, () => []).add(c); } return byModule.entries.map((e) { final caps = e.value; return ModuleSummary( name: e.key, version: caps.first.moduleVersion, capabilities: caps .map((c) => '${c.capability}@${c.version}') .toList(), ); }).toList() ..sort((a, b) => a.name.compareTo(b.name)); } /// Fully-detailed manifest for one installed module. Future moduleInfo(String name) async { final r = await _client.moduleInfo(name); return ModuleDetail( name: r.name, version: r.version, capabilities: r.provides .map((c) => '${c.namespace}.${c.name}@${c.versionReq}') .toList(), permissions: r.permissions, directory: r.directory, ); } /// Saved flows known to the hub. Future> listFlows() async { final flows = await _client.listFlows(); return flows .map( (f) => SavedFlow( name: f.name, path: f.path, sizeBytes: f.sizeBytes.toInt(), ), ) .toList() ..sort((a, b) => a.name.compareTo(b.name)); } /// Run a saved flow with the supplied text inputs. Returns /// the named outputs as a map of UI-friendly strings. Future> runSavedFlow({ required String name, required Map textInputs, }) async { final r = await _client.runSavedFlow( name: name, textInputs: textInputs, ); return { for (final entry in r.outputs.entries) entry.key: _payloadToText(entry.value), }; } String _payloadToText(Payload p) { if (p.hasText()) return p.text; if (p.hasJson()) return p.json.toString(); if (p.hasBytes()) { return ''; } if (p.hasFile()) { return ''; } return ''; } Future> recentEvents({ int limit = 50, List types = const [], }) async { final events = await _client.eventLog(limit: limit, types: types); return events .map( (e) => AuditEvent( eventId: e.eventId, timestamp: DateTime.tryParse(e.timestamp) ?? DateTime.now(), type: e.eventType, flowName: e.flowName.isEmpty ? null : e.flowName, stepId: e.stepId.isEmpty ? null : e.stepId, moduleName: e.moduleName.isEmpty ? null : e.moduleName, durationMs: e.durationMs == 0 ? null : e.durationMs.toInt(), error: e.error.isEmpty ? null : e.error, ), ) .toList(); } Future> pendingApprovals() async { final entries = await _client.listApprovals(statuses: ['pending']); return entries .map( (e) => PendingApproval( id: e.approvalId, flowName: e.flowName, stepId: e.stepId, prompt: e.prompt, showPreview: e.payloadPreview.isEmpty ? null : e.payloadPreview, createdAt: DateTime.tryParse(e.createdAt) ?? DateTime.now(), expiresAt: e.expiresAt.isEmpty ? null : DateTime.tryParse(e.expiresAt), ), ) .toList(); } Future approve(String id, String reviewer) => _client.approve(approvalId: id, reviewer: reviewer); Future reject(String id, String reviewer, String reason) => _client.reject( approvalId: id, reviewer: reviewer, reason: reason, ); /// Composite "doctor" snapshot. One round-trip per piece, run /// in parallel so the page populates fast. Future doctor() async { final results = await Future.wait([ _client.listCapabilities().catchError((_) => []), _client.listApprovals(statuses: const ['pending']) .catchError((_) => []), _client.verifyEventChain().catchError( (_) => VerifyEventChainResponse(), ), _client.listServices().catchError((_) => []), ]); final caps = results[0] as List; final approvals = results[1] as List; final chain = results[2] as VerifyEventChainResponse; final services = results[3] as List; // Module count = distinct module_name across capabilities. final moduleNames = {for (final c in caps) c.moduleName}; return DoctorSnapshot( moduleCount: moduleNames.length, capabilityCount: caps.length, pendingApprovals: approvals.length, eventChainTotal: chain.total.toInt(), eventChainVerified: chain.verified.toInt(), eventChainTamperedAt: chain.tamperedAt.isEmpty ? null : chain.tamperedAt, services: services .map( (s) => ServiceEntry( name: s.name, endpoint: s.endpoint, tags: s.tags, ), ) .toList(), ); } } class DoctorSnapshot { final int moduleCount; final int capabilityCount; final int pendingApprovals; final int eventChainTotal; final int eventChainVerified; final String? eventChainTamperedAt; final List services; const DoctorSnapshot({ required this.moduleCount, required this.capabilityCount, required this.pendingApprovals, required this.eventChainTotal, required this.eventChainVerified, required this.eventChainTamperedAt, required this.services, }); bool get chainHealthy => eventChainTamperedAt == null; } class ServiceEntry { final String name; final String endpoint; final List tags; const ServiceEntry({ required this.name, required this.endpoint, required this.tags, }); } /// Three-way switch persisted across launches. enum ThemeModeValue { system('system'), light('light'), dark('dark'); final String wire; const ThemeModeValue(this.wire); static ThemeModeValue? fromWire(String? s) { if (s == null) return null; for (final v in values) { if (v.wire == s) return v; } return null; } } /// UI-side type, decoupled from the proto wire type so pages /// don't import protobuf packages. class ModuleSummary { final String name; final String version; final List capabilities; const ModuleSummary({ required this.name, required this.version, required this.capabilities, }); } class SavedFlow { final String name; final String path; final int sizeBytes; const SavedFlow({ required this.name, required this.path, required this.sizeBytes, }); } class ModuleDetail { final String name; final String version; final List capabilities; final List permissions; final String directory; const ModuleDetail({ required this.name, required this.version, required this.capabilities, required this.permissions, required this.directory, }); } class AuditEvent { final String eventId; final DateTime timestamp; final String type; final String? flowName; final String? stepId; final String? moduleName; final int? durationMs; final String? error; const AuditEvent({ required this.eventId, required this.timestamp, required this.type, this.flowName, this.stepId, this.moduleName, this.durationMs, this.error, }); } class PendingApproval { final String id; final String flowName; final String stepId; final String prompt; final String? showPreview; final DateTime createdAt; final DateTime? expiresAt; const PendingApproval({ required this.id, required this.flowName, required this.stepId, required this.prompt, this.showPreview, required this.createdAt, this.expiresAt, }); }