// HubService — singleton wrapper around chain_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 'dart:io'; import 'dart:typed_data'; import 'package:chain_client_sdk/chain_client_sdk.dart'; import 'package:flutter/widgets.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../l10n/app_localizations.dart'; import 'flow_output.dart'; import 'hub_auth_token.dart'; export 'flow_output.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; /// The channel Studio is connected to, discovered from /// `~/.chain/current-channel` when no explicit endpoint was set. /// Shown next to the connection caption so "connected" names which /// daemon — never contradicting the Diagnose page again. String? activeChannel; /// Best-effort name of the channel the current endpoint targets, /// matched by its well-known port (50071 → production). Falls back to /// the auto-discovered channel. `null` for a custom host/port. String? get connectedChannelName { final port = _client.endpoint.port; for (final e in _channelPorts.entries) { if (e.value == port) return e.key; } return activeChannel; } static const _kHostKey = 'hub.host'; static const _kPortKey = 'hub.port'; static const _kSecureKey = 'hub.secure'; /// gRPC default ports per channel (mirror of the Rust /// `Channel::default_port`). Used as the fallback when the daemon /// has not written its `run/.endpoint` file yet. static const _channelPorts = { 'local': 50051, 'dev': 50041, 'beta': 50061, 'production': 50071, }; /// The endpoint of the channel the operator is actually on, read /// from `~/.chain` (the hub writes `current-channel` and, when a /// daemon is up, `run/.endpoint`). Lets a fresh Studio /// follow the installed channel — a `curl | sh` user lands on /// `production` (:50071), not the hard-coded local :50051. Returns /// null when it cannot be determined (then we keep the default). static ({String channel, HubEndpoint endpoint})? _discoverActiveChannel() { try { final home = Platform.environment['HOME'] ?? Platform.environment['USERPROFILE']; if (home == null || home.isEmpty) return null; final sep = Platform.pathSeparator; final base = '$home$sep.chain'; final ccFile = File('$base${sep}current-channel'); final channel = ccFile.existsSync() ? ccFile.readAsStringSync().trim() : 'local'; if (channel.isEmpty) return null; // Prefer the actual bound endpoint the daemon wrote. final epFile = File('$base${sep}run$sep$channel.endpoint'); if (epFile.existsSync()) { final raw = epFile.readAsStringSync().trim().replaceFirst(RegExp(r'^\w+://'), ''); final i = raw.lastIndexOf(':'); if (i > 0) { final host = raw.substring(0, i); final port = int.tryParse(raw.substring(i + 1)); if (port != null) { return (channel: channel, endpoint: HubEndpoint(host: host, port: port)); } } } // Fall back to the channel's well-known default port. return ( channel: channel, endpoint: HubEndpoint(host: '127.0.0.1', port: _channelPorts[channel] ?? 50051), ); } catch (_) { return null; } } /// Sentinel distinguishing "caller did not pass authToken" /// from "caller passed null to drop the token". static const Object _unset = Object(); /// Read persisted endpoint + auth token, then reconnect. /// Called once at app start; safe to call again after the /// operator updates the token in Settings. Future loadPersistedEndpoint() async { final prefs = await SharedPreferences.getInstance(); final host = prefs.getString(_kHostKey); final port = prefs.getInt(_kPortKey); final secure = prefs.getBool(_kSecureKey); final token = await HubAuthToken.read(); // 1) An endpoint the operator explicitly chose in Settings wins. if (host != null) { await reconnect( HubEndpoint( host: host, port: port ?? _client.endpoint.port, secure: secure ?? false, ), authToken: token, ); return; } // 2) Otherwise follow the active channel from ~/.chain, so Studio // connects to the daemon the operator installed (e.g. production // :50071) instead of the hard-coded local :50051. Not persisted, // so Studio re-discovers each launch and tracks channel switches. final discovered = _discoverActiveChannel(); if (discovered != null) { activeChannel = discovered.channel; await reconnect(discovered.endpoint, authToken: token, persist: false); return; } // 3) Nothing to discover: keep the default client, apply any token. if (token != null) { await reconnect(_client.endpoint, authToken: token, persist: false); } } /// Reconnect to a new endpoint. With [persist] (the default) the /// endpoint is saved for next launch — that is an explicit operator /// choice. Auto-discovery passes `persist: false` so it never /// overwrites a real choice and keeps re-following the active channel. /// [authToken] is read from `~/.chain/hub-auth-token` by default — /// pass `null` to drop a token, or omit to keep the current value. Future reconnect( HubEndpoint endpoint, { Object? authToken = _unset, bool persist = true, }) async { await _client.close(); final token = identical(authToken, _unset) ? await HubAuthToken.read() : authToken as String?; _client = HubClient(endpoint: endpoint, authToken: token); if (persist) { final prefs = await SharedPreferences.getInstance(); await prefs.setString(_kHostKey, endpoint.host); await prefs.setInt(_kPortKey, endpoint.port); await prefs.setBool(_kSecureKey, endpoint.secure); } } /// Reload the token from disk and reconnect using the current /// endpoint. Called by Settings after the operator pastes or /// clears a token. Future reloadAuthToken() async { await reconnect(_client.endpoint); } static const _kThemeKey = 'theme.mode'; static const _kLocaleKey = 'locale.code'; /// 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); } /// Read the persisted UI locale. Defaults to English when /// nothing is stored — operators on a fresh install land in /// English; the sidebar toggle flips to German on demand. Future loadLocale() async { final prefs = await SharedPreferences.getInstance(); final raw = prefs.getString(_kLocaleKey); if (raw == 'de') return const Locale('de'); return const Locale('en'); } /// Persist the locale code (`en` / `de`). Future saveLocale(Locale locale) async { final prefs = await SharedPreferences.getInstance(); await prefs.setString(_kLocaleKey, locale.languageCode); } Future healthy() => _client.healthy(); /// Configured module stores (+ the bundled seed) for the store manager. Future> listStores() => _client.listStores(); /// Register a new module store; the hub fetches + merges it live. Future addStore({ required String name, required String url, String bearerEnv = '', String pinnedPubkeyPem = '', }) => _client.addStore( name: name, url: url, bearerEnv: bearerEnv, pinnedPubkeyPem: pinnedPubkeyPem, ); /// Drop a configured store by name (the bundled seed cannot be removed). Future removeStore(String name) => _client.removeStore(name); /// List of installed WASM modules, grouped by `module_name`. /// Built-in and federated capabilities are excluded — they /// don't correspond to a bundle on disk and would confuse the /// Modules page (a "system" or "via:filesystem" pseudo-module /// has nothing to uninstall). Callers that need every callable /// capability — e.g. the flow's missing-dependencies check — /// use [allCapabilities] instead. Future> listModules() async { final caps = await _client.listCapabilities(); final wasm = caps.where((c) => c.kind.isEmpty || c.kind == 'wasm'); final byModule = >{}; for (final c in wasm) { 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)); } /// Every capability the hub can execute, with the `kind` tag /// telling apart wasm / builtin / federated. The flow page /// uses this to decide which capabilities are "already /// available" — including built-ins like `system.approval` /// and federated MCP / n8n tools — so the Run button enables /// when the dependency is reachable, not only when a bundle /// happens to be installed. Future> allCapabilities() async { final caps = await _client.listCapabilities(); return caps .map( (c) => CapabilityInfo( capability: c.capability, version: c.version, moduleName: c.moduleName, kind: c.kind.isEmpty ? 'wasm' : c.kind, provider: c.provider, sourceKind: c.sourceKind, versionAdvisory: c.versionAdvisory, ), ) .toList(); } /// 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, acceptsMime: r.acceptsMime, inputs: r.inputs .map( (f) => ModuleFieldInfo( name: f.name, type: f.type, description: Map.from(f.description), ), ) .toList(), outputs: r.outputs .map( (f) => ModuleFieldInfo( name: f.name, type: f.type, description: Map.from(f.description), ), ) .toList(), ); } /// System-AI status snapshot — used by the Settings dialog /// and the audit drill-down to decide whether the "Explain" /// affordance is active. Always returns a struct (no /// exceptions on disabled state). Future systemAiStatus() async { final r = await _client.systemAiStatus(); return SystemAiStatus( enabled: r.enabled, provider: r.provider, endpoint: r.endpoint, model: r.model, privacyMode: r.privacyMode, apiKeyEnv: r.apiKeyEnv, cacheCount: r.cacheCount.toInt(), ); } /// Send a one-shot prompt to the System AI. Returns the /// answer + latency on success or an [AskAiResult] with /// `errorKind` set on failure (never throws). Studio maps /// the error kind to its inline-fix copy. Future askAi(String prompt, {bool forceFresh = false}) async { final r = await _client.askAi(prompt, forceFresh: forceFresh); return AskAiResult( errorKind: r.errorKind, text: r.text, latencyMs: r.latencyMs, cached: r.cached, cachedAt: r.cachedAt, cacheHits: r.cacheHits, ); } /// Drop every cached System-AI explanation. Returns the count /// of entries that were purged. Future clearSystemLlmCache() { return _client.clearSystemLlmCache(); } /// Forget a single cached entry by prompt (for "Regenerate"). Future forgetCachedExplanation(String prompt) { return _client.forgetCachedExplanation(prompt); } /// Persist a new System-AI configuration. The hub writes back /// to `~/.chain/config.yaml` and hot-reloads the in-memory copy /// in one round-trip; the caller gets the resulting status /// for an immediate UI refresh. Future updateSystemAi({ required String provider, required String endpoint, required String model, required String apiKeyEnv, required String privacyMode, }) async { final r = await _client.updateSystemAi( provider: provider, endpoint: endpoint, model: model, apiKeyEnv: apiKeyEnv, privacyMode: privacyMode, ); return SystemAiStatus( enabled: r.enabled, provider: r.provider, endpoint: r.endpoint, model: r.model, privacyMode: r.privacyMode, apiKeyEnv: r.apiKeyEnv, cacheCount: r.cacheCount.toInt(), ); } /// Probe a System-AI configuration with a tiny ping. Pass /// the editor's current form values so "Test connection" /// works *before* save. Empty fields fall back to the live /// in-memory config. Future testSystemAi({ String provider = '', String endpoint = '', String model = '', String apiKeyEnv = '', String privacyMode = '', }) async { final r = await _client.testSystemAi( provider: provider, endpoint: endpoint, model: model, apiKeyEnv: apiKeyEnv, privacyMode: privacyMode, ); return AskAiResult( errorKind: r.errorKind, text: r.text, latencyMs: r.latencyMs, ); } /// List the models the provider exposes via `GET /v1/models`. /// Studio's editor uses this to populate a dropdown instead /// of forcing the operator to type the id manually. Future<({String errorKind, String text, List ids})> listSystemAiModels({ String provider = '', String endpoint = '', String apiKeyEnv = '', }) async { final r = await _client.listSystemAiModels( provider: provider, endpoint: endpoint, apiKeyEnv: apiKeyEnv, ); return ( errorKind: r.errorKind, text: r.text, ids: r.models.map((m) => m.id).toList(), ); } /// Pull (download) an Ollama model via /api/pull. /// Synchronous; can take minutes for large models. Errors /// come back as `errorKind`. Future<({String errorKind, String text, int elapsedMs})> pullSystemAiModel({ required String endpoint, required String model, String apiKeyEnv = '', }) async { final r = await _client.pullSystemAiModel( endpoint: endpoint, model: model, apiKeyEnv: apiKeyEnv, ); return (errorKind: r.errorKind, text: r.text, elapsedMs: r.elapsedMs); } /// Detected host hardware. Used by the System-AI editor to /// mark models as "recommended" / "may be slow" against the /// operator's actual machine. Future hardwareInfo() async { final r = await _client.hardwareInfo(); return HardwareSnapshot( tier: r.tier, cpuBrand: r.cpuBrand, cpuCores: r.cpuCores, ramMb: r.ramMb.toInt(), appleSilicon: r.appleSilicon, dedicatedGpu: r.dedicatedGpu, summary: r.summary, ); } /// Bundled curated model database. Refresh requires a hub /// redeploy; the `lastReviewed` field shows how stale the /// curation is. Future listSystemAiCuratedModels() async { final r = await _client.listSystemAiCuratedModels(); return CuratedSnapshot( lastReviewed: r.lastReviewed, models: r.models .map( (m) => CuratedModelInfo( id: m.id, family: m.family, paramsB: m.paramsB, qualityTier: m.qualityTier, minHwTier: m.minHwTier, contextK: m.contextK, notes: m.notes, license: m.license, ), ) .toList(), ); } /// Wipe the audit log on the active channel and seed a fresh /// `chain.reset` marker. Refused server-side on `beta` / /// `production`; the gRPC error surfaces as an exception so /// the caller can show the operator why it was blocked. /// Returns `(purged, channel)` so the UI can confirm what /// just happened. Future<({int purged, String channel})> clearEventLog({ required String reviewer, required String reason, }) async { final r = await _client.clearEventLog(reviewer: reviewer, reason: reason); return (purged: r.purged.toInt(), channel: r.channel); } /// Filesystem paths the daemon owns. Used by Doctor to wire /// "Open log file" / "Open config file" buttons. Future daemonPaths() async { final r = await _client.daemonPaths(); return DaemonPathsSnapshot( logPath: r.logPath, dbPath: r.dbPath, modulesDir: r.modulesDir, flowsDir: r.flowsDir, configPath: r.configPath, pidPath: r.pidPath, ); } /// Remove an installed module by name. Returns the version /// that was removed so the caller can confirm. /// /// Pass [version] to target a specific version when multiple /// versions of the same `(provider, name)` are installed /// side-by-side. Empty / omitted lets the hub pick the /// highest-version match. Future<({String name, String version})> uninstallModule( String name, { String version = '', }) async { final r = await _client.uninstallModule(name, version: version); return (name: r.name, version: r.version); } /// Read the operator's `default_scope:` plus the catalog-known /// publisher shortlist. Used by Settings → DEFAULT SCOPE panel. Future<({List scope, List knownProviders})> getDefaultScope() async { return _client.getDefaultScope(); } /// Replace the operator's `default_scope:` and hot-swap on the /// running hub. Caller passes the new ordered list; hub rejects /// an empty list with InvalidArgument. Future<({List scope, List knownProviders})> setDefaultScope( List scope, ) async { return _client.setDefaultScope(scope); } /// Every installed version of one module, sorted newest-last. /// Returns an empty list when nothing matches. Used by the /// uninstall flow to show a picker when more than one version /// is installed side-by-side. Future> installedVersions(String moduleName) async { final caps = await _client.listCapabilities(); final versions = caps .where((c) => c.moduleName == moduleName) .where((c) => c.kind.isEmpty || c.kind == 'wasm') .map((c) => c.moduleVersion) .toSet() .toList() ..sort(); return versions; } /// Read the installed module's `MODULE.md` (or /// `MODULE..md`) from disk under /// `~/.chain/modules//`. Air-gap friendly, no network. /// /// `state` is one of: /// - `'found'` — `text` holds the markdown /// - `'no_docs'` — module installed but bundle had no docs /// - `'not_installed'` — module isn't installed at all Future<({String state, String text, String sourcePath})> readInstalledModuleDocs(String name, {String locale = ''}) async { final r = await _client.getInstalledModuleDocs(name, locale: locale); final String state; if (r.notInstalled) { state = 'not_installed'; } else if (r.text.isEmpty) { state = 'no_docs'; } else { state = 'found'; } return (state: state, text: r.text, sourcePath: r.sourcePath); } /// Invoke a Studio theme plugin's `theme` hook and unwrap /// the result into Flutter-friendly `ColorScheme` slots. /// /// Returns a record: /// - `brightness`: echo of the input. /// - `tokens`: 14 32-bit ARGB values in Material 3 /// order. Caller passes them to /// `ColorScheme(...)` after deciding the /// `brightness` enum. /// /// Throws on hub-side errors (plugin not installed, plugin /// declined the brightness, etc.) — caller routes the /// exception through `friendlyError` like every other RPC. Future<({String brightness, List tokens})> invokePluginTheme({ required String capability, required String brightness, }) async { final r = await _client.invokePluginTheme( capability: capability, brightness: brightness, ); return (brightness: r.brightness, tokens: r.tokens.toList()); } /// Invoke an installed Studio translate plugin and return /// the translated text. Empty `fromLocale` lets the plugin /// auto-detect; `toLocale` is the active Studio locale. /// /// Caller catches the gRPC exception and routes it through /// `friendlyError` like every other RPC. Future invokePluginTranslate({ required String capability, required String text, required String toLocale, String fromLocale = '', }) async { final r = await _client.invokePluginTranslate( capability: capability, text: text, toLocale: toLocale, fromLocale: fromLocale, ); return r.translated; } /// Snapshot of every configured MCP server. Future> listMcpClients() async { final r = await _client.listMcpClients(); return _mapMcpClients(r); } /// Re-run discovery against every configured MCP server on /// the running daemon. Returns the same shape as listMcpClients. Future> refreshMcpClients() async { final r = await _client.refreshMcpClients(); return _mapMcpClients(r); } Future> addMcpClient({ required String name, required String endpoint, String apiKeyEnv = '', String description = '', }) async { final r = await _client.addMcpClient( name: name, endpoint: endpoint, apiKeyEnv: apiKeyEnv, description: description, ); return _mapMcpClients(r); } Future> removeMcpClient(String name) async { final r = await _client.removeMcpClient(name); return _mapMcpClients(r); } List _mapMcpClients(ListMcpClientsResponse r) { return r.clients .map( (s) => McpClientInfo( name: s.config.name, endpoint: s.config.endpoint, apiKeyEnv: s.config.apiKeyEnv, description: s.config.description, toolCount: s.toolCount, errorKind: s.errorKind, message: s.message, ), ) .toList(); } /// Snapshot of every configured n8n endpoint. Future> listN8nEndpoints() async { final r = await _client.listN8nEndpoints(); return _mapN8nEndpoints(r); } Future> refreshN8nEndpoints() async { final r = await _client.refreshN8nEndpoints(); return _mapN8nEndpoints(r); } Future> addN8nEndpoint({ required String name, required String baseUrl, String apiKeyEnv = '', String description = '', }) async { final r = await _client.addN8nEndpoint( name: name, baseUrl: baseUrl, apiKeyEnv: apiKeyEnv, description: description, ); return _mapN8nEndpoints(r); } Future> removeN8nEndpoint(String name) async { final r = await _client.removeN8nEndpoint(name); return _mapN8nEndpoints(r); } List _mapN8nEndpoints(ListN8nEndpointsResponse r) { return r.endpoints .map( (s) => N8nEndpointInfo( name: s.config.name, baseUrl: s.config.baseUrl, apiKeyEnv: s.config.apiKeyEnv, description: s.config.description, workflowCount: s.workflowCount, errorKind: s.errorKind, message: s.message, ), ) .toList(); } /// Active channel + per-channel daemon status snapshot. Future channelStatus() async { final r = await _client.channelStatus(); return ChannelStatusSnapshot( active: r.active, channels: r.channels .map( (c) => ChannelInfo( name: c.name, port: c.port, running: c.running, endpoint: c.endpoint, ), ) .toList(), ); } /// Browse the bundled store index. All filters optional; /// empty query returns the first [limit] entries. Future> searchStore({ String query = '', String category = '', String tag = '', String status = '', int limit = 50, }) async { final entries = await _client.searchStore( query: query, category: category, tag: tag, status: status, limit: limit, ); return entries .map( (e) => StoreItem( name: e.name, taglineEn: e.taglineEn, taglineDe: e.taglineDe, descriptionEn: e.descriptionEn, descriptionDe: e.descriptionDe, category: e.category, tags: e.tags, requiresCapabilities: e.requiresCapabilities, requiresServices: e.requiresServices, license: e.license, repository: e.repository, bestVersion: e.bestVersion, status: e.status, installed: e.installed, featured: e.featured, iconUrl: e.iconUrl, screenshotUrls: e.screenshotUrls, docsUrl: e.docsUrl, kind: e.kind, provider: e.provider, sourceKind: e.sourceKind, ), ) .toList(); } /// Install a module from a `.fai` bundle (URL or local path). /// Returns the installed module's name + version on success; /// throws on hub error (signature mismatch, sha256 fail, etc.). Future<({String name, String version})> installModule({ required String source, String expectedSha256 = '', }) async { final r = await _client.installModule( source: source, expectedSha256: expectedSha256, ); return (name: r.name, version: r.version); } /// 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(), requiredCapabilities: List.from(f.requiredCapabilities), ), ) .toList() ..sort((a, b) => a.name.compareTo(b.name)); } /// Return the parsed input schema of a saved flow. Used by /// Studio's Flows page to render a typed run-flow form /// before calling [runSavedFlow]. Each entry's `type` is /// the verbatim type tag from the flow YAML (`text`, /// `bytes`, `json`, …). Future> getFlowDefinition(String name) async { return _client.getFlowDefinition(name); } /// Run a saved flow with the supplied inputs. Text values /// land as text Payloads, byte values as bytes Payloads — /// flows that mix both (e.g. extract taking a `document: /// bytes` plus a text-shaped option) flow through one RPC. /// [fileMimeTypes] is keyed the same as [fileInputs]; missing /// entries fall back to `application/octet-stream`. Modules /// often gate on MIME (the `text.extract` module refuses /// octet-stream), so callers should provide a real type. /// Returns the named outputs as a map of typed [FlowOutput] /// values so each page can render text / JSON / bytes / file /// with the right widget. Future> runSavedFlow({ required String name, Map textInputs = const {}, Map fileInputs = const {}, Map fileMimeTypes = const {}, }) async { final r = await _client.runSavedFlow( name: name, textInputs: textInputs, fileInputs: fileInputs, fileMimeTypes: fileMimeTypes, ); return { for (final entry in r.outputs.entries) entry.key: FlowOutput.fromPayload(entry.value), }; } /// Live audit-event stream. The hub's StreamEvents RPC /// optionally replays [backfill] historical events first, then /// keeps the channel open and forwards every new event as soon /// as it lands. Filtering by [types] is applied on the hub /// side so this surface stays lean even when the log is busy. /// /// Subscribe **before** triggering the work whose progress you /// want to follow — otherwise the earliest `*.started` events /// can arrive before the subscriber is ready and slip past. Stream streamEvents({ int backfill = 0, List types = const [], }) { return _client .streamEvents(backfill: backfill, types: types) .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, moduleVersion: e.moduleVersion.isEmpty ? null : e.moduleVersion, invocationId: e.invocationId.isEmpty ? null : e.invocationId, flowExecution: e.flowExecution.isEmpty ? null : e.flowExecution, durationMs: e.durationMs == 0 ? null : e.durationMs.toInt(), error: e.error.isEmpty ? null : e.error, detail: e.detail.isEmpty ? null : e.detail, ), ); } 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, moduleVersion: e.moduleVersion.isEmpty ? null : e.moduleVersion, invocationId: e.invocationId.isEmpty ? null : e.invocationId, flowExecution: e.flowExecution.isEmpty ? null : e.flowExecution, durationMs: e.durationMs == 0 ? null : e.durationMs.toInt(), error: e.error.isEmpty ? null : e.error, detail: e.detail.isEmpty ? null : e.detail, ), ) .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(); } /// List approvals filtered by status. Empty [statuses] returns /// every row (pending + decided + expired). Returned records /// carry the decided-side fields so the History tab can render /// "rejected by alice@studio at … because …". Future> listApprovalsRecords({ List statuses = const [], int limit = 200, }) async { final entries = await _client.listApprovals( statuses: statuses, limit: limit, ); return entries .map( (e) => ApprovalRecord( id: e.approvalId, flowName: e.flowName, stepId: e.stepId, prompt: e.prompt, payloadPreview: e.payloadPreview.isEmpty ? null : e.payloadPreview, createdAt: DateTime.tryParse(e.createdAt) ?? DateTime.now(), expiresAt: e.expiresAt.isEmpty ? null : DateTime.tryParse(e.expiresAt), status: e.status, decidedAt: e.decidedAt.isEmpty ? null : DateTime.tryParse(e.decidedAt), decidedBy: e.decidedBy, reason: e.reason, ), ) .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); /// Federation satellites currently connected to this hub /// (primary side). Empty when none are connected. Future> listSatellites() async { final entries = await _client.listSatellites(); return entries .map( (e) => Satellite( hubId: e.hubId, displayName: e.displayName, hubVersion: e.hubVersion, wireVersion: e.wireVersion, region: e.region, capabilities: e.capabilities.toList(), ), ) .toList(); } /// Issue a single-use federation bootstrap token enrolling /// `name`. The result carries the primary CA so the operator can /// bootstrap the satellite in one step (secure first connect). Future issueSatelliteToken( String name, { String region = '', }) async { final r = await _client.issueBootstrapToken(name, region: region); return SatelliteEnrollment( token: r.token, expiresAt: r.expiresAt, primaryCaPem: r.primaryCaPem, ); } /// 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((_) => []), // checkUpdate does a live manifest fetch (server-side 8s timeout). // The other five are local-socket RPCs that return in ms, so cap // this one tightly — otherwise it alone gates the whole Diagnose // page open. A timeout reads as "release host unreachable", which // the UI already renders as an offline banner, not an error. _client .checkUpdate() .timeout( const Duration(seconds: 2), onTimeout: () => CheckUpdateResponse(), ) .catchError((_) => CheckUpdateResponse()), _client.daemonPaths().catchError((_) => DaemonPathsResponse()), ]); final caps = results[0] as List; final approvals = results[1] as List; final chain = results[2] as VerifyEventChainResponse; final services = results[3] as List; final update = results[4] as CheckUpdateResponse; final paths = results[5] as DaemonPathsResponse; // Module count = distinct module_name across capabilities. final moduleNames = {for (final c in caps) c.moduleName}; // Per-source-kind breakdown (0.12+). Pre-0.12 hubs return // empty `source_kind` strings — group those under `kind` // as a fallback so the doctor panel still shows a count. final bySource = {}; for (final c in caps) { final key = c.sourceKind.isEmpty ? c.kind : c.sourceKind; bySource[key] = (bySource[key] ?? 0) + 1; } return DoctorSnapshot( moduleCount: moduleNames.length, capabilityCount: caps.length, capabilitiesBySource: bySource, 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(), update: UpdateStatus( channel: update.channel, localVersion: update.localVersion, latestVersion: update.latestVersion, updateAvailable: update.updateAvailable, manifestReachable: update.manifestReachable, releaseNotesUrl: update.releaseNotesUrl.isEmpty ? null : update.releaseNotesUrl, reason: update.reason.isEmpty ? null : update.reason, ), paths: DaemonPathsSnapshot( logPath: paths.logPath, dbPath: paths.dbPath, modulesDir: paths.modulesDir, flowsDir: paths.flowsDir, configPath: paths.configPath, pidPath: paths.pidPath, ), ); } } class UpdateStatus { final String channel; final String localVersion; final String latestVersion; final bool updateAvailable; final bool manifestReachable; final String? releaseNotesUrl; final String? reason; const UpdateStatus({ required this.channel, required this.localVersion, required this.latestVersion, required this.updateAvailable, required this.manifestReachable, this.releaseNotesUrl, this.reason, }); } class DoctorSnapshot { final int moduleCount; final int capabilityCount; final int pendingApprovals; final int eventChainTotal; final int eventChainVerified; final String? eventChainTamperedAt; final List services; final UpdateStatus update; /// Filesystem paths the daemon owns. Empty fields when the /// hub couldn't resolve them (older daemon, no $HOME). final DaemonPathsSnapshot paths; /// Per-source-kind capability count: bundle / system / mcp / /// n8n / temporal / webhook (0.12+). Pre-0.12 hubs return /// an empty map and the panel falls back to a flat count. final Map capabilitiesBySource; const DoctorSnapshot({ required this.moduleCount, required this.capabilityCount, required this.pendingApprovals, required this.eventChainTotal, required this.eventChainVerified, required this.eventChainTamperedAt, required this.services, required this.update, required this.paths, this.capabilitiesBySource = const {}, }); 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; } } /// Single capability with its provenance. `kind` is one of /// `wasm` (installed module), `builtin` (hub-internal, e.g. /// `system.approval`), or `federated` (MCP / n8n tool reachable /// through the bridge). UI uses `kind` to decide what actions /// to offer — only `wasm` capabilities have an install / /// uninstall affordance. class CapabilityInfo { final String capability; final String version; final String moduleName; final String kind; /// Publisher segment of the fully-qualified identifier. /// "fai" for legacy bundles, "fai.system" for built-ins, /// operator-config service name for federated capabilities. final String provider; /// Transport that delivers the capability: bundle / system / /// mcp / n8n / temporal / webhook. Empty string falls back /// to deriving from `kind` for pre-0.12 hubs. final String sourceKind; /// Whether the version on this capability is a label rather /// than a hard contract. True for federation sources whose /// upstream service does not honour semver. final bool versionAdvisory; const CapabilityInfo({ required this.capability, required this.version, required this.moduleName, required this.kind, this.provider = '', this.sourceKind = '', this.versionAdvisory = false, }); } /// 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; /// Verbatim `use:` strings of every step in this flow, /// deduplicated and sorted by the hub. Lets Studio decide /// whether the flow is currently runnable (every entry /// resolves to an installed module). final List requiredCapabilities; const SavedFlow({ required this.name, required this.path, required this.sizeBytes, required this.requiredCapabilities, }); } class ModuleDetail { final String name; final String version; final List capabilities; final List permissions; final String directory; /// MIME allow-list declared in the module's `module.yaml`. /// Empty when the module didn't declare any — caller falls /// back to its built-in extension heuristic. final List acceptsMime; /// Declared inputs (schema v3 verbose form when present). The /// flow editor uses these to render per-field input ports with /// canonical names + i18n tooltips. final List inputs; /// Declared outputs (schema v3 verbose form when present). /// Rendered by the flow editor as distinct output ports so a /// step like `summarize` exposes its `response`, /// `model_endpoint`, `model_name`, `model_digest` separately /// instead of fanning them all out of one anchor. final List outputs; const ModuleDetail({ required this.name, required this.version, required this.capabilities, required this.permissions, required this.directory, this.acceptsMime = const [], this.inputs = const [], this.outputs = const [], }); } /// One declared input or output field on a module. Mirrors /// `proto.ModuleField`: name, type descriptor, and a locale /// → description map for tooltips. class ModuleFieldInfo { /// Field name, e.g. "prompt" or "model_endpoint". final String name; /// Type descriptor (text / json / bytes / file). final String type; /// Locale tag → description. Use [descriptionFor] to look up /// with English fallback. final Map description; const ModuleFieldInfo({ required this.name, required this.type, this.description = const {}, }); /// Returns the description in [locale] when present, else the /// English peer, else null. Studio passes its active locale's /// short code (e.g. "de") and shows the result as a tooltip. String? descriptionFor(String locale) { final exact = description[locale]; if (exact != null && exact.isNotEmpty) return exact; final en = description['en']; if (en != null && en.isNotEmpty) return en; return null; } } class AuditEvent { final String eventId; final DateTime timestamp; final String type; final String? flowName; final String? stepId; final String? moduleName; final String? moduleVersion; final String? invocationId; final String? flowExecution; final int? durationMs; final String? error; /// Free-form JSON-string carrying event-type-specific fields. /// Surfaced verbatim in the audit drill-down dialog. final String? detail; const AuditEvent({ required this.eventId, required this.timestamp, required this.type, this.flowName, this.stepId, this.moduleName, this.moduleVersion, this.invocationId, this.flowExecution, this.durationMs, this.error, this.detail, }); } 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, }); } /// Full approval record including decided-side fields. Used by /// the History tab so operators can review what they (or the /// A federation satellite currently connected to this hub. class Satellite { final String hubId; final String displayName; final String hubVersion; final int wireVersion; final String region; final List capabilities; const Satellite({ required this.hubId, required this.displayName, required this.hubVersion, required this.wireVersion, required this.region, required this.capabilities, }); } /// The artifact a satellite operator needs to enrol: the single-use /// bootstrap token plus the primary's CA cert (for a secure first /// connect). Both are pasted into the satellite's config. class SatelliteEnrollment { final String token; final String expiresAt; final String primaryCaPem; const SatelliteEnrollment({ required this.token, required this.expiresAt, required this.primaryCaPem, }); /// A ready-to-paste `federation:` config block for the satellite. String toConfigYaml(String name) { final ca = primaryCaPem.trim().isEmpty ? '' : '\n primary_ca: |\n${primaryCaPem.trimRight().split('\n').map((l) => ' $l').join('\n')}'; return 'federation:\n' ' upstream: https://:\n' ' hub_name: $name\n' ' bootstrap_token_env: CHAIN_BOOTSTRAP_TOKEN$ca'; } } /// system) decided. class ApprovalRecord { final String id; final String flowName; final String stepId; final String prompt; final String? payloadPreview; final DateTime createdAt; final DateTime? expiresAt; /// One of: pending / approved / rejected / expired. final String status; final DateTime? decidedAt; /// Reviewer handle (e.g. "alice@studio") or "system" for /// auto-expired approvals. Empty until decided. final String decidedBy; /// Reject reason — empty for approve / pending / expired. final String reason; const ApprovalRecord({ required this.id, required this.flowName, required this.stepId, required this.prompt, required this.payloadPreview, required this.createdAt, required this.expiresAt, required this.status, required this.decidedAt, required this.decidedBy, required this.reason, }); } class SystemAiStatus { final bool enabled; final String provider; final String endpoint; final String model; /// off / redacted / full. final String privacyMode; final String apiKeyEnv; /// Number of cached System-AI explanations currently stored. /// Surfaced in Doctor + Settings so operators see the cache /// earning its keep. final int cacheCount; const SystemAiStatus({ required this.enabled, required this.provider, required this.endpoint, required this.model, required this.privacyMode, required this.apiKeyEnv, this.cacheCount = 0, }); } class AskAiResult { /// "" on success; otherwise one of: /// disabled / env_missing / network / http / parse / empty_response. final String errorKind; /// On success: the assistant's reply. /// On failure: a human-readable diagnostic. final String text; /// Round-trip latency in ms (success only). For cache hits /// this is the *original* generation latency. final int latencyMs; /// True iff the answer came from the persistent cache. final bool cached; /// ISO-8601 timestamp the cached entry was first generated. /// Empty for fresh answers. final String cachedAt; /// How many times this entry has been served from cache. /// 0 for fresh answers. final int cacheHits; const AskAiResult({ required this.errorKind, required this.text, required this.latencyMs, this.cached = false, this.cachedAt = '', this.cacheHits = 0, }); bool get isSuccess => errorKind.isEmpty; /// One-line fix hint per error kind, mirroring /// `docs/architecture/system-ai.md`. Empty on success. /// Localized — pass the active [AppLocalizations]. String fixHint(AppLocalizations l) { switch (errorKind) { case '': return ''; case 'disabled': return l.sysAiFixDisabled; case 'env_missing': return l.sysAiFixEnvMissing; case 'network': return l.sysAiFixNetwork; case 'http': return l.sysAiFixHttp; case 'parse': return l.sysAiFixParse; case 'empty_response': return l.sysAiFixEmptyResponse; default: return l.sysAiFixUnknown(errorKind); } } } class ChannelInfo { final String name; final int port; final bool running; final String endpoint; const ChannelInfo({ required this.name, required this.port, required this.running, required this.endpoint, }); } class ChannelStatusSnapshot { final String active; final List channels; const ChannelStatusSnapshot({required this.active, required this.channels}); } /// One configured n8n endpoint plus the last-discovery /// report. Sister of [`McpClientInfo`]. class N8nEndpointInfo { final String name; final String baseUrl; final String apiKeyEnv; final String description; final int workflowCount; final String errorKind; final String message; const N8nEndpointInfo({ required this.name, required this.baseUrl, required this.apiKeyEnv, required this.description, required this.workflowCount, required this.errorKind, required this.message, }); bool get healthy => errorKind.isEmpty && workflowCount > 0; } /// One configured MCP server plus the last-discovery report. /// Surfaced by Studio's MCP-clients editor in Settings. class McpClientInfo { final String name; final String endpoint; final String apiKeyEnv; final String description; /// Number of tools the last discovery returned. 0 before /// discovery has run or on failure. final int toolCount; /// Empty on success; otherwise one of: /// invalid_endpoint / not_implemented / network / http / /// server / parse / env_missing. final String errorKind; /// Human-readable diagnostic. final String message; const McpClientInfo({ required this.name, required this.endpoint, required this.apiKeyEnv, required this.description, required this.toolCount, required this.errorKind, required this.message, }); bool get healthy => errorKind.isEmpty && toolCount > 0; } /// Filesystem paths the daemon owns. Studio's Doctor page /// surfaces these as "Open" buttons so Windows operators who /// never touch a shell can still inspect everything via the OS /// file manager / default editor. class DaemonPathsSnapshot { final String logPath; final String dbPath; final String modulesDir; final String flowsDir; final String configPath; final String pidPath; const DaemonPathsSnapshot({ required this.logPath, required this.dbPath, required this.modulesDir, required this.flowsDir, required this.configPath, required this.pidPath, }); } /// Detected host hardware — see /// docs/architecture/system-ai.md → "Hardware tiers". class HardwareSnapshot { /// One of: tiny / small / balanced / large / unknown. final String tier; final String cpuBrand; final int cpuCores; final int ramMb; final bool appleSilicon; final bool dedicatedGpu; /// Human-readable summary the editor renders verbatim. final String summary; const HardwareSnapshot({ required this.tier, required this.cpuBrand, required this.cpuCores, required this.ramMb, required this.appleSilicon, required this.dedicatedGpu, required this.summary, }); /// Numeric rank used for "is `min_hw_tier` ≤ detected?" /// comparisons. Unknown sits at -1 so it never qualifies as /// "at least" anything; the editor falls back to size-only /// heuristics in that case. int get rank { switch (tier) { case 'tiny': return 0; case 'small': return 1; case 'balanced': return 2; case 'large': return 3; default: return -1; } } } /// One curated model with editorial metadata. Wire-faithful /// with `crates/chain_hub/system-ai/models.yaml`. class CuratedModelInfo { final String id; final String family; final double paramsB; /// basic / good / excellent. final String qualityTier; /// tiny / small / balanced / large. final String minHwTier; final int contextK; final String notes; final String license; const CuratedModelInfo({ required this.id, required this.family, required this.paramsB, required this.qualityTier, required this.minHwTier, required this.contextK, required this.notes, required this.license, }); /// Same rank as HardwareSnapshot; lets us compare with `<=`. int get minHwRank { switch (minHwTier) { case 'tiny': return 0; case 'small': return 1; case 'balanced': return 2; case 'large': return 3; default: return -1; } } } /// Curated database snapshot returned from the hub. class CuratedSnapshot { /// ISO date string from `models.yaml`, e.g. "2026-05-07". final String lastReviewed; final List models; const CuratedSnapshot({required this.lastReviewed, required this.models}); } /// One row from the store-index search. class StoreItem { final String name; final String taglineEn; /// German tagline. Falls back to [taglineEn] in the UI when /// the index entry does not ship a localized one. final String taglineDe; final String descriptionEn; /// German long-form description. Falls back to [descriptionEn] /// when missing. final String descriptionDe; final String category; final List tags; final List requiresCapabilities; final List requiresServices; final String license; final String repository; final String bestVersion; /// "published", "alpha", "planned", or empty when unknown. final String status; final bool installed; /// True iff the store-index marks this entry as editorially /// featured (curated quarterly in the bundled seed.yaml). /// Drives the "Featured" strip at the top of the Store page. final bool featured; /// Optional icon URL — square PNG / SVG. Empty for entries /// that don't supply one; UI falls back to a category icon. final String iconUrl; /// Ordered list of screenshot URLs; empty when none. Studio /// renders them as a horizontally-scrollable strip in the /// detail sheet. final List screenshotUrls; /// Explicit docs URL override. When empty, the hub falls back /// to the well-known raw-README paths off [repository]. final String docsUrl; /// "native" (downloadable bundle) or "federated" (routes /// through a bridge — MCP, n8n, …). Empty falls back to /// "native". final String kind; /// Provider id for federated entries. Empty for native. /// E.g. "filesystem" for an `mcp.filesystem.read_file` tool. final String provider; /// Transport delivering this entry. Distinct from `kind` — /// `sourceKind` answers "HOW does the hub reach this", not /// "is it installable". Values: "bundle", "mcp", "n8n", /// "temporal", "webhook". Empty falls back to deriving /// from `kind` for pre-0.12 hubs. final String sourceKind; bool get isFederated => kind == 'federated'; const StoreItem({ required this.name, required this.taglineEn, required this.taglineDe, required this.descriptionEn, required this.descriptionDe, required this.category, required this.tags, required this.requiresCapabilities, required this.requiresServices, required this.license, required this.repository, required this.bestVersion, required this.status, required this.installed, required this.featured, required this.iconUrl, required this.screenshotUrls, required this.docsUrl, required this.kind, required this.provider, this.sourceKind = '', }); }