// HubClient — typed Dart wrapper around the F∆I gRPC surface. // // Holds a single grpc.ClientChannel keyed by [HubEndpoint]. Each // public method maps onto exactly one RPC on Hub or HubAdmin, so // callers don't have to know which service hosts which call. import 'dart:typed_data'; import 'package:grpc/grpc_connection_interface.dart'; import 'channel_factory_io.dart' if (dart.library.html) 'channel_factory_web.dart' as channel_factory; import 'generated/fai/v1/common.pb.dart' as pb_common; import 'generated/fai/v1/hub.pb.dart' as pb; import 'generated/fai/v1/hub.pbgrpc.dart' as grpc_hub; import 'generated/google/protobuf/empty.pb.dart' show Empty; /// Connection coordinates for a hub endpoint. class HubEndpoint { /// Hostname or IP. Defaults to localhost for the canonical /// `fai serve` setup. final String host; /// gRPC port. Defaults to 50051. final int port; /// When true, use TLS. Phase 1+ will add credential support. final bool secure; const HubEndpoint({ this.host = '127.0.0.1', this.port = 50051, this.secure = false, }); @override String toString() => '${secure ? "https" : "http"}://$host:$port'; } /// Re-exports of generated protobuf types so callers don't have /// to import the `generated/` directory directly. typedef CapabilityEntry = pb.CapabilityEntry; typedef CapabilityList = pb.CapabilityList; typedef LoggedEvent = pb.LoggedEvent; typedef PendingApprovalEntry = pb.PendingApprovalEntry; typedef DeclaredService = pb.DeclaredService; typedef VerifyEventChainResponse = pb.VerifyEventChainResponse; typedef CheckUpdateResponse = pb.CheckUpdateResponse; typedef ChannelStatusResponse = pb.ChannelStatusResponse; typedef ChannelEntry = pb.ChannelEntry; typedef SystemAiStatusResponse = pb.SystemAiStatusResponse; typedef AskAiResponse = pb.AskAiResponse; typedef ListSystemAiModelsResponse = pb.ListSystemAiModelsResponse; typedef SystemAiModel = pb.SystemAiModel; typedef PullSystemAiModelResponse = pb.PullSystemAiModelResponse; typedef HardwareInfoResponse = pb.HardwareInfoResponse; typedef ListSystemAiCuratedModelsResponse = pb.ListSystemAiCuratedModelsResponse; typedef CuratedModel = pb.CuratedModel; typedef ClearEventLogResponse = pb.ClearEventLogResponse; typedef DaemonPathsResponse = pb.DaemonPathsResponse; typedef UninstallModuleResponse = pb.UninstallModuleResponse; typedef FetchModuleDocsResponse = pb.FetchModuleDocsResponse; typedef GetInstalledModuleDocsResponse = pb.GetInstalledModuleDocsResponse; typedef InvokePluginThemeResponse = pb.InvokePluginThemeResponse; typedef InvokePluginTranslateResponse = pb.InvokePluginTranslateResponse; typedef ListMcpClientsResponse = pb.ListMcpClientsResponse; typedef McpClientStatus = pb.McpClientStatus; typedef McpClientConfigEntry = pb.McpClientConfigEntry; typedef ListN8nEndpointsResponse = pb.ListN8nEndpointsResponse; typedef N8nEndpointStatus = pb.N8nEndpointStatus; typedef N8nEndpointConfigEntry = pb.N8nEndpointConfigEntry; typedef ModuleInfoResponse = pb.ModuleInfoResponse; typedef FlowSummary = pb.FlowSummary; typedef StoreEntry = pb.StoreEntry; typedef StoreSearchResponse = pb.StoreSearchResponse; typedef InstallModuleResponse = pb.InstallModuleResponse; typedef Payload = pb_common.Payload; typedef SubmitResponse = pb.SubmitResponse; /// Typed client for the F∆I Hub gRPC surface. Construct once, /// reuse for the process lifetime, call [close] on shutdown. /// /// The channel type is picked at compile time: /// * native targets → plain HTTP/2 gRPC via [ClientChannel]. /// * web targets → HTTP/1.1 gRPC-Web via [GrpcWebClientChannel]. /// Requires the hub to be running with the /// `tonic-web` layer enabled. class HubClient { final HubEndpoint endpoint; final ClientChannelBase _channel; final grpc_hub.HubClient _hub; final grpc_hub.HubAdminClient _admin; HubClient._(this.endpoint, this._channel) : _hub = grpc_hub.HubClient(_channel), _admin = grpc_hub.HubAdminClient(_channel); factory HubClient({HubEndpoint endpoint = const HubEndpoint()}) { return HubClient._(endpoint, channel_factory.createChannel(endpoint)); } /// Liveness probe. Returns true when the hub responds with /// SERVING. False on any failure (includes connection refused). Future healthy() async { try { final r = await _hub.health(Empty()); return r.state == pb.HealthStatus_State.SERVING; } catch (_) { return false; } } /// All capabilities provided by installed modules. Future> listCapabilities() async { final r = await _admin.listCapabilities(Empty()); return r.capabilities; } /// Detailed info for one installed module: name, version, /// provided capabilities, declared permissions, on-disk path. Future moduleInfo(String moduleName) { final req = pb.ModuleInfoRequest()..moduleName = moduleName; return _admin.moduleInfo(req); } /// Recent events from the audit log, newest-first. Pass an /// empty `types` list for all event types. Future> eventLog({ int limit = 100, List types = const [], }) async { final req = pb.EventLogRequest() ..limit = limit ..eventTypes.addAll(types); final r = await _admin.eventLog(req); return r.events; } /// Pending approvals filtered by status. Defaults to all. Future> listApprovals({ List statuses = const [], int limit = 100, }) async { final req = pb.ListApprovalsRequest() ..limit = limit ..statuses.addAll(statuses); final r = await _admin.listApprovals(req); return r.entries; } /// Approve a pending approval. The flow that posted it resumes /// on its next polling tick. Future approve({ required String approvalId, required String reviewer, }) async { final req = pb.DecideApprovalRequest() ..approvalId = approvalId ..reviewer = reviewer ..decision = pb.DecideApprovalRequest_Decision.APPROVE; await _admin.decideApproval(req); } /// Reject a pending approval. The flow fails with the supplied /// reason on its next polling tick. Future reject({ required String approvalId, required String reviewer, required String reason, }) async { final req = pb.DecideApprovalRequest() ..approvalId = approvalId ..reviewer = reviewer ..reason = reason ..decision = pb.DecideApprovalRequest_Decision.REJECT; await _admin.decideApproval(req); } /// Walk the event-log hash chain on the hub. Returns total / /// verified counts and an `tamperedAt` id (empty when clean). Future verifyEventChain() { return _admin.verifyEventChain(Empty()); } /// Wipe every audit event and seed a fresh `chain.reset` /// marker carrying [reviewer] + [reason]. The hub refuses on /// `beta` / `production` channels; on `local` / `dev` it /// returns the count of events that were purged. Future clearEventLog({ required String reviewer, required String reason, }) { return _admin.clearEventLog(pb.ClearEventLogRequest( reviewer: reviewer, reason: reason, )); } /// Host services declared in the operator config. Each entry /// is a name + endpoint + tags; reachability is up to the /// caller (typically a follow-up HTTP probe). Future> listServices() async { final r = await _admin.listServices(Empty()); return r.services; } /// Compare local hub version vs. release-channel manifest. /// Network failures land in `manifestReachable=false` rather /// than as exceptions — Studio renders that case as a quiet /// "release host unreachable" pill instead of an error. Future checkUpdate() { return _admin.checkUpdate(Empty()); } /// Active channel name + per-channel daemon status. Studio's /// settings page calls this to render which channel is current /// and whether each daemon is up. Future channelStatus() { return _admin.channelStatus(Empty()); } /// Filesystem paths the daemon owns (log file, db, config, /// flows, modules, pid). Studio's Doctor page surfaces these /// as "Open in file manager" buttons so Windows operators can /// inspect everything without a shell. Future daemonPaths() { return _admin.daemonPaths(Empty()); } /// Remove an installed module by name. Wipes the module /// directory + re-scans the registry; writes a /// `module.uninstalled` audit event. Returned response carries /// the version that was removed. Future uninstallModule(String name) { return _admin.uninstallModule(pb.UninstallModuleRequest(name: name)); } /// Fetch a module's README markdown via the hub. The hub /// authenticates against the registry with FAI_REGISTRY_TOKEN /// when set, so Studio doesn't have to handle credentials. /// Errors come back inside [FetchModuleDocsResponse.errorKind] /// (not as exceptions) so the UI can render fallbacks. /// /// Deprecated: prefer [getInstalledModuleDocs], which reads /// `MODULE.md`/`MODULE..md` shipped inside the bundle. @Deprecated('Use getInstalledModuleDocs (reads from bundle, no network)') Future fetchModuleDocs( String name, { String locale = '', }) { return _admin.fetchModuleDocs( pb.FetchModuleDocsRequest(name: name, locale: locale), ); } /// Read the installed module's `MODULE.md` (or `MODULE..md`) /// from disk under `~/.fai/modules//`. No network. Works in /// air-gapped installs. The response distinguishes three states via /// [GetInstalledModuleDocsResponse.notInstalled] and an empty /// [GetInstalledModuleDocsResponse.text]: /// /// - text non-empty → docs found, render them /// - text empty + notInstalled=false → installed but bundle had /// no inline docs /// - text empty + notInstalled=true → module is not installed Future getInstalledModuleDocs( String name, { String locale = '', }) { return _admin.getInstalledModuleDocs( pb.GetInstalledModuleDocsRequest(name: name, locale: locale), ); } /// Invoke an installed Studio plugin's `theme` hook and /// receive a Material 3 ColorScheme for the brightness /// ("light" or "dark") the caller asked for. /// /// [capability] is the full Studio-plugin capability name /// (e.g. "studio.theme.solarized"). [brightness] must be /// "light" or "dark"; other values surface as /// `INVALID_ARGUMENT` from the hub. A plugin that doesn't /// recognise the brightness returns Declined, which the /// hub maps to `FAILED_PRECONDITION` — Studio's /// friendly-error mapper renders it with a recovery hint. /// /// The first end-to-end RPC of the Studio-plugin /// subsystem; `translate` and `output-view` hooks get /// their own methods once the matching Studio surfaces /// ship. Future invokePluginTheme({ required String capability, required String brightness, }) { return _admin.invokePluginTheme( pb.InvokePluginThemeRequest( capability: capability, brightness: brightness, ), ); } /// Invoke an installed Studio plugin's `translate` hook and /// receive translated text. [fromLocale] may be empty /// (auto-detect); [toLocale] must be a BCP-47 code (the /// host always knows the target). /// /// Errors surface as gRPC exceptions: /// * NOT_FOUND — plugin not installed. /// * FAILED_PRECONDITION — plugin returned Declined or /// Misconfigured (e.g. unreachable /// LLM endpoint). /// * INTERNAL — generic catch-all. Future invokePluginTranslate({ required String capability, required String text, required String toLocale, String fromLocale = '', }) { return _admin.invokePluginTranslate( pb.InvokePluginTranslateRequest( capability: capability, text: text, fromLocale: fromLocale, toLocale: toLocale, ), ); } /// List every configured MCP server with the last-discovery /// snapshot. Drives Studio's MCP-clients editor. Future listMcpClients() { return _admin.listMcpClients(Empty()); } /// Re-run discovery against every configured MCP server and /// update the running daemon's store-index in place. Future refreshMcpClients() { return _admin.refreshMcpClients(Empty()); } /// Persist a new MCP server in `~/.fai/config.yaml` and /// trigger discovery in one round-trip. Future addMcpClient({ required String name, required String endpoint, String apiKeyEnv = '', String description = '', }) { return _admin.addMcpClient(pb.McpClientConfigEntry( name: name, endpoint: endpoint, apiKeyEnv: apiKeyEnv, description: description, )); } /// Remove an MCP server by name + drop its synthetic /// capabilities from the running store-index. Future removeMcpClient(String name) { return _admin.removeMcpClient(pb.RemoveMcpClientRequest(name: name)); } /// Sister methods for n8n-endpoint federation. Same shape /// as the MCP-client surface above; Studio's n8n editor /// reuses the MCP editor patterns wholesale. Future listN8nEndpoints() => _admin.listN8nEndpoints(Empty()); Future refreshN8nEndpoints() => _admin.refreshN8nEndpoints(Empty()); Future addN8nEndpoint({ required String name, required String baseUrl, String apiKeyEnv = '', String description = '', }) { return _admin.addN8nEndpoint(pb.N8nEndpointConfigEntry( name: name, baseUrl: baseUrl, apiKeyEnv: apiKeyEnv, description: description, )); } Future removeN8nEndpoint(String name) { return _admin.removeN8nEndpoint(pb.RemoveN8nEndpointRequest(name: name)); } /// Read-only system-AI status. `enabled=false` lets Studio /// render "configure in Settings" without making a request. Future systemAiStatus() { return _admin.systemAiStatus(Empty()); } /// One-shot prompt to the configured System AI. Errors come /// back inside [AskAiResponse.errorKind] (not as exceptions) /// so Studio can map each category to its inline-fix copy. /// /// Cache-aware: identical prompts hit the persistent /// per-(model, privacy_mode, prompt) cache; the response /// carries `cached: true` plus the original generation /// latency. Pass [forceFresh] to skip the cache (Studio's /// Regenerate button). Future askAi(String prompt, {bool forceFresh = false}) { return _admin.askAi( pb.AskAiRequest(prompt: prompt, forceFresh: forceFresh), ); } /// Drop every cached System-AI explanation. Used by Settings /// when the operator wants a clean slate without changing /// model/privacy mode (which would also flush). Future clearSystemLlmCache() async { final r = await _admin.clearSystemLlmCache(Empty()); return r.purged.toInt(); } /// Drop a single cached entry by prompt. Studio's Regenerate /// button calls this so the next askAi falls through to the /// live provider. Future forgetCachedExplanation(String prompt) async { await _admin.forgetCachedExplanation(pb.AskAiRequest(prompt: prompt)); } /// Persist a new System-AI configuration. Validates + /// writes back to ~/.fai/config.yaml + hot-reloads the live /// hub state in one call. Returns the resulting status so /// the UI can refresh in one round-trip. Future updateSystemAi({ required String provider, required String endpoint, required String model, required String apiKeyEnv, required String privacyMode, }) { return _admin.updateSystemAi(pb.UpdateSystemAiRequest( provider: provider, endpoint: endpoint, model: model, apiKeyEnv: apiKeyEnv, privacyMode: privacyMode, )); } /// Probe a (possibly draft) System-AI configuration. Pass the /// form values from the editor so "Test connection" works /// without a save first. Empty fields fall back to the live /// in-memory config. Future testSystemAi({ String provider = '', String endpoint = '', String model = '', String apiKeyEnv = '', String privacyMode = '', }) { return _admin.testSystemAi(pb.TestSystemAiRequest( provider: provider, endpoint: endpoint, model: model, apiKeyEnv: apiKeyEnv, privacyMode: privacyMode, )); } /// List the models the configured provider exposes via /// `GET /v1/models`. Same draft-vs-live semantics as /// [testSystemAi]. Errors come back inside /// [ListSystemAiModelsResponse.errorKind]. Future listSystemAiModels({ String provider = '', String endpoint = '', String apiKeyEnv = '', }) { return _admin.listSystemAiModels(pb.TestSystemAiRequest( provider: provider, endpoint: endpoint, apiKeyEnv: apiKeyEnv, )); } /// Pull (download + install) an Ollama model via /api/pull. /// Synchronous; takes minutes for large models. Errors come /// back inside [PullSystemAiModelResponse.errorKind]. Future pullSystemAiModel({ required String endpoint, required String model, String apiKeyEnv = '', }) { return _admin.pullSystemAiModel(pb.PullSystemAiModelRequest( endpoint: endpoint, model: model, apiKeyEnv: apiKeyEnv, )); } /// Detected host hardware. Cached server-side; cheap to call. /// Returned tier is one of `tiny`/`small`/`balanced`/`large`/ /// `unknown`. See `docs/architecture/system-ai.md` → /// "Hardware tiers". Future hardwareInfo() { return _admin.hardwareInfo(Empty()); } /// Bundled curated model database. Used by Studio's editor /// to colour-code models by hardware suitability. Refresh /// requires a hub redeploy — `lastReviewed` shows how stale /// the curation is. Future listSystemAiCuratedModels() { return _admin.listSystemAiCuratedModels(Empty()); } /// Search the hub's bundled store index. All filters are /// optional; an empty query returns the first [limit] entries /// the index ships with. Future> searchStore({ String query = '', String category = '', String tag = '', String status = '', int limit = 50, }) async { final r = await _admin.searchStore(pb.SearchStoreRequest( query: query, category: category, tag: tag, status: status, limit: limit, )); return r.entries; } /// Install a module from a `.fai` bundle. [source] is either a /// URL or a local filesystem path; [expectedSha256] is an /// optional hex digest the hub verifies before unpacking. Future installModule({ required String source, String expectedSha256 = '', }) { return _admin.installModule(pb.InstallModuleRequest( source: source, expectedSha256: expectedSha256, )); } /// All saved flows known to the hub. Each entry carries the /// flow name, on-disk path and byte size. Future> listFlows() async { final r = await _admin.listFlows(Empty()); return r.flows; } /// Return the parsed input schema of a saved flow so a GUI /// client can render an input form before calling /// [runSavedFlow]. Each entry's `type` is the verbatim type /// tag from the flow YAML (`text`, `bytes`, `json`, `file`, /// or anything else the flow author put there). Future> getFlowDefinition(String name) async { final req = pb.GetFlowDefinitionRequest()..name = name; final resp = await _admin.getFlowDefinition(req); return resp.inputs .map((spec) => FlowInputDef(name: spec.name, type: spec.type)) .toList(); } /// Run a saved flow by name with the supplied named inputs. /// Two input shapes are supported in the same call so a /// flow that mixes text and binary inputs (e.g. extract /// taking a `document: bytes`) flows through one RPC: /// * [textInputs] — string values wrapped as text Payloads /// * [fileInputs] — raw bytes wrapped as bytes Payloads. /// MIME type defaults to /// `application/octet-stream`; clients that /// know the real type pass it via /// [fileMimeTypes] (same key). Modules /// typically gate on MIME — e.g. /// `text.extract` rejects octet-stream — so /// accurate types matter. /// All maps default to empty so existing text-only callers /// are backward-compatible. Keys must not collide between /// [textInputs] and [fileInputs]; on collision the bytes-shaped /// entry wins (the less-common case is more likely to be the /// operator's intent). Future runSavedFlow({ required String name, Map textInputs = const {}, Map fileInputs = const {}, Map fileMimeTypes = const {}, }) async { final req = pb.RunSavedFlowRequest()..name = name; for (final entry in textInputs.entries) { req.inputs[entry.key] = pb_common.Payload()..text = entry.value; } for (final entry in fileInputs.entries) { final mime = fileMimeTypes[entry.key] ?? 'application/octet-stream'; final bytes = pb_common.Bytes() ..mimeType = mime ..data = entry.value; req.inputs[entry.key] = pb_common.Payload()..bytes = bytes; } return _admin.runSavedFlow(req); } /// Closes the gRPC channel. Idempotent. Future close() async { await _channel.shutdown(); } } /// One declared input in a saved flow's `inputs:` section. /// Returned by [HubClient.getFlowDefinition] so GUI clients /// can build a typed run-flow form. The [type] field carries /// the flow YAML's verbatim type tag — opaque to the SDK so /// the type system can grow without an SDK release. class FlowInputDef { /// Input key (the name the flow YAML uses). final String name; /// Type tag — typically `text`, `bytes`, `json`, or `file`. final String type; const FlowInputDef({required this.name, required this.type}); }