Some checks failed
Security / Security check (push) Failing after 1s
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>
622 lines
22 KiB
Dart
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});
|
|
}
|