chain-client-sdk-dart/lib/src/hub_client.dart
flemming-it f481eee687
Some checks failed
Security / Security check (push) Failing after 1s
feat(client): GrpcWebClientChannel on web targets via conditional import
Splits channel construction into two files selected at
compile-time via `if (dart.library.html)`:

  channel_factory_io.dart   — native ClientChannel (HTTP/2)
  channel_factory_web.dart  — GrpcWebClientChannel.xhr (HTTP/1.1)

HubClient._channel is now `ClientChannelBase` so both
implementations satisfy the same field type. dart:html-only
imports (`package:grpc/grpc_web.dart`) stay isolated from the
native compile path.

Pairs with the hub-side `fai_grpc_web` adapter crate
(fai/platform 0d07892) — `tonic-web` on the same port as
native gRPC, so one `fai serve` listener handles both
surfaces.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
2026-05-26 13:34:26 +02:00

622 lines
22 KiB
Dart

// 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<bool> 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<List<CapabilityEntry>> 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<ModuleInfoResponse> 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<List<LoggedEvent>> eventLog({
int limit = 100,
List<String> 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<List<PendingApprovalEntry>> listApprovals({
List<String> 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<void> 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<void> 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<VerifyEventChainResponse> 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<ClearEventLogResponse> 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<List<DeclaredService>> 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<CheckUpdateResponse> 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<ChannelStatusResponse> 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<DaemonPathsResponse> 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<UninstallModuleResponse> 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.<locale>.md` shipped inside the bundle.
@Deprecated('Use getInstalledModuleDocs (reads from bundle, no network)')
Future<FetchModuleDocsResponse> fetchModuleDocs(
String name, {
String locale = '',
}) {
return _admin.fetchModuleDocs(
pb.FetchModuleDocsRequest(name: name, locale: locale),
);
}
/// Read the installed module's `MODULE.md` (or `MODULE.<locale>.md`)
/// from disk under `~/.fai/modules/<module>/`. 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<GetInstalledModuleDocsResponse> 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<InvokePluginThemeResponse> 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<InvokePluginTranslateResponse> 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<ListMcpClientsResponse> listMcpClients() {
return _admin.listMcpClients(Empty());
}
/// Re-run discovery against every configured MCP server and
/// update the running daemon's store-index in place.
Future<ListMcpClientsResponse> refreshMcpClients() {
return _admin.refreshMcpClients(Empty());
}
/// Persist a new MCP server in `~/.fai/config.yaml` and
/// trigger discovery in one round-trip.
Future<ListMcpClientsResponse> 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<ListMcpClientsResponse> 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<ListN8nEndpointsResponse> listN8nEndpoints() =>
_admin.listN8nEndpoints(Empty());
Future<ListN8nEndpointsResponse> refreshN8nEndpoints() =>
_admin.refreshN8nEndpoints(Empty());
Future<ListN8nEndpointsResponse> 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<ListN8nEndpointsResponse> 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<SystemAiStatusResponse> 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<AskAiResponse> 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<int> 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<void> 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<SystemAiStatusResponse> 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<AskAiResponse> 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<ListSystemAiModelsResponse> 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<PullSystemAiModelResponse> 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<HardwareInfoResponse> 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<ListSystemAiCuratedModelsResponse>
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<List<StoreEntry>> 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<InstallModuleResponse> 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<List<FlowSummary>> 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<List<FlowInputDef>> 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<SubmitResponse> runSavedFlow({
required String name,
Map<String, String> textInputs = const {},
Map<String, Uint8List> fileInputs = const {},
Map<String, String> 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<void> 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});
}