chain-studio-flow-editor/lib/src/run_driver.dart
flemming-it ae09a55146 feat(run): host-injected native file picker for flow file inputs
The Run tab asked the operator to type an absolute path into a text
field — with no file dialog anywhere. FlowEditorPage now accepts an
onPickFile callback (PickedFileData: name + bytes) that hosts wire
to their native picker; the path-entry dialog remains only as the
no-host fallback. 0.23.0.

Signed-off-by: flemming-it <sf@flemming.it>
2026-07-17 01:04:40 +02:00

216 lines
7.2 KiB
Dart

// Host-supplied bridge between the editor and the hub.
//
// The editor package can't talk to the hub directly — that's
// the host's job (Studio uses the gRPC SDK, a CLI host could
// shell out to `fai run`, a test harness can stub everything
// in-memory). The bridge keeps the editor reusable across
// any host that can implement the two methods below.
//
// Lifecycle expected by the editor:
//
// 1. Subscribe to `events()` BEFORE calling `runFlow()`.
// The hub starts emitting `step.started` events the
// instant the flow begins; the host's subscription must
// already be live so the first one doesn't get lost.
// 2. Call `runFlow()` with the flow name + collected inputs.
// The returned Future resolves with the final outputs
// (or throws on failure).
// 3. Unsubscribe from `events()` when the run resolves.
import 'dart:typed_data';
/// A file chosen by the host's native picker for a flow file input.
class PickedFileData {
final String fileName;
final Uint8List bytes;
const PickedFileData({required this.fileName, required this.bytes});
}
/// Host-side native file picker. Returns null when the operator
/// cancels. When a host does not supply one, the Run tab falls back
/// to its manual path-entry dialog.
typedef PickFileCallback = Future<PickedFileData?> Function();
abstract class FlowRunDriver {
/// Trigger a run of [flowName] with the supplied inputs.
/// Text + file inputs come in separate maps so the host
/// can wire them to the right payload shape. Returns the
/// flow's final outputs map keyed by output name.
Future<Map<String, FlowOutputValue>> runFlow({
required String flowName,
required Map<String, String> textInputs,
required Map<String, Uint8List> fileInputs,
required Map<String, String> fileMimes,
});
/// Live event stream scoped to whatever the host can
/// observe — typically the hub's `StreamEvents` RPC
/// filtered to step.* event types for the running flow.
/// May arrive late (subscribe BEFORE calling runFlow!) and
/// may emit events for unrelated runs; the editor filters
/// by [FlowRunEvent.flowName] before applying.
Stream<FlowRunEvent> events();
/// Look up an installed module's manifest by capability
/// reference. The editor uses this to render per-field
/// input/output ports with their declared names + types +
/// tooltips, instead of collapsing every downstream reference
/// into a single output anchor.
///
/// Default returns `null` so legacy hosts that didn't update
/// their FlowRunDriver implementation keep compiling — the
/// editor falls back to the YAML-reference-derived ports.
/// A real implementation hits the hub's ModuleInfo RPC and
/// maps the result to [ModuleSpec].
Future<ModuleSpec?> moduleInfo(String capability) async => null;
/// Resolve the pending approval id for `(flowName, stepId)`
/// when the flow is paused on a `system.approval@^0` step.
/// Default returns `null` so legacy hosts that didn't update
/// keep compiling — the editor's Run tab then falls back to
/// directing the operator at the standalone Approvals page.
Future<String?> pendingApprovalIdForStep({
required String flowName,
required String stepId,
}) async =>
null;
/// Approve the named approval. `reviewer` is the audit-log
/// identity the host wants attributed (typically OS user +
/// '@studio'). Throws on hub-side failure; UI surfaces it.
Future<void> approveApproval({
required String approvalId,
required String reviewer,
}) async {
throw UnsupportedError(
'FlowRunDriver host did not implement approveApproval',
);
}
/// Reject the named approval with a free-form reason.
Future<void> rejectApproval({
required String approvalId,
required String reviewer,
required String reason,
}) async {
throw UnsupportedError(
'FlowRunDriver host did not implement rejectApproval',
);
}
}
/// Declared inputs + outputs of one installed module, as seen
/// by the editor. The shape mirrors `ModuleInfoResponse.inputs`
/// / `outputs` over gRPC but without proto dependencies.
class ModuleSpec {
/// Capability identifier the host resolved (e.g. `text.summarize`).
final String capability;
/// Declared inputs, in stable alphabetical order. The editor
/// draws one input port per entry.
final List<ModuleField> inputs;
/// Declared outputs, in stable alphabetical order. The editor
/// draws one output port per entry — this is what makes
/// `summarize.response`, `summarize.model_endpoint`,
/// `summarize.model_name`, `summarize.model_digest` appear as
/// four distinct anchors instead of one.
final List<ModuleField> outputs;
const ModuleSpec({
required this.capability,
required this.inputs,
required this.outputs,
});
}
/// One declared input or output field. Carries the type
/// descriptor (`text` / `json` / `bytes` / `file`) and a locale
/// → description map for tooltip rendering.
class ModuleField {
/// Field name, e.g. `prompt` or `model_endpoint`.
final String name;
/// Type descriptor.
final String type;
/// IETF locale tag → description string. Empty when the
/// manifest used the shorthand form (no description).
final Map<String, String> description;
const ModuleField({
required this.name,
required this.type,
this.description = const {},
});
/// Description in [locale] with English fallback. Returns
/// null when the manifest carries no description for either.
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;
}
}
/// What the editor needs out of a single event tick. Maps
/// 1:1 to the hub's `step.*` events but with the typing
/// projected into the editor's vocabulary so we don't carry
/// the SDK's proto types across the package boundary.
sealed class FlowRunEvent {
final String flowName;
final String stepId;
const FlowRunEvent({required this.flowName, required this.stepId});
}
class StepStarted extends FlowRunEvent {
const StepStarted({required super.flowName, required super.stepId});
}
class StepCompleted extends FlowRunEvent {
final int durationMs;
const StepCompleted({
required super.flowName,
required super.stepId,
required this.durationMs,
});
}
class StepFailed extends FlowRunEvent {
final String error;
const StepFailed({
required super.flowName,
required super.stepId,
required this.error,
});
}
class StepAwaitingApproval extends FlowRunEvent {
const StepAwaitingApproval({required super.flowName, required super.stepId});
}
/// One output value, after the run completed. The editor
/// renders text inline, JSON pretty-printed, bytes as a
/// download badge.
sealed class FlowOutputValue {
const FlowOutputValue();
}
class FlowOutputText extends FlowOutputValue {
final String value;
const FlowOutputText(this.value);
}
class FlowOutputJson extends FlowOutputValue {
final Object? value;
const FlowOutputJson(this.value);
}
class FlowOutputBytes extends FlowOutputValue {
final Uint8List value;
final String mimeType;
const FlowOutputBytes(this.value, this.mimeType);
}